브라우저에서 오픈 월드 만들기, 15부: 기준선을 교체하고, 그다음 동기화하기
글쓴이 Oleg Sidorkin, Cinevva CTO 겸 공동 창업자
처음 보시나요? 시리즈 가이드를 보세요. 스파이크가 무엇인지 설명하고 모든 부분을 링크해 두었습니다.
앞선 열네 개 부분은 스파이크 1부터 30까지를 다뤘습니다. 그 흐름은 실시간으로 조각할 수 있는 지형 시스템과, 그 위를 걷고 활공하고 떨어질 수 있는 캐릭터로 마무리됐습니다. 이번 부분은 스파이크 31에서 시리즈를 다시 이어갑니다. 그리고 우리가 가장 먼저 결정해야 했던 건 기술적인 게 아니었습니다. 그 수많은 스파이크 코드를 어떻게 할 것인가였습니다.
"교체하라, 백포트하지 마라"는 결정
우리에겐 각각 하나의 고립된 개념을 증명하는 독립 HTML 파일 30개가 있었고, 통합은 전혀 되어 있지 않았습니다. 프로덕션의 world/client/는 여전히 옛 스택이었습니다. WebGL, 단순한 heightmap, 75줄짜리 캐릭터 컨트롤러, 메시지 타입이 아홉 개인 MessagePack 프로토콜. 편집도, WebGPU도, 머티리얼도, 식생도 없었습니다.
뻔한 계획은 스파이크 결과물을 그 프로덕션 코드베이스에 하나씩 백포트하는 것이었습니다. 우리는 그걸 버렸습니다. 스파이크 30은 이미 world/client/가 도달했던 그 어떤 것보다도 나은 지형, 물리, 머티리얼, 식생, 카메라를 갖고 있었습니다. 옛 WebGL 코드에 백포트한다는 건 처음부터 끝까지 그 코드와 싸운다는 뜻이었습니다. 그래서 우리는 결정을 내렸습니다. 가장 성공적인 스파이크로 월드 구현을 교체하고 거기서부터 앞으로 만들어가기로요. 스파이크 30이 새 기준선이 되었고, world/client/는 죽은 코드가 됐습니다.
이 결정이 남은 작업을 다시 짰습니다. "멋진 싱글플레이어 기술 데모"에서 "제품"으로 가려면 멀티플레이, 영속성, 무한 월드 스트리밍, 오브젝트 배치가 필요했습니다. 멀티플레이 지형 동기화를 먼저 했습니다. 그것이 아키텍처를 강제하는 작업이기 때문입니다. 그것이 답하는 질문은 묻기는 쉽고 틀리면 비싼 질문입니다. 플레이어 A가 조각할 때, 실제로 회선을 타고 가는 건 무엇인가?
픽셀 동기화가 아닌 브러시 파라미터 재생
네트워크 코드를 한 줄 쓰기 전에, 우리는 브러시 스트로크 하나가 정확히 무엇을 하는지 따라가 봤습니다. heightmap 브러시는 커서 주변 반경을 CPU의 Float32Array에서 돌면서 smoothstep 감쇠를 적용하고, 더하거나 빼거나 부드럽게 하거나 평평하게 만듭니다. SDF 브러시는 복셀 구체 위에서 3D로 같은 일을 합니다. 두 경로 모두 순수한 CPU 배열 연산입니다. 루프 안에 GPU 컴퓨트도 없고, 무작위성도 없고, 비결정적 부동소수점도 없습니다. 같은 입력 배열에 같은 파라미터면 모든 기계에서 같은 출력입니다.
이게 비결의 전부입니다. 우리는 편집된 지형을 보내지 않습니다. 브러시 파라미터를, 스트로크 틱당 56바이트를 보내고, 모든 클라이언트가 같은 결정적 함수를 재생합니다. 동기화 프로토콜은 네 가지 메시지 타입입니다. 피어 발견, 브러시 메시지 {op, wx, wy, wz, radius, strength, flattenTarget}, 그리고 20Hz의 플레이어 위치 메시지.
이 스파이크에서는 서버를 아예 건너뛰고 BroadcastChannel을 썼습니다. 동일 출처 탭 간 메시징을 위한 브라우저 API입니다. 탭 두 개를 열면 서로 통신합니다. 인프라는 전혀 없습니다. 이렇게 하면 동기화 문제를 지연, 인증, Durable Object 배선에서 떼어내 격리할 수 있습니다. 파라미터 재생이 탭 사이에서 수렴한다면, WebSocket 너머에서도 수렴할 겁니다.
재생이 어긋날 수 있는 유일한 지점은 순서에 의존하는 연산입니다. 올리기와 내리기는 교환 가능해서, val + strength * falloff는 누가 먼저 적용했든 같은 자리에 떨어집니다. 부드럽게 하기와 평평하게 하기는 이웃 값을 읽기 때문에, 두 클라이언트가 정확히 같은 지점을 같은 순간에 부드럽게 하면 틱당 수천분의 1밀리미터씩 어긋날 수 있습니다. 실제로는 그런 일이 절대 발생하지 않고, 프로덕션에서의 수정 방법은 이미 분명합니다. 편집을 DO로 라우팅하고, DO가 단조 증가하는 시퀀스 번호를 할당하게 하고, 클라이언트에서는 낙관적으로 적용하고, 권위 있는 시퀀스가 다르면 순서를 바로잡으면 됩니다. 전형적인 낙관적 동시성 제어이고, 어차피 DO는 자연스러운 직렬화 지점입니다.
계속 사라지던 피어 캡슐
편집은 첫 시도에 동기화됐습니다. 원격 플레이어의 캡슐은 아니었습니다. 다른 탭에서 있다가 없다가 깜빡였고, 그것이 견고하게 남도록 만드는 데 서로 다른 버그 세 개를 잡아야 했습니다.
캡슐은 월드 원점에서 스폰됐는데, 그곳은 지형 아래에 묻혀 있습니다. join 메시지가 어떤 위치 데이터보다도 먼저 도착하기 때문입니다. 수정: 숨긴 상태로 시작하고 첫 위치 업데이트에서 드러나게 합니다. 위치 브로드캐스트는 렌더 루프 안에 있었는데, Chrome은 포커스가 없는 탭에서 requestAnimationFrame을 스로틀링합니다. 그래서 다른 탭의 만료 검사가 피어를 거둬가고 다음 메시지가 그것을 다시 만들어냈습니다. 수정: 브로드캐스트를 setInterval로 옮깁니다. 이건 보이는 탭에 대해 스로틀링되지 않습니다. 그리고 만료 타임아웃이 5초로 너무 공격적이어서 어떤 GC 일시정지에도 걸렸습니다. 수정: 30초로 올리고 정상 종료에는 깔끔한 leave 메시지에 의존합니다.
영속성과 늦은 합류, 같은 포맷
우리는 영속성을 새 스파이크를 띄우는 대신 같은 스파이크 안에 접어 넣었습니다. 목적지가 IndexedDB든 다른 탭이든 직렬화 포맷이 동일하기 때문입니다. 스냅샷 하나는 전체 heightmap(129×129 Float32Array, 약 66 KB)에, 편집된 SDF 청크(각
첫 영속성 테스트가 괜찮은 순서 버그를 드러냈습니다. 풀은 초기화 때 절차적 높이를 써서 동기적으로 흩뿌려지는데, IndexedDB 복원은 비동기이고 그 후에 heightmap을 덮어씁니다. 그래서 모든 풀잎이 공중에 뜨거나 가라앉았습니다. 수정은 refreshAllGrass() 패스로, 각 인스턴스 아래의 높이를 다시 샘플링하고 이제 나쁜 경사나 고도에 놓인 풀잎을 숨깁니다. 같은 함수가 로드와 늦은 합류 둘 다를 맡습니다.
경사 연속극
조각된 지형은 매끈한 절차적 기준선보다 거칠어서, 옛 지형이 절대 일으킬 수 없던 물리 버그 세 개를 드러냈습니다. 똑바로 언덕을 올라가면 캡슐이 옆으로 미끄러졌습니다. 원인은 이동을 지면에 접하도록 유지하려던 속도 투영이었는데, 법선의 수평 성분만으로 작성되어 있었습니다. 법선이
드리프트는 두 번째 원인에서 계속 이어졌습니다. SDF 충돌 프로브는 침투 깊이만큼 그래디언트 방향으로 몸을 밀어냅니다. 어떤 경사에서든 그래디언트는 수평 성분을 가지므로, 15° 경사에서 0.1 m 침투는 한 스텝당 옆으로 약 0.026 m를 밀고, 120Hz에서는 대략 3 m/s의 보이지 않는 드리프트가 됩니다. 수정: 경사에 따라 반응을 나눕니다. 걸을 수 있는 지면(
세 번째 버그는 청크 경계에서 캡슐을 얼어붙게 했습니다. 충돌 프로브가 단일 청크의 SDF만 샘플링하다가 프로브가 이웃으로 넘어가면 "공중 깊숙이"라는 센티넬 값을 받았기 때문입니다. 수정은 sdfSampleWorld(wx, wy, wz)와 sdfGradientWorld(...)였습니다. 이들은 임의의 월드 위치에 대해 올바른 청크를 찾고, SDF가 없는 곳에서는 heightmap 거리 추정으로 폴백합니다. 이제 SDF에서 heightmap으로의 충돌 전환은 연속적입니다.
물이 월드를 완성한다
여기까지의 모든 스파이크는 "물 위의 땅"이었습니다. 스파이크 32는 바다를 추가했고, 그와 함께 새 이동 동사를 추가했습니다. 우리는 대략 8에서 58까지 범위인 지형에서 수위를 22로 설정했습니다. 이렇게 하면 낮은 골짜기가 잠기고, 물가에 해변이 남고, 놀기에 충분한 마른 땅이 남습니다.
수면은 TSL로 만든 MeshStandardNodeMaterial로, 지형과 같은 노드 방식입니다. 서로 다른 주파수의 겹쳐진 사인파 세 개가 정점을 변위시키고, 표면 법선은 메시 법선이 아니라 그 파동들의 해석적 코사인 도함수에서 나옵니다. 색은
수영은 부력 스프링입니다. 발이 수위 아래로 내려가고 몸 중심이 표면으로부터 캡슐 반높이 이내에 있을 때 플레이어가 수영 모드에 들어갑니다. 스프링이 몸을 표면 바로 아래의 목표로 끌어당기고, 부력 상수 12 대 물 감쇠 4면 플레이어가 머리를 내놓은 채 흔들림 없이 안정적으로 둥실거립니다. 수영 속도는 걷기보다 느리고 둥실거리는 가속과 항력이 붙으며, 수면 근처에서 점프하면 정상 점프 속도의 60%로 물 밖으로 솟구치고, 입수 시 하강 속도를 -5 m/s로 막아 곤두박질치지 않게 합니다. 지형 충돌은 물속에서도 여전히 작동하므로, 호수 바닥이 수영 목표보다 위로 올라온 곳에서는 걸을 수 있습니다. 수영 플래그는 위치 브로드캐스트에 함께 실려서 피어들이 당신이 수영하는 걸 볼 수 있고, 카메라가 수면 아래로 잠기면 HTML 그래디언트 오버레이가 화면을 물들입니다.
이 장에서 다룬 기술
결정적 브러시 파라미터 재생. 편집된 지형을 스트리밍하는 대신, 각 클라이언트는 브러시 파라미터만 보내고 같은 CPU 함수를 재생합니다. heightmap과 SDF 브러시 둘 다 무작위성이나 GPU 비결정성이 없는 순수한 Float32Array 연산이라서 동일한 입력이 어디서나 비트 단위로 동일한 출력을 만들어내기 때문에 가능합니다. 페이로드는 스트로크 틱당 56바이트입니다. 교환 가능한 연산(올리기, 내리기)은 순서와 무관하게 수렴하고, 이웃을 읽는 연산(부드럽게 하기, 평평하게 하기)은 수렴을 보장하기 위해 직렬화 지점이 필요한데, 이는 프로덕션의 Durable Object가 단조 시퀀스 번호로 제공합니다.
WebSocket 대역의 BroadcastChannel. 서버가 전혀 없는, 동일 출처 탭 간 메시징용 브라우저 API입니다. 여기서는 동기화 프로토콜을 네트워크 지연과 인증에서 격리해 테스트하는 데 썼습니다. 직렬화 포맷(원본 Float32Array heightmap 더하기 편집된 SDF 청크 더하기 MC로 잠긴 청크 ID)은 IndexedDB 영속성과 늦은 합류 상태 전송에 쓰이는 바로 그 바이트라서, 한 포맷이 세 가지 일을 담당합니다.
경사 분할 SDF 충돌 반응. 캡슐 프로브가 볼류메트릭 지형을 침투할 때, 순진한 수정은 침투 깊이만큼 SDF 그래디언트 방향으로 몸을 밀어냅니다. 경사에서는 그 그래디언트가 수평 성분을 가져 측면 드리프트를 주입합니다. 반응을 나눠서 걸을 수 있는 표면(
해석적 파동 법선을 쓰는 TSL 물. 바다는 정점이 합산된 사인파 세 개로 변위되는 노드 머티리얼입니다. 변위 후 메시 법선을 다시 계산하는 대신, 표면 법선은 파동 함수의 코사인 도함수에서 해석적으로 유도합니다. 이게 더 저렴하고, 거친 그리드에서 유한 차분 법선이 만드는 아티팩트를 피합니다. 깊이 기반 색, 물가 거품, 깊이 기반 투명도는 모두 단 하나의 깊이 추정값에 기반합니다.
부력 스프링 수영. 수영 물리는 몸을 표면 바로 아래의 목표로 끌리는 감쇠 스프링으로 모델링합니다. 부력 상수 12, 감쇠 4면 플레이어가 흔들림 없이 수면에 자리를 잡습니다. 별도의 이동 상수(더 느린 속도, 둥실거리는 가속, 무거운 항력)가 수영에 걷기와 다른 느낌을 주고, 기존의 캡슐 대 지형 충돌이 물속에서도 계속 작동합니다.
29부 중 15부. 이전: 14부 - 세계가 살아난다 다음: 16부 - 계속 자라는 세계를 위한 구조 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide