게임 캐릭터가 뒤돌아서 공격한다? — 좌우 반전 버그와의 사투기
시작은 단순했습니다
"나이트 공격 모션을 3종류로 랜덤하게 나오게 하고, 궁수한테 활 쏘는 모션 넣어주세요."
간단한 요청이었습니다. 공격 애니메이션 다양화. 어떤 게임이든 한 가지 공격 모션만 반복되면 지루하니까요. 나이트는 Chop, Slice, Stab 세 가지, 궁수는 Draw → Release 시퀀스. 여기까지는 순조로웠습니다.
그런데 이 작업이 좌우 반전 버그라는 토끼굴로 빠져드는 입구가 될 줄은 몰랐습니다.
1단계: 공격 모션 랜덤화
기존 autoRegister는 attack 상태에 첫 번째 매칭 애니메이션만 등록하고 나머지를 버리고 있었습니다.
현재
autoRegister에서attack상태에 첫 번째 매칭만 등록하고 나머지를 버리고 있습니다. 여러 공격 애니메이션을 저장하고 랜덤 재생하도록 수정하겠습니다.
수정 방향은 명확했습니다. 공격 애니메이션을 배열로 수집하고, play 호출 시 랜덤으로 하나를 골라 재생하면 됩니다.
// attack 변형들을 배열로 수집
const attackVariants: Animation[] = [];
for (const clip of clips) {
if (clip.name.match(/chop|slice|stab/i)) {
attackVariants.push(clip);
}
}
// play 시 랜덤 선택
const variant = attackVariants[Math.floor(Math.random() * attackVariants.length)];
나이트의 Chop, Slice, Stab 랜덤 재생은 깔끔하게 완료. 여기까지는 좋았습니다.
2단계: 궁수의 활 쏘기 — 어느 손에 활을 들었나?
궁수(로그)에게 활 시퀀스를 추가하는 것도 비교적 순조로웠습니다. Draw(시위 당기기) → Release(발사) 순서로 재생하고, 거리에 따라 일반/Up 변형을 선택하는 구조입니다.
장비 설정에서 활을 왼손(handslot.l)에 장착하고 회전값을 맞춰줬습니다.
bow_string:handslot.l(왼손)에 장착, 회전 X=180°, Y=180°로 설정했습니다.
그런데 여기서 혼란이 시작됩니다. 게임 화면에서는 오른손에 활을 들고 있는 것처럼 보이는데, 액션 에디터에서는 왼손에 들고 있는 겁니다.
처음에는 카메라 시점 차이로 판단했습니다.
카메라 시점 차이입니다. 액션 에디터는 캐릭터를 정면에서, 게임은 아이소메트릭으로 뒤/위에서 봅니다. 실제로는 둘 다 같은 본에 붙어 있습니다.
"카메라 각도 차이일 뿐이에요. 정상입니다." — 이렇게 결론 내렸습니다. 스크린샷을 봐도 양쪽 다 왼손에 활, 오른손으로 시위를 당기는 자세니까요.
하지만 사용자는 계속 좌우 반전이라고 주장했습니다.
3단계: scaling.setAll(1.0)의 함정
결국 코드를 다시 들여다봤습니다. 그리고 발견한 것은—
// 문제의 코드
model.rootMesh.scaling.setAll(1.0);
glTF 모델은 좌표계 변환을 위해 루트 메시에 특정 스케일링 값을 가지고 있습니다. 예를 들어 scaling.z = -1로 Z축을 뒤집어서 오른손/왼손 좌표계를 맞추는 식입니다. 그런데 setAll(1.0)이 이 원본 스케일링을 통째로 덮어씌우고 있었습니다.
Z축 스케일이 -1이어야 할 것이 1이 되면? 좌우 반전입니다.
확인했습니다. 게임에서
model.rootMesh.scaling.setAll(1.0)이 glTF 루트 메시의 원래 스케일링(좌표계 변환용)을 덮어씌워서 좌우 반전이 생깁니다. 원본 스케일링의 부호를 보존하도록 수정합니다.
그리고 이어진 사과:
죄송합니다. 처음부터 말씀을 믿고 코드를 확인했어야 했습니다.
이게 핵심 교훈입니다. "정상입니다"라고 단정짓기 전에, 사용자가 보고한 현상을 실제로 코드에서 검증해야 합니다. 시각적으로 비슷해 보인다고 같은 것이 아닙니다.
4단계: 연쇄 반응 — 방향 보정값 충돌
좌우 반전을 고치자 새로운 문제가 터졌습니다. 캐릭터가 뒤로 공격하는 현상.
기존에는 좌우 반전된 모델을 보정하기 위해 공격 방향에 + Math.PI(180도)를 더하고 있었거든요. 반전을 고쳤으니 이 보정이 오히려 방향을 반대로 만든 겁니다.
스케일링 보존으로 모델 방향이 바뀌어서
+ Math.PI보정이 안 맞게 됐습니다. 방향 계산도 같이 수정해야 합니다.
// 이전: 반전된 모델 보정용
const angle = Math.atan2(dx, dz) + Math.PI;
// 수정: 원본 스케일 보존 후
const angle = Math.atan2(dx, dz);
하나를 고치면 다른 곳이 깨지는 전형적인 패턴입니다. 좌표계 관련 버그가 무서운 이유가 바로 이것입니다.
5단계: 시작할 때 뒤돌아 서 있는 유닛
방향 보정을 제거하니 이번에는 유닛들이 게임 시작 시 뒤를 보고 서 있었습니다. 이동이나 공격 시에는 방향 계산이 적용되니 정상인데, 초기 회전값이 설정되지 않아서 기본값 0으로 서 있던 겁니다.
유닛 생성 시 초기 회전이 없어서 기본 0으로 서 있는 게 문제입니다. 공격/이동 때는 방향 계산이 적용되니 정상이고요.
초기 방향을 남쪽(적이 오는 방향)으로 설정해서 해결했습니다.
정리: 하나의 setAll(1.0)이 만든 나비효과
단 한 줄의 코드가 만들어낸 연쇄 버그를 정리하면 이렇습니다:
| 순서 | 버그 | 원인 |
|---|---|---|
| 1 | 좌우 반전 | scaling.setAll(1.0)이 glTF 좌표계 변환 스케일을 덮어씀 |
| 2 | 뒤로 공격 | 반전 보정용 + Math.PI가 원본 복원 후 역효과 |
| 3 | 시작 시 뒤돌아 서 있음 | 초기 회전값 미설정 (기존엔 보정값이 마스킹하고 있었음) |
핵심 교훈
1. glTF 모델의 루트 스케일링을 함부로 건드리지 마세요.
glTF 로더가 설정하는 루트 메시의 스케일링은 좌표계 변환(오른손↔왼손)을 위한 것입니다. 이걸 setAll(1.0)으로 날려버리면 모델이 거울상이 됩니다. 크기를 조절하고 싶다면 원본 부호를 보존하면서 절대값만 변경해야 합니다.
2. 보정값(offset)은 기술 부채입니다.
근본 원인을 고치지 않고 + Math.PI 같은 보정을 넣으면, 나중에 근본 원인이 수정됐을 때 보정값이 버그가 됩니다. 보정값을 넣을 때는 반드시 주석으로 "왜 필요한지"를 남겨야 합니다.
3. 사용자의 눈을 믿으세요.
"카메라 시점 차이일 뿐입니다"라고 넘어갔다가 결국 실제 버그였습니다. 특히 시각적 버그는 코드만 보고 판단하면 안 됩니다. 사용자가 "이상하다"고 하면, 일단 코드를 검증하는 습관이 중요합니다.
4. 좌표계 버그는 혼자 오지 않습니다.
스케일 반전을 고치면 회전 보정이 깨지고, 회전 보정을 제거하면 초기 방향이 드러납니다. 좌표계 관련 수정은 항상 연쇄 영향을 체크해야 합니다. 고칠 때 "이 값에 의존하는 다른 코드가 있나?"를 반드시 확인하세요.