three.js에서 FBX 캐릭터가 깨져 보인 원인은 bind 행렬이었습니다

2026년 8월에 3ds Max에서 내보낸 수어 모션캡처 FBX 여섯 개 중 세 개가 three.js에서 찢어진 채로 나타났습니다. 경계 상자를 재 보니 폭 303cm, 키 172cm, 두께 181cm였습니다. 키는 사람인데 두께가 1.8m인 캐릭터입니다. 원인은 스킨 가중치도, 본 구조도 아니었습니다. 메시를 바인드할 때 파일에 기록된 행렬(bindMatrix)과 로드 뒤 계산된 메시의 월드 행렬이 서로 달랐고, three.js의 SkinnedMesh는 기본 설정에서 이 차이를 스키닝 계산 한가운데에 끼워 넣습니다. 메시를 현재 월드 행렬로 다시 바인드하자 같은 파일이 44×170×27cm, 정상 자세로 돌아왔습니다.

브라우저용 FBX → VRM 변환기를 만들며 추적한 사례이고, 코드와 동작 설명은 변환기가 쓰는 three.js r160 기준입니다.

같은 FBX 캐릭터를 세 방식으로 렌더한 3단 비교. 첫째는 파일 그대로로 팔이 거대한 띠처럼 늘어난 303×172×181cm, 둘째는 bindMatrixInverse만 맞춰 첫째와 달라지지 않은 모습, 셋째는 matrixWorld로 다시 바인드해 정상 자세로 돌아온 44×170×27cm. 텍스처 없이 보라·파랑·초록 재질 기본색으로 보인다.
① 파일 그대로: 303×172×181cm, 팔이 거대한 띠로 늘어남 ② bindMatrixInverse만 맞춘 경우: 변화 없음 ③ matrixWorld로 다시 바인드: 44×170×27cm, 정상 자세. 텍스처 없이 FBX 재질 기본색으로 렌더했습니다.

증상: 부위마다 다르게 망가진 캐릭터

문제의 파일은 S1_t01, S2_t01, S3_t01 세 개였고, 여섯 개 중 나머지 세 개는 멀쩡했습니다. 먼저 눈에 띈 것은 망가진 모양이 부위마다 달랐다는 점입니다.

  • 눈은 제자리에 있었습니다. 높이 약 157cm, 사람 눈이 있어야 할 곳입니다.
  • 머리카락과 눈썹은 높이 대신 앞뒤(z축) 방향으로 약 150cm 떨어진 곳에 있었습니다. 위쪽으로 있어야 할 거리가 앞뒤 방향으로 옮겨 간, 축이 뒤바뀐 모양입니다.
  • 이빨과 잇몸은 원점 한 점으로 쪼그라들었습니다.
  • 몸과 옷은 세 축 모두로 늘어났습니다. 팔은 거대한 띠처럼 펼쳐졌습니다.

캐릭터 전체가 한 방향으로 밀리거나 통째로 커졌다면 좌표축이나 단위 설정을 먼저 의심했을 것입니다. 부위마다 증상이 다르다는 것은 메시나 본 단위로 무언가 어긋났다는 신호였습니다.

세운 가설 네 개와 기각한 근거

스키닝 결과는 정점 위치, 본 번호와 가중치, 본의 현재 자세, 바인드 시점의 기준 행렬로 정해집니다. 깨지기 쉬운 재료부터 차례로 확인했습니다.

1. 스킨 가중치가 깨졌다

한 정점의 가중치 합이 1보다 작으면 그 정점은 원점 쪽으로 끌려가고, 합이 0이면 원점에 붙습니다. 이빨이 원점으로 모인 증상은 이 가설과 잘 맞아 보였습니다. 그러나 정점별 가중치 합은 1로 정상이었습니다.

2. 정점이 엉뚱한 본에 붙었다

머리카락이 다른 본을 따른다면 머리에서 떨어져 나갈 수 있습니다. 그러나 Hair 메시의 0번 본은 Head로 정상이었습니다.

3. 중복된 본 노드가 끼어들었다

이 파일에는 같은 본이 중첩되어 들어간 노드가 179개 있었습니다. 변환기 시험 모델 Character_0726도 본 노드 132개 중 같은 이름 중복을 걸러 80개만 쓰므로 낯선 구조는 아니었습니다. 하지만 179개의 월드 위치는 전부 같았고, 제거해도 결과는 바뀌지 않았습니다.

4. 애니메이션 트랙이 일부 본에 붙지 않았다

트랙이 대상 노드를 찾지 못하면 일부 본만 움직여 몸이 찢어진 것처럼 보일 수 있습니다. 그러나 트랙 218개가 전부 대상에 결합되어 있었습니다.

가설별 확인 결과 표. 스킨 가중치(정점별 합 1), 본 배정(Hair의 0번 본 Head), 중복 본 노드 179개(월드 위치 동일), 애니메이션 트랙 218개(전부 결합)는 기각했고, 월드 행렬 갱신 뒤 오차가 0에서 156.57로 바뀐 bind 행렬을 원인으로 판정했다.
네 가설은 모두 정상 값이 나와 기각했고, 월드 행렬 갱신 뒤의 bind 행렬 오차만 비정상이었습니다.

재료가 모두 정상인데 결과만 틀렸다면 남은 것은 재료를 조합하는 기준 행렬입니다.

진짜 원인: attached 모드가 bindMatrixInverse를 다시 계산한다

three.js r160의 스키닝 셰이더(skinning_vertex)는 정점 하나를 다음 순서로 계산합니다. 실제 코드는 본 네 개(boneMatX~W)를 풀어 쓴 형태이고, 아래는 그것을 합 기호로 줄인 것입니다.

skinVertex  = bindMatrix * position
skinned     = Σ weight_i * boneMatrix_i * skinVertex
transformed = bindMatrixInverse * skinned

boneMatrix_i는 Skeleton.update()가 만드는 값으로, 본의 현재 월드 행렬에 바인드 시점의 역행렬(boneInverses)을 곱한 것입니다. 본이 바인드 자세 그대로면 단위행렬이 됩니다. 정점을 bindMatrix로 바인드 공간에 올리고, 본이 움직인 만큼 옮긴 뒤, bindMatrixInverse로 되돌리는 왕복 구조라서 두 행렬이 서로 역행렬일 때만 제자리로 돌아옵니다.

FBXLoader는 파일의 BindPose 기록에서 노드별 행렬을 읽어 두었다가 mesh.bind(skeleton, bindMatrix)로 넘깁니다. bind()는 그 값을 bindMatrix에 복사하고 역행렬을 bindMatrixInverse에 넣습니다. 그래서 파싱 직후에는 두 행렬의 곱이 정확히 단위행렬이고, 문제 파일도 이 시점의 오차는 0이었습니다.

문제는 그다음입니다. SkinnedMesh의 bindMode 기본값은 'attached'이고, 이 모드에서 updateMatrixWorld()는 호출될 때마다 아래 한 줄을 실행합니다.

// SkinnedMesh.updateMatrixWorld(), bindMode가 'attached'일 때 (r160)
this.bindMatrixInverse.copy( this.matrixWorld ).invert();

공식 문서(2026년 9월 확인 기준)는 attached를 스킨 메시가 스켈레톤과 같은 월드 공간을 공유하는 모드로 설명합니다. 그래서 bindMatrixInverse를 파일 기록이 아니라 메시의 현재 월드 행렬에서 새로 만듭니다. bindMatrix가 메시의 월드 행렬과 같다면 아무 일도 일어나지 않습니다. 다르면 그 차이가 매 프레임 스키닝에 끼어듭니다.

문제 파일은 첫 월드 행렬 갱신 뒤 bindMatrix × bindMatrixInverse의 오차가 156.57이 되었습니다. 바인드 시점에 기록된 메시 행렬과, 로드 뒤 노드 계층에서 계산된 메시의 월드 행렬이 달랐다는 뜻입니다. 바인드 이후에 축·단위 변환이 들어가면서 두 행렬이 갈라진 것이고, 그 차이를 풀어 보면 X축 -90° 회전과 약 156cm 이동이었습니다.

왜 옮겨지지 않고 찢어지는가

화면에 그려지는 월드 위치는 matrixWorld × bindMatrixInverse × (본 변환) × bindMatrix × 정점입니다. attached 모드에서는 앞의 두 항이 서로 상쇄되어 (본 변환) × bindMatrix × 정점만 남습니다. 다시 바인드해 정상으로 돌아온 결과는 (본 변환) × matrixWorld × 정점이므로, 둘의 차이는 bindMatrix와 matrixWorld 사이의 어긋남, 곧 -90° 회전과 약 156cm 이동이 본 변환보다 먼저 한 번 걸린 것입니다.

본 변환이 모든 본에서 같았다면 이 어긋남은 메시를 통째로 돌리고 옮기는 데서 끝났을 것입니다. 모캡 캐릭터는 본마다 본 변환이 다르므로, 같은 어긋남이 본마다 다른 회전을 거쳐 서로 다른 방향으로 벌어집니다. 두 본에 가중치를 나눠 가진 정점은 그 사이에서 양쪽으로 끌려 늘어나고, 한 덩어리였던 몸이 띠처럼 찢어집니다. X축 -90° 회전은 높이(y) 성분을 앞뒤(z) 방향으로 돌리는 회전이라, 머리카락이 위가 아닌 앞뒤로 약 150cm 떨어져 있던 증상과도 방향이 들어맞습니다. 부위별 이동량까지 계산으로 재현하지는 않았으므로, 이 단락은 식에서 끌어낸 해석입니다.

bindMatrixInverse만 맞추면 안 되는 이유

먼저 떠오르는 복구는 bindMatrixInverse를 bindMatrix의 역행렬로 되돌려 놓는 것입니다. 시험해 보니 결과는 303×172×181cm 그대로였습니다. 다음 updateMatrixWorld()가 곧바로 덮어쓰기 때문입니다. 고쳐야 할 쪽은 bindMatrix입니다.

bindMode를 'detached'로 바꾸는 방법은 시험하지 않았습니다. 이 모드는 갱신 때 bindMatrixInverse를 bindMatrix의 역행렬로 되돌리므로 덮어쓰기는 멈춥니다. 다만 식으로 따지면 본이 바인드 자세일 때만 맞고 본이 움직이면 다시 어긋나서, 움직이는 모캡 파일의 해법이 되기는 어렵다고 봅니다. 공식 문서도 detached를 여러 스킨 메시가 스켈레톤 하나를 나눠 쓸 때 쓰는 모드로 설명합니다.

판정식과 복구 코드

월드 행렬을 한 번 갱신한 뒤, 스킨 메시마다 bindMatrix × bindMatrixInverse가 단위행렬인지 봅니다. 아니면 그 메시만 현재 matrixWorld로 다시 바인드합니다. 아래는 판정 원리만 추린 최소 코드입니다.

const product = new THREE.Matrix4();
const identity = new THREE.Matrix4();

function bindError(mesh) {
  product.multiplyMatrices(mesh.bindMatrix, mesh.bindMatrixInverse);
  let err = 0;
  for (let i = 0; i < 16; i++) {
    err = Math.max(err, Math.abs(product.elements[i] - identity.elements[i]));
  }
  return err;
}

function rebindMismatched(root) {
  root.updateMatrixWorld(true); // 갱신한 뒤에 잰다
  root.traverse((obj) => {
    if (!obj.isSkinnedMesh) return;
    if (bindError(obj) > 1e-3) {
      obj.bind(obj.skeleton, obj.matrixWorld);
    }
  });
}

쓸 때 주의할 점이 세 가지 있습니다.

  • 갱신한 뒤에 잽니다. 파싱 직후에는 문제 파일도 오차가 0으로 나옵니다. updateMatrixWorld(true)를 먼저 호출해야 어긋남이 드러납니다.
  • bind()의 두 번째 인자를 빼지 않습니다. r160의 bind()는 bindMatrix 없이 호출되면 skeleton.calculateInverses()로 본의 현재 자세를 새 바인드 자세로 삼습니다. 로드 시점 자세가 바인드 자세가 아닌 모캡 파일이라면 파일에 있던 boneInverses가 사라집니다. matrixWorld를 명시하면 bindMatrix만 바뀝니다.
  • 모델을 옮기기 전에 판정합니다. 코드로 따져 보면 루트를 확대하거나 돌린 뒤에는 정상 메시도 곱이 단위행렬에서 벗어납니다. 그 상태에서 다시 바인드하면 루트 변환이 두 번 걸립니다. 크기 정규화나 정면 회전보다 판정이 먼저 와야 합니다.

임계값은 부동소수 오차만 거르면 됩니다. 정상 파일은 0, 문제 파일은 156.57로 간격이 넓으니 위 코드의 1e-3은 그 사이에 그은 예시 값입니다.

다른 파일로 회귀 검증

판정식은 정상 파일을 건드리지 않아야 쓸모가 있습니다. 정상으로 읽히던 모캡 파일 두 개와 변환기 시험 모델(3ds Max 리그 Character_0726)에 같은 판정을 돌렸습니다.

파일갱신 뒤 오차조치결과(폭×키×두께)
S1_t01, S2_t01, S3_t01156.57해당 메시 재바인드303×172×181cm → 44×170×27cm
S1000_t010손대지 않음변화 없음
S244_t010손대지 않음변화 없음
Character_07260손대지 않음163×170×29cm (T포즈)

정상 파일 세 개는 오차가 0이라 판정식이 발동하지 않았고, 이를 확인한 뒤 변환기에 반영했습니다. 폭 차이는 자세 때문입니다. Character_0726은 팔을 옆으로 뻗은 T포즈라 163cm이고, 복구한 모캡 파일은 팔을 뻗지 않은 자세라 44cm입니다.

2026년 9월에 같은 모캡 데이터를 다시 내보낸 파일에는 모델이 찢어지는 문제가 나타나지 않았습니다(44×170×29cm). 대신 연결 표에 없는 텍스처 노드 하나 때문에 FBXLoader가 “Cannot read properties of undefined (reading ‘children’)”로 멈추는 다른 문제가 생겼고, 그 한 줄을 너그럽게 처리하도록 로더를 고친 뒤에는 재바인드 없이 44×170×29cm로 읽혔습니다. 다시 내보내는 것이 깔끔한 해결이지만 그것만으로 모든 문제가 사라지지는 않습니다.

경계 상자 치수에서 재바인드까지 좁혀 가는 여섯 단계

  1. 경계 상자 치수부터 잽니다. 사람 캐릭터인데 두께(앞뒤)가 1m를 넘으면 스키닝 기준 행렬을 의심합니다. 폭은 T포즈만 돼도 키와 비슷해지므로(Character_0726은 163cm) 폭 하나로 판단하지 않습니다. 반대로 키가 40cm 안팎으로 줄어 있다면 본의 로컬 위치가 전부 0으로 저장된 다른 문제일 수 있습니다.
  2. 부위별로 봅니다. 통째로 밀렸거나 커졌으면 축·단위 설정을, 부위마다 증상이 다르면 메시별 바인드를 의심합니다.
  3. 가중치 합과 본 배정을 확인합니다. 여기서 이상이 나오면 다른 문제입니다.
  4. updateMatrixWorld(true)를 호출한 뒤 메시마다 bindMatrix × bindMatrixInverse를 계산합니다. 파싱 직후의 값은 믿지 않습니다.
  5. 단위행렬이 아닌 메시만 bind(skeleton, matrixWorld)로 다시 바인드하고 치수를 다시 잽니다. 이때 크기 조정이나 회전은 아직 하지 않은 상태여야 합니다.
  6. 가능하면 원본을 다시 내보내 비교합니다. 새 파일의 오차가 0이면 원인이 내보내기 쪽이었다는 확인도 됩니다.

코드를 직접 짜지 않아도 된다면 FBX → VRM 변환기에 이 판정과 재바인드가 들어가 있고, 파일은 서버로 올라가지 않습니다. 변환 전에 메시 수와 정점 수부터 보고 싶다면 로컬 3D 모델 뷰어로 먼저 열어 보는 것도 방법입니다. 다른 브라우저 도구는 도구 모음에 있습니다.

참고한 자료

위로 스크롤