TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

[번역] 넷이즈 레이훠 LGDC 시리즈 게임 개발에서 비바람을 부르는 마법(하편): 3D 번개, 폭풍운, 지표면 구름바다 효과 공유

jplee 2026. 5. 25. 16:39

도입: 이 글은 《날씨 시스템 설계와 실천》의 보충편입니다. 세 가지 렌더링 효과의 구현을 공유합니다. 먼저 형태를 편집할 수 있는 3D 프랙털 번개, 그리고 지평선 폭풍운, 지표면 구름바다, RayMarching 효과입니다.

본문의 저자는 넷이즈 레이화의 브루스입니다. 환경 표현 관련 렌더링 분야에서 거의 10년의 경험을 갖고 있으며, 2015년에 레이화에 합류한 뒤 《역수한》, 싱글 프로젝트, 《역수한 모바일》, 《나라카: 블레이드포인트 모바일》 등의 프로젝트에 참여했습니다.


형태를 편집할 수 있는 3D 프랙털 번개

《역수한 모바일》의 구광한림 맵에서는 나뭇가지 위에 번개가 걸려 있는 효과가 필요했습니다. 그런데 일반 이펙트 방식만으로는 성능과 효과를 동시에 챙기기 어려웠기 때문에, 절차적으로 생성되는 프랙털 번개를 사용해야 했습니다.

처음 생각은 2D 번개를 바탕으로 조금 확장하는 것이었습니다. 2D 번개의 프랙털 결과는 하나의 평면 위에 놓이고, 번개를 편집한다는 것도 결국 그 평면을 이동·회전·스케일링하는 작업입니다. 하지만 이렇게 하면 각 번개의 가지가 평면 안에서만 움직일 수 있어 여전히 충분히 유연하지 않았습니다. 그래서 3D 프랙털 번개를 개발했습니다. 2D 번개는 레이화의 기존 글 「가장 아름다운 비 오는 날」을 참고하면 됩니다.

먼저 3D 번개의 최종 효과를 보겠습니다.

데이터 구조

먼저 직렬화 데이터 구조와 런타임 데이터 구조를 잘 설계합니다. 데이터 구조가 또렷해지면 로직은 자연스럽게 따라옵니다.

직렬화 데이터 구조

직렬화 데이터 구조는 그림과 같습니다. 씬 안에는 여러 개의 3D 번개가 있고, 각 번개 LocalFractalLightning3D는 색상, 프랙털 파라미터, 애니메이션 파라미터를 조정할 수 있습니다. 예를 들어 프랙털 파라미터에서는 가지가 갈라지는 수를 조절하고, 애니메이션 파라미터에서는 성장 속도 같은 값을 조절합니다.

각 번개는 다시 여러 개의 LightningShape를 포함합니다. 하나의 LightningShape는 한 줄기 번개이며, 사용자가 정의한 여러 개의 핵심 위치점 KeyPosition을 포함합니다. 또한 이 번개 줄기가 어느 번개 줄기(ParentShapeIndex)의 어느 핵심 위치점(KeyPositionIndexInParentShape)에서 갈라져 나왔는지도 기록합니다. 기본값은 -1이며, 이는 자기 자신이 독립적인 번개 줄기라는 뜻입니다.

런타임 데이터 구조

런타임에서 주로 하는 일은 부모·자식 관계로 연결된 여러 개의 LightningShape를 하나의 ShapeGroup 트리로 합치는 것입니다. 이 ShapeGroup 트리는 번개의 다른 속성과 결합되어 완전한 번개 인스턴스 LocalLightning을 구성합니다. 각 인스턴스의 ShapeGroup 트리에 대해 너비 우선 탐색을 수행하고, 프랙털 알고리즘을 적용해 번개 메시를 생성한 뒤 렌더링합니다.

아래 예를 하나 보겠습니다.

직렬화 데이터 예시 — 0번 번개와 1번 번개를 연결하기 전

씬 안에 0번 번개 LocalFractalLightning3D_0이 있고, 여기에 세 개의 Shape, 즉 Shape_0, Shape_1, Shape_2가 추가되어 있습니다. 각각 3개, 3개, 4개의 핵심 위치점을 갖고 있으며, 서로 독립적이고 아무 관계도 없습니다.

이때 b와 d, 두 개의 핵심 위치점을 연결하고 싶다면 어떻게 해야 할까요?

직렬화 데이터 예시 — 0번 번개와 1번 번개를 연결한 뒤

Shape_1의 ParentShapeIndex를 0으로, KeyPositionIndexInParentShape를 1로 설정하기만 하면 됩니다. 이는 현재 번개가 0번 Shape의 1번 핵심 위치점에서 갈라져 나왔다는 뜻입니다.

런타임 데이터 예시 — 0번 번개와 1번 번개 연결

런타임에서는 ShapeGroup_1이 ShapeGroup_0의 자식 번개로 존재하게 됩니다. 이 작업은 재귀적으로 계속될 수 있으므로, 각 LocalFractalLightning3D는 하나 또는 여러 개의 트리형 번개로 구성됩니다. 각 트리형 번개는 단일 가지일 수도 있고, 얼마든지 무성하게 뻗어나갈 수도 있습니다.

다시 이 예로 돌아오면, 최종적으로 LocalFractalLightning3D_0에는 0번과 2번, 두 개의 ShapeGroup만 남게 됩니다.

렌더링

한마디로 설명하면 DrawInstance 방식으로 렌더링하며, 각 인스턴스는 하나의 Cube입니다. 이어서 몇 가지 세부 내용을 공유하겠습니다.

  1. 번개 끝부분의 대나무 마디 같은 결함

Cube가 겹치는 부분은 더 밝아집니다.

원인은 두 Cube 인스턴스의 이음새 부분에 겹치는 영역이 생기기 때문입니다. 일반적인 alpha blend를 사용하면 이 부분이 다른 곳보다 더 밝아지고, 그래서 대나무 마디 같은 결함이 생깁니다.

왼쪽은 alpha blend, 오른쪽은 max blend입니다.

일반적인 alpha blend 대신 max blend 방식을 사용하면 마디 문제를 해결할 수 있습니다. 하지만 끝부분의 색상이 배경에 잘 녹아들지 못합니다. 예를 들어 오른쪽 그림처럼 노란 번개가 파란 하늘에 max blend되면, 끝부분에 붉은색이 나타납니다.

닫힌 파이프 형태의 번개를 구성하면 alpha blend로 인한 마디 문제를 크게 완화할 수 있습니다. 하지만 단일 번개의 경우 프랙털로 인한 자기 회전 때문에 특정 각도에서 삼각형이 보일 수 있어, 이것도 완벽한 해결책은 아닙니다. 다만 이 방식은 나뭇가지처럼 불투명한 프랙털 구조를 만들 때는 문제가 없습니다. 다른 절충안도 있기는 하지만 실제로 구현하지는 않았으므로 여기서는 더 펼치지 않겠습니다.

  1. 끝부분 연소 효과

셰이더 안에서는 현재 인스턴스가 전체 번개 진행 과정에서 어느 정규화 위치에 있는지 정확히 얻을 수 있습니다. 바꿔 말하면, 각 인스턴스는 자신이 번개 “불꽃놀이”의 가장 앞쪽에 있는지 아닌지를 알고 있습니다. 그래서 끝부분이 타들어 가며 떨어지는 효과를 만들 수 있습니다.

  1. LOD

번개 Cube의 폭을 카메라 거리와 함께 적응형으로 조절해, 카메라가 멀어질 때 생기는 깜빡임 문제를 피합니다.

LOD를 켠 상태입니다.

  1. ACES 톤매핑은 매우 중요합니다

고휘도 빛은 채도를 낮춰야 합니다.

왼쪽은 비-ACES, 오른쪽은 ACES입니다.

  1. 재미있는 효과

지평선 폭풍운

최종 효과입니다.

렌더링과 노이즈 구성은 비교적 일반적인 방식입니다. 자세한 내용은 www.schneidervfx.com을 참고하면 됩니다.

Cube에 대해 RayMarching을 수행합니다. 예시에서 Cube의 크기는 4096 × 512 × 4096이며, 노이즈는 저주파 density 또는 profile, 그리고 고주파 erosion 노이즈로 나뉩니다.

각각 저주파 노이즈의 한 slice, 고주파 노이즈의 한 slice입니다.

중심을 기준으로 회전시킵니다. 체적 구름은 몇 개의 원형 고리로 나뉘며, 각 고리, 즉 ring 또는 layer는 서로 다른 속도를 갖습니다. 두 고리 사이에서는 blend를 수행합니다. RayMarching의 각 샘플링 지점은 두 개의 layer 인덱스와 보간값 layerFactor를 갖게 됩니다.

float2 sampleVector = (samplePos - stormCenterPos).xz;
float layer = length(sampleVector) / layer_width;
int curLayer = (int)layer;
int nextLayer = curLayer + 1;
float layerFactor = frac(layer);

layer의 인덱스를 서로 다른 회전 속도에 매핑하면, 회전된 두 개의 샘플링 지점을 구할 수 있습니다.

curRingSamplePos = GetRotatedPosition(samplePos, time * RingSpeed[curLayer]);
nextRingSamplePos = GetRotatedPosition(samplePos, time * RingSpeed[nextLayer]);

두 샘플링 지점에서 각각 두 가지 노이즈를 샘플링하는 부분은 더 길게 설명하지 않겠습니다.

이제 중요한 부분이 나옵니다. 가장자리 왜곡과 서로 섞이는 느낌을 만드는 부분입니다. 지평선의 GDC 발표 원문에는 이렇게 되어 있습니다.

Noise sample positions for rings nested outside faster moving rings were twisted about the center.

그리고 아래와 같은 코드 한 줄이 붙어 있었습니다.

float noise = SampleNoise(GetRotatedPosition(sample_position, superstorm_center, time_offset * RingRotationSpeed[n] + ring_skew[n]));

저는 처음에 저자가 말하려는 뜻을 바로 이해하지 못했습니다. 한참 파고든 뒤에야, 제대로 하려면 두 가지를 맞춰야 한다는 것을 알게 되었습니다.

  1. 먼저 blend하고 그다음 erosion을 수행한다.
  2. twist는 바깥 layer에만 적용한다.

먼저 잘못된 효과를 보겠습니다.

먼저 erosion을 하고 나중에 blend하며, twist는 바깥 layer에만 적용한 경우입니다. 왜곡 방향과 운동 속도가 맞지 않아 분리감이 매우 강합니다.

잘못된 순서, 즉 먼저 erosion을 하고 나중에 blend하는 방식입니다.

finalDensityCurRing = Erosion(densityCurRing, detailCurRing);
finalDensityNextRing = Erosion(densityNextRing, detailNextRing);
finalDensity = lerp(finalDensityCurRing, finalDensityNextRing, layerFactor);

올바른 순서, 즉 먼저 blend하고 나중에 erosion을 수행하는 방식입니다.

density = lerp(densityCurRing, densityNextRing, layerFactor);
detail = lerp(detailCurRing, detailNextRing, layerFactor);
finalDensity = Erosion(density, detail);

첫 번째 일, 즉 먼저 blend하고 나중에 erosion을 수행하는 순서를 맞춘 뒤, 두 번째 일인 “twist는 바깥 layer에만 적용한다”를 봅니다.

아래는 각각 이중 layer twist와 단일 layer twist의 결과 비교입니다.

바깥 layer에만 twist를 적용한 결과는 완벽합니다.

안쪽과 바깥쪽 layer에 모두 twist를 적용한 잘못된 방식입니다.

curRingSamplePos = GetRotatedPosition(samplePos, time * RingSpeed[curLayer]) + twist * layerFactor;
nextRingSamplePos = GetRotatedPosition(samplePos, time * RingSpeed[nextLayer] + twist * (1 - layerFactor));

바깥 layer에만 twist를 적용한 올바른 방식입니다.

curRingSamplePos = GetRotatedPosition(samplePos, time * RingSpeed[curLayer]);
nextRingSamplePos = GetRotatedPosition(samplePos, time * RingSpeed[nextLayer] + twist * (1 - layerFactor));

원리는 간단합니다. 안쪽 ring의 디테일 노이즈가 바깥 ring에서 왜곡된 기본 형태 위를 흐르게 함으로써, 서로 섞여 들어가는 느낌을 만들어 내는 것입니다.

위 작업의 프레임워크는 여름 인턴이 만든 것입니다. 다만 효과가 맞지 않았고, 저는 최근에 그 부분을 조금 수정했습니다. 그들이 복각한 Nubis 3 체적 구름 효과는 꽤 괜찮습니다.

지표면 구름바다

핵심은 지표면에 붙는 mesh를 구성해 RayMarching을 가속하는 것입니다.

  1. 런타임에 Cube mesh를 구성합니다. 정점 수는 구름바다의 크기에 따라 적응형으로 결정됩니다.
WorldSpaceSizePerPixel = 50;
MeshVertexNumInX = cloudScale.x / WorldSpaceSizePerPixel;
MeshVertexNumInZ = cloudScale.z / WorldSpaceSizePerPixel;

런타임에 구성한 Cube mesh입니다. 이후 vertex shader에서 displacement를 수행합니다.

  1. Cube mesh의 위아래 표면 높이는 에디터에서 브러시로 조각합니다. 구름바다가 지형과 접촉하는 경우에는 지형 높이도 함께 더해집니다. vertex shader에서는 높이맵을 샘플링해 Cube mesh에 displacement를 적용합니다.

vertex shader에서 Cube mesh에 displacement를 적용합니다.

  1. displacement 이후의 mesh를 세 번 그립니다. 먼저 앞면 depth를 그리고, 다음으로 뒷면 depth를 그린 뒤, 마지막으로 RayMarching을 통해 최종 결과를 얻습니다. 이때 최종 결과는 뒷면을 렌더링합니다.

RayMarching 시작점 계산입니다. 앞면과 뒷면 depth는 각각 frontDepth, backDepth입니다.

if (frontDepth == FAR_CLIP_VALUE) // 카메라가 구름바다 내부에 있거나, 구름바다가 시야 절두체 안에 없음
    beginPosWS = worldSpaceCameraPos;
else if (frontDepth > backDepth) // 카메라가 구름바다 내부에 있고, 구름바다가 오목 다면체라 앞면이 뒷면보다 카메라에서 더 멀리 있음
    beginPosWS = worldSpaceCameraPos;
else
    beginPosWS = ComputeWorldSpacePosition(frontDepth);

RayMarching 끝점 계산입니다.

depth = GetNearDepth(SampleSceneDepth(ScreenUV), positionCS.z);
endPosWS = ComputeWorldSpacePosition(depth);

최종 효과입니다.

브러시로 구름바다를 편집하는 모습입니다.

이상이 전체 내용입니다. 여러분의 작업에 도움이 되었으면 합니다.

다음 회 예고: 《ShaderLab For UE — 전능한 Shader 개발 솔루션을 어떻게 만들 것인가》

간단한 머티리얼 효과 개발부터 대형 shader 라이브러리 유지보수까지, 다음 회에서는 사용 경험, 프레임워크 설계, 기술 세부 사항, shader 지식 등 여러 측면에서 shader 시스템을 전면적으로 소개하겠습니다.

많은 관심 부탁드립니다!

이 전 엮인 글

[번역] 넷이즈 레이훠 LGDC 시리즈|게임 개발에서 ‘마법’처럼 쓰이는 기술(상): 날씨 시스템 설계와 실전

 

[번역] 넷이즈 레이훠 LGDC 시리즈|게임 개발에서 ‘마법’처럼 쓰이는 기술(상): 날씨 시스템 설

읽기 전에: 날씨 시스템(환경 제어 시스템)은 게임의 품질감과 몰입감을 크게 끌어올립니다. 이 글은 상·하 두 편으로 구성되며, 상편에서는 날씨 시스템이 보통 어떤 구성과 일을 담당하는지

techartnomad.tistory.com