브라우저에서 오픈 월드 만들기, 12부: 링, 하늘 안개, 그리고 다시 해도 똑같이 할 것들
글쓴이 Oleg Sidorkin, Cinevva CTO 겸 공동창업자
처음이신가요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 파트를 링크해 둡니다.
Spike 24는 원래 "지형에 clipmap 링을 추가"하는 작업이어야 했습니다. 그런데 렌더링, 셰이더, 모듈 인프라, 비주얼 통합을 한꺼번에 건드린 본격적인 피날레가 되어 버렸습니다.
핵심 지형 작업은 버텍스 셰이더에서 카메라를 중심으로 한 동심원 clipmap 링을 생성하는 것이었습니다. 각 링은 카메라를 중심으로 한 평면 그리드 메시이고, 버텍스는 heightmap 샘플로 변위됩니다. 가장 안쪽 링은 최대 해상도를 씁니다. 바깥쪽으로 갈수록 링마다 버텍스 간격이 두 배가 되고 더 넓은 영역을 덮습니다. 까다로운 부분은 링 사이의 경계입니다. 고해상도 링이 저해상도 링과 만나는 곳에서, 더 촘촘한 메시의 가장자리 버텍스는 더 성긴 메시 가장자리의 중점으로 스냅되어야 합니다. 우리는 경계 버텍스(링 가장자리를 따라 그리드 좌표가 홀수인 버텍스)를 감지해 그 높이를 양쪽 짝수 이웃의 중점으로 스냅하는 방식으로 2:1 가장자리 모핑을 했습니다.
그다음은 안개와 하늘 통합이었습니다. 우리는 멀리 있는 지형이 평평한 상수 색이 아니라 실제 하늘 색으로 페이드되길 원했습니다. 그러려면 안개 셰이더가 각 프래그먼트의 방향에서 하늘이 어떤 색일지 알아야 했습니다. 우리는 equirectangular HDR 스카이박스 텍스처를 로드하고, 카메라에서 프래그먼트로 향하는 뷰 방향으로 프래그먼트 셰이더에서 샘플링했습니다. 이때 TSL의 equirectUV 노드로 equirectangular UV 좌표로 변환했습니다. 안개 계수는 카메라 공간 깊이를 위한 positionView.z.negate()를 써서 거리 기반으로 구하고, 가까운 거리와 먼 거리 사이를 smoothstep으로 블렌딩했습니다.
모듈 배선은 어떤 지오메트리보다도 더 성가신 일이었습니다. 우리는 Three.js 0.183.1로 올렸는데, 이게 빌드 출력물 구조를 바꿔 놨습니다. three/tsl import는 three.tsl.js로 해석되어야 했고, TSL은 내부적으로 three/webgpu를 bare specifier로 import했습니다. 두 매핑 모두 HTML import map에 명시되어야 했습니다. 어느 하나라도 빠지면 어떤 매핑이 잘못됐는지 전혀 알려주지 않는 "does not provide an export" 또는 "failed to resolve module specifier" 같은 알쏭달쏭한 오류가 났습니다. 둘 다 import map에 들어가고 나니 셰이더 그래프가 제대로 로드됐습니다.
스카이박스 방향 문제도 있었는데, 텍스처가 위아래가 뒤집혀 렌더링됐습니다. 해결책은 equirectangular 텍스처에 flipY = true를 주는 것이었습니다. 이건 로드된 텍스처에 대한 Three.js 기본값이지만, 우리 초기 코드에서는 false로 설정되어 있었습니다.
원래 안개 구현은 거의 상수에 가까운 방향에서 하늘을 샘플링해서, 자연스러운 그라디언트 대신 지평선 색의 얇은 띠를 만들어 냈습니다. 해결책은 픽셀마다 실제 카메라-프래그먼트 월드 방향을 positionWorld.sub(cameraPosition).normalize()로 계산해, 그걸 equirectUV에 넘겨 안개 색을 조회하는 것이었습니다. 이렇게 하니 지형 프래그먼트가 실제로 그 뒤에 있는 하늘 색으로 페이드돼서, 어느 카메라 각도에서 봐도 맞아 보였습니다.
개별 수정들 아래에서, 핵심 결과는 유지됐습니다. 이제 우리는 근거리 볼류메트릭 편집(marching cubes에 Transvoxel 이음새), 중거리 heightmap 청크, 원거리 clipmap 링을 결합한 지형 시스템을 갖췄고, 이 모두가 모드, LOD, 전환 동작을 결정하는 정책 레이어의 관리를 받습니다.
다음 프로젝트에서 다시 쓸 패턴을 꼽으라면, 이런 것들입니다.
기능 작업에 들어가기 전에 리스크 spike부터 시작하세요. Spike 1은 콘텐츠 파이프라인에 투자하기 전에 "우리가 충분히 빠르게 렌더링이라도 할 수 있나"라는 질문을 먼저 정리해 줬습니다.
통합 점프 전에 검증된 안정 베이스라인을 동결하세요. Spike 13과 14는 회귀를 이분 탐색하느라 날릴 며칠을 아껴 줬습니다.
최적화 마라톤 전에 정책과 관측 가능성을 강제하세요. Spike 23은 정체불명의 버그를 트리거 규칙이 붙은 이름 있는 조건으로 바꿔 줬습니다.
스크린샷이 아니라 움직임 속에서 테스트하세요. clipmap 팝, 이음새 깜빡임, 스트리밍 끊김은 전부 정지 프레임에 숨어 있습니다.
평균 FPS가 아니라 기능별 프레임 시간 비용을 측정하세요. 평균은 사용자가 실제로 느끼는 스파이크를 가립니다.
그리고 지저분한 부분을 공개하세요. 잘못 든 길, 오래된 버퍼 유령 사냥, 실제로는 draw range가 잘못됐는데 전환 로직을 탓하며 보낸 이틀. 사람들이 정말로 배울 수 있는 건 바로 그런 부분입니다.
외부 현실 점검: Vuntra City 개발 일지
이 시리즈를 끝낸 뒤, 우리는 우리 오픈 월드 가정에 대한 외부 구현 점검으로 @VuntraCity 개발 일지를 살펴봤습니다. 그건 브라우저 스택이 아니라 네이티브 UE5 프로젝트지만, 시스템 패턴이 꽤 잘 대응돼서 비교가 쓸모 있었습니다.
첫 번째 신호는 이동 속도가 게임플레이만이 아니라 스트리밍 제어로 다뤄져야 한다는 것입니다. Vuntra City에서는 고속 이동이 의도적으로 대부분의 실내 위로 우회되고, 스폰 churn과 정체를 피하려고 디테일 범위가 이동 속도에 맞춰 조정됩니다(교통 시스템, 성능 기법). 이건 우리 정책 레이어 방향과 일치합니다. 이동 모드는 청크 반경, 실내 활성화, 프레임당 허용 작업량에 직접 영향을 줘야 합니다.
두 번째 신호는 아키텍처입니다. 그들의 맵과 주소 시스템은, 로드되지 않은 영역에 대해서도 전역 쿼리가 돌 수 있도록 월드 토폴로지를 렌더링되는 오브젝트와 분리해야 했습니다(맵과 주소). 이건 브라우저에서 월드 검색, 퀘스트 라우팅, 모더레이션 스캔, POI 인덱싱을 렌더링에 묶인 데이터 경로를 강제하지 않고 하기 위해 우리에게도 필요한 바로 그 분리입니다.
세 번째 신호는 시뮬레이션 티어링입니다. 그들의 백만 NPC 설계는 거친 스케줄 상태는 저렴하고 전역적으로 유지하면서, 비싼 행동 예산은 플레이어 근처에서만 씁니다(백만 NPC 개요, 시스템 심층 분석). 이건 우리 AOI 우선 시뮬레이션 모델을 뒷받침합니다. 근거리 충실도와 원거리 결정론은 별도의 예산을 가진 별개의 관심사입니다.
그리고 네 번째 신호는 순수한 규모가 아니라 디자인 품질입니다. 그들의 가장 강렬한 탐험 순간은 끊임없는 UI 오버레이가 아니라 가중 분포, 희귀한 이상치, 디제틱한 길찾기 단서에서 나옵니다(절차적 환경 노트, 미니맵 없는 루프). 우리에게 이건, 기술 시스템이 단순히 최대 처리량이 아니라 발견 가능한 다양성을 만들어 내도록 튜닝되어야 한다는 점을 일깨워 줍니다.
이 챕터에서 언급한 기술
Clipmap 링 지오메트리. 각 링은 카메라를 중심으로 한 평면 그리드 메시이고, 버텍스는 heightmap 샘플로 변위됩니다. 가장 안쪽 링은 최대 해상도를 씁니다. 바깥쪽으로 갈수록 링마다 버텍스 간격이 두 배가 되고 더 넓은 영역을 덮습니다. 까다로운 부분은 경계입니다. 고해상도 링이 저해상도 링과 만나는 곳에서, 더 촘촘한 메시의 가장자리 버텍스는 더 성긴 메시 가장자리의 중점으로 스냅됩니다. 이 기법은 Losasso와 Hoppe의 SIGGRAPH 2004 논문(PDF)에서 비롯됐고 GPU Gems 2, 2장에서 자세히 다룹니다. 우리의 지오메트리 clipmap에 관한 지형 가이드를 보세요.
2:1 가장자리 모핑. 두 clipmap 링 사이의 경계에서, 더 촘촘한 링은 더 성긴 링이 공유하지 않는 위치에 버텍스를 가집니다. 링 가장자리를 따라 그리드 좌표가 홀수인 경계 버텍스를 감지하고, 그 높이를 이웃한 두 짝수 버텍스 사이에서 보간합니다. 이렇게 하면 전용 전환 지오메트리 없이도 빈틈없는 이음새가 만들어집니다. 보간은 버텍스 셰이더에서 실행됩니다. 경계 버텍스에 대해 morphedHeight = mix(heightLeft, heightRight, 0.5)이며, 우리 가이드에서 설명한 것과 같은 지오모핑 프레임워크를 씁니다.
Equirectangular 스카이박스 매핑. 경도-위도 투영으로 하늘 방향의 완전한 구면을 매핑하는 하나의 2D 이미지입니다. 가로축은 0~360도를 덮고, 세로축은 0~180도를 덮습니다. 정규화된 뷰 방향
Three.js에서는 texture.mapping = EquirectangularReflectionMapping을 SRGBColorSpace와 함께 설정하면 이걸 씬 배경으로 활성화합니다. TSL에서는 equirectUV(direction)이 같은 변환을 적용해, 3D 뷰 방향을 텍스처 샘플링용 2D UV 좌표로 바꿉니다.
하늘에서 가져오는 프래그먼트별 안개 색. 표준 안개는 프래그먼트를 하나의 상수 색으로 블렌딩합니다. 디테일한 스카이박스가 있는 씬에서는 하늘 색이 방향마다 다르기 때문에 이게 잘못돼 보입니다. 해결책은 픽셀마다 카메라-프래그먼트 월드 방향(positionWorld.sub(cameraPosition).normalize())을 계산하고, 그 방향에서 스카이박스를 샘플링해 안개 색으로 쓰는 것입니다. 각 프래그먼트는 실제로 그 뒤에 있는 하늘 색으로 페이드돼서, 어느 카메라 각도에서도 올바르게 블렌딩됩니다. 안개 계수는 카메라 공간 깊이를 위한 positionView.z.negate()와 함께 smoothstep(nearDist, farDist, viewDepth)을 씁니다.
ES 모듈용 import map. bare 모듈 specifier(three/tsl 같은)를 실제 URL로 매핑하는 브라우저 네이티브 메커니즘(<script type="importmap">)입니다. Three.js 0.183.1이 빌드 출력물을 재구성하면서, three/tsl은 three.tsl.js로 해석되어야 했고 TSL은 내부적으로 three/webgpu를 bare specifier로 import했습니다. 두 매핑 모두 import map에 명시되어야 했고, 그렇지 않으면 브라우저가 "does not provide an export" 또는 "failed to resolve module specifier" 오류를 냈습니다.
더 읽을거리
이 시리즈 전반에서 사용한 기술을 더 깊이 다룬, 우리 동반 가이드를 보세요.
- 브라우저 오픈 월드를 위한 동적 LOD와 스트리밍 기반 지형 생성은 heightmap, SDF, marching cubes, Transvoxel, 지오메트리 clipmap, 지오모핑, 스트리밍 아키텍처, 지형 머티리얼, 식생 렌더링을 다룹니다.
- 멀티플레이어 크리에이터 월드를 위한 브라우저 3D 오픈 월드 기술은 렌더링 스택, WebGPU, 물리, 네트워킹, 멀티플레이어 아키텍처, 그리고 Skyrim, The Witcher 3, Breath of the Wild, GTA V에서 얻은 교훈을 다룹니다.
12부에 걸친 이 여정을 따라와 주셔서 감사합니다.
1부: 우리는 그걸 부수려는 것부터 시작했다
2부: 워커 물리와 입력 지연 공포
3부: 우리를 구한 화려하지 않은 spike들
4부: 화려한 지형보다 스트리밍 먼저
5부: 예쁜 것들에 예산 책정하기
6부: Clipmap이 줄거리를 바꿨다
7부: Marching cubes와 첫 진짜 동굴
8부: 베이스라인을 잃지 않고 통합하기
9부: Transvoxel은 뼈대부터 시작했다
10부: 이음새 혼돈과 코너 보스전
11부: 하드코딩 모드가 아니라 정책 모드
14부 중 12부.
이전: 11부 - 하드코딩 모드가 아니라 정책 모드
다음: 13부 - 지형 조각과 수학 함수의 죽음
시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide