Skip to content

브라우저에서 오픈 월드 만들기, 23부: 아바타 50개와 방 안의 목소리

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

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

22부는 세계 위에 하늘을 덮었습니다. 이번 부분은 그 안에 사람을 넣습니다. 3인칭 오픈 월드는 어느 순간에든 50명이 넘는 캐릭터가 보이길 원하고, 바로 옆에 서 있는 사람들의 목소리도 들리길 원합니다. Spike 45는 렌더링 쪽입니다. 그 많은 애니메이션 아바타를 메인 스레드를 녹이지 않고 GPU에 올리는 일이죠. Spike 46은 오디오 쪽입니다. 위치에 따라 패닝되고 감쇠하는 P2P 음성을, 기술 데모가 아니라 평범한 화상 통화처럼 들리도록 튜닝했습니다.

댄서 50명에게 draw call 하나

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

기본 Three.js 경로는 캐릭터마다 자기 SkinnedMesh, 자기 AnimationMixer, 자기 본 행렬 업로드, 자기 draw call을 줍니다. 로컬 Mac에서 아바타 50개일 때 그건 GPU가 일을 하나라도 하기 전에 프레임당 약 13ms의 순수 JavaScript 오버헤드였습니다. 멀티플레이어 트랙 전체의 리스크 해소 질문은, 하나의 배치 스키닝 아키텍처가 이걸 통제하고 캐릭터 수에 따라 선형으로 확장될 수 있느냐였습니다.

답은 애니메이션을 어디서 계산하는가와 어디서 그리는가를 나누는 것입니다. 세 개의 캐릭터 클래스가 하나의 FBX 템플릿을 공유합니다. 로컬 플레이어는 평범한 Avatar로, 자기 mixer를 가진 완전한 스켈레톤 클론이며 표준 Three.js 경로를 따릅니다. 어차피 하나뿐이니까요. 모든 원격 peer는 VirtualSkeleton입니다. 마찬가지로 자기 mixer를 가지고 같은 클립을 돌리는 완전한 클론이지만, 모든 SkinnedMesh 노드는 클론 직후에 곧바로 벗겨내서 본만 남습니다. 그것은 절대 씬에 들어가지 않습니다. 매 프레임, mixer가 업데이트되고 행렬이 자리를 잡은 뒤에, 100개 본 전부의 (bone.matrixWorld × boneInverse)를 공유 Float32Array의 한 슬롯에 채워 넣습니다. 그러면 BatchSkinnedRenderer가 지오메트리 조각마다 하나의 InstancedMesh를 소유하고, 모두 maxInstances × numBones × mat4 크기의 단일 StorageBufferAttribute에서 본 행렬을 읽습니다. 즉 60 × 100 × 64 = 384 KB입니다. 커스텀 positionNodenormalNode를 가진 MeshStandardNodeMaterial이 정점마다 네 개의 본 영향을 그 storage buffer에서 곧장 읽습니다. 결과는 군중 전체가 안에 몇 명이 있든 지오메트리 조각마다 storage 업로드 한 번과 draw call 한 번뿐입니다. 스키닝은 정점 셰이더 안에 살고, 아바타당 JavaScript 비용은 mixer를 돌리고 행렬 100개를 복사하는 수준으로 떨어집니다.

이걸 측정하는 HUD도 다시 만들어야 했습니다. 옛 버전은 전체 프레임 GPU 시간이 3ms를 넘으면 "예산 초과"라고 표시했지만, 프레임에는 항상 shadow map, 지면, 로컬 플레이어의 완전한 skinned mesh가 들어가고, 이들이 합쳐 실제 하드웨어에서 합성 peer가 몇 개든 상관없이 3~5ms를 돌립니다. 해결책은 스스로 보정하는 예산입니다. 배치 아바타가 없는 동안에는 빠른 EMA를 통해 실시간 GPU 시간을 기준선으로 잡고, 그 기준선을 동결한 다음 합성체가 나타나면 아바타가 추가될 때마다 0.06ms씩 예산을 선형으로 키웁니다. 모든 머신에서 유휴 상태일 때 PASS로 읽히고, 군중이 늘면 비례해서 조여집니다.

버그는 볼 수 없는 얼굴이었다

Chrome에서 첫 실행은 지면에 은색 그림자만 보이고 아바타는 하나도 없이, WGSL 파싱 오류를 냈습니다. cannot index type 'f32'였고, 스칼라로 선언된 uniform인데 object.nodeUniform2[i]로 첨자 접근을 시도하는 줄에서 났습니다. 이 이야기의 정직한 부분은, 첫 수정이 틀렸는데도 어쨌든 작동했다는 겁니다. 추측은 InstancedMesh의 인스턴스 행렬 경로가 그 나쁜 코드를 생성한다는 것이었고, StorageInstancedBufferAttribute로 바꿔 끼우니 Chrome에서 오류가 사라졌습니다. 하지만 그건 새 경로가 다른 셰이더 코드를 내보냈기 때문에 사라진 것이지, 원인을 다뤘기 때문이 아니었습니다. 가장 위험한 종류의 수정이죠.

진짜 범인은 morph target이었습니다. 3MIKE.fbx에는 blend-shape 표정이 딸려 오고, 클론된 지오메트리는 morphAttributes를 상속하며, Three.js의 MorphNode.setup()morphTargetInfluences를 스칼라 float로 선언한 다음 합성된 루프 안에서 그것을 .element(i) 하려 합니다. 이게 바로 컴파일러가 거부한 그 스칼라 첨자입니다. 수정은 한 줄로, morph를 쓰지 않는 지오메트리에서 geometry.morphAttributes = {}를 비워서 Three.js가 MorphNode를 아예 주입하지 않게 합니다. 그 우연한 Chrome 수정은 한동안 남아 있다가 되갚아 줬습니다. Safari에서 storage-instanced 경로가 Vertex buffer is not big enough를 256번이나 냈는데, Safari의 WebGPU 백엔드가 그걸 깔끔하게 변환하지 못하기 때문이었습니다. 되돌린 건 옳은 결정이었고, 생성된 인스턴스 경로가 아니라 코어 WebGPU인 평범한 본 행렬 storage buffer는 어디서나 잘 작동합니다. 새겨둘 만한 교훈은 이겁니다. 어떤 수정이 한 브라우저에서 작동하는데 그 메커니즘을 설명할 수 없다면, 당신은 증상에 반창고를 붙인 것이니 실제로 생성된 WGSL을 읽으세요. spike 후반에 추가한 getCompilationInfo() 심은 Three.js의 두루뭉술한 "module is not valid"를 진짜 Tint 오류로 바꿔 줘서, 그 값을 여러 번 톡톡히 했습니다.

그 옆에는 관련된 프레임워크 회피 트릭이 하나 있습니다. Three.js는 표준 skinIndexskinWeight 속성 이름을 감지하면, 커스텀 positionNode가 이미 스키닝을 하는 InstancedMesh에서도 자기 SkinningNode를 주입하려 듭니다. 그 속성들을 boneIndexboneWeight로 이름 바꾸면 프레임워크 눈에서 숨겨지고, 커스텀 TSL이 새 이름으로 그것들을 읽습니다.

말과 말 사이에 너를 잊는 릴레이

첫 버전은 BroadcastChannel로 peer를 동기화했습니다. 실제 와이어 포맷과 박자를 가진, 같은 브라우저 안의 대역이었고, 프로토콜 주석은 실제 전송으로 교체하는 게 한 줄이면 된다고 약속했습니다. 그 약속을 현금으로 바꾸는 건 AvatarRoomDO, 즉 36바이트 바이너리 프레임을 디코드조차 하지 않는 74줄짜리 Cloudflare Durable Object를 뜻했습니다. 그것은 각 메시지를 방 안의 다른 모든 peer에게 그대로 전달합니다. 발신자의 id가 프레임에 박혀 있고 각 수신자가 자기 echo를 클라이언트 쪽에서 걸러내기 때문입니다. 이 릴레이는 신원에 대한 인식이 전혀 없습니다. Hibernating WebSocket은 유휴 방을 공짜로 만듭니다. DO는 메시지 사이에 메모리에서 빠져나가고, 런타임은 다음 패킷에서 태그된 소켓을 복원합니다. peer당 초당 10개 이벤트면 그건 peer-시간당 36,000번의 DO 요청, 약 0.5센트이고, Cloudflare에서 egress가 공짜이며 동등한 AWS WebSocket 형태보다 대략 6~10배 저렴합니다.

이 교체는 간직할 만한 상태 기계 버그를 드러냈습니다. 한 원격 플레이어가 멈춘 뒤에도 계속 걸었습니다. 애니메이션 요청은 마지막으로 큐에 들어간 이름이 아니라 this._state, 즉 지금 재생 중인 클립을 기준으로 가드했습니다. 그래서 같은 tick에 두 네트워크 메시지가 도착해 먼저 walk 다음 idle이 오면, idle이 아직 전진하지 않은 상태와 비교되어 조용히 버려졌습니다. peer는 영원히 걷기에 갇혔는데, 미래의 idle 패킷이 변하지 않은 것으로 상류에서 중복 제거되었기 때문입니다. 수정은 대기 중인 이름을 항상 덮어쓰고, 진짜 같은 상태 요청에 대해서는 전환 헬퍼가 단락하도록 두는 것입니다. 헬퍼는 이미 그렇게 하고 있었죠. 이 부류의 버그는 일반적입니다. 잘못된 참조 값에 대한 중복 제거 검사는 정작 중요한 입력을 조용히 삼킵니다.

Safari는 가드 두 개를 더 필요로 했습니다. Chrome보다 WebSocket을 빨리 여는 탓에, 첫 인바운드 peer 메시지가 배치 렌더러 구성이 끝나기 전에 도착해 null을 역참조할 수 있었습니다. 렌더러가 없는 동안 메시지를 버리는 건 안전한데, peer가 100ms마다 다시 브로드캐스트하기 때문입니다. 그리고 'gpu' in navigator는 true를 반환하는데 requestAdapter()는 null을 반환해서, Three.js가 조용히 WebGL2로 폴백했고, 거기서는 storage-buffer 스키닝 체인에 유효한 변환이 없어 오류를 쏟아냈습니다. 진짜 adapter가 있는지 확인하고 백엔드가 실제로 WebGPU인지 단언하면, 망가진 렌더링을 명확한 로딩 화면 메시지로 바꿔 줍니다. WGSL 방언 격차마저 있었습니다. Three.js는 현대적인 두 인자 @interpolate(flat, either)를 내보내는데 WebKit의 컴파일러는 아직 출시하지 않았습니다. createShaderModule로 들어가는 길에 셰이더 소스를 다시 써서 두 번째 인자를 떨어뜨려 패치했고, 이건 공짜입니다. flat 보간은 어차피 모든 정점에서 같은 값을 나르니까요.

방과 함께 패닝되는 목소리

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

Spike 46은 근접 음성입니다. HRTF 공간 음향을 쓰는 P2P WebRTC로, 조용하거나 적당히 시끄러운 방에서 Google Meet와 Microsoft Teams 품질을 맞추는 것으로 명시적으로 범위를 좁혔습니다. VoiceRoomDO가 시그널링을 JSON 릴레이로 처리합니다. 새 peer마다 명단을 보내고, 입장과 퇴장을 알리고, 소켓 태그로 SDP와 ICE를 특정 peer 하나에게 라우팅하고, 공간 패너를 구동하는 위치 업데이트를 브로드캐스트합니다. 모든 메시지에 발신자 id를 찍어서 peer들이 서로를 사칭하지 못하게 하고, 오디오 자체는 절대 DO를 거치지 않습니다. 원격 peer마다 RTCPeerConnection 하나이며, 사전순으로 더 작은 peer id가 항상 offer를 보내서 완전한 perfect-negotiation 구현 없이도 양쪽이 누가 시작할지 합의합니다.

받는 쪽에서는 각 peer의 오디오가 HRTF에 역거리 롤오프로 설정된 PannerNode를 통과하고, AudioListener는 로컬 플레이어의 위치와 방향에서 매 프레임 forwardX = sin(facing), forwardZ = cos(facing)로 업데이트되는데, 이는 씬의 atan2(wx, wz) 방향 규약과 맞습니다. Chrome 특유의 버릇 하나가 한 시간을 잡아먹었습니다. Web Audio만 소비하는 MediaStream은 때때로 패킷을 끌어오지 않으므로, 각 스트림을 숨겨진 음소거 <audio> 요소에도 붙여서 디코더가 스케줄링하도록 강제합니다. 품질 쪽에서는 브라우저가 기본적으로 약 32 kbps 모노 Opus를 쓰므로, 이 spike는 모든 offer와 answer의 fmtp 줄을 손봐서 in-band FEC를 켜고 DTX를 끈 채 128 kbps로 올린 다음, 높은 최대 비트레이트로 setParameters를 호출해 인코더가 SDP가 광고한 값을 실제로 쓰도록 보장합니다. FEC는 비트레이트 향상 다음으로 두 번째로 큰 들리는 이득으로, 재협상 없이 패킷 손실에서 복구합니다.

깨끗한 오디오로 가는 삭제의 길

출시된 오디오 체인은 내가 시작했던 것보다 훨씬 작고, 그걸 줄인 것이 진짜 교훈이었습니다. 첫 버전에는 하이패스 필터, 키보드 소음을 잡으려고 튜닝한 클릭 리미터, 컴프레서, 노이즈 게이트, 그리고 wet-dry 크로스페이드가 있었고, 앞쪽엔 슬라이더가 열두 개도 넘는 떠다니는 패널이 붙어 있었습니다. 사용자가 들리는 키보드 클릭을 신고했을 때, 본능은 클릭 리미터를 더 세게 조이고 dry 믹스를 낮추는 것, 즉 반창고 더미였습니다. 구조적인 답은, 일단 체인에 ML 디노이저가 들어오면 클릭 리미터와 게이트와 하이패스 대부분이 전부 군더더기라는 것이었습니다. RNNoise는 정확히 키보드와 마우스와 타이핑 소음으로 학습되었고, 진폭 클리핑은 같은 일을 명백히 더 못하는 버전이기 때문입니다. 프로덕션 클라이언트는 ML 디노이즈, 에코 캔슬, 자동 게인, 그리고 레벨용 소프트 컴프레서를 출시하고 그 외엔 아무것도 안 합니다. 그래서 네 단계가 빠졌고, 슬라이더 패널이 빠졌고, "노이즈 감소를 골라라" 토글도 빠지면서, 고정된 파이프라인 하나가 남았습니다.

살아남은 단계마다 자기 자리를 벌었습니다. 브라우저 에코 캔슬은 켜진 채로 둡니다. RNNoise는 에코를 다루지 않고, 그게 없으면 스피커-마이크 피드백이 무한정이기 때문입니다. 브라우저 노이즈 억제는 끕니다. RNNoise 위에 그걸 쌓으면 마찰음에 아티팩트가 생기므로 디노이저는 하나만 고릅니다. 브라우저 자동 게인은 켜진 채로 둡니다. 그걸 끄면 신호가 너무 작아져 컴프레서가 작업할 게 없어지고, Web Audio의 DynamicsCompressorNode에는 이를 보정할 makeup-gain 파라미터가 없기 때문입니다. 브라우저의 넓은 레벨링과 spike의 빠른 컴프레서는 다른 시간 척도에서 작동하며 공존합니다. RNNoise는 92퍼센트 wet에 8퍼센트 dry를 섞어 돌리는데, s, sh, f 같은 무성 자음을 과하게 억제할 수 있기 때문입니다. 이런 음들은 voice 확률이 떨어지는데, 작은 dry 경로가 약간의 키 입력 누출을 대가로 그것들을 보존합니다.

두 기능이 마무리를 짓습니다. Push-to-talk는 track.enabled를 뒤집지 않습니다. 그러면 파이프라인 버퍼에 아직 남은 모든 걸 버리고 키를 뗄 때 마지막 음절을 잘라내기 때문입니다. 대신 꼬리 근처의 GainNodesetTargetAtTime으로 램프하는데, 빠른 어택으로 첫 음절이 살아남고 느린 릴리스로 마지막 자음이 빠져나가며, 트랙은 영구히 켜둡니다. 그리고 라디오 스타일 기능으로 요청된 5초 방송 지연은, 바이패스 갈래와 DelayNode 갈래를 크로스페이드해서 함께 돌리고, 지연된 출력을 순간 무음으로 끊고 오디오가 돌아오기 전 HUD에서 카운트다운하는 덤프 버튼을 둡니다. 디노이저를 번들링하는 건 그 자체로 작은 무용담이었습니다. 발행된 RNNoise worklet은 어떤 CDN도 해석하지 못하는 bare-specifier 임포트를 쓰므로, 수정은 로컬 esbuild 번들로 WASM을 base64로 인라인한 자체 포함 1.9 MB 파일 하나를 만들어 리포에 커밋하고, 모듈에 상대적인 URL로 참조해서 개발 서버, VitePress 빌드, 커스텀 도메인 모두에서 해석되게 하는 것이었습니다. 혹시 worklet이 로드에 실패하면 체인은 여전히 평범한 하이패스와 컴프레서를 통해 오디오를 내고, HUD가 그 실패를 빨간색으로 보여줍니다.

이 장에서 다룬 기술

군중을 위한 배치 GPU 스키닝. 원격 아바타는 헤드리스 VirtualSkeleton(skinned mesh를 벗기고 본을 남긴, 자기 mixer를 가진 완전한 클론)을 돌려서 모든 본의 bone.matrixWorld × boneInverse를 공유 StorageBufferAttribute에 채웁니다. 지오메트리 조각마다 하나의 InstancedMesh가 그 행렬들을 커스텀 TSL positionNode/normalNode에서 읽으므로, 군중 전체가 조각당 storage 업로드 한 번과 draw call 한 번이 들고, 아바타당 CPU 작업은 mixer 업데이트 한 번과 행렬 복사 한 번으로 제한됩니다. GPU 구동 LOD를 보세요.

증상이 아니라 생성된 WGSL을 읽기. cannot index type 'f32' 컴파일 오류는 Three.js의 MorphNodemorphTargetInfluences를 스칼라로 선언하고 첨자를 다는 데서 비롯됐고, morph를 쓰지 않는 지오메트리에서 morphAttributes를 비워 고쳤습니다. 어떤 셰이더 경로가 생성되는지만 바꾼 첫 수정은 원인을 가렸고 나중에 Safari를 깨뜨렸습니다. skinIndex/skinWeightboneIndex/boneWeight로 이름 바꾸면 Three.js의 자동 SkinningNode 주입에서 속성을 숨겨, 커스텀 스키닝 머티리얼이 수학을 소유합니다.

Hibernating Durable Object 릴레이. 순수 바이너리 AvatarRoomDO는 36바이트 프레임을 디코드하지 않고 다른 모든 peer에게 전달하며, 발신자 신원은 프레임에 박혀 있고 자기 echo는 클라이언트 쪽에서 걸러집니다. Hibernating WebSocket은 유휴 방을 공짜로 만들고, 이 형태는 10 Hz에서 peer-시간당 약 0.5센트로, 동등한 관리형 WebSocket 가격보다 훨씬 낮습니다. 마지막으로 큐에 들어간 상태가 아니라 재생 중인 애니메이션 상태와 비교한 중복 제거 가드는 정지 메시지를 조용히 버려서 원격 플레이어를 걷기 루프에 가두었습니다.

HRTF를 쓰는 WebRTC 근접 음성. peer마다 하나의 RTCPeerConnection으로 offer/answer 역할은 peer-id 순서로 결정하고, 오디오는 HRTF PannerNode를 통과하며 AudioListener는 플레이어 방향에서 매 프레임 업데이트되고, Opus는 회복력을 위해 in-band FEC와 함께 128 kbps로 손봤습니다. 음소거된 숨겨진 <audio> 요소가 Chrome이 Web Audio 전용 스트림에서 패킷을 끌어오게 강제합니다.

빼는 오디오 엔지니어링. Meet/Teams 품질을 맞추는 건 단계를 더하는 게 아니라 빼는 것이었습니다. ML 디노이즈 더하기 에코 캔슬 더하기 자동 게인 더하기 소프트 컴프레서, 게이트도 클릭 리미터도 없이. 키보드 소음으로 학습된 ML 디노이저가 진폭 클리핑을 군더더기로 만들기 때문입니다. Push-to-talk는 트랙을 뒤집는 대신 비대칭 엔벨로프로 꼬리 GainNode를 램프해서 음절이 잘리지 않게 하고, 디노이저 worklet은 bare-specifier 임포트 해석을 피하려고 단일 자체 포함 esbuild 번들로 출시됩니다.


29부 중 23부. 이전: 22부 - 빛을 줄 수 있는 구름, 그리고 먹여야만 하는 컬링 다음: 24부 - 세계를 저장하기, 그리고 눈에 보이는 바람 시리즈 안내: /ko/blog/2026-02-25-open-world-browser-series-guide