Skip to content

언덕 뒤의 잔디는 렌더링하지 마세요: 지형 인식 오클루전 컬링

글: Oleg Sidorkin, Cinevva CTO 겸 공동 창업자

파트 28에서는 스파이크 57을 소개하고 네 가지 컬링 경로의 벤치마크를 몇 단락으로 정리했습니다. 이번 글은 그 긴 버전입니다. 왜 그 네 가지를 골랐는지, 나머지 네 가지는 무엇이었는지, 그리고 실제로 출시할 방식을 선택한 근거를 설명합니다.

카메라와 초원 사이에 언덕이 하나 있습니다. 초원을 렌더링할 필요는 없습니다. 모든 최신 AAA 엔진은 이 사실을 압니다. 하지만 대부분의 브라우저 기반 3D 엔진은 그렇지 않으며, 지난주까지는 우리 엔진도 마찬가지였습니다.

이 글은 제대로 조사하고, 실제 코드베이스를 기준으로 검토하고, 작동하는 스파이크를 만든 뒤, 현재는 WebGL로 제공하고 내일은 WebGPU로 전환할 우리에게 각 기법이 구체적으로 얼마나 도움이 되는지 평가한 결과입니다.

계속 읽기 전에 아래의 라이브 스파이크를 직접 조작해 볼 수 있습니다. T/Y/U/I로 컬링 경로를 전환하고, C로 카메라 프리셋을 순환하며, B를 누르면 숨겨지는 청크가 빨간색 와이어프레임으로 표시되어 테스트가 무엇을 제거하는지 확인할 수 있습니다. HUD에는 각 단계가 제외한 인스턴스 수가 표시됩니다.

새 탭에서 스파이크 57 열기 ↗ · 소스 보기

현재 우리 엔진이 하는 것과 하지 않는 것

스트리밍 청크 관리자는 플레이어 주변에 64m 청크를 세 가지 LOD 단계의 고리 형태로 로드합니다. 각 청크에는 하이트맵 위에 흩뿌려진 인스턴싱 트리가 들어 있습니다. Chunk.updateObjectVisibility에 있는 컬링 로직은 단순한 거리 검사입니다. 60m 안의 나무는 전체 메시로 렌더링하고, 60m에서 140m 사이는 빌보드로 렌더링하며, 그보다 멀면 아무것도 렌더링하지 않습니다.

이 컬링 방식은 막대한 낭비를 일으키는 두 가지 범주를 놓칩니다.

  1. 60m 반경 안에 있지만 카메라 뒤에 있는 모든 것. 플레이어가 4m 넘게 이동할 때마다 인스턴스 버퍼를 다시 정렬하지만 뷰 프러스텀은 검사하지 않습니다. 따라서 평균적으로 원의 절반을 GPU에 보내고, 버텍스 변환 후에야 클리핑합니다.
  2. 60m 반경 안에 있지만 언덕 뒤에 있는 모든 것. 우리 지형은 고도 차가 80m에 달하고 계곡도 많습니다. 카메라가 계곡 안에 있으면 컬링 반경 내 식생 대부분이 가장 가까운 능선에 기하학적으로 가려집니다. 그래도 우리는 전부 그립니다.

이 글에서는 두 번째 범주에 집중합니다. AAA 엔진은 여러 영리한 기법을 쌓아 이 문제를 해결하지만, 언뜻 보기에는 그 방식을 브라우저로 깔끔하게 옮기기 어렵습니다.

Guerrilla의 Gilbert Sanders가 Horizon Zero Dawn에서 오픈 월드 식생을 렌더링하는 방식을 설명합니다. 마지막 20분에서는 컬링 스택을 다루며, 왜 "더 빠르게 그리기"보다 "더 적게 그리기"가 나은지를 훌륭하게 보여줍니다.

우리 사례에 맞춰 평가한 8가지 기법

최신 오클루전 컬링 스택을 살펴보고, 절차적으로 생성되는 하이트맵 기반 브라우저 오픈 월드에 각 요소가 얼마나 도움이 되는지 평가했습니다. 평가 축은 두 가지입니다. 가치, 즉 우리 장면에서 얼마나 많은 낭비를 제거하는지와 비용, 즉 엔지니어링 작업량 및 스택을 얼마나 크게 변경해야 하는지입니다. 전체 근거는 이 글의 바탕이 된 리서치 파일에 있으며, 여기서는 짧게 정리하겠습니다.

1. 인스턴스별 프러스텀 컬링. 높은 가치, 낮은 비용. 현재 인스턴싱 식생에는 프러스텀 검사를 전혀 적용하지 않습니다. 이 검사를 추가하면 어떤 뷰에서든 작업량이 약 절반으로 줄고, 코드도 30줄 정도면 되며, 현재 WebGL에서도 바로 출시할 수 있습니다. 적은 비용으로 얻을 수 있는 단일 개선 중 가장 크며, 몇 달 전에 이미 했어야 합니다.

2. 하이트맵 수평선 레이캐스트. 높은 가치, 낮음–중간 비용. 카메라에서 각 후보 인스턴스까지 레이를 전진시키며 경로상의 지형 높이를 샘플링합니다. 지형이 레이보다 높아지는 지점이 하나라도 있으면 해당 인스턴스는 가려진 것입니다. 우리 월드가 하이트맵이기 때문에 정확히 맞아떨어지는 방식으로, 가시성 문제를 수평 방향을 따른 1차원 문제로 축소합니다. 조밀한 버전은 프레임마다 인스턴스별로 O(steps)의 비용이 듭니다. 가속 버전인 다음 항목은 이를 O(log steps)로 낮춥니다.

3. 최대 높이 피라미드 가속. 높은 가치, 중간 비용. 각 텍셀이 자신의 영역 안에 있는 최대 지형 높이를 저장하는 하이트맵의 2D 밉맵입니다. 주변이 평평할 때는 수평선 레이캐스트가 큰 간격으로 건너뛰고, 언덕이 있는 곳에서만 세밀하게 검사할 수 있습니다. 이 자료구조 덕분에 2번 기법을 실서비스에서 저렴하게 사용할 수 있습니다.

4. 계층형 Z 오클루전(Hi-Z / HZB). 높은 가치, 높은 비용. 뎁스 버퍼 밉맵을 만들고, 각 인스턴스의 바운드를 화면 공간에 투영한 뒤, 알맞은 밉 레벨을 대상으로 검사합니다. Unreal의 Nanite, Bevy의 가상 지오메트리, VTK의 WebGPU 포트에서 사용하는 현대적인 GPU 측 표준입니다. 지형뿐 아니라 모든 것에 적용할 수 있지만 WebGPU, 인다이렉트 드로우, 컴퓨트 패스가 필요합니다. 수천 그루의 나무가 아니라 수백만 장의 풀잎을 렌더링할 때 투자 가치가 생깁니다.

5. 2패스 Hi-Z(Nanite 방식). 4번 대비 이점은 미미하고 비용은 높음. 1패스 이후 현재 프레임의 뎁스를 다시 렌더링해 한 프레임 동안 발생하는 디스오클루전 아티팩트를 방지합니다. GPU 주도형 경로가 충분히 발전해 추가 비용이 크지 않을 때만 가치가 있습니다.

6. 소프트웨어 오클루전 래스터라이저(Frostbite/Intel MOC). 중간 가치, 높은 비용. CPU에서 대형 오클루더를 저해상도 뎁스 버퍼에 래스터라이즈합니다. 리드백 지연이 전혀 없습니다. 레퍼런스 구현은 AVX/SSE C++로 작성되어 있어 WASM으로 포팅하는 것 자체가 큰 프로젝트입니다. 반면 하이트맵 레이캐스트는 훨씬 적은 작업으로 거의 같은 이점을 얻습니다.

7. 사전 계산된 PVS. 낮은 가치, 높은 비용. Quake 시대의 정적 맵에는 훌륭합니다. 우리 지형은 절차적으로 생성되고 무한하므로 모든 전처리를 청크 스트리밍 시점에 해야 하며, 이는 런타임에 가시성을 계산하는 것만큼이나 비용이 듭니다. 제외합니다.

8. Cesium 방식 수평선 컬링. 우리에게는 가치 없음, 중간 비용. 행성 타원체를 위해 설계되었습니다. 우리 월드는 대체로 평평하고 경계가 있으므로 이 수학은 적용되지 않으며, 아무 효과가 없거나 잘못 컬링할 수 있습니다. 제외합니다.

결론은 이렇습니다. 지금은 CPU와 WebGL에서 1번과 2번+3번을 구현합니다. WebGPU 마이그레이션 때 4번을 도입할 계획을 세웁니다. 나머지는 제외합니다.

브라우저 지형에는 하이트맵 레이캐스트가 가장 적합한 이유

최신 렌더링 강연에서 흔히 듣는 표준적인 조언은 "Hi-Z 버퍼를 구축하라"입니다. Brian Karis의 SIGGRAPH 2021 Nanite 심층 분석이 대표적인 참고 자료이며, 한 번은 볼 만합니다.

이미 모든 것을 GPU 주도형 인다이렉트 드로우로 처리하는 엔진이라면 이것이 정답입니다. 하지만 우리 엔진을 포함한 대부분의 브라우저 엔진은 그렇지 않습니다. 우리는 CPU 측 인스턴스 버퍼와 WebGL 드로우 콜을 사용하며 컴퓨트 단계도 없습니다. 이 스택에 Hi-Z를 억지로 붙이려면 WebGPU 포팅, 식생 파이프라인의 인다이렉트 드로우 전환, 뎁스 피라미드 빌드 패스 추가를 동시에 해야 합니다. 아이디어를 검증하는 첫 화면을 보기까지 한 분기나 걸릴 작업입니다.

하이트맵 레이캐스트는 CPU와 WebGL에서 이미 보유한 데이터로 작동합니다. AAA 엔진은 활용하기 어려운 우리 월드만의 한 가지 특성을 이용합니다. 우리의 오클루더는 1차원 높이 함수로 표현됩니다. 레이를 따라 이 함수를 샘플링하는 데 필요한 것은 배열 조회 두 번과 곱셈 한 번뿐입니다. Hi-Z 버퍼는 같은 사실을 픽셀마다 새로 알아내야 합니다.

이 기법은 IEEE Visualization 2002에서 "Horizon Occlusion Culling for Hierarchical Terrains"라는 제목으로 처음 발표되었습니다(PDF). 평평한 지형에서는 저렴하고, 실제 언덕이 있는 곳에서만 비용이 증가하며, 병렬화도 아주 쉽다는 알맞은 특성 덕분에 20년 넘게 활용되고 있습니다.

그림으로 보는 최대 높이 피라미드

단순한 레이캐스트는 각 레이를 따라 약 24개 지점에서 terrainHeight를 샘플링하고, 지형이 레이를 가로지르면 일찍 종료합니다. 수천 그루의 나무에는 충분합니다. 하지만 수십만 장의 풀잎에서는 성능이 무너집니다.

해결책은 각 텍셀이 자신의 영역 안에 있는 최대 높이를 저장하는 하이트맵의 밉맵입니다.

레벨 0 (256×256, 텍셀당 2.25m):  무작위 편차를 준 4×4 샘플의 최대 h
레벨 1 (128×128, 텍셀당 4.5m ):  max(0,0), max(1,0), max(0,1), max(1,1)
레벨 2 ( 64×64,  텍셀당 9.0m):  한 단계 위에서 같은 방식으로 축소
...
레벨 8 (   1×1,  텍셀당 576m):  전체 최댓값

레이 구간이 길고 평평할 때는 거친 레벨을 샘플링합니다. 한 번의 조회만으로 "이 9m 정사각형 안의 어떤 지형도 고도 12m를 넘지 않고, 이 지점에서 레이 높이는 30m이므로 계속 진행해도 된다"는 사실을 알 수 있습니다. 거친 텍셀이 "지형이 레이보다 높을 수 있다"고 알려줄 때만 한 레벨 아래로 내려가 정밀하게 검사합니다. 전체 자료구조는 수백 KB에 불과하고 수십 밀리초면 구축됩니다.

도식으로 나타내면 다음과 같습니다.

                                            시점에서 나가는 레이
        시점 1.7m                         o─────────────────────►
              o─────────────────────────·─·─·─·─·─·──────────────
              │                          \                       │
              │   레벨 3(큰 간격)          \   레벨 0(정밀 검사)   │
              │   "8m보다 높은 지형 없음"    \   "여기 9m 언덕!"    │
              │                              \                   │
        ──────┴────────────/▔▔▔\─────────────/▔▔▔▔▔\─────────────
                                 언덕 A(8m)    언덕 B(12m)

                                            여기서 가려짐

언덕 A 부근을 지나는 레이 구간에서는 레벨 3 조회 결과인 "폭 18m인 이 상자의 최대 높이는 8m"만으로도 1.7m에서 시작해 몇 미터 상승한 레이에 장애물이 없다는 사실을 알 수 있습니다. 한 번의 조회로 36m의 전진 검사를 건너뜁니다. 언덕 B에서는 레벨 3이 "여기 최댓값은 12m"라고 알려주므로 한 단계 아래로 내려갑니다. 레벨 0이 "맞다, 바로 이 텍셀의 높이가 12m다"라고 확인하면 인스턴스를 제외합니다.

피라미드 빌드 코드는 height-pyramid.mjs에 있고, 이를 사용하는 컬링 경로는 cull.mjs에 있습니다. 두 파일 모두 스파이크 소스 브라우저에서 읽어볼 수 있습니다.

스파이크가 실제로 보여주는 것

위의 스파이크 57을 열고 네 가지 경로를 차례로 확인해 보세요.

  • T0은 현재 프로덕션에서 사용하는 방식입니다. 거리만 검사합니다. C1(계곡 바닥)에서 HUD에는 약 12,000장의 풀잎이 보인다고 표시됩니다.
  • T1은 인스턴스별 프러스텀 컬링을 추가합니다. 카메라 뒤나 화면 옆에 있는 모든 인스턴스를 GPU에 도달하기 전에 제거하므로 가시 인스턴스 수가 약 절반으로 줄어듭니다.
  • T2는 브루트포스 하이트맵 레이캐스트를 추가합니다. C1에서는 가장 가까운 능선 반대편에 필드 대부분이 있기 때문에 그 수가 다시 60~80% 감소합니다. 인스턴스마다 terrainHeight를 약 24번 샘플링하므로 Cull-ms 열의 수치는 높아집니다.
  • T3는 브루트포스 방식을 최대 높이 피라미드로 교체합니다. 가시성 개선은 유지하면서 Cull-ms가 T1에 가까운 수준으로 다시 낮아집니다. 실제로 출시할 경로는 이것입니다.

이 패턴은 프로덕션 엔진에서 관찰되는 결과와 일치합니다. Acerola의 잔디 렌더링 2부작(게임은 어떻게 그렇게 많은 잔디를 렌더링할까?, 내 게임의 잔디를 최적화하기 위해 한 일)은 셰이딩 단계가 아니라 컬링 단계에 엔지니어링 역량을 집중해야 하는 이유를 설명하는 YouTube 영상 중 가장 이해하기 쉬운 자료입니다.

카메라 C3와 "언덕 정상" 실패 사례

스파이크의 세 번째 카메라 프리셋은 카메라를 언덕 정상에 놓고 플레이 영역 전체를 내려다보게 합니다. 이 경우에는 카메라와 월드 대부분의 사이에 지형이 없기 때문에 수평선 컬링이 거의 아무 효과도 내지 못합니다. HUD를 보면 T2/T3가 T1보다 가시 인스턴스 수를 고작 5~10% 정도 줄입니다.

이것은 버그가 아니라 의도된 특성입니다. 가릴 것이 없을 때, 정확히 그래야 하는 상황에서 이 기법은 작동을 멈춥니다. 프러스텀 컬링은 여전히 실질적인 작업을 하고, 거리 컬링은 계속 예산의 상한을 정하며, 수평선 검사는 자연스럽게 아무 작업도 하지 않는 상태로 돌아갑니다. 이를 구현한다면 이 무작업 사례도 저렴한지 확인해야 합니다. 어떤 레이도 제외되지 않을 때조차 최대 높이 피라미드가 중요한 이유입니다. 가장 거친 레벨만으로도 "아무것도 가려지지 않았다"는 사실을 확인할 수 있는 경우가 많습니다.

다음으로 출시할 것

순서대로 해야 할 일은 세 가지입니다.

첫째, T1과 T3를 프로덕션의 Chunk.updateObjectVisibility로 포팅합니다. 피라미드는 여러 청크에 걸쳐 있으므로 한 단계 위인 청크 관리자에 두는 것이 좋습니다. 컬링은 기존의 청크별 배칭이 계속 작동하도록 Chunk에 유지합니다. 예상 작업량은 테스트를 포함해 하루입니다. 둘째, 잔디가 구현되면 잔디에도 같은 방식을 적용한다. 현재 실험 구현은 576m 크기의 플레이필드에 잔디 잎 12,000개를 흩뿌린다. 프로덕션 환경의 밀도는 이보다 대략 한 자릿수 배수만큼 높아야 한다. CPU 컬링 경로는 12,000개를 1밀리초 이내에 처리하며, 피라미드 가속 덕분에 120,000개에서도 이 성능을 유지할 수 있다.

셋째, THREE.WebGPURenderer로 마이그레이션할 때는 동일한 루프를 컴퓨트 셰이더로 옮긴다. 메타데이터는 스토리지 버퍼가 된다. 컬링 단계는 drawIndirect 인자를 기록한다. 피라미드는 최대값 리덕션이 미리 적용된 2D 텍스처로 업로드한다. 코드의 형태는 거의 동일하게 유지된다. 이것이 핵심이다. 마이그레이션의 성공 여부를 새로운 알고리즘에 걸지 않고, 이미 검증한 알고리즘을 더 빠른 실행 환경으로 옮기는 것이다.

Guerrilla는 Horizon Zero Dawn의 절차적 배치 시스템을 위해 이 방식의 GPU 버전을 다뤘다. 렌더링 파이프라인보다 중요한 것은 데이터 구조이며, 그들의 데이터 구조도 동일한 형태다.

건물과 높이맵으로 표현할 수 없는 방향을 가리는 빽빽한 소품을 출시할 때가 되면 Hi-Z도 여전히 제 역할을 할 것이다. 하지만 지면에 밀착된 인스턴스에는 Hi-Z보다 명백히 비용이 적게 들기 때문에 높이맵 피라미드도 파이프라인에 남는다. 특히 능선의 실루엣에 걸쳐 있는 잔디는 Hi-Z가 가장 처리하기 어려운 사례이기도 하다.

참고 자료

전체 우선순위의 근거와 아키텍처 결정 사항은 위에서 설명했다. 다음은 스택에 등장하는 순서에 따라 대략 정리한 각 기법의 대표 자료다.