브라우저에서 오픈 월드 만들기, 29부: 컨트롤러 하나로 어떤 몸이든
글쓴이: Oleg Sidorkin, Cinevva의 CTO 겸 공동 창업자
처음 오셨나요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 파트로 연결해 둡니다.
28부에서는 풀과 오클루전을 다뤘습니다. 28개 파트가 안에 서 있을 수 있는 세계를 만들었습니다. 읽어낼 수 있는 지형, 수영할 수 있는 물, 지평선까지 버텨주는 식생, 당신이 바꾼 것을 기억하는 서버. 이번 파트는 그 모든 것을 가로질러 움직이는 그 무언가에 관한 것이고, 엔진 전체의 결실입니다. 목표는 플레이어 컨트롤러가 아니거든요. 자기가 어떤 몸을 구동하는지, 애니메이션이 어디서 왔는지, 운전대를 잡은 게 사람인지 AI인지 신경 쓰지 않는 컨트롤러입니다. Spike 58은 locomotion에 대해 아무것도 모르는 물리 엔진 위에 플러그인 가능한 동작들을 쌓아 올린 형태로 움직임을 만들고, 그런 다음 한 벌의 같은 엔진으로 서로 다른 세 가지 몸을 굴려 이 아키텍처를 증명합니다. Spike 59는 그 컨트롤러를 단 한 줄도 바꾸지 않고 진짜로 리타게팅한 아바타를 올립니다. Spike 60은 리타게팅이 전혀 필요 없는 다른 애니메이션 팩을 올리고, 클립 선택 로직을 끄집어내 실제로 테스트할 수 있는 무언가로 만듭니다.
걷기에 대해 아무것도 모르는 물리 엔진
이 설계 규칙은 가혹하고, 그게 핵심 전부입니다. capsule 엔진은 어떤 locomotion도 소유하지 않습니다. 걷기도, 달리기도, 점프도, 마찰도, 최고 속도 상한도 없고, 중력조차 없습니다. kinematic capsule을 지형에 대해 적분하는 것, 그뿐입니다. 모든 locomotion 동작, 즉 걷기, 미끄러지기, 활공, 등반, 수영, 웅크리기, stamina는 엔진에 등록된 자기완결적인 컨트롤러 안에 삽니다. 매 프레임마다 엔진은 모든 컨트롤러를 tick하고, 각각에게 제어권을 원하는지 묻고, 우선순위가 가장 높은 요구자가 velocity를 쓰게 합니다. 걷기에는 특별한 지위가 없습니다. 항상 yes라고 말하는 가장 낮은 우선순위의 컨트롤러일 뿐이라서, 다른 게 아무것도 발동하지 않을 때의 기본값이 됩니다. 컨트롤러는 작은 함수 여섯 개입니다. 활성화되지 않았을 때조차 매 프레임 자기 내부 상태를 갱신하는 tick, 그 프레임을 요구하는 순수한 wantsControl 술어, 승자만 실행하며 velocity를 쓰고 원한다면 자기 몫의 중력을 적용하는 applyForces, 그리고 선택적인 onEnter, onExit, stateName입니다. 수영은 applyForces에서 ownsCollision: true를 반환해 지형 처리를 넘겨받는데, 그렇지 않으면 부력 스프링이 엔진의 발 스냅과 싸우기 때문입니다.
locomotion이 아닌 것들조차 여전히 그 계약을 통해 새어 나오고, 그게 다음 아이디어를 끌어냈습니다. 이 아키텍처가 본전을 뽑게 해주는 부분은 channel입니다. 이전의 단일 channel 설계는 모든 것을 하나의 중재를 거쳐 돌렸는데, 이는 stamina 추적기나 웅크림 자세가 locomotion인 척하고 나서 자기 장부 기록을 돌리려고 wantsControl이 false를 반환하는 꼼수로 제어권을 거절해야 했다는 뜻입니다. 해결책은 컨트롤러를 독립적으로 중재하고 고정된 순서로 적용되는 이름 붙은 channel로 나눕니다. resource 다음 stance 다음 locomotion 순입니다. resource가 먼저 도는 이유는 stamina 감소 같은 그 쓰기를 다른 것들이 읽기 때문입니다. stance가 두 번째로 도는 이유는 웅크림이 capsule 높이를 줄이는 일이, 걷기가 그것을 읽어 최고 속도를 제한하기 전에 자리잡아야 하기 때문입니다. locomotion은 마지막으로 돌고 프레임별 velocity 쓰기를 소유합니다. 그래서 stamina 관찰자와 웅크림 수정자와 활성화된 수영 컨트롤러가 모두 각자 자기 channel 안에서 깔끔하게 공존하고, 어떤 컨트롤러도 자기가 무엇인지에 대해 거짓말할 필요가 없습니다. 데모는 활성 컨트롤러에 따라 색이 바뀌는 헐벗은 capsule로 이 모든 것을 시각화해서, 중재가 일어나는 걸 지켜볼 수 있습니다. 초록색 걷기가 가파른 비탈에서 미끄러지기가 넘겨받으며 주황색으로 바뀌고, 호수에서는 수영이 이기며 청록색으로, 공중에서 활공을 톡 누르면 크림색으로 바뀝니다.
엔진 하나, 몸 셋
"locomotion을 소유하지 않는다"의 진짜 시험은 플레이어가 아닙니다. 같은 엔진이, 손대지 않은 채로, 플레이어가 전혀 아닌 무언가를 구동할 수 있느냐입니다. createCapsuleEngine은 모듈 수준 상태도, 싱글톤도, 인스턴스별 부작용도 없는 순수한 factory라서, 이 spike는 그것을 세 번 인스턴스화합니다. 플레이어는 전체 컨트롤러 세트를 가진 한 인스턴스입니다. 탈 수 있는 말은 걷기 컨트롤러만 등록하는 두 번째 인스턴스인데, 이게 그 아이디어를 글자 그대로 구현한 것입니다. 한 몸의 움직임 어휘는 당신이 등록한 컨트롤러들 그 자체일 뿐이라서, 말은 평지에서 더 빠르고 등반, 수영, 활공은 물리적으로 못 합니다. 그 컨트롤러들이 애초에 추가되지 않았기 때문입니다. 자율 NPC는 세 번째 인스턴스로, 플레이어와 나란히 매 프레임 tick되고, 자기 입력을 합성하는 wander 컨트롤러가 구동해서 키보드에 손이 없는데도 몸이 스스로 방향을 잡습니다. 엔진은 자기 몸 중 하나가 말이라거나 다른 하나가 AI 구동이라는 걸 결코 알지 못합니다. 모두 컨트롤러 목록만 다른 똑같은 capsule 적분기입니다.
탑승은 의도적으로 엔진 바깥에 사는 한 조각입니다. 플레이어와 말 사이에 제어권을 바꾸는 건 두 엔진을 조율한다는 뜻이고, 컨트롤러는 한 엔진 안에서 돌며 그 경계 너머를 볼 수 없어서, 탑승 로직은 host 수준에 앉습니다. 이번 프레임에 어느 엔진을 스텝할지 결정하고, 다른 하나는 얼리고, 4분의 3초에 걸친 smoothstep으로 라이더를 안장 위로 부드럽게 올리고, 카메라에 어느 몸을 추적할지 알려주는 작은 상태 기계입니다. 엔진 factory는 이 중 무엇도 듣지 못합니다. 그게 아키텍처가 긋고 지키는 선입니다. 한 몸에 속하는 동작은 컨트롤러이고, 몸 사이의 조율은 host의 일이며, 그것들을 분리해 두는 것이 네 번째나 마흔 번째 몸을 추가해도 새로 드는 비용이 전혀 없는 이유입니다.
브라우저 없이 테스트하기
엔진은 어떤 window도, document도, Three.js도 손대지 않기 때문에 전체가 헤드리스로 돌아갑니다. 이 spike는 지형 인터페이스를 mock하고, 스크립트로 짠 입력으로 엔진을 프레임 단위로 구동하고, 결과 상태에 대해 단언하는 Node harness를 함께 출하해서, 플레이 테스트로 잡기에는 비참한 회귀들이 대신 스크립트 안에서 잡힙니다. 점프와 착지 전환, 수영 진입과 이탈 임계값, 표면이 평평해질 때 등반 자동 해제, stamina 소모율 같은 것들입니다. 같은 입력과 timestep 시퀀스를 두 번 먹이고 바이트 단위로 동일한 출력 상태를 확인하는 것이 엔진을 결정적인 것으로 못 박는데, 그건 네트워크 빌드가 결국 기대게 될 속성입니다. harness가 잡은 회귀 하나는 간직할 만합니다. 걸을 수 있는 땅에서 걷기보다 가파른 비탈로 올라서면, 예전에는 미끄러지기 컨트롤러가 작동해 플레이어를 언덕 위로 도로 튕겨 올렸습니다. 해결책은 하강 게이트였습니다. capsule이 실제로 비탈 위로 떨어지고 있을 때만 미끄러지기가 시작되도록 해서, 이제는 가파른 면으로 수평으로 걸어 들어가면 미끄러지지 않고 깔끔하게 막히고, "걸을 수 없는 비탈로 걸어 들어가면 플레이어가 막힌다"를 단언하는 테스트가 그걸 고정해 둡니다.
컨트롤러를 건드리지 않고 진짜 아바타 끼워 넣기
Spike 59는 컨트롤러 층이 정말로 몸과 분리되어 있는지에 대한 시험입니다. 색칠된 capsule을 진짜 스킨드 캐릭터로, 즉 Quaternius Universal Animation Library 클립을 리타게팅해 올린 3MIKE FBX rig로 바꾸는데, 컨트롤러는 전혀 바뀌지 않습니다. 이음매는 문자열 하나입니다. 각 컨트롤러는 이미 stateName을 통해 상태 이름을 보고합니다. idle, walk, run, jump, fall, land, slide, glide, swim, swimIdle이고, 아바타 층은 그 이름을 별칭 테이블을 통해 리타게팅한 클립으로 매핑하고 전환할 때 crossfade합니다. 걷기는 접지 더하기 수직 velocity 더하기 수평 속도로부터 자기 하위 상태를 동적으로 고르기 때문에, 단일 걷기 컨트롤러가 idle, walk, run, jump, fall, land를 구동하고, 아바타는 그저 보고된 이름을 따라갑니다. 이 spike는 싱글 플레이어라서, 아바타는 이전 네트워크 spike들의 wire-integer 간접 참조와 다중 캐릭터 추상을 떼어내고 상태 이름을 곧장 클립으로 매핑합니다. 증명은 capsule에서 rig를 가진 사람으로의 시각적 업그레이드 전체가 locomotion 코드를 0줄 건드렸다는 것이고, 그게 바로 플러그인 가능한 컨트롤러가 사주기로 한 것입니다.
리타게팅이 필요 없는 팩, 그리고 테스트할 수 있는 선택기
Spike 60은 세 번째 몸, Synty의 POLYGON Base Locomotion 팩을 끼워 넣는데, 로더는 거의 아무것도 아닙니다. 각 클립은 같은 Synty 스켈레톤의 내장 복사본 하나에 더해 베이크된 애니메이션 하나를 실은 독립 FBX로 출하되고, 캐릭터 rig와 모든 클립이 동일한 뼈 이름을 쓰기 때문에 fbx.animations[0]에서 클립을 집어 캐릭터의 mixer에서 바로 재생합니다. 루프 안에 리타게팅 라이브러리가 전혀 없습니다. Three.js는 애니메이션 track의 대상을 객체 정체성이 아니라 뼈 이름으로 해석하기 때문에, 맞는 rig에 맞춰 제작된 Synty나 Mixamo 스타일의 팩은 그냥 동작합니다. 그게 이전 spike의 UAL 경로와의 의도적인 대비인데, 그 경로는 소스 클립과 대상 rig가 서로 다른 스켈레톤에 맞춰 제작되었기 때문에 무거운 리타게팅이 필요합니다. 같은 컨트롤러, 같은 상태 이름 이음매, 그 뒤에 완전히 다른 두 애니메이션 파이프라인.
Spike 60의 다른 절반은 클립 선택 로직을 테스트 가능하게 만드는 것입니다. 어느 클립을 재생할지 고르는 일은 판단 임계값으로 가득하고, 그 로직은 아바타 층 안 FBXLoader와 DOM 옆에 묻혀 있어서 거기서는 굴려볼 수가 없었습니다. 이 spike는 선택기를 Three.js도 window도 손대지 않는 순수 함수로 추출합니다. 평범한 플레이어 레코드(velocity, 수평 속도, 향하는 방향, 접지, 지면 법선, 충격 velocity)와 clip-action 대역을 받아 클립 별칭 문자열을 반환합니다. 그러면 임계값들이 이름 붙고 단언된 상수로 살 수 있습니다. 점프는 걷기 컨트롤러의 실제 스프린트 임계값에 맞춘 속도 버킷으로 걷기, 달리기, 스프린트 변형으로 나뉘고, 착지는 충격 velocity로 부드러움, 중간, 강함으로 나뉘고, 오르막과 내리막 클립 변형은 약 7도 아래에 평평한 클립 데드존을 둔 비탈 투영 내적으로 발동하고, idle에서 locomotion으로 가는 다리는 항상 앞쪽으로 해석되어 멈춰 서서 출발할 때 클립을 거꾸로 재생하는 일이 결코 없으며, 멈춤 다리는 최소 루프 내 시간으로 게이트됩니다. Synty의 발 위상 멈춤 클립이 제작된 감속을 약 1초 담고 있어서, 플레이어가 거의 떼지 않은 걸음에 붙이면 우스워 보이기 때문입니다. 작은 상태 디바운서가 fall 상태를 몇 프레임 미뤄서, 이음매를 가로지를 때 한 프레임 지면 접촉이 끊겨도 애니메이션이 깜빡이지 않습니다. 이 모든 것을 렌더링 껍데기에서 빼낸다는 건 규칙을 GPU 없이 유닛 테스트할 수 있다는 뜻이고, spike 58에서 엔진 자체가 받은 것과 같은 헤드리스 규율이며, 이것이 짐작으로 조율하는 locomotion 감각과 못 박을 수 있는 locomotion 감각 사이의 차이입니다.
이 챕터에서 언급한 기술
locomotion 없는 물리 엔진. capsule 엔진은 kinematic 몸을 지형에 대해 적분하고, 걷기, 달리기, 점프, 마찰, 속도 상한, 중력을 소유하지 않습니다. 모든 동작은 tick, wantsControl, applyForces와 선택적인 onEnter/onExit/stateName을 노출하는 등록된 컨트롤러입니다. 걷기는 가장 낮은 우선순위의 항상 yes 기본값일 뿐이고, 컨트롤러는 ownsCollision: true를 반환해 지형 처리를 넘겨받을 수 있습니다(수영이 그렇게 해서 부력 스프링이 발 스냅과 싸우지 않습니다).
독립적인 중재 channel. 컨트롤러는 따로 중재하고 고정된 순서로 적용되는 이름 붙은 channel(resource, stance, locomotion)에 등록되어서, stamina 같은 관찰자와 웅크림 같은 수정자가 제어를 흉내 냈다가 거절하는 대신 활성 locomotion과 공존합니다. resource 쓰기(stamina)는 stance와 locomotion이 읽고, stance 쓰기(웅크림 capsule 높이)는 locomotion의 속도 상한이 읽고, locomotion은 마지막으로 돌며 velocity 쓰기를 소유합니다.
factory 하나, 몸 여럿. createCapsuleEngine은 싱글톤 없는 순수 factory라서, 같은 엔진이 플레이어, 걷기만 하는 탈 수 있는 말, 합성 입력을 먹는 스스로 방향 잡는 NPC를 구동합니다. 한 몸의 움직임 어휘는 정확히 그 등록된 컨트롤러 세트라서, 말은 그 컨트롤러들이 애초에 추가되지 않았기 때문에 등반이나 수영을 못 합니다. 탑승은 컨트롤러가 아니라 host 수준에 사는데, 서로를 가로질러 볼 수 없는 두 엔진을 조율하기 때문입니다. GPU 구동 LOD를 보세요.
헤드리스, 결정적 테스트. 엔진은 어떤 window, document, Three.js도 손대지 않아서, Node harness가 프레임 단위로 그것을 구동하고 점프와 착지, 수영 임계값, 등반 자동 해제, stamina 소모에 대해 단언합니다. 동일한 입력을 두 번 재생하고 동일한 출력을 확인하는 것이 결정성을 증명하고, 회귀 테스트가 가파른 면으로의 수평 걷기가 플레이어를 뒤로 미끄러뜨리는 걸 막는 하강 게이트를 고정합니다.
몸과 무관한 아바타 바인딩과 테스트 가능한 선택기. 컨트롤러는 상태 이름 문자열을 보고하고 시각 층이 그것을 클립으로 매핑해서, capsule을 리타게팅한 3MIKE + UAL 아바타로 바꿔도 locomotion 코드를 0줄 건드립니다. Synty POLYGON 클립은 rig와 뼈 이름을 공유해서 리타게팅 없이 재생되고(Three.js가 track을 뼈 이름으로 바인딩합니다), UAL 경로의 무거운 리타게팅과 다릅니다. 클립 선택기는 점프 속도 버킷, 착지 강도, 비탈 투영 변형, 항상 앞쪽인 idle 다리, 멈춤 다리 최소 시간 가드, fall 상태 디바운서를 위한 이름 붙은 상수와 함께 순수 함수로 추출되어서, locomotion 감각이 짐작이 아니라 유닛 테스트 가능합니다.
29부 / 30부. 이전: 28부 - 지평선까지의 풀, 그리고 스스로를 숨기는 땅 다음: 30부 - 벽을 존중하는 카메라 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide