Skip to content

브라우저에서 오픈 월드 만들기, 28부: 지평선까지 이어지는 풀, 그리고 스스로를 가리는 지면

글쓴이 Oleg Sidorkin, Cinevva의 CTO 겸 공동 창업자

처음 오셨나요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 부분을 링크해 둡니다.

27부에서는 섬을 만들고 타일처럼 보이지 않는 지면을 입혔습니다. 이번 부분은 지형을 텅 빈 곳이 아니라 사람이 사는 곳처럼 느끼게 만드는 두 가지를 다룹니다. Spike 56은 풀, 즉 텍스처가 입혀진 비탈을 걸어 다니고 싶은 장소로 바꿔주는 표면 디테일이고, 여기서의 문제는 한 들판이 흩어진 줄무늬가 아니라 들판으로 읽히게 만드는 것입니다. Spike 57은 더 많이 그리는 것의 반대입니다. 언덕이 이미 가리고 있는 것을 그리지 않는 일이고, 흥미로운 결과는 그것을 검사하는 빠른 방법이 알고 보니 올바른 방법이기도 했다는 점입니다.

색종이 조각이 아니라 들판으로 읽히는 풀

새 탭에서 Spike 56 열기 ↗ · 소스 보기

핵심 결정은 기하학적입니다. 가늘고 끝이 좁아지는 풀잎 하나는 대부분의 카메라 각도에서 서브 픽셀이라, 50만 개의 납작한 풀잎은 들판이 아니라 듬성듬성한 초록색 색종이 조각으로 읽힙니다. 해결책은 교차 quad 덩어리입니다. 끝이 좁아지는 quad 세 개를 로컬 업 축을 기준으로 서로 60도씩 회전시켜 두면, 어느 시점 방향에서 보든 적어도 하나의 quad가 카메라에 거의 수직으로 서고 각 instance가 실제 화면 면적으로 대략 풀잎 세 개 너비를 덮습니다. 이것이 초록색 줄무늬가 보이느냐 풀이 보이느냐의 차이입니다.

WebGPU 컴퓨트 커널이 init 시점에 모든 덩어리를 한 번씩 배치합니다. instance 인덱스를 다섯 개의 비상관 랜덤 스트림으로 해싱하고, 패치 안에서 XZ 위치를 고르고, CPU 지면 mesh가 쓰는 것과 같은 FBM에서 지면 높이를 샘플링하고(부호를 보존하는 mod를 쓴 TSL 포팅이라 값이 정확히 일치하고, 풀잎이 위에 떠 있지 않고 표면에 앉습니다), 중심 차분 법선을 취하고, 덩어리별 너비, 높이, 색조를 굴려서 정합니다. 렌더 쪽은 instance 행렬을 완전히 우회해 곧장 클립 공간에 쓰는 TSL 정점 그래프입니다. 단위 교차 quad의 크기를 조절하고, Rodrigues 공식으로 로컬 업을 지면 법선 위로 회전시키고, 덩어리 위치로 평행 이동합니다. 거리 LOD는 컬링 패스 없이 공짜로 따라옵니다. 정점 그래프가 덩어리의 높이에 1smoothstep(fadeNear,fadeFar,dist)를 곱하기 때문에, 먼 덩어리는 높이가 0으로 납작하게 무너져 더 이상 fill 비용을 쓰지 않습니다. 바람은 덩어리의 월드 XZ에 시간을 더한 값에 대한 sin과 cos의 두 옥타브로, 수평으로 적용되고 높이 분수로 게이팅되어 밑동은 고정된 채 끝만 움직입니다. 그리고 작은 카메라 인식 기울기가 각 덩어리를 보는 사람 쪽으로 기울여 줘서, 납작한 카드처럼 읽히는 대신 원근감 있게 펼쳐집니다.

색상 레시피는 NedMakesGames의 Breath of the Wild URP 셰이더에서 빌려 왔습니다. 플랫 셰이딩에, 풀잎 색은 높이 분수를 따라 뿌리 톤에서 끝 톤으로 lerp 하고, 그 뿌리 톤과 끝 톤 자체는 덩어리별 색조 값에 따라 두 팔레트 사이에서 lerp 됩니다. 이것이 진짜 BotW 풀밭이 가진 얼룩덜룩한 투톤 룩을 만들어 냅니다. Diffuse는 지면 법선을 태양에 대고 환경광 바닥을 더해 씁니다. 교차 quad의 quad별 법선은 양식화된 룩으로 개별 셰이딩하기에는 너무 노이즈가 많기 때문입니다. 전체 들판은 한 번의 draw이고, 배치와 애니메이션 모두 GPU에서 이뤄집니다.

언덕이 가리고 있는 것을 컬링하는 네 가지 방법

새 탭에서 Spike 57 열기 ↗ · 소스 보기

Spike 57은 프로덕션 클라이언트가 스트리밍하는 것과 같은 절차적 월드에서 네 가지 컬링 경로를 벤치마킹하는데, 언덕이 실제로 식생을 가리도록 수직으로 1.8배 스케일을 키웠습니다. T0은 거리 전용으로, 출시된 chunk 가시성이 이미 하고 있는 것과 같습니다. T1은 frustum 컬링을 더합니다. 가장 큰 단일 저비용 이득으로, 시야 원뿔 밖의 모든 것을 떨궈냅니다. T2는 지형 인식 지평선 검사를 더합니다. 눈에서 각 instance로 광선을 march 시켜, 경로 어디에서든 높이맵이 광선 위로 솟으면 그 instance를 기각합니다. 그래서 능선 뒤의 나무는 frustum 안에 있더라도 컬링됩니다. T3은 같은 지평선 검사를 유지하되 최대 높이 피라미드로 가속하고, T4는 거리에 frustum에 지평선까지 더한 검사 전체를 TSL 컴퓨트 커널로 포팅합니다. 이 커널은 instance별 가시성 스케일을 쓰고, 식생 머티리얼이 그것을 읽어 가려진 정점을 무너뜨립니다. 두 프레임 동안 보이는 상태를 유지하는 카운터가 단일 프레임 깜빡임을 부드럽게 다듬는데, 이는 카메라가 살짝 움직일 때 샘플 지점이 어떤 texel의 안이나 밖에 아슬아슬하게 떨어질 때 생기는 것입니다. 일부러 짧게 유지하는데, 더 길게 잡으면 알고리즘 버그를 고치는 게 아니라 가려버리기 때문입니다.

피라미드는 이 spike가 제 값을 하는 부분이고, 교훈은 검사를 실제로 화면에 있는 것에 맞추는 일에 관한 것입니다. 렌더링되는 지형은 정점이 고정된 2미터 격자 위에 있는 삼각형 mesh이고, 정점 사이에서 래스터라이저가 선형 보간을 하므로, 어느 사각형 안에서든 실제 렌더링되는 높이는 그 안에 있는 정점들의 최댓값이지 그것들 사이의 연속 노이즈 봉우리가 결코 아닙니다. 만약 오클루전 피라미드가 더 촘촘한 격자에서 노이즈를 샘플링하면, mesh가 절대 표시하지 않는 유령 봉우리를 찾아내 카메라가 빤히 꿰뚫어 볼 수 있는 광선을 막기 시작합니다. 그래서 피라미드의 베이스 texel은 정확히 정점 격자 위에 앉아 각각 자기 네 모서리 정점의 최댓값을 저장하고, 상위 레벨은 교과서적인 2x2 최댓값 리덕션이라, 렌더링되는 mesh에 대해 정확해집니다. 단계별 광선 검사에서는 코드가 일부러 피라미드를 질의하는 대신 양선형 보간으로 mesh를 점 샘플링합니다. 서브 texel AABB 질의는 texel 전체의 최댓값을 반환해 가파른 능선에서 높이를 몇 미터씩 부풀리는데, 그것이 바로 보이는 prop을 가려버릴 과도한 오클루전이기 때문입니다.

놀라운 결과는 무차별 대입 기준점인 T2가 틀린 쪽이라는 것입니다. T2는 연속 노이즈를 직접 점 샘플링하기 때문에 렌더링되지 않는 mesh 정점 사이의 봉우리를 잡아내고, 그래서 약간 과도하게 가려서 플레이어가 실제로 볼 수 있는 식생을 숨깁니다. 피라미드 경로는 더 빠르고(chunk 수준 검사에 대해 O(logN)의 AABB 리덕션 덕분), 동시에 기하학적으로 더 올바릅니다(mesh가 진짜로 표시하는 높이만 반환할 수 있기 때문). 그것이 이 spike의 요점 전부입니다. 가속 구조는 속도를 위해 받아들이는 품질 타협이 아니라, 현실과 일치하는 버전입니다. Chunk 컬링은 chunk마다 5점 검사를 돌리고(꼭대기 네 모서리에 chunk의 최대 높이에서의 중심을 더함) 각 chunk 자신의 풋프린트를 오클루더 집합에서 제외해 chunk가 자기 자신을 가리는 일이 없게 합니다. 권장하는 프로덕션 경로는 T4입니다. instance 메타데이터와 높이 필드를 storage buffer로 밀어 넣고 동일한 루프를 컴퓨트 셰이더에서 indirect draw로 돌리면 됩니다. 어차피 렌더러가 WebGPU로 옮겨가는 중이니까요.

이 장에서 언급한 기술

교차 quad GPU 풀. 덩어리마다 끝이 좁아지는 quad 세 개를 서로 60도씩 회전시키면 어느 각도에서든 거의 수직인 quad 하나가 보장되어, 50만 개의 instance가 서브 픽셀 색종이 조각이 아니라 들판으로 읽힙니다. 컴퓨트 커널이 모든 덩어리를 배치하고(CPU mesh와 같은 부호 보존 mod로 샘플링한 FBM 지면 높이, 중심 차분 법선, 덩어리별 크기와 색조), TSL 정점 그래프가 instance 행렬을 우회해 크기를 조절하고, Rodrigues 회전으로 법선 위에 올리고, 평행 이동하고, 높이 게이팅된 바람을 적용합니다. 거리 LOD는 공짜입니다. 덩어리는 1smoothstep(fadeNear,fadeFar,dist)에 따라 높이 0으로 무너집니다. 색상은 NedMakesGames의 BotW 레시피를 따라, 색조를 섞은 두 팔레트 위에 뿌리에서 끝으로 가는 그라데이션을 깝니다. GPU 주도 LOD를 보세요.

지형 지평선 오클루전 컬링. 프로덕션 월드에서 벤치마킹한 네 가지 경로: 거리, 거기에 frustum, 거기에 능선 뒤의 instance를 기각하는 높이맵 광선 검사, 거기에 그것을 가속하는 최대 높이 피라미드, 거기에 TSL 컴퓨트 포팅. 피라미드는 mesh가 테셀레이션하는 바로 그 2미터 정점 격자 위에서 샘플링하므로(베이스 texel = 네 모서리 정점의 최댓값, 위로 2x2 최대 리덕션), 래스터라이저가 실제로 표시하는 높이만 반환합니다.

가속되고 올바름, 타협이 아님. 연속 노이즈를 점 샘플링하면(무차별 대입 T2 경로) 절대 렌더링되지 않는 mesh 정점 사이의 봉우리를 찾아내 보이는 식생을 과도하게 가립니다. 정점 격자 피라미드는 더 빠르고(O(logN) AABB 리덕션) 기하학적으로 정확해서, 단계별 광선 검사는 피라미드의 texel별 최댓값을 질의하는 대신 mesh를 양선형 샘플링합니다. 후자는 가파른 능선에서 높이를 몇 미터씩 부풀릴 테니까요. Chunk 컬링은 5점 검사를 쓰고 각 chunk 자신의 풋프린트를 제외해 절대 자기 자신을 가리지 않습니다. 프로덕션 경로는 메타데이터와 높이 필드를 storage buffer로 밀어 넣어 indirect draw로 컴퓨트 셰이더 컬을 돌립니다.


29부 중 28부. 이전: 27부 - 노이즈로 만든 섬, 지면처럼 보이는 지면 다음: 29부 - 컨트롤러 하나로 어떤 몸이든 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide