브라우저에서 오픈 월드 만들기, 16부: 계속 자라나는 세계를 위한 구조
글: Oleg Sidorkin, Cinevva CTO 겸 공동창업자
처음 오셨나요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 부분을 링크해 둡니다.
13부 이후 모든 spike는 같은 방식을 따랐습니다. 이전 모놀리스를 복사하고, 기능 하나를 더한다. spike 32가 끝날 무렵 그 모놀리스는 단일 <script type="module"> 안에 들어 있는 6,285줄짜리 index.html이었습니다. 코드 검색은 시끄러웠고, 기능을 어디에 추가할지 찾는 게 쓰는 것보다 오래 걸렸으며, 어떤 아키텍처 변경이든 머릿속으로 diff하기엔 너무 큰 파일을 건드려야 했습니다. 다음 기능을 추가하기 전에 우리는 구조라는 빚을 갚았습니다.
동작 변화 없이 모놀리스 쪼개기
제약은 엄격했습니다. 모든 분리는 재설계가 아니라 순수한 리팩터링이어야 했습니다. 모놀리스는 19개의 .mjs 파일과 151줄짜리 호스트 셸이 되었습니다. scene, water, grass, character, physics, multiplayer, UI를 위한 최상위 모듈들, 모든 WGSL 소스 문자열을 GPU 진실의 단일 출처로 쥐고 있는 wgsl.mjs, 그리고 heightmap, SDF, chunk, GPU buffer 래퍼, 브러시, LOD, 영속화를 담은 terrain/ 하위 트리로 나뉘었습니다. 전체 코드는 6,555줄이 나왔는데, 기본적으로 모놀리스에 import 보일러플레이트를 더한 것입니다. 분량의 순변화는 없었지만, 탐색 편의성의 변화는 컸습니다.
그런데 페이지가 검은 화면으로 로드됐습니다. 에러 메시지 두 개, 서로 무관한 근본 원인 두 개. 첫 번째는 0바이트 buffer 바인딩에 대한 WebGPU의 불평이었습니다. 모놀리스에서는 SDF 브러시 buffer가 첫 marching-cubes chunk가 나타날 때 지연 할당됐고, bind-group 팩토리는 마침 그 뒤, buffer가 존재한 다음에 돌았습니다. terrain/gpu.mjs를 terrain/brush.mjs에서 분리하면서 모듈 평가 순서가 바뀌었고, 이제 팩토리가 먼저 돌아 null 자리표시자를 바인딩하려 했습니다. 해법은 bind-group 생성을 첫 dispatch까지 미루는 getOrCreateBindGroup(chunk) 헬퍼였습니다. "모든 것을 미리 만들어 두는" 패턴은 모놀리스의 단일 초기화 경로가 남긴 흔적이었습니다.
두 번째 메시지는 무섭게 생긴 FBX 스켈레톤 경고였는데, 알고 보니 헛다리였습니다. spike 25부터 계속 찍혀 왔고 무해했습니다. 캐릭터가 사라진 건 단지 첫 번째 버그가 연쇄됐기 때문입니다. 모든 compute dispatch가 예외를 던졌고, heightmap이 끝내 쓰이지 않았으며, 높이 샘플이 0을 반환했고, 캐릭터가 원점에 스폰돼 세계를 뚫고 떨어졌습니다. buffer를 고치면 캐릭터는 경고를 다 달고도 멀쩡히 애니메이션됩니다.
이게 리팩터링의 진짜 교훈입니다. 모놀리스는 "X는 Y가 만들어지기 전에 존재해야 한다"는 모든 관계를 위에서 아래로 흐르는 스크립트 순서 안에 숨겼습니다. 모듈화가 그 순서를 뒤섞으면서 잠복해 있던 순서 버그 세 개가 더 드러났습니다. 풀이 heightmap 업로드 전에 흩뿌려지는 문제, 물 평면이 환경 맵 디코딩이 끝나기 전에 추가되는 문제, 영속화 로드가 첫 프레임 이후에야 완료되는 문제였습니다. 셋 다 한 줄 수정이었고, 분리하지 않았다면 하나도 잡히지 않았을 것입니다.
무엇도 키우지 않고 소품 백 개 추가하기
Spike 34는 그 구조가 값을 했는지 확인하는 시험이었습니다. 목표는 CC0 모델 팩에서 나무, 바위, 덤불, 버섯, 길을 배치하는 1인칭 팔레트였고, 지형 인식 정렬, 영속화, 멀티플레이어 동기화, 물리 콜라이더를 모두 조작을 벗어나지 않고 처리하는 것이었습니다. 새 코드는 한 줄도 빠짐없이 src/props/ 아래 다섯 개의 새 파일에 들어갔고, 기존 모듈은 어느 것도 연결용 코드 열 줄을 넘게 늘지 않았습니다.
에셋 작업은 기록해 둘 만한 우회로를 거쳤습니다. 이런 일은 하루를 통째로 잡아먹기 때문입니다. 우리는 Quaternius의 Ultimate Nature 팩으로 시작했는데, 텍스처가 내장되지 않은 FBX 라이브러리였습니다. FBX 머티리얼은 map 없는 MeshPhong으로 들어와서, 머티리얼 이름을 PNG에 매핑하는 표를 손으로 연결하고, Phong을 Standard로 변환하고, 색 공간을 일일이 설정했습니다. 머티리얼의 약 30%는 맞는 PNG가 없었고, 비슷한 나무들 사이에서 이름이 모호한 것도 몇 개 있었습니다. 두 번째 FBX 팩도 같은 구멍이 있었습니다. 해법은 매핑 표를 더 늘리는 게 아니라 더 잘 만들어진 팩이었습니다. Quaternius의 Stylized Nature MegaKit은 내장 PBR 머티리얼과 베이크된 노멀을 갖춘 완전한 glTF 116개를 제공합니다. FBXLoader를 GLTFLoader로 바꾸자 cm-to-meters 스케일링, 텍스처 표, Phong 변환이 사라졌고 library.mjs가 약 80줄 줄었습니다. 결론은 이렇습니다. PBR이 담긴 glTF는 그것을 제공하는 CC0 팩에 맞는 파이프라인이고, 수동 텍스처 매핑이 붙은 FBX는 코드는 두 배에 품질은 절반이었습니다.
glTF 경로에는 까다로운 부분 몇 가지가 따라왔습니다. 팔레트는 썸네일 116개를 렌더링하는데, WebGPU의 canvas.toDataURL()은 GPUCanvasContext 표면에 대해 빈 값을 반환합니다. 그래서 썸네일은 RenderTarget에 렌더링한 뒤 readRenderTargetPixelsAsync로 다시 읽어 와 2D canvas에 blit하면서 WebGPU의 256바이트 행 정렬을 챙깁니다. 고스트 프리뷰는 각 머티리얼을 복제해 초록색으로 물들이는데, material이 배열인 mesh에서 깨졌고 Array.isArray 분기로 고쳤습니다. 그리고 약 200 MB로 들어온 16비트 노멀 맵은 일회성 mogrify -depth 8로 약 32 MB까지 줄였는데, 어차피 브라우저가 업로드 시 다운샘플링하므로 시각적으로는 똑같습니다.
렌더링되는 지오메트리가 GPU에만 존재할 때
가장 배울 게 많았던 버그는 커서가 움직일 때 고스트 프리뷰가 1~2미터 단위로 툭툭 끊겨 붙는 것이었습니다. 지형 mesh는 정점 위치를 StorageBufferAttribute에 저장합니다. compute 파이프라인이 GPU에서 직접 그것들을 쓰기 때문에 three.js의 CPU Raycaster는 그것들을 볼 수 없고 아무것도 반환하지 않습니다. 폴백은 해석적 heightmap에 대한 거친 1.5 m 레이 마치였고, 그 고정 간격이 바로 사용자가 본 격자였습니다. 우리는 그것을 적응형 마치로 바꿨습니다. 표면 위로 높이 떨어져 있을 땐 간격 2.5 m, 표면 5 m 이내로 들어오면 0.4 m로 줄이고, 그다음
편집을 거쳐도 정직하게 남는 콜라이더
우리는 볼록 껍질이나 mesh 콜라이더 대신 프리미티브 프록시를 골랐습니다. Quaternius 소품은 둥글둥글하고 로우폴리이며 의미 있는 오목부가 없어서, 껍질을 쓰면 같은 플레이 경험을 위해 코드는 약 50배, 런타임 비용은 약 10배가 듭니다. 각 소품은 바운딩 박스에서 유도한 하나의 형태로 줄어듭니다. 나무와 선인장은 수직 캡슐, 바위는 구, 통나무는 긴 축을 따라 놓인 수평 캡슐, 장식용 덤불과 꽃은 아무것도 없음입니다. 바위와 통나무는 걸어다닐 수 있고(수직으로만 밀어내서 위에 올라설 수 있음), 나무와 선인장은 막힙니다(완전 3D 밀어내기라 줄기를 타고 오를 수 없음). 8 m 공간 해시가 프레임당 테스트를 플레이어의 3×3 이웃으로, 보통 소품 0~6개로 제한합니다.
두 가지 설계 결정이 시스템을 일관되게 유지했습니다. 지형 정렬은 코드에 하드코딩된 카테고리 열거형이 아니라 매니페스트 플래그입니다. 그래서 고스트 프리뷰와 확정된 배치가 같은 placement.alignToTerrain 값을 읽고 서로 어긋날 수 없습니다. 그리고 이미 배치된 소품은 헬퍼 하나를 통해 지형 편집에 반응합니다. 브러시 스트로크(로컬이든 피어에서 재생된 것이든) 이후 refreshPlacementsInRadius가 영향받은 원판 안에 있는 모든 소품 아래의 지면을 다시 샘플링하고, 정렬을 다시 적용하고, 콜라이더 끝점을 다시 유도합니다. 나무 아래에 언덕을 깎으면 나무가 함께 솟아오릅니다. 영속화와 멀티플레이어는 spike 31의 패턴을 그대로 재사용해, {uid, propId, x, y, z, rotY, scale}의 평탄한 목록을 저장하고 배치, 제거, 미세 조정 이벤트를 BroadcastChannel로 미러링합니다.
이 장에서 다룬 기술
WebGPU에서의 ES 모듈 분해. 모놀리식 <script type="module">을 베어 경로 .mjs import로 쪼개는 건, 모듈을 정적 에셋으로 제공할 때 번들러가 필요 없고, three.js TSL도 모듈 경계를 넘나들며 잘 작동합니다. 숨은 비용은 초기화 순서입니다. 모놀리스는 "X를 Y보다 먼저 만든다"를 위에서 아래로 흐르는 스크립트 순서에 인코딩하지만, 모듈은 import 순서로 평가되어 GPU bind-group 팩토리를 그 buffer가 존재하기 전에 돌릴 수 있습니다. 수정 패턴은 지연 초기화(첫 사용 시 getOrCreate...)와 선언 순서에 기대는 대신 올바른 promise를 await하는 것입니다.
내장 PBR이 담긴 glTF vs 수동 매핑이 붙은 FBX. glTF는 미터 단위로 들어오고, 자기 텍스처를 참조하며, MeshStandardMaterial을 곧바로 줍니다. 그래서 glTF로 만들어진 CC0 팩은 PBR 파이프라인에 바로 들어갑니다. 텍스처 바인딩 메타데이터가 없는 FBX 팩은 팩이 업데이트될 때마다 어긋나는, 손으로 유지하는 머티리얼 이름-PNG 표가 필요하고, 거기에 Phong-Standard 변환과 수동 색 공간 태깅까지 붙습니다. 식생 안전망은 alphaTest가 없는 transparent 머티리얼을 alphaTest: 0.5 컷아웃 카드로 승격시켜, 불투명 지오메트리 뒤에서 제대로 정렬되게 합니다.
WebGPU 오프스크린 썸네일. canvas.toDataURL()은 GPUCanvasContext 기반 canvas에 대해 빈 값을 반환합니다. 프레젠테이션 표면에서 2D 컨텍스트로 돌아오는 경로가 없기 때문입니다. RenderTarget에 렌더링하고, readRenderTargetPixelsAsync로 픽셀을 읽고, 2D canvas에 blit하는 방식은, blit이 WebGPU의 256바이트 정렬 읽기 간격을 따르는 한 잘 작동합니다. 결과는 버전 번호가 붙은 키 아래 localStorage에 캐시되므로 팩이 바뀌면 오래된 렌더가 무효화됩니다.
해석적 heightmap에 대한 적응형 레이 마치. 지형 정점이 GPU StorageBufferAttribute에 살 때 CPU raycaster는 그것들을 볼 수 없습니다. 해석적 높이 함수를 따라, 표면에서 멀 땐 큰 간격으로, 가까울 땐 작은 간격으로 마치하고,
공간 해시를 곁들인 프리미티브 캡슐 콜라이더. 각 소품은 바운딩 박스에서 카테고리 기반의 캡슐이나 구로 줄어들어 {kind, walkable, radius, p1, p2}로 기록되고, 겹치는 8 m 해시 버킷마다 등록됩니다. 프레임당 플레이어는 자신의 3×3 버킷 이웃에 있는 소품만 테스트하고, 각각 캡슐 대 캡슐 해소를 한 번씩 합니다. 걸어다닐 수 있는 프록시(바위, 통나무)는 수직으로만 밀어내고, 막는 프록시(나무)는 완전 3D 밀어내기를 받습니다. 이것이 기반으로 삼는 캡슐 수식은 SDF 지형 충돌을 참고하세요.
29부 중 16부. 이전: 15부 - 베이스라인을 교체한 다음, 동기화하기 다음: 17부 - 리타기팅이 필요 없던 애니메이션, 그리고 실시간 에셋 검색 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide