브라우저에서 오픈 월드 만들기, 13부: 지형 조각과 수학 함수의 죽음
글쓴이 Oleg Sidorkin, Cinevva CTO 겸 공동창업자
처음 오셨나요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 부분을 링크해 둡니다.
열두 부분 동안 우리는 바라볼 수 있는 지형 엔진을 만들었습니다. 그 위를 날아다닐 수 있고, 이음새가 갈라지지 않는 걸 감상할 수 있었죠. 이번에는 그걸 직접 만져보고 싶었습니다.
목표는 간단하게 들렸습니다. 플레이어가 브러시로 지형을 실시간으로 조각하되, 24개의 spike를 들여 쌓아 올린 어떤 시스템도 망가뜨리지 않게 하자. 결국 세 번의 시도, 렌더링 실패처럼 보였지만 사실은 데이터 모델 실패였던 버그 두 개, 그리고 지형 데이터가 어떻게 동작해야 하는지에 대한 근본적인 재고가 필요했습니다.
잘못된 출발 세 번
Spike 25는 쉬운 작업이어야 했습니다. 프로덕션 코드베이스에는 이미 지형 메시에 닿는 레이캐스터가 있었습니다. 배치 도구가 그걸로 오브젝트를 떨어뜨리죠. 브러시 도구도 같은 형태를 따르되, prefab을 생성하는 대신 heightmap 값을 수정할 뿐입니다. 간단합니다.
첫 번째 시도: 프로덕션 world/client/ TypeScript 코드에 직접 만들어 넣었습니다. 새 terrain-brush.ts, chunk.ts 수정, 프로토콜 변경, Vue 컴포넌트 업데이트. 한 시간 안에 어느 정도 동작하는 브러시가 생겼지만, chunk 경계에서 눈에 보이는 법선 불연속이 나타났습니다. 이 버그가 내 브러시 코드인지, 기존 chunk 봉합인지, 아니면 전체 렌더 루프와의 어떤 상호작용인지 구분할 수 없었습니다. spike 방법론이 막으려고 존재하는 게 바로 이 상황입니다. 규칙을 건너뛰었고 그 대가를 바로 치렀습니다. 전부 되돌렸습니다.
두 번째 시도: 독립 spike였지만, Three.js 0.170.0과 WebGL을 집어 들었습니다. 프로덕션 코드가 WebGL을 쓰니 자연스러워 보였죠. 하지만 Spike 13-24는 모두 WebGPU로 옮겨가 있었습니다. WebGL 브러시 spike를 만들면 우리가 옮겨가려는 렌더러가 아니라 레거시 렌더러에서 동작한다는 걸 증명하는 셈입니다. 방향이 틀렸습니다. 다시 시작했습니다.
세 번째 시도: WebGPU, WebGPURenderer, 정점 생성용 컴퓨트 셰이더, Spike 22 스택에 맞춤. 이번엔 아키텍처가 맞았습니다. 브러시 동작 다섯 개가 작동했습니다. 올리기, 내리기, 매끄럽게, 평탄화, 노이즈. 브러시 한 사이클 P95가 M1에서 4ms 아래였습니다.
그리고 이음새 버그는 여전히 있었습니다.
죽지 않는 이음새 버그
chunk 간 법선 계산의 표준 해법은 경계 중첩입니다. 각 chunk가 이웃의 데이터를 한 줄 더 저장해서, 경계에서의 법선 계산이 양쪽을 모두 샘플링할 수 있게 하는 거죠. 그렇게 구현했습니다. 이웃의 가장자리 데이터를 확장된 버퍼로 복사했죠. 이음새는 여전히 갈라졌습니다.
수학을 파고들었습니다. chunk A(cx=-1)와 chunk B(cx=0)의 경계에서, 두 chunk 모두 공유 정점에서 같은 법선을 계산해야 합니다. chunk A의 셰이더는 mix(own_col31, own_col32, 0.85)를 샘플링했습니다. chunk B는 mix(neighbor_edge, own_col0, 0.85)를 샘플링했죠. 이건 서로 다른 데이터를 지나는 서로 다른 쌍선형 보간 경로입니다. 경계 데이터가 정확해도, 두 chunk는 같은 점에서 다른 법선을 계산합니다.
바로 이 순간 경계 복사가 진짜 버그가 아니라는 걸 깨달았습니다. 진짜 버그는 데이터 모델이었습니다.
1번부터 24번까지 모든 spike는 height_at()라는 절차적 수학 함수를 썼습니다. 월드 좌표를 넣으면 높이가 나오죠. 깔끔하고, 전역적이고, 상태가 없습니다. 브러시는 수학 함수를 수정할 수 없어서, 그 위에 displacement 버퍼를 얹었습니다. 지형은 이제 height_at(x,z) + displacement[i]가 됐습니다. GPU 셰이더에는 기본 지형용 노이즈 함수 30줄과 변위 오버레이용 쌍선형 보간 코드가 박혀 있었습니다. 평탄화 브러시는 목표 높이를 만들어낼 변위 값을 알아내려고 height_at()를 빼야 했습니다. 두 시스템이 서로 위에 쌓여서, 서로 다른 샘플링 전략으로 서로 다른 걸 계산하고 있었던 겁니다.
이건 실제 게임이 동작하는 방식이 아닙니다. 프로덕션에서는 제작된 지형이 버퍼에 저장된 샘플 데이터입니다. 절차적 함수는 초기 spike에서 편하게 쓴 대역일 뿐이었습니다. 제 역할은 다 했죠. 이제는 적극적으로 버그를 만들어내고 있었습니다.
그걸 죽였습니다.
이제 각 chunk는 실제 높이 값을 담은 heightmap Float32Array를 직접 소유합니다. 생성 시 절차적 노이즈가 그걸 채웁니다. 그 후로 노이즈 함수는 다시는 호출되지 않습니다. 브러시는 저장된 높이를 직접 수정합니다. GPU 셰이더는 하나의 버퍼에서 하나의 함수로 읽습니다. hm_at(i,j). 법선은 같은 데이터에 격자 정렬 중심 차분을 써서 구합니다. 쌍선형 보간의 모호함도 없고, 두 시스템 간 불일치도 없습니다. 셰이더가 90줄에서 40줄로 줄었습니다.
이음새는 저절로 고쳐졌습니다. 공유 가장자리에 있는 두 chunk가 이제 각자의 버퍼에서 같은 이산 높이 값을 읽습니다(경계 중첩에 정확한 이웃 내부 점을 담아서). 같은 데이터가 들어가면 같은 법선이 나옵니다.
이건 브러시에 관한 교훈이 아니었습니다. 브러시가 드러낸 데이터 아키텍처에 관한 교훈이었습니다.
폭발한 메시
Spike 26은 볼류메트릭 쪽 대응물이었습니다. 64세제곱 SDF 볼륨을 브러시로 수정하고, marching cubes로 다시 메싱하기. Spike 25와 같은 질문이지만 3D에서요.
처음 실행했을 때 메시가 폭발했습니다. 긴 가시들이 사방으로 뻗어 나갔죠. 하루를 망친 성게 같았습니다.
내가 생성한 MC 케이스 테이블에는 4096개 대신 3840개의 항목이 있었습니다. 전체 테이블은
가
해법은 어이없을 만큼 단순했습니다. Spike 12에서 검증된 테이블을 바이트 단위로 그대로 복사하기. 교훈을 얻었습니다. 검증된 사본이 있을 때는 절대 룩업 테이블을 다시 생성하지 마라.
두 번째 버그는 더 미묘했습니다. 매끄럽게 다듬는 브러시는 지형 특징을 부드럽게 만들어야 했습니다. 그런데 날카로운 주름을 만들어냈죠. 문제는 이거였습니다. 나는 모든 SDF 값을 0(등위면)을 향해 끌어당기고 있었습니다. 그러면 매끄러워질 것 같지만, 사실은 거리장을 붕괴시킵니다. 표면 위아래의 복셀이 모두 0으로 몰려서, 브러시 반경 안의 모든 걸 평평하게 눌러버립니다. 경계에서는 매끄럽게 다듬어진 복셀이 다듬어지지 않은 복셀을 만나 단단한 계단이 생깁니다. "매끄럽게" 브러시가 주름 생성기였던 겁니다.
해법은 제대로 된 라플라시안 평활화였습니다. 각 값을 0으로 끌어당기는 대신, 직접 이웃한 6개의 평균을 향해 끌어당기는 거죠.
괄호 안의 항은 이산 라플라시안이고,
전부 한꺼번에
Spike 27은 통합 관문이었습니다. Spike 24의 전체 파이프라인(heightmap 패치, MC chunk, Transvoxel 이음새, geomorph LOD)을 Spike 25의 샘플 데이터 모델, 그리고 두 가지 브러시 타입과 결합하기.
제일 먼저 한 일은 모든 셰이더에서 height_at()를 뜯어내는 것이었습니다. 세 개의 컴퓨트 셰이더(SDF 채우기, heightmap 패치, Transvoxel 이음새)가 이제 같은 129x129 heightmap GPU 버퍼를 바인딩하고, 공유 WGSL 프리앰블을 통해 같은 hm_sample() 쌍선형 보간 함수를 씁니다. 하나의 데이터 소스, 여러 소비자. Spike 1부터 모든 셰이더에 살고 있던 절차적 노이즈 함수가 사라졌습니다.
그러고 나서 흥미로운 문제들이 시작됐습니다.
SDF 브러시가 한 chunk를 MC 모드로 잠그면, 그 chunk와 heightmap 이웃 사이의 Transvoxel 이음새는 heightmap이 아니라 SDF 볼륨에서 샘플링해야 합니다. 이음새 셰이더에 추가 스토리지 버퍼 바인딩과 chunk별 MC 플래그를 더했습니다. 처리할 경계 조합은 네 가지였습니다. HM-HM, HM-MC, MC-HM, MC-MC.
LOD는 또 다른 퍼즐이었습니다. 앞선 spike에서는 MC chunk를 더 낮은 LOD로 전환한다는 게 더 거친 해상도로 SDF를 다시 채운다는 뜻이었습니다. 그걸 스트라이드 기반 샘플링으로 바꿨습니다. SDF 데이터는 전체 해상도(격자 점 65개)를 유지합니다. MC 셰이더는 격자 크기와 셀 개수의 비율로 스트라이드를 계산합니다. LOD0에서는 스트라이드가 1입니다. LOD1에서는 스트라이드가 2가 되어 한 칸 건너 복셀을 샘플링합니다. chunk는 SDF 데이터를 건드리지 않고 자유롭게 LOD를 바꿀 수 있습니다.
가장 만족스러웠던 해법은 동적 수직 chunk 생성이었습니다. chunk 꼭대기 위로 위쪽으로 조각하면, 그 위에 MC 전용 chunk가 새로 나타나고 그 SDF는 아래 chunk의 경계면에서 초기화됩니다. 아래쪽으로 조각해도 마찬가지입니다. 월드가 편집에 맞게 자라납니다.
마지막 함정은 heightmap 브러시가 MC로 잠긴 chunk에 아무 일도 안 하면서 조용히 넘어가는 것이었습니다. HM 브러시는 heightmapCPU를 수정하고 다시 업로드합니다. MC chunk는 더 이상 heightmap에서 읽지 않습니다. 그 SDF가 heightmap으로 채워진 뒤에 따로 갈라졌기 때문이죠. syncHeightmapToSdf()를 추가했습니다. heightmap이 바뀐 뒤, 브러시 반경 안의 MC chunk에 대해 SDF 열을 다시 파생시키고 새 값을 업로드합니다. 이제 두 브러시 타입 모두 두 chunk 타입에서 동작합니다.
우리가 실제로 배운 것
브러시 spike들은 성능 질문에 답하려는 것이었습니다. 조각이 프레임 예산 안에서 돌아갈 수 있는가? 가능합니다. 그게 쉬운 부분이었습니다.
어려운 부분은, 24개 spike 동안 height_at()를 지형의 진실로 써온 게 보이지 않는 의존성을 만들어냈고, 우리가 뭔가를 편집하려는 순간 그게 깨진다는 걸 발견한 것이었습니다. 그 절차적 함수는 깨끗하고 전역적이고 상태가 없었습니다. 더는 지형이 아니게 되기 직전까지는요.
우리가 적어두었고 잊지 않을 규칙들:
- 지형 높이는 샘플 데이터에서 나온다. chunk가 자신의 버퍼를 소유한다.
- 절차적 생성은 초기 데이터를 채운다. 그것은 런타임의 진실이 아니다.
- 브러시는 chunk 데이터를 직접 수정한다. 변위 오버레이를 쓰지 않는다.
- 법선은 같은 데이터에서 격자 정렬 중심 차분으로 나온다.
- 경계 중첩(이웃 내부에서 1셀)이 chunk 간 법선을 처리한다.
- 검증된 사본이 있을 때는 절대 룩업 테이블을 다시 생성하지 않는다.
14부에서는 디버그 지오메트리를 조각하는 걸 멈추고, 그것을 실제 장소처럼 보이고 느껴지게 만들기 시작합니다.
이 장에서 언급한 기술
샘플 heightmap 아키텍처. 지형을 런타임에 절차적 함수로 평가하는 대신, chunk별로 소유하는 데이터로 저장합니다. 각 chunk는 실제 높이 값이 담긴 Float32Array를 가집니다. 절차적 노이즈가 생성 시점에 초기 데이터를 채우고, 그 후로 함수는 다시 호출되지 않습니다. 이로써 수학 기반 지형과 편집 오버레이 사이의 이중 시스템 불일치가 사라지고, 브러시 동작이 단순해지며(저장된 값을 직접 편집), GPU 셰이더가 아주 단순해집니다(버퍼에서 읽고, 격자 정렬 중심 차분으로 법선 계산). 스트리밍이 있는 오픈 월드에서는 이웃에서 1셀 경계 중첩을 가진 chunk별 소유가 표준 방식입니다. 우리의 지형 생성 가이드를 보세요.
SDF 브러시 동작. 부호 있는 거리장을 수정해서 지형을 조각합니다. 더하기(팽창)는 구 주위의 스무스 스텝 감쇠를 씁니다. 빼기(파내기)는 같은 형태를 음수로 합니다. 매끄럽게 다듬기는 라플라시안 평균을 씁니다. 직접 이웃 6개를 읽고 평균을 계산해 그 평균을 향해 끌어당깁니다. 0을 향해 끌어당기는 단순한 방식은 거리장을 붕괴시켜 날카로운 모서리를 만듭니다. 라플라시안 평활화는 특징을 부드럽게 하면서 장의 기울기를 보존합니다. SDF 지형 표현을 보세요.
혼합 데이터 소스를 가진 Transvoxel. MC chunk와 heightmap chunk 경계의 전환 셀은 양쪽에서 서로 다른 데이터를 샘플링해야 합니다. 이음새 셰이더는 네 가지 조합(HM-HM, HM-MC, MC-HM, MC-MC)을 모두 처리하기 위해 chunk별 플래그와 버퍼 바인딩을 가집니다. 한쪽이 MC로 잠겨 있으면, 셰이더는 heightmap을 샘플링하는 대신 SDF 버퍼를 삼선형 보간합니다.
marching cubes를 위한 스트라이드 기반 LOD. chunk의 현재 LOD 레벨과 상관없이 SDF 데이터는 전체 해상도로 저장합니다. MC 셰이더는 SDF 격자 점 수와 MC 셀 수의 비율로 샘플링 스트라이드를 계산합니다. 전체 해상도에서는 스트라이드가 1, 절반 해상도에서는 2입니다. 이로써 SDF 데이터가 LOD 변경에서 분리되어, chunk는 SDF를 다시 만들지 않고 자유롭게 LOD를 전환할 수 있습니다.
14부 중 13부. 이전: 12부 - 링, 하늘 안개, 그리고 다시 해볼 것들 다음: 14부 - 세계가 살아난다 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide