한양과학기술고등학교

에듀테크 프로젝트 대시보드

쿼터니언 회전 (Quaternion)

쿼터니언 회전 (Quaternion · 사원수)

3D 공간에서 물체를 돌리는 방법은 여러 가지다. 게임 엔진·로봇·드론·위성·VR 헤드셋은 거의 예외 없이 쿼터니언이라는 4개의 숫자 (w, x, y, z) 로 회전을 표현한다. 오일러 각(x·y·z 축을 차례로 회전)이 직관적인데도 버리는 이유는 짐벌락이라는 함정과 부드러운 보간의 어려움 때문이다. 아래에서 큐브를 직접 돌려 보고, 오일러 각이 무너지는 순간과 쿼터니언이 그 사이를 최단 경로로 미끄러지는 모습을 확인해 보자.

1. 축 하나 + 각도 하나 = 회전 하나

오일러의 회전 정리: 어떤 3D 회전이든 "축 하나를 중심으로 각도 θ만큼 한 번 돌리기"로 표현된다. 쿼터니언은 딱 그걸 담는 그릇이다 — w = cos(θ/2), (x,y,z) = sin(θ/2)·축. 슬라이더로 회전축 방향과 각도를 바꾸면 4개 성분이 어떻게 움직이는지 보라.

물체 X축 물체 Y축 물체 Z축 회전축
…
왜 θ가 아니라 θ/2일까? 회전 공식이 쿼터니언을 양쪽에서 두 번 (q · v · q⁻¹) 곱하기 때문에, 각도가 두 번 더해져 정확히 θ가 된다. 그 대가로 q 와 −q 가 완전히 같은 회전을 의미한다(이중 덮개).

2. 오일러 각의 함정 — 짐벌락(Gimbal Lock)

오일러 각은 Yaw(Z) → Pitch(Y) → Roll(X) 순서로 축을 차례로 돌린다. 그런데 Pitch를 ±90°로 맞추면 Roll축과 Yaw축이 같은 방향으로 포개져, 서로 다른 두 슬라이더가 똑같은 회전을 만든다 — 자유도 1개를 잃는다. 이게 짐벌락이다. Pitch 슬라이더를 90° 근처로 올린 뒤 Yaw와 Roll을 움직여 보라.

자유도 정상 (3)
…
쿼터니언(과 회전 행렬)은 축을 차례로 돌리지 않고 회전을 통째로 하나로 다루므로 포개질 축 자체가 없다 — 짐벌락이 원천적으로 발생하지 않는다. 1969년 아폴로 11호의 관성항법장치도 물리적 짐벌 3개여서, 승무원이 특정 자세를 피하도록 "짐벌락까지 얼마 안 남았다"는 경고를 받았다.

3. 두 자세 사이를 부드럽게 — SLERP

카메라를 지금 자세 A 에서 목표 자세 B 로 옮길 때, 쿼터니언은 4차원 구면 위의 두 점을 대원(great circle)을 따라 일정한 각속도로 잇는다. 이게 구면 선형 보간(SLERP)이다. 아래 슬라이더 t 가 0→1로 갈 때 큐브가 흔들림 없이 최단 경로로 회전한다.

A (시작) 보간 중 B (목표)
…
오일러 각을 그냥 선형 보간하면 세 숫자가 제각각의 속도로 변해 경로가 휘고 짐벌락 근처에서 홱 돌아간다. 회전 행렬 9개를 보간하면 더 이상 회전이 아닌 행렬이 나온다. 구면 위 4개 숫자라서 SLERP가 깔끔하게 성립한다.

4. 핵심 공식

q = ( cos(θ/2),   sin(θ/2)·aₓ, sin(θ/2)·a_y, sin(θ/2)·a_z )   축 a 는 단위벡터
벡터 v 회전  →  v′ = q · v · q⁻¹    ( 단위 쿼터니언이면 q⁻¹ = conjugate = (w, −x, −y, −z) )
두 회전 합성: q_total = q₂ · q₁   ( 곱셈 순서 = 적용 순서의 역, 교환법칙 성립 안 함 )
정규화 유지

회전 쿼터니언은 항상 w²+x²+y²+z² = 1. 여러 번 곱하면 부동소수점 오차로 크기가 어긋나므로 주기적으로 단위 길이로 다시 나눠 준다.

곱셈(해밀턴 곱)

i²=j²=k²=ijk=−1. 두 쿼터니언 곱은 실수부·벡터부를 내적과 외적으로 조합한 16개 항이다. 행렬 곱보다 연산이 적다.

저장 비용

회전 행렬은 숫자 9개(+직교 조건 6개 제약), 쿼터니언은 4개(+길이 1개). 네트워크로 자세를 보낼 때 대역폭이 절반 이하.

1843년

해밀턴이 브룸 다리를 걷다 i²=j²=k²=ijk=−1 을 떠올려 다리 돌에 새겼다. 3D 그래픽은 1985년(Shoemake)에야 이걸 회전에 쓰기 시작했다.

5. 축-각 → 쿼터니언 → 벡터 회전 (JavaScript)

// 단위축 [ax,ay,az] 를 중심으로 angle(라디안)만큼 도는 쿼터니언 function fromAxisAngle(ax, ay, az, angle) { const h = angle / 2, s = Math.sin(h); return [Math.cos(h), ax * s, ay * s, az * s]; // [w, x, y, z] } // 해밀턴 곱 a ⊗ b function mul(a, b) { const [aw, ax, ay, az] = a, [bw, bx, by, bz] = b; return [ aw*bw - ax*bx - ay*by - az*bz, aw*bx + ax*bw + ay*bz - az*by, aw*by - ax*bz + ay*bw + az*bx, aw*bz + ax*by - ay*bx + az*bw, ]; } // 벡터 v 를 q 로 회전: q ⊗ (0,v) ⊗ q* function rotate(q, v) { const [w, x, y, z] = q; const conj = [w, -x, -y, -z]; const r = mul(mul(q, [0, ...v]), conj); return [r[1], r[2], r[3]]; }
실무에선 rotate 를 전개한 최적화 공식 v′ = v + 2w(u×v) + 2(u×(u×v)) (u=쿼터니언 벡터부)를 쓴다 — 곱셈을 두 번 하지 않아 더 빠르다.

6. 놓치기 쉬운 함정

q 와 −q 는 같은 회전

부호가 통째로 뒤집혀도 물체는 똑같이 놓인다. SLERP 전에 dot(A,B) < 0 이면 B의 부호를 뒤집어 짧은 쪽으로 돌게 해야 한다.

곱셈 순서가 중요

q₁·q₂ ≠ q₂·q₁. "먼저 Yaw, 그다음 Pitch"와 순서를 바꾼 결과는 전혀 다른 자세다. 회전은 교환법칙이 성립하지 않는다.

정규화 드리프트

매 프레임 곱하다 보면 길이가 1에서 벗어나 물체가 미묘하게 찌그러진다. 수십 프레임마다 q /= |q| 로 되돌린다.

사람에겐 안 읽힘

(0.707, 0, 0.707, 0) 이 무슨 자세인지 감이 안 온다. 그래서 에디터 UI는 오일러 각으로 보여 주고 내부 저장·계산만 쿼터니언으로 한다.