Skip to content

브라우저에서 오픈 월드 만들기, 24부: 월드를 저장하기, 그리고 눈에 보이는 바람

Oleg Sidorkin, Cinevva CTO 겸 공동창업자

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

23부에서는 사람들을 월드 안에 넣고 목소리를 주었습니다. 이번 부분은 월드가 사람들이 자신에게 한 일을 기억하게 만들고, 아무도 건드리지 않을 때에도 살아 있는 느낌을 주는 이야기입니다. Spike 47은 영속화입니다. 창작자가 지형을 조각하고 소품을 배치하면, 그 편집이 다시 로드해도 살아남고, 다른 모든 peer로 동기화되고, 두 사람이 동시에 편집할 때 깔끔하게 중재됩니다. Spike 49는 바람입니다. 양식화된 자연 에셋 팩의 식생 셰이더를 이식해서 나무, 덤불, 풀이 아티스트가 의도한 대로 움직이게 했는데, 이건 수학보다는 셰이더 컴파일러와의 싸움에 더 가까워졌습니다.

기억하는 월드

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

설정은 공유 창작 세션입니다. 플레이어가 월드를 걸어 다니며 클릭으로 소품을 배치하고, 우클릭으로 제거하고, 브러시를 드래그해 지면을 조각합니다. 높이맵을 올리고 내리거나, 한 chunk를 marching cubes로 승격시키는 SDF 지형을 통해 부피 있는 동굴을 파냅니다. 이들이 하는 모든 것은 Cloudflare WorldChunkDO에 영속화됩니다. 자체 SQLite 저장소로 뒷받침되는, 서버 권위형 Durable Object입니다. 클라이언트는 상태를 직접 쓰지 않습니다. 클라이언트는 의도를 보내고, DO가 중재하고 결과를 브로드캐스트하며, DO가 유일한 진실의 원천이기 때문에 새로 들어온 사람은 스냅샷을 받아 다른 모든 사람이 보는 것과 정확히 같은 월드에 안착합니다.

두 가지 영속화 세부 사항이 제 몫을 해냈습니다. 지형은 들어올 때 다시 재생하는 이벤트 로그로 저장되지 않습니다. chunk별 바이너리 blob으로 저장되는데, 이 blob이 진실의 원천이고 스트로크가 끝날 때 업로드되므로, 들어온 사람은 수천 번의 브러시 샘플을 다시 돌리는 대신 커밋된 바이트를 직접 로드합니다. 그리고 그 blob은 큰 한 행이 아니라 chunk당 저장 키 하나로 살아 있습니다. 단일 SDF chunk가 168 KB이고 DO에는 행당 2 MB 제한이 있기 때문입니다. chunk 인덱스가 어떤 키가 존재하는지 추적하므로 DO는 깨어날 때 맵 전체를 다시 수화할 수 있습니다. 신원은 클라이언트에서 두 가지 저장소로 처리됩니다. 플레이어 id는 sessionStorage에 살아서 두 탭이 한 peer가 DO의 플레이어 맵에서 자기 자신을 덮어쓰는 게 아니라 서로 다른 두 peer가 되고, 표시 이름은 localStorage에 살아서 한 탭에서 이름을 바꾸면 모든 탭에 걸쳐 적용됩니다.

동시 편집을 수렴시키기

멀티플레이어 창작에서 정말로 어려운 부분은 두 사람이 같은 순간에 겹치는 지면을 편집할 때 무슨 일이 벌어지느냐입니다. 이 spike는 편집을 그 대수로 나눕니다. 덧셈 연산, 즉 높이맵에서의 올리기와 내리기, SDF에서의 더하기와 빼기는 가환입니다. 어떤 순서로 적용해도 같은 결과에 도달하므로, 이들은 낙관적-스탬프 경로를 탑니다. 각 클라이언트가 로컬에서 적용하고 스탬프를 보내면 DO가 조율 없이 그것을 모두에게 브로드캐스트합니다. 순서가 정말로 상관없으니 조율할 것이 없습니다.

순서에 의존하는 연산, 즉 스무딩과 평탄화가 흥미로운 경우였습니다. 첫 설계는 이들에게 영역 잠금을 주었습니다. 클라이언트가 포인터를 누를 때 잠금을 요청하면 DO가 허가하거나 거부하고, 클라이언트는 누르고 있는 동안 샘플을 버퍼링하며, 포인터를 뗄 때 DO가 스트로크 전체를 원자적으로 적용합니다. 작동하긴 하지만, 자체 잠금 TTL과 허가/거부 왕복을 가진 별개의 프로토콜입니다. 이를 대체한 더 깔끔한 답은 미리 계산된 delta입니다. 발신자가 스무딩이나 평탄화 브러시를 로컬에서 돌린 뒤 그 결과인 셀별 delta 목록을 보내면, 각 peer는 아무것도 다시 유도하지 않고 그 delta를 자기 셀에 더하기만 하면 됩니다. 이는 결과를 원천에서 얼려 버림으로써 순서 의존 연산을 가환 연산으로 바꿉니다. 그래서 편집 시스템 전체가 동일한 수렴 결과를 가진 하나의 균일한 가환 프로토콜 위에서, 잠금 없이 완전히 돌아갑니다. 소품 잠금은 다른 이유로 살아남았습니다. 레코드별 잠금이 소유자만 삭제 가능하던 방식을 대체해서, 누가 잠그지 않은 한 어떤 peer든 어떤 소품이든 삭제할 수 있고, 잠근 사람만 그것을 풀 수 있습니다. 실행 취소와 재실행은 클라이언트가 소품을 배치하기 전에 그 id를 직접 이름 짓게 함으로써 작동합니다. 그래서 서버 응답 이전에 id를 알고 자기 동작을 결정론적으로 되돌릴 수 있습니다. Hibernating WebSocket이 줄곧 유휴 방을 무료로 유지하는데, 이전 부분에서 avatar 릴레이를 저렴하게 만든 바로 그 성질입니다.

충실하게 이식한 바람, 그리고 시작된 싸움

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

Spike 49는 Quaternius의 Stylized Nature MegaKit을 가져와 그 바람을 우리 스택으로 이식합니다. 이 팩은 소스 Godot 셰이더 네 개를 함께 제공하는데, 올바른 선택은 재발명이 아니라 충실한 번역이었습니다. Bark는 빈 정점 함수를 가지므로 줄기는 강체입니다. 앞선 절차적 마스크 시도가 줄기를 흔들리게 만들고 있었는데, 해결책은 그냥 bark에 바람을 아예 적용하지 않는 것이었습니다. Leaves는 삼각 펄스 해시에서 정점별 무질서한 흔들림을 얻고, 높이로 마스킹되어 수관은 움직이고 밑동은 심긴 채로 있습니다. Base foliage는 월드 공간에서 노이즈로 변조된 sin/cos 흔들림을 얻습니다. Grass는 base foliage에 wind-line 흔들림을 더한 것으로, 거듭제곱 곡선을 통해 스크롤하는 노이즈 텍스처를 샘플링해서 텍스처의 밝은 띠만 기여하게 합니다. 이것이 초원을 가로질러 이동하는 눈에 보이는 잔물결을 만들어 냅니다. 디스패치는 팩 자체의 머티리얼 명명 규칙을 따르므로, Leaves_Birch라는 머티리얼은 leaves 경로로, Grass_Common은 grass 경로로 라우팅되며 추측할 필요가 없습니다.

이식에서 나온 놀라움은 잎 색이 텍스처에 없다는 점입니다. Quaternius는 잎의 외형을 전적으로 수직 그라디언트와 Fresnel 림으로 만듭니다. 알베도는 수관 아래쪽의 추가 색에서 위쪽의 잎 색으로 가는 혼합이며 높이로 키잉되고, 여기에 (1NV)3 Fresnel 항으로 스케일된 발광으로 더해진 표면하 산란 색조가 붙습니다. 평범한 텍스처 pass가 밋밋해 보였던 이유가 이것입니다. 색은 칠해진 맵이 아니라 그라디언트와 림에서 옵니다. 이식은 원시 로컬 Y 대신 베이크된 heightFactor 정점 속성을 읽으며, 로드 시점에 월드 공간 Y로부터 잎 그룹마다 정규화합니다. 그래서 FBX 임포트가 각 mesh의 로컬 축을 어떻게 회전시켰든 그라디언트와 바람 마스크가 모두 올바르게 동작합니다. Godot 색 상수는 sRGB로 태그되어 셰이더가 보기 전에 선형으로 변환되므로, 이식도 같은 변환을 합니다. 밝은 sRGB 수치를 선형인 양 그냥 먹여서 식생을 바래게 만드는 대신에요.

프레임률을 먹어 치운 재컴파일

이것이 완전한 바람 머티리얼을 .bak 파일에 따로 빼 두고 먼저 FBX 기준선으로 출시된 이유는, 장면을 약 1 fps로 떨어뜨린 프레임마다 일어나는 재컴파일 버그 때문입니다. 커스텀 TSL을 FBX로 로드된 머티리얼 위에 얹으면 Three.js가 프레임마다 셰이더 프로그램을 다시 빌드하도록 촉발했고, needsUpdate가 사실상 켜진 채로 막혀 있었습니다. 진단 규율은 각 식생 머티리얼을 커스텀 노드가 없는 평범한 텍스처 pass로 되돌려 놓고 재컴파일 루프가 계속되는지 보는 것이었습니다. 멈추면 커스텀 그래프가 범인이고, 계속되면 원인은 상류의 FBX 머티리얼 설정이나 Three.js 자체에 있었습니다. 진짜 셰이더를 되돌아오게 한 수정은 머티리얼별 차이인 잎 색, SSS 색, 강도, 블렌드를 전부 uniform으로 바인딩해서, 컴파일러가 고유한 색 조합마다 새 셰이더를 내뱉어 컴파일 큐를 들쑤시는 대신 모든 잎 에셋이 컴파일된 프로그램 하나를 공유하게 한 것입니다.

기록해 둘 만한 조각이 두 개 더 있습니다. 풀은 소스 파일마다 InstancedMesh 하나로 렌더링되고, sin 위상이 월드 위치를 키로 삼기 때문에 그 바람은 월드 공간에서 계산됩니다. 하지만 변위는 인스턴스 행렬이 돌기 전에 로컬 공간에서 적용해야 하는데, WGSL에는 호출할 inverse()가 없습니다. 평행이동, Y 회전, 균일 스케일로 이루어진 인스턴스 행렬의 경우 상단 3×3의 역은 그저 그것의 전치를 스케일 제곱으로 나눈 것입니다. 그래서 셰이더는 월드 변위에 전치된 모델 행렬을 곱하고 행렬 첫 열 길이의 제곱으로 나누어, 제곱근 없이 스케일을 복원합니다. 정점 변환이 행렬을 다시 적용한 뒤, 운동은 각 풀 다발의 회전이나 스케일과 무관하게 정확히 만든 대로 월드 공간에 안착합니다. 그리고 각 풀 다발은 정점별 GPU 절두체 컬링을 돌립니다. 인스턴스 중심을 클립 공간으로 투영하고, 여유를 두고도 절두체 밖에 떨어지면 모든 정점을 로컬 원점으로 붕괴시켜 각 삼각형의 세 정점이 일치하게 합니다. 그러면 래스터라이저가 퇴화 삼각형을 버리고, 화면 밖 풀에 대해서는 어떤 프래그먼트, 알파 테스트, 그림자 작업도 일어나지 않습니다. 이것은 Three.js가 이미 하는 거친 chunk별 바운딩 구 컬링 위에 쌓이며, 불리언이 아니라 부동소수점 step으로 만들어져 위치 혼합에 곧장 곱해집니다.

이 장에서 언급된 기술

서버 권위형 월드 영속화. WorldChunkDO Durable Object가 소품 배치와 지형 편집을 중재하고, chunk별 바이너리 blob을 진실의 원천으로 영속화하며(그래서 들어온 사람은 이벤트 로그를 재생하는 대신 커밋된 바이트를 로드합니다), DO의 행당 2 MB 제한 아래로 유지하기 위해 chunk당 저장 키 하나로 저장합니다. 플레이어 id는 sessionStorage에 살아서 탭이 서로 다른 peer가 되고, 표시 이름은 localStorage에 살아서 이름 변경이 탭 전체에 적용됩니다.

가환 편집 수렴. 덧셈 지형 연산(올리기/내리기, SDF 더하기/빼기)은 가환이며 조율 없이 낙관적-스탬프 경로를 탑니다. 순서 의존 연산(스무딩, 평탄화)은 영역 잠금을 얻는 대신 미리 계산된 셀별 delta를 보냄으로써 가환으로 만들어지므로, 시스템 전체가 잠금 없이 하나의 균일한 프로토콜 아래 수렴합니다. 레코드별 소품 잠금이 소유자만 삭제 가능하던 방식을 대체하고, 클라이언트가 이름 지은 객체 id가 서버 응답이 도착하기 전에 결정론적인 실행 취소/재실행을 가능하게 합니다. GPU 구동 LOD를 보세요.

Godot에서 TSL로의 충실한 셰이더 이식. Quaternius의 소스 바람 셰이더 네 개를 한 줄씩 번역합니다. 강체 bark, 높이로 마스킹된 잎 흔들림, 월드 공간 식생 흔들림, 그리고 스크롤하는 wind-line 흔들림을 가진 풀이며, 팩의 머티리얼 명명 규칙으로 디스패치됩니다. 잎 색은 텍스처가 아니라 높이 그라디언트에 Fresnel 구동 SSS 림을 더한 데서 오고, sRGB 제작 상수는 선형으로 변환되어 외형이 참조 렌더와 일치합니다.

프레임마다 일어나는 셰이더 재컴파일 피하기. 커스텀 TSL을 FBX 머티리얼 위에 얹으면 needsUpdate가 고정되어 프레임마다 프로그램을 다시 빌드하고, 약 1 fps로 붕괴할 수 있습니다. 머티리얼별 차이를 전부 uniform으로 바인딩하면 고유한 매개변수 집합마다 새 셰이더를 내뱉는 대신 모든 변형이 컴파일된 프로그램 하나를 공유합니다. 평범한 텍스처 진단 pass가 커스텀 그래프가 원인인지 상류 설정이 원인인지 격리해 줍니다.

인스턴스화된 식생의 월드 공간 바람. WGSL에 inverse()가 없기 때문에, 풀 바람은 월드 공간에서 계산되어 손으로 유도한 역(스케일 제곱으로 나눈 전치)으로 로컬로 변환됩니다. 정점별 GPU 절두체 컬링이 화면 밖 풀 다발을 퇴화 삼각형으로 붕괴시켜 어떤 프래그먼트나 그림자 작업도 돌지 않게 하며, Three.js의 chunk별 바운딩 구 컬링 위에 쌓입니다.


29부 중 24부. 이전: 23부 - 쉰 명의 avatar와 방 안의 목소리 다음: 25부 - 하나의 스켈레톤, 모든 의상 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide