브라우저에서 오픈월드 만들기, 19부: 숲에서 살아남아야 하는 임포스터
글쓴이 Oleg Sidorkin, Cinevva CTO 겸 공동창업자
처음 오셨나요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 부분을 링크해 둡니다.
18부는 창작자에게 산비탈을 나무로 채우는 브러시를 줬습니다. 문제는 화면에 나무가 수만 그루 있을 때 그 나무들이 얼마나 비싸지는가입니다. 멀리 있는 나무는 픽셀 네 개를 채우려고 삼각형 2,000개를 쓸 필요가 없습니다. 이번 편은 가장 깊은 LOD입니다. 임포스터, 즉 나무 사진을 입은 평평한 quad, 그리고 "제대로 보이는 나무 한 그루"에서 GPU 위 백만 그루까지 가는 길입니다.
나무 하나는 빌보드 위 두 장의 텍스처
임포스터는 프롭을 시점 각도 격자에서 두 장의 텍스처 아틀라스로 미리 렌더링합니다. 하나는 색상용, 하나는 월드 공간 노멀용입니다. 그런 다음 런타임에는 현재 시점과 맞는 타일을 샘플링하는, 카메라를 향한 단일 quad 하나를 보여줍니다. 베이크는 타일마다 두 패스입니다. 디퓨즈는 무광 머티리얼로 굽기 때문에 조명이 텍스처에 구워지지 않고, 노멀은 normalWorld × 0.5 + 0.5로 인코딩하며 알파는 소스에서 그대로 가져와 실루엣이 픽셀 단위로 맞습니다. 런타임 머티리얼은 완전한 MeshStandardNodeMaterial이라서, 임포스터도 다른 표면과 똑같이 씬의 태양광과 IBL을 받습니다. 이득은 지오메트리에 있습니다. 삼각형 수천 개 대신 quad 하나, 그리고 디테일은 1 MB짜리 텍스처 안에 들어 있습니다.
이걸 별도 spike로 떼어낸 것 자체가 하나의 교훈이었습니다. 임포스터는 처음에 spike 37의 스캐터 시스템 안에서 가장 깊은 LOD였고, 베이크를 한 번 고칠 때마다 전체 스캐터 파이프라인을 거쳐 테스트해야 했습니다. 베이크의 정확성이 인스턴스 행렬 마이그레이션과 LOD 교체에 뒤엉켜 있었던 거죠. 이걸 떼어내 프롭 하나, quad 하나로 원본과 나란히 놓으니 반복 시간이 몇 분에서 몇 초로 줄었습니다.
교과서 정답이 오답일 때
첫 번째 구현은 옥타헤드럴 인코딩을 썼습니다. 구면 방향을 정사각형에 채우는 교과서적 매핑이죠. 수치 왕복 테스트는 통과했는데, 사용자는 임포스터가 정면이 아니라 약간 위에서 본 나무처럼 보이는 타일로 스냅됐다는 스크린샷을 계속 보냈습니다. 여섯 라운드의 수정이 이어졌습니다(quad별 시점 방향 uniform, 정사각형 베이크 종횡비, 디버그 머티리얼, 정적 빌보딩). 모두 진짜로 필요한 것이었지만, 그중 어느 것도 실제 버그는 아니었습니다. 수정은 "처음부터 다시 생각하자, KISS, 반창고는 그만"에서야 나왔습니다.
재작성은 옥타헤드럴 폴딩을 버리고 단순한 방위각 곱하기 고도각으로 갔습니다. az = atan2(dir.x, dir.z), el = asin(dir.y), uv = (az/2π, el/π + ½). 이게 인코딩의 전부입니다. L1 정규화도 없고, 부호가 0인 코너 케이스도 없습니다. 여기서 이게 더 나은 이유는 더 정확해서가 아니라(오히려 덜 균일한 구면을 샘플링합니다), CPU 셀 선택기와 GPU 셰이더가 같은 기본 연산을 써서 옥타헤드럴 쌍이 조용히 그랬던 것처럼 경계 방향에서 서로 어긋날 수가 없기 때문입니다. 아틀라스는 컨택트 시트처럼 읽힙니다. 열은 프롭 주위를 도는 각도, 행은 고도각이라서 오버레이에서 한눈에 분명합니다.
그 뒤에도 "약간 위에서 본 것 같다"는 불만은 살아남았고, 원인은 인코딩이 아니라 양자화 선택이었습니다. 4×4 격자에서는 행 중심이 ±22.5°와 ±67.5°에 떨어져서 정확히 0° 고도각인 행이 없습니다. 수평으로 보는 시청자는, 압도적으로 흔한 경우인데, 항상 기울어진 채로 구워진 행에 떨어집니다. 해법은 홀수 N입니다. 5×5 격자는 행 중심을 0°와 ±36°, ±72°에 놓아서 수평 시청자가 정확히 수평으로 구워진 타일을 받습니다. 같은 부류의 "반 셀 차이" 실수는 다음 편의 패럴랙스 작업에서 또 나타나고, 처방은 같은 질문입니다. 내 이산 샘플 점이 정규 입력에 대해 정말 내가 생각하는 자리에 떨어지는가?
두 가지가 더 중요했습니다. 빌보드는 카메라를 계속 향하는 게 아니라 구간별로 정적이어야 합니다. 임포스터는 특정 베이크 방향에서 찍은 평평한 사진이라서 런타임 quad의 이미지 평면이 그 베이크 카메라의 평면과 맞아야 합니다. 즉 한 셀이 선택된 채로 유지되는 호 구간 동안에는 방향을 유지하다가 경계에서 스냅합니다. 그리고 예산은 플레이어가 실제로 보는 쪽을 따라가야 합니다. 이후 한 패스에서 기울어진 탑다운 행들을 통째로 버리고 15° 간격의 수평 링 슬롯 24개에 정중앙 아래 타일 하나를 더한 구성으로 바꿨습니다. 나무는 거의 언제나 눈높이에서 보이기 때문입니다.
스냅, 그리고 블렌딩이 그걸 지운 방법
구간별로 정적인 빌보드는 멀리서는 보이지 않고 가까이서는 튀는데, 이건 궤도를 돌기 전까지는 괜찮습니다. Spike 42는 변형 네 개를 나란히 놓아(원본 프롭, az/el 5×5 기준선, 헤미옥타헤드럴 격자 둘) 깜빡임을 분리하고 없앴습니다. 두 가지 아티팩트가 스냅을 일으킵니다. 셀 팝은 프래그먼트 셰이더가 시점 방향을 25개 셀 중 하나로 양자화하기 때문에 일어납니다. 경계를 넘으면 같은 프레임에서 샘플링하는 타일이 바뀌고 quad가 다시 조준됩니다. 극점 퇴화는 탑다운 시점인데, 거기서는 모든 방위각이 한 점으로 무너지고 링과 상단 타일 사이의 다리가 아틀라스에서 가장 나쁜 전환입니다.
헤미옥타헤드럴 매핑은 둘 다 고칩니다. 상반구를 단위 정사각형 위로 연속적으로 매핑하기 때문에 인접한 3D 방향이 인접한 UV에 떨어지고, 극점 특이점도 없고 특수한 탑다운 타일도 필요 없습니다. 깜빡임의 처방은 양선형 셀 블렌딩입니다. 가장 가까운 타일로 스냅하는 대신, 인코딩된 방향을 감싸는 2×2 타일 묶음을 찾아 넷 모두를 블렌딩합니다. 텍스처 탭은 총 8회입니다(디퓨즈 4, 노멀 4). 인접한 시점들이 이제 팝 대신 크로스페이드합니다. 두 단위 벡터의 노멀 블렌드 자체는 단위 길이가 아니라서 다시 정규화하는데, 이웃 타일 사이의 작은 각도에 대해서는 slerp처럼 동작합니다. 비용은 실제로 듭니다(12×12 아틀라스는 약 9 MB로 az/el의 1.6 MB와 비교되고, 베이크는 288개 렌더 타깃 패스에서 대략 5배 더 오래 걸립니다). 하지만 베이크는 로드 시 한 번뿐이고, 블렌딩은 팝 없는 결과를 사 줍니다. 이게 바로 카메라가 실제로 움직이는 동안 임포스터를 쓸 만하게 만드는 것입니다.
나무 백만 그루, 프레임당 카메라 위치 복사 한 번
Spike 38의 런타임은 프레임마다 quad마다 CPU lookAt을 한 번 합니다. 나무 한 그루엔 괜찮지만 숲엔 치명적입니다. 나무 백만 그루면 프레임당 행렬 갱신과 인스턴스 버퍼 업로드가 모든 걸 압도합니다. Spike 41은 프레임당 파이프라인 전체를 GPU로 옮깁니다. 각 인스턴스의 중심, 요(yaw), 스케일은 빌드 시점에 인스턴스 attribute로 한 번 업로드되고 다시는 바뀌지 않습니다. 버텍스 셰이더는 월드 공간 시점 방향 camPos − center에서 빌보드 기저를 만들고 공유된 단위 quad를 월드 공간으로 펼칩니다. 프래그먼트 셰이더는 픽셀마다 헤미옥타헤드럴 인코딩과 양선형 블렌드를 합니다. 숲 전체에서 프레임당 유일한 CPU 작업은 카메라 위치 uniform을 갱신하는 Vector3.copy 한 번이고, 이건 나무 수에 전혀 비례하지 않습니다.
수학에서 멋진 한 부분은 인스턴스별 요가 노멀 디코드에서 상쇄된다는 것입니다. 베이크는 노멀을 베이크 카메라의 프레임에 저장하는데, 월드 업 축 둘레의 요 회전이 +Y를 보존하고 외적이 회전 등변이기 때문에, 월드 업 기준으로 만든 런타임 기저는 이미 회전된 베이크 기저와 같습니다. 그래서 셰이더는 인스턴스별 요를 전혀 건드리지 않고 런타임 기저 varying을 통해 노멀을 그대로 디코드합니다. 배치는 순수 랜덤 스캐터가 아니라 지터링된 격자를 씁니다. 영역을 셀로 나누고 셀마다 중심에 경계가 있는 오프셋을 더해 나무 하나를 떨어뜨리는데, 이렇게 하면 최소 간격이 보장되어(두 나무가 겹치지 않음) 동시에 여전히 자연스러운 숲으로 읽힙니다. 놓치기 쉬운 한 가지 디테일은 바운딩 스피어입니다. 지오메트리 템플릿은 그냥 단위 quad라서, 카메라가 원점에서 다른 방향을 보는 순간 three.js가 숲 전체를 프러스텀 컬링해 버립니다. 전체 영역에 quad 한 개분의 여유를 더해 덮는 명시적 바운딩 스피어를 설정하면 모서리의 나무들이 비스듬한 각도에서 잘려 나가지 않습니다.
이 장에서 다룬 기술
옥타헤드럴 및 방위각-고도각 임포스터 아틀라스. 임포스터는 프롭을 시점 방향 격자에서 디퓨즈 아틀라스 하나와 월드 공간 노멀 아틀라스 하나로 구운 뒤, 맞는 타일을 샘플링하는 단일 빌보드를 렌더링해 삼각형 수천 개를 두 장의 텍스처로 대체합니다. 교과서적인 옥타헤드럴 매핑은 균일한 구면 커버리지를 주지만 폴딩 경계에서 CPU/GPU가 어긋나기 쉽습니다. 단순한 방위각 곱하기 고도각 격자는 덜 균일한 구면을 샘플링하지만 구조적으로 셀 선택기와 셰이더가 일치하도록 보장합니다. 홀수 N을 써서 한 행이 정확히 0° 고도각에 떨어지게 하고, 프롭은 대부분 눈높이에서 보이므로 타일 예산을 수평 링에 쓰세요.
구간별 정적 빌보드 방향. 임포스터는 특정 베이크 방향에서 찍은 사진이라서 런타임 quad의 이미지 평면이 런타임 카메라를 계속 향하는 게 아니라 베이크 카메라의 평면과 맞아야 합니다. quad는 한 셀이 선택된 채 유지되는 호 구간 동안 방향을 유지하다가 경계에서 스냅하는데, 이건 임포스터 거리에서는 보이지 않고 임포스터를 쓰지 않는 가까운 거리에서만 튑니다.
양선형 셀 블렌드를 적용한 헤미옥타헤드럴 아틀라스. 상반구를 단위 정사각형 위로 연속적으로 매핑하면 극점 특이점과 특수한 탑다운 타일이 사라집니다. 스냅은 인코딩된 시점 방향을 감싸는 2×2 타일 묶음을 샘플링해 넷 모두를 양선형 블렌딩(8 탭)함으로써 없애므로 인접한 시점들이 크로스페이드합니다. 블렌딩된 노멀은 다시 정규화되어 타일 간의 작은 각도에 대해 slerp를 근사합니다. 비용은 더 큰 아틀라스(12×12에서 약 9 MB)와 더 긴 일회성 베이크이고, 그 대가로 카메라 움직임 아래에서 팝 없는 셰이딩을 얻습니다.
GPU 구동 인스턴스화 임포스터. 인스턴스별 중심, 요, 스케일은 인스턴스 attribute로 한 번 업로드됩니다. 버텍스 셰이더는 빌보드 기저를 만들고 공유된 단위 quad를 펼치며, 프래그먼트 셰이더는 픽셀마다 인코딩과 블렌딩을 합니다. 숲 전체에 대한 프레임당 CPU 비용은 카메라 위치 uniform 복사 한 번이고 인스턴스 수와 무관합니다. 인스턴스별 요는 노멀 디코드에서 상쇄되는데, 월드 업 둘레의 요가 외적 기저 구성에 의해 보존되기 때문입니다. 명시적인 숲 전역 바운딩 스피어는 카메라가 단위 quad 템플릿의 원점에서 다른 방향을 볼 때 three.js가 인스턴스화 메시 전체를 프러스텀 컬링하는 것을 막습니다. GPU 구동 LOD를 보세요.
29부 중 19부. 이전: 18부 - AI가 배치한 듯한 스캐터 브러시 다음: 20부 - 평평한 면에서 깊이 속이기 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide