BUG · 버그 도감 #3
캐릭터가 전부 T자 자세로 굳었다: three.js clone()과 스켈레톤
최후의 파도에 리깅된 3D 캐릭터를 넣자, 적과 영웅이 모두 팔을 벌린 기본 자세로 굳어 움직이지 않았습니다. 원인은 three.js의 일반 clone()이 복제된 메시를 원본 스켈레톤에 묶어 둔다는 데 있었습니다. 증상, 원리, 해결과 복제할 때 주의할 점을 정리했습니다.
버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.
프로젝트 최후의 파도 · 시기 2026년 8월 17일 · 관련 기술 three.js, 스키닝 애니메이션, glTF
증상
최후의 파도는 콜로세움에서 몰려오는 적을 버티는 3D 생존 게임입니다. 처음에는 캐릭터를 상자와 캡슐을 조합해 코드로 만들었는데, 8월 17일에 이를 뼈대(리그)와 애니메이션이 들어 있는 무료 3D 캐릭터 모델로 바꾸는 작업을 했습니다. 기사, 해골 병사 같은 모델 파일(glTF/GLB)을 불러와 영웅과 적으로 쓰는 방식입니다.
그런데 화면에 올라온 캐릭터가 모두 팔을 양옆으로 쭉 뻗은 채 굳어 있었습니다. 걷기 애니메이션도, 공격 애니메이션도 재생되지 않았습니다. 3D 모델링에서 이 자세를 T 포즈 또는 바인드 포즈라고 부릅니다. 모델에 뼈대를 처음 입힐 때의 기준 자세로, 애니메이션이 하나도 적용되지 않았다는 신호입니다.
커밋 기록은 원인을 한 줄로 정리합니다. "일반 clone은 스키닝을 원본 스켈레톤에 묶어 두어 전 캐릭터가 바인드 포즈로 굳었다."
배경: 모델 하나를 여러 번 쓰는 이유
이 게임에는 같은 종류의 적이 한 화면에 수십 마리씩 나옵니다. 코드상 동시에 등장할 수 있는 적은 최대 72마리입니다. 그때마다 모델 파일을 새로 내려받고 해석할 수는 없으니, 모델은 한 번만 불러오고 화면에 올릴 때마다 복제(clone)해서 씁니다. three.js의 모든 3D 객체에는 이를 위한 clone() 메서드가 있습니다.
// 흔히 떠올리는 방법 (여기서 문제가 생긴다)
const enemy = gltf.scene.clone();
scene.add(enemy);
const mixer = new THREE.AnimationMixer(enemy);
mixer.clipAction(walkClip).play();
뼈대가 없는 일반 모델이라면 이 코드로 충분합니다. 문제는 애니메이션이 뼈대로 움직이는 스킨드 메시(SkinnedMesh)였습니다. (위 코드는 원리를 보여 주는 예시이고, 실제로 고친 코드는 아래에 있습니다.)
원인: 복제본이 원본의 뼈를 보고 있었다
리깅된 캐릭터는 두 부분으로 나뉩니다. 눈에 보이는 몸통 메시, 그리고 메시를 잡아당겨 움직이는 보이지 않는 뼈(Bone)들입니다. 메시는 "나는 이 뼈들을 따라 움직인다"는 정보, 즉 스켈레톤(Skeleton)을 들고 있습니다.
일반 clone()은 객체 계층을 그대로 복사합니다. 뼈도 새로 복사되고 메시도 새로 복사됩니다. 그런데 복사된 메시가 들고 있는 스켈레톤은 새 뼈들이 아니라 원본 모델의 뼈들을 그대로 가리킵니다. 결과는 이렇게 됩니다.
- 애니메이션 믹서는 복제본 안에서 이름으로 뼈를 찾아 움직입니다. 그래서 복제된 뼈들은 열심히 걷고 휘두릅니다.
- 하지만 화면에 그려지는 메시는 원본의 뼈를 따라 모양을 잡습니다.
- 원본 모델은 화면에 올리지도, 애니메이션을 걸지도 않았으니 원본의 뼈는 처음 자세 그대로입니다.
그래서 모든 복제본이 기준 자세, 즉 T 포즈로 굳어 보였습니다. 애니메이션은 실제로 재생되고 있었지만, 아무도 볼 수 없는 뼈를 움직이고 있었던 셈입니다. 오류 메시지는 하나도 나오지 않습니다.
해결: SkeletonUtils.clone
three.js에는 바로 이 문제를 위한 도구가 따로 있습니다. SkeletonUtils의 clone 함수는 계층을 복사한 뒤, 복제된 메시가 복제된 뼈를 가리키도록 스켈레톤을 다시 연결해 줍니다. 실제 게임 코드(src/render/actorView.ts)는 이렇게 바뀌었습니다.
import { clone as cloneSkinned } from 'three/addons/utils/SkeletonUtils.js';
export function cloneActor(gltf: GLTF, opts: CloneOpts = {}): ActorView {
const root = new THREE.Group();
const model = cloneSkinned(gltf.scene) as THREE.Group;
// ...
const mixer = new THREE.AnimationMixer(model);
for (const clip of gltf.animations) {
const action = mixer.clipAction(clip);
// ...
바뀐 건 복제 함수 하나입니다. 이것만으로 모든 캐릭터가 각자의 뼈로 따로 움직이기 시작했습니다. 같은 커밋에서 모델 파일(GLB)도 계층 구조를 평탄화(flatten)하지 않은 채로 다시 내보냈고, 피격 시 번쩍이는 효과와 카메라 각도도 함께 조정했습니다.
복제할 때 함께 알아 둘 것
T 포즈 문제를 고친 뒤에도 "복제"에는 함정이 더 있습니다. 최후의 파도 코드에 남아 있는 대응을 같이 정리합니다.
- 재질(Material)은 공유된다.
clone()과SkeletonUtils.clone모두 기본적으로 재질과 형상(Geometry)은 원본과 함께 씁니다. 메모리에는 좋지만, 적 하나의 색을 바꾸면 같은 모델을 쓰는 모든 적의 색이 바뀝니다. 그래서 이 게임은 캐릭터마다 색조와 피격 효과를 따로 주기 위해 재질을 따로 복제해서 씁니다(actorMats.ts의src.clone()). - 애니메이션 믹서는 복제본마다 하나씩. 믹서를 원본에 하나만 만들면 모든 캐릭터가 같은 순간에 같은 동작을 합니다. 각자 다른 타이밍에 걷고 공격하려면 복제본마다 믹서를 만들어야 합니다.
- 많이 만들고 지우는 객체는 다시 쓴다. 이 게임은 8월 30일 커밋에서 적과 던지는 창을 매번 새로 만들지 않고 재사용(풀링)하도록 바꿔, 크롬과 휴대폰에서 끊기는 현상을 줄였습니다.
교훈
- "복제"가 무엇을 복제하는지 확인한다. 계층은 복사돼도 내부의 참조(스켈레톤, 재질, 형상)는 원본을 가리킬 수 있습니다.
- T 포즈는 "애니메이션이 안 붙었다"는 신호다. 애니메이션 코드보다 먼저, 메시가 어떤 뼈를 보고 있는지부터 확인하면 빠릅니다.
- 라이브러리에 이미 있는 도구를 찾는다. 흔히 겪는 문제일수록 전용 함수가 준비돼 있습니다. 이 버그는 함수 한 줄로 끝났습니다.
최후의 파도가 2D 대전 게임에서 3D 생존 게임이 되기까지의 과정은 최후의 파도 제작기에 정리했습니다. 이 프로젝트는 Cursor의 코딩 에이전트와 함께 작업했습니다.