Skip to content

브라우저에서 오픈 월드 만들기, 18부: AI가 배치한 것처럼 느껴지는 스캐터 브러시

글쓴이 Oleg Sidorkin, Cinevva의 CTO 겸 공동 창업자

처음 오셨나요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 부분을 링크해 둡니다.

17부에서는 플레이어에게 전투 수준의 애니메이션 세트와 CC0 모델이라면 무엇이든 월드로 끌어오는 방법을 줬습니다. 이번 부분은 다시 창작자 쪽으로 돌아갑니다. Spike 34의 팔레트는 클릭 한 번에 프롭 하나를 배치하는데, 주인공 오브젝트 하나를 세우는 데는 괜찮지만 숲을 만드는 데는 쓸모가 없습니다. Spike 37은 그 브러시입니다. 지형을 가로질러 드래그하면 나무가 있어야 할 곳에 나무가 채워집니다.

AI 없는 "AI 배치"

새 탭에서 Spike 37 열기 ↗ · 소스 보기

이 spike가 답하려는 질문은, 순수하게 휴리스틱으로 만든 브러시가 LLM을 건너뛸 만큼 충분히 똑똑하게 느껴지는가입니다. "AI 배치"에 대한 검증은 구체적입니다. 나무는 절벽을 피하고, 바위는 경사면 쪽으로 기울고, 해변 자갈은 물가에서 멈춥니다. 전부 첫 스트로크에서요. 우리는 경사와 고도 술어, 가중치 추첨, 패밀리별 간격으로 거기에 도달했고 모델 호출은 단 한 번도 없었습니다.

브러시는 손으로 다듬은 지형 특징을 가진 257×257 CPU 하이트맵 위에서 작동하므로 모든 프리셋이 내려앉을 곳을 갖습니다. 혼합 경사 선택용 북부 산악, 스크리(자갈)용 동쪽 절벽 띠, 해변과 초원용 남쪽 해안 평야, 남서쪽 호수 분지가 있습니다. 지형은 (altitude, slope) 바이옴 분류기로 정점 색을 굽기 때문에, 나무 한 그루를 칠하기도 전에 어떤 프리셋이 어디서 발동할지 볼 수 있습니다. 다섯 개 프리셋이 평면 데이터로 제공되며, 각각은 { category, weight, slopeMin, slopeMax, altMin, altMax, minSpacing, alignToSlope } 같은 선택 항목의 목록입니다. Cliff와 Scree는 slopeMin: 0.3으로 설정해 바위가 실제 경사면에만 떨어지게 하고, alignToSlope: true로 설정해 각 바위의 up 벡터가 표면 법선을 따르게 합니다.

각 스트로크마다 스캐터 엔진은 브러시 디스크 안에서 densityPerM2 × area 개의 후보 점을 샘플링하고, 후보마다 높이와 경사를 읽고, 프리셋의 선택 항목을 술어를 통과한 것들로 거른 뒤 가중치로 하나를 뽑고, 반경 내 공간 해시에 대해 간격 검사를 돌립니다. 전체가 결정적입니다. 시드를 줄 수 있는 Mulberry32 RNG가 모든 추첨을 소유하므로 (seed, brush events)가 어떤 세션이든 정확히 재현합니다. 부팅 지형에서 평탄한 초원 위 Mixed Forest 스트로크는 후보 158개 중 139개를 5ms에 배치했고, 같은 프리셋이 절벽 위에서는 226개 중 106개만 배치했으며 HUD는 그중 81개가 경사 때문에 거부됐다고 보고했습니다. 그 거부 내역이 곧 UX 전부입니다. 절벽이 나무를 몇 그루만 받았는지 추측하지 않고 볼 수 있으니까요.

프리셋을 평면 데이터로 유지하는 핵심은, LLM 버전이 도착했을 때 그것이 재작성이 아니라 JSON 교체가 된다는 점입니다. paint({ preset })preset.picks가 손으로 다듬은 레시피에서 왔는지, "이끼 낀 바위가 있는 활엽수림"을 가중치로 펼친 워커에서 왔는지 신경 쓰지 않습니다. 엔진은 프롭 id도 절대 하드코딩하지 않으므로, 다른 카탈로그를 끼워 넣어도 엔진 변경이 필요 없습니다.

드로우 콜 300개에서 49개로

첫 번째 버전은 각 배치를 멀티 메시 그룹의 clone(true)로 렌더링했는데, 프롭이 몇백 개일 때는 괜찮지만 2,500개 상한에서는 드로우 콜이 수천 개로 치솟는 벽에 부딪힙니다. 우리는 그 벽에 닿기 전에 (propId, partIndex)당 버킷 하나를 두는 InstancedMesh로 바꿨습니다. 각 버킷은 두 배씩 늘려 성장합니다. 더 큰 InstancedMesh를 할당하고, 살아 있는 행렬을 복사하고, 씬 부모를 교체하고, 옛 속성을 폐기합니다. 지우기는 스왑 리무브라서, 버킷 크기와 상관없이 인스턴스 하나를 제거하는 일은 O(1)입니다. 교체가 전적으로 배치 레코드 아래에 살기 때문에 결정성, 간격, 거부 HUD가 모두 변경 없이 그대로 이어집니다.

MegaKit 팩에 대한 진단이 진짜 아키텍처 질문 하나를 정리해 줬습니다. 멀티 프리미티브 glTF 메시(줄기 + 잎)는 three.js에 두 가지 형태 중 하나로 도착할 수 있습니다. 배열 머티리얼과 geometry.groups를 가진 메시 하나거나, 각각 머티리얼 하나씩을 가진 별도의 형제 메시들이거나입니다. 이 팩에서는 로더가 두 번째 경로를 택합니다. 모든 파트가 빈 그룹을 가진 단일 머티리얼 메시입니다. 그게 스캐터에는 더 나은 형태인데, 프리미티브별로 버킷이 분리되면 줄기 버킷과 잎 버킷의 개수가 갈라질 때 서로 독립적으로 성장할 수 있기 때문입니다. 드로우 콜 수는 어느 쪽이든 같지만, 분리하면 메모리 형태가 더 좋습니다. 측정된 이득은 사실로 드러났습니다. ~300 드로우 콜이던 숲 스트로크가 49개가 됐고, 멀티 스트로크 세션 전체는 51 드로우 콜에서 75 FPS로 3,221개 인스턴스에 도달했습니다. 클론 경로로는 프레임 예산이 붕괴하기 전에 결코 닿을 수 없던 상한이죠.

거리 LOD, 그리고 그 안에 숨은 버그 네 개

인스턴싱이 드로우 콜은 줄였지만, 모든 인스턴스는 여전히 전체 삼각형 개수를 그렸습니다. 90m 밖에서 잎 디테일을 2픽셀 기여하는 나무까지요. 그래서 우리는 meshoptimizer로 프롭 파트마다 LOD 세 단계(full, 50%, 15%)를 굽고, 버킷 키를 (propId, partIndex, lod)로 확장하고, 배치를 형제 버킷 사이로 할당 없이 실어 나르는 move()를 추가했습니다. 거리 밴드는 0~30m, 30~90m, 그 너머이며, 각 경계 주위에 ±4m의 히스테리시스를 둬서 밴드 가장자리 근처를 맴도는 카메라가 배치를 앞뒤로 흔들며 매 프레임 행렬을 다시 업로드하지 않게 했습니다. 재평가는 4Hz로 상한이 걸려 있고 카메라가 실제로 움직였을 때만 게이트되므로, 정지한 카메라는 프레임당 제곱 거리 비교 한 번만 듭니다.

그 LOD 경로가 바로 교훈적인 버그들이 살던 곳입니다. 첫 번째는 카메라가 궤도를 돌 때 배치가 사라지거나 중복되는 모습으로 나타났고, 씬이 채워질수록 심해졌습니다. 원인은 공유 스크래치 행렬이었습니다. move()가 배치의 변환을 모듈 스코프의 _tmpMat로 읽어 들였는데, 원본 버킷의 스왑 리무브가 자기 내부 셔플에 같은 _tmpMat를 쓰면서, 목적지가 쓰기 전에 실어 나르던 행렬을 덮어쓴 것입니다. 이 버그는 옮겨지는 슬롯이 이미 버킷의 마지막일 때만 비켜갔는데, 대략 1/count 확률이고, 이게 정확히 플레이테스트에서 본 "씬이 커질수록 심해지는 드문 깜빡임"입니다. 수정은 move() 전용으로 예약한 _carryMat였습니다. 누적 1,274회 이동으로 스트레스 테스트했을 때 클러스터는 픽셀 단위로 동일하게 유지됐습니다.

두 번째 버그는 더 미묘했습니다. 모든 LOD 전환이 부드럽게 느껴졌는데 첫 번째만 예외였습니다. LOD1으로 넘어가는 나무는 실루엣이 거의 안 변하는데도 음영이 눈에 띄게 바뀐 반면, 사다리 뒤쪽의 더 큰 삼각형 감소는 멀쩡해 보였습니다. LockBorder를 쓰는 단순화기는 정점을 옮기거나 만들어내지 않으므로 살아남은 정점은 법선을 그대로 유지하는데, 그런데도 우리는 단순화 후마다 computeVertexNormals()를 호출하고 있었습니다. LOD0은 아티스트가 만든 원본 법선을 손대지 않고 반환하지만, LOD1 이상은 three.js의 일반적인 면 평균 재계산을 받았습니다. 0에서 1로 가는 경계가 사다리에서 법선 체계가 바뀌는 유일한 지점이었으니, 팝이 살던 곳도 거기였습니다. 그 방어적인 한 줄을 빼니 음영이 고쳐졌고, 덤으로 파트당 네 개 LOD에서 법선을 다시 계산하지 않게 되면서 프롭당 굽기 시간이 대략 절반으로 줄었습니다.

단순화기가 만들어낸 것을 감사하다 세 번째 이득이 떠올랐습니다. 각 LOD는 새 인덱스를 가진 original.clone()이었고, BufferGeometry.clone()은 모든 속성을 깊은 복사하므로, 다섯 개 LOD가 position, normal, UV, color 버퍼의 독립 복사본 다섯 개를 들고 있었고 그 값들은 전부 비트 단위로 동일했습니다. 우리는 속성 참조를 공유하고 LOD마다 전용 인덱스 버퍼만 소유하도록 리팩터링해서, 전형적인 나무 파트를 서로 다른 속성 아이덴티티 20개에서 9개로 줄이고 각 정점 버퍼를 GPU에 한 번만 업로드했습니다. 별칭(aliased) 저장에는 계약 두 가지가 따라옵니다. 어떤 한 LOD를 통해서도 속성 데이터를 변형하지 말 것, 그리고 LOD 하나의 지오메트리를 dispose()하지 말 것. 둘 다 그 버퍼를 공유하는 모든 형제에 영향을 주기 때문입니다.

네 번째 버그는 페인팅과는 아무 상관이 없었습니다. 버튼을 누르지도 않고 지형 위에서 그저 커서를 흔들기만 해도 프레임 레이트가 떨어졌습니다. pointermove 핸들러가 공간 구조가 없는 131,072 삼각형 평면인 지형 메시에 대해 레이캐스트를 했고, 그래서 three.js가 이벤트마다 초당 최대 1,000회까지 전체 인덱스 버퍼를 훑었습니다. 지형이 파라메트릭 하이트맵이라서 그 조회에는 메시가 전혀 필요 없었습니다. sampleHeight에 대한 적응형 레이 마치(표면 위로 높을 때는 큰 보폭, 가까울 때는 0.4m 바닥, 그다음 부호 전환에서 12회 이분법)는 광선당 131,072번의 삼각형 테스트 대신 대략 8~30개 샘플이 드는데, 약 세 자릿수만큼 더 싸고, 호버는 다시 프레임 상한을 유지합니다.

비용은 그냥 옮겨갈 뿐이다. 클릭에서 옮겨가게 하라

spike를 프로덕션 타깃인 three r184의 WebGPURenderer로 바꾼 뒤, DevTools 프로파일은 첫 페인트가 265ms 동안 막히고 그중 79%가 meshoptimizer WASM 안에 있다는 것을 보여줬습니다. 굽기는 실제 작업이었습니다. 콜드 프리셋에 대해 약 180회의 simplify 호출이었죠. 하지만 그게 클릭 핸들러 안에서 돌고 있었는데, preloadProps가 씬을 가져와 파싱만 했지 LOD 굽기를 전혀 촉발하지 않았기 때문입니다. 수정은 프리셋 선택이 배경에서 전체 굽기를 하게 만드는 것이었습니다. preloadProps는 이제 파트 해석 경로를 호출하고, 진행 중인 프로미스를 캐시해서 빠른 클릭이 중복을 만들지 않고 그것에 합류하게 하며, 단순화기가 파트당 네 번씩 다시 하던 지오메트리별 전처리를 메모이즈합니다. 첫 페인트는 HUD에서 209ms에서 4ms로 떨어졌습니다. WASM 시간은 사라지지 않았습니다. 그저 사용자의 임계 경로를 떠나, 사용자가 지형을 보며 어디를 칠할지 정하는 동안 돌 뿐입니다.

그게 이 spike의 되풀이되는 교훈입니다. 이 수정들 거의 어느 것도 브러시가 하는 일을 바꾸지 않았습니다. 그것들은 비용이 언제 내려앉는지를 바꿨습니다. 클릭에서, 호버에서, 카메라가 맴도는 경계에서 비용을 떼어냈죠. 즉각적으로 느껴지는 스캐터 도구는 일을 덜 하는 게 아니라, 사용자가 기다리지 않는 곳에서 그 일을 하는 것입니다.

이 장에서 언급한 기술

휴리스틱 적합도 스캐터. 브러시가 디스크 안에서 후보 점을 샘플링하고, 점마다 CPU 하이트맵에서 (height, slope)를 읽고, 경사와 고도 술어로 프리셋의 선택 항목을 거르고, 가중치로 하나를 뽑고, 공간 해시에 추적되는 패밀리별 최소 간격을 위반하면 거부합니다. 경사 정렬 선택은 up 벡터를 표면 법선으로 회전시킵니다. 이렇게 하면 학습된 가중치 없이도 의도적으로 읽히는 배치(나무는 절벽을 피하고, 바위는 경사 쪽으로 기울고, 자갈은 물가에서 멈춤)가 나오고, 프리셋을 평면 데이터로 유지해 LLM이 생성한 선택 목록을 그대로 끼워 넣을 수 있게 합니다.

비동기 로드 하의 결정적 배치. 시드를 줄 수 있는 Mulberry32 RNG가 모든 추첨을 소유하므로 (seed, brush events)가 세션을 정확히 재현합니다. RNG 추첨은 어떤 await보다 먼저 일어나고, 간격 예약은 glTF 클론이 해석되기 전에 공간 인덱스에 삽입되므로, 동시 후보들은 서로를 존중하고 비동기 에셋 로딩이 시퀀스를 교란할 수 없습니다.

O(1) 편집이 가능한 버킷형 InstancedMesh. (propId, partIndex, lod)InstancedMesh 하나이며, 살아 있는 행렬을 더 큰 버퍼로 복사해 요청에 따라 용량을 두 배로 늘립니다. 지우기와 FIFO 축출은 옮겨진 인스턴스의 인덱스를 패치하는 역참조 배열을 동반한 스왑 리무브라서, 버킷 크기와 상관없이 제거가 O(1)입니다. 한 진단이 glTF 파트가 단일 머티리얼 메시로 도착함을 확인했고, 이로써 프리미티브당 버킷 하나가 활성 경로가 되어 각 프리미티브에 독립적으로 성장 가능한 버킷을 줍니다.

히스테리시스와 공유 속성 버퍼를 쓰는 거리 LOD. 파트당 meshopt로 단순화한 세 단계를 ±4m 히스테리시스가 있는 거리 밴드로 선택해 경계 근처 카메라가 흔들리지 않게 하고, 상한이 걸린 비율로, 실제 카메라 움직임에 게이트해 재평가합니다. LockBorder 단순화는 정점을 절대 옮기지 않기 때문에, 모든 LOD가 하나의 position/normal/UV/color 버퍼 세트를 공유하고 전용 인덱스 버퍼만 다르며, 이로써 서로 다른 GPU 정점 버퍼를 대략 절반으로 줄입니다. 방어적인 computeVertexNormals 하나를 건너뛰면 아티스트 법선이 LOD 전반에 동일하게 유지되고 사다리에서 유일한 음영 불연속이 사라집니다. LOD와 meshoptimizer를 보세요.

고빈도 조회를 위한 해석적 하이트맵 레이캐스트. 131k 삼각형 평면 메시에 대한 pointermove 빈도의 커서 조회는 이벤트마다 전체 인덱스 버퍼를 훑습니다. 이것을 해석적 높이 함수에 대한 적응형 레이 마치(표면에서 멀 때는 큰 보폭, 가까울 때는 작은 바닥, (rayyterrainy)의 부호 전환에서 이분법)로 바꾸면 수만 번의 삼각형 테스트 대신 수십 개 샘플이 들어 약 세 자릿수만큼 더 싸고, 미리 할당한 출력 벡터가 핫 패스를 할당 없이 유지합니다.

상호작용의 임계 경로에서 작업을 떼어내라. 비싼 일회성 작업(meshopt LOD 굽기, WGSL 파이프라인 컴파일)은 클릭 핸들러 안이 아니라 유휴 틈에 돌아야 합니다. 프리셋 선택 시 활성 프리셋의 전체 굽기를 미리 로드하고, 진행 중인 프로미스를 캐시해 빠른 클릭이 포크가 아니라 합류하게 하고, 지오메트리별 전처리를 메모이즈한 결과, 전체 작업량을 전혀 줄이지 않고도 첫 페인트 지연을 209ms에서 4ms로 떨어뜨렸습니다.


29부 중 18부. 이전: 17부 - 리타기팅이 필요 없던 애니메이션, 그리고 실시간 에셋 검색 다음: 19부 - 숲에서 살아남아야 하는 임포스터 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide