방향을 바꾸지 않으면 지금 향하는 곳에 도착하게 될지도 모른다. — 노자(기원전 600–531)
이 장에서는 3차원에서 물체의 자세(orientation)를 표현하는 문제를 다룹니다. 이와 밀접하게 연결된 회전과 각변위의 개념도 함께 살펴보겠습니다. 3차원의 자세와 각변위를 표현하는 방법은 여러 가지가 있습니다. 여기서는 가장 중요한 세 가지 방법인 행렬, 오일러 각, 쿼터니언을 중심으로, 상대적으로 덜 알려진 축–각과 지수 사상 표현까지 설명합니다. 각 표현이 어떻게 작동하는지 정확히 정의하고, 그 특성과 장점, 단점을 차례로 살펴보겠습니다.
상황에 따라 필요한 기법은 달라지며, 각 기법에는 저마다 장단점이 있습니다. 따라서 작동 원리를 이해하는 것만으로는 충분하지 않습니다. 어떤 상황에 어떤 표현이 적합한지, 그리고 서로 다른 표현 사이를 어떻게 변환할 수 있는지도 알아야 합니다.
•
8.1절: ‘자세’, ‘방향’, ‘각변위’ 같은 용어 사이의 미묘한 차이.
•
8.2절: 행렬로 자세 표현하기.
•
8.3절: 오일러 각으로 각변위 표현하기.
•
8.4절: 축–각과 지수 사상 표현.
•
8.5절: 쿼터니언으로 각변위 표현하기.
•
8.6절: 여러 방법의 비교.
•
8.7절: 자세를 한 표현에서 다른 표현으로 변환하기.
이 장에서는 오브젝트 공간(object space)과 직립 공간(upright space)이라는 용어를 자주 사용합니다. 두 용어가 아직 익숙하지 않다면, 처음 소개한 3.2절을 먼저 확인해 주세요.
8.1 ‘자세’란 정확히 무엇인가? (Orientation)
3차원에서 자세를 표현하는 방법을 논의하기 전에, 우리가 정확히 무엇을 표현하려는지부터 정의해 보겠습니다. ‘자세’는 방향(direction), 각변위(angular displacement), 회전(rotation)처럼 비슷해 보이는 용어들과 연결되어 있습니다. 이 용어들이 어떤 점에서 같고 다른지 구분하는 것이 출발점입니다.
직관적으로 물체의 ‘자세’는 그 물체가 어느 쪽을 향하고 있는지를 나타냅니다. 그러나 자세와 방향은 정확히 같은 개념이 아닙니다. 예를 들어 벡터에는 방향이 있지만 자세는 없습니다. 특정 방향을 가리키는 벡터를 길이 방향의 축을 중심으로 비틀어 보세요(그림 8.1). 벡터 자체에는 아무런 변화가 생기지 않습니다. 벡터에는 길이 외에 두께나 다른 차원이 없기 때문입니다.
그림 8.1. 벡터를 비틀어도 벡터에는 실질적인 변화가 없다
그림 8.1. 벡터를 비틀어도 벡터에는 실질적인 변화가 없다
이번에는 같은 방향을 향하고 있는 제트기와 같은 물체를 생각해 보겠습니다. 벡터를 비틀었던 것과 같은 방식으로 제트기를 비틀면, 제트기의 자세는 달라집니다(그림 8.2). 물체의 자세에 포함되는 이 비틀림 성분을 8.3절에서는 뱅크(bank)라고 부릅니다.
그림 8.2. 물체를 비틀면 자세가 바뀐다
그림 8.2. 물체를 비틀면 자세가 바뀐다
방향과 자세의 차이는 이를 표현하는 데 필요한 숫자의 개수에서도 드러납니다. 3차원의 방향은 구면좌표계의 두 각도, 즉 숫자 두 개로 표현할 수 있습니다(7.3.2절). 반면 물체의 자세를 표현하려면 최소한 숫자 세 개가 필요합니다. 8.3절에서 다룰 오일러 각이 그 예입니다.
2.4.1절에서는 물체의 위치를 절대적인 값으로 표현할 수 없으며, 언제나 특정 기준틀 안에서 표현해야 한다고 설명했습니다. ‘점’과 ‘벡터’의 관계를 살펴볼 때도 같은 생각을 사용했습니다. 위치를 지정한다는 것은 실제로 어떤 기준점, 보통 좌표계의 원점으로부터 얼마나 평행이동했는지를 지정하는 것과 같습니다.
자세도 마찬가지입니다. 위치를 알려진 기준점으로부터의 평행이동으로 나타내듯, 자세는 알려진 기준 자세로부터의 회전으로 나타냅니다. 이 기준 자세를 흔히 ‘항등(identity)’ 또는 ‘홈(home)’ 자세라고 부릅니다. 기준 자세에서 회전한 양이 바로 각변위입니다. 따라서 자세를 표현하는 일은 수학적으로 각변위를 표현하는 일과 같습니다.
그럼에도 ‘수학적으로 같다’고 구분해서 말하는 이유는, 이 책에서 자세와 각변위, 회전의 개념을 미묘하게 구별하기 때문입니다. 각변위는 입력을 받아 출력을 만드는 연산자로 생각하면 이해하기 쉽습니다. 이전 자세에서 새로운 자세로, 또는 직립 공간에서 오브젝트 공간으로 바꾸는 것처럼 변환의 방향도 함께 포함됩니다. 예를 들어 ‘z축을 중심으로 90° 회전하라’는 각변위입니다. 벡터에 어떤 동작을 수행할지 설명하는 표현입니다.
반면 상태 변수 등을 다룰 때는 입력과 출력이라는 관점보다 부모와 자식의 관계로 생각하는 편이 더 자연스러울 수 있습니다. 이런 상황에서는 주로 ‘자세’라는 말을 사용합니다. ‘똑바로 서서 동쪽을 바라보고 있다’가 그 예입니다. 어떤 동작을 시키는 것이 아니라, 현재 어떤 상태인지 설명하는 것입니다.
물론 ‘똑바로 서서 동쪽을 바라본다’는 자세를 ‘똑바로 서서 북쪽을 바라본 뒤 z축을 중심으로 90° 회전한다’는 각변위로 설명할 수도 있습니다. 자세와 각변위의 관계는 점과 벡터의 관계와 비슷합니다. 수학적으로는 같아도 개념적으로는 구별합니다. 두 경우 모두 첫 번째 용어는 주로 하나의 상태를, 두 번째 용어는 두 상태 사이의 차이를 나타냅니다. 이러한 구분은 관례에 따른 것이지만, 생각을 정리하는 데 도움이 됩니다.
물체, 특히 항공기의 자세를 가리킬 때 애티튜드(attitude)라는 말도 들을 수 있습니다.
방향과 자세를 게임 카메라로 이해하기
카메라의 조준점은 ‘어디를 보는가’라는 방향입니다. 같은 조준점을 계속 보면서 화면을 시계 방향으로 기울이면 방향은 유지되지만 자세가 바뀝니다. 헤딩·피치가 조준 방향을, 뱅크가 화면의 기울기를 나타냅니다. 따라서 단위 방향벡터 하나만으로 카메라의 모든 자세가 결정되지는 않습니다. 위쪽 방향 같은 정보가 더 필요합니다.
원문 표현과 계산 조건
8.1절의 북쪽→동쪽 예에서 이 책의 +y가 위인 규약을 적용하면 수직 회전축은 y축입니다. 해당 문장의 z축은 다른 축 규약을 떠올리게 하므로 주의합니다.
8.2 행렬 표현 (Matrix Representation)
3차원 좌표 공간의 자세를 설명하는 한 방법은 그 좌표 공간의 기저벡터, 즉 +x, +y, +z축이 어느 방향을 가리키는지 말하는 것입니다. 물론 설명하려는 그 좌표 공간 자체로 벡터를 측정하지는 않습니다. 그 공간에서라면 자세와 관계없이 정의상 항상 [1, 0, 0], [0, 1, 0], [0, 0, 1]이기 때문입니다. 다른 좌표 공간을 사용하여 기저벡터를 표현해야 합니다. 그렇게 하면 두 좌표 공간의 상대적 자세가 정해집니다.
이 기저벡터들을 3×3 행렬의 행으로 놓으면 자세를 행렬 형식으로 표현한 것입니다.[원주 1] 달리 말해 한 좌표 공간에서 다른 좌표 공간으로 벡터를 변환하는 회전행렬을 주면, 두 좌표 공간의 상대적인 자세를 표현할 수 있습니다.
8.2.1 어느 행렬을 써야 할까?
행렬로 점을 한 좌표 공간에서 다른 좌표 공간으로 변환하는 방법은 이미 보았습니다. 그림 8.3의 오른쪽 위 행렬은 제트기의 오브젝트 공간에 있는 점을 직립 공간으로 회전시키는 데 쓸 수 있습니다. 행렬의 행들을 따로 꺼내 표시하여 제트기 몸체 축의 좌표와 직접 대응한다는 점을 강조했습니다.
회전행렬에는 직립 공간으로 표현된 오브젝트의 축들이 들어 있습니다. 동시에 이것은 회전행렬이므로 행벡터에 이 행렬을 곱하면 그 벡터를 오브젝트 공간 좌표에서 직립 공간 좌표로 변환할 수 있습니다.
그림 8.3. 행렬로 자세 정의하기
그림 8.3. 행렬로 자세 정의하기
여기서 자연스럽게 질문이 생깁니다. 왜 행렬에는 직립 공간의 좌표로 표현한 물체의 축을 넣을까요? 오브젝트 공간의 좌표로 표현한 직립 공간의 축을 넣으면 안 될까요? 다시 말해, 왜 오브젝트 공간에서 직립 공간으로 변환하는 행렬을 선택했을까요? 그 반대 방향으로 변환하는 행렬을 선택할 수도 있지 않을까요?
수학적인 관점에서 보면 이 선택은 큰 문제가 아닙니다. 회전행렬은 직교행렬이므로 역행렬이 전치행렬과 같기 때문입니다(6.3.2절). 어느 쪽을 선택하든 전치만 하면 반대 방향을 얻을 수 있습니다. 그러나 실제 코드를 작성할 때는 이 선택이 꽤 중요합니다. 코드를 직관적으로 읽고 처음부터 올바르게 작성할 수 있는지, 아니면 코드를 해석하기 위해 많은 노력을 들이거나 문서에 적히지 않은 규약까지 알고 있어야 하는지가 달라지기 때문입니다.
따라서 3.2.4절에서 직립 공간을 소개하며 시작했던 논의를 잠시 이어가 보겠습니다. 좌표 공간 변환의 수학을 코드로 옮길 때 어떤 문제가 생기는지 살펴보고, 프로그래머들이 회전행렬을 다루는 모습을 보며 얻은 저자들의 의견도 함께 설명하겠습니다. 모든 독자가 같은 의견을 가질 필요는 없습니다.
다만 이런 문제를 고민할 가치만큼은 모든 독자가 알아주기를 바랍니다.
좋은 수학 라이브러리라면 임의의 변환을 표현하는 3×3 행렬 클래스가 있을 것입니다. 즉, 행렬 원소의 값에 어떤 가정도 두지 않는 클래스입니다. 투영을 할 수 있는 4×4 행렬이나, 평행이동은 가능하지만 투영은 못 하는 4×3 행렬일 수도 있습니다. 지금은 그 차이가 중요하지 않습니다. 이런 행렬의 연산에는 본질적으로 입력 좌표 공간과 출력 좌표 공간이 있습니다. 이는 행렬 곱셈이라는 생각 자체에 내재합니다. 출력에서 입력으로 가려면 역행렬을 구해야 합니다.
일반적인 변환행렬 클래스로 물체의 자세를 표현하는 것은 흔한 관행입니다. 이 경우 회전도 다른 변환과 똑같이 취급되고 인터페이스도 출발 공간과 도착 공간으로 표현됩니다. 안타깝게도 우리의 경험상 압도적으로 많이 사용하는 행렬 연산은 다음 둘입니다.[원주 2]
•
오브젝트 공간 벡터를 직립 좌표로 표현하기.
•
직립 공간 벡터를 오브젝트 좌표로 표현하기.
여기서 중요한 점은 양방향 변환이 모두 필요하다는 것입니다. 어느 한 방향이 훨씬 더 자주 쓰인다는 경험이나 증거는 없습니다. 더 중요한 것은 프로그래머가 이 연산을 생각하는 방식입니다. 보통은 ‘오브젝트 공간과 직립 공간’, 또는 그에 대응하는 ‘자식 공간과 부모 공간’의 관계로 생각합니다. 단순히 ‘출발 공간과 도착 공간’이라고만 생각하지는 않습니다. ‘어느 행렬을 써야 할까?’라는 질문도 이러한 맥락에서 살펴봐야 합니다.
먼저 8.1절 끝에서 설명한 자세와 각변위의 차이를 떠올려 보겠습니다. 두 개념은 수학적으로는 같지만, 코드에서 표현하려는 의도는 다를 수 있습니다. ‘x축을 중심으로 30도 회전하라’처럼 특정 각변위를 수행하려는 경우라면, 앞에서 나열한 두 공간 변환과는 다른 동작을 생각하고 있을 가능성이 큽니다. 이때는 변환 방향이 정해진 일반 변환행렬을 사용해도 문제가 없습니다. 여기서 초점을 맞추는 것은 물체의 자세를 상태 변수로 저장하는 경우입니다.
일반적인 변환행렬로 자세를 저장한다고 가정해 보겠습니다. 하나의 규약을 선택해야 하므로, 이 행렬을 곱하면 오브젝트 공간에서 직립 공간으로 변환되도록 정하겠습니다. 그러면 직립 공간의 벡터를 오브젝트 공간의 좌표로 표현할 때는 같은 행렬의 역행렬을 곱해야 합니다.[원주 3]
이 규약은 게임 프로그래머가 수백 번 읽고 쓰는 코드에 어떤 영향을 줄까요?
•
오브젝트 공간에서 직립 공간으로 벡터 회전하기 → 행렬 곱셈.
•
직립 공간에서 오브젝트 공간으로 벡터 회전하기 → 역행렬 또는 전치행렬 곱셈.
이렇게 작성한 코드는 프로그래머의 고수준 의도와 일대일로 대응하지 않습니다. 행렬을 사용할 때마다 사용자가 변환 규약을 기억해야 하기 때문입니다. 저자들의 경험에 따르면, 이런 코딩 방식은 초보자가 행렬 사용법을 배우기 어려워하는 이유 중 하나입니다. 결과가 예상과 다르면 원인을 파악하기보다 무작위로 전치하거나 부호를 바꾸게 되는 경우도 많습니다.
임의 변환이 아니라 물체의 자세 저장만을 위한 별도의 3×3 행렬 클래스가 도움이 된다는 것을 알게 되었습니다. 이 클래스는 행렬이 직교행렬, 즉 회전만 포함한다는 사실을 불변조건으로 삼습니다. 직교행렬에도 반사가 있을 수 있지만 보통 반사도 포함하지 않는다고 가정할 것입니다.
이러한 전제를 두면 더 높은 추상화 수준에서 회전 연산을 제공할 수 있습니다. 인터페이스 함수가 프로그래머가 하려는 동작을 그대로 나타내도록 만드는 것입니다. 그러면 사용자는 행벡터와 열벡터 중 무엇을 쓰는지, 공간이 곱셈의 어느 쪽에 놓이는지, 어느 방향이 정방향이고 역방향인지 같은 세부 사항에 매번 신경 쓰지 않아도 됩니다.
물론 이러한 선형대수 세부 사항이 사라지는 것은 아닙니다. 클래스 내부에서 처리하도록 한정하는 것입니다. 구현자는 하나의 규약을 선택하고 문서로 남겨야 합니다. 하지만 자세를 다루는 전용 클래스의 사용자는 ‘벡터를 곱한다’거나 ‘역행렬을 구한다’는 연산보다, ‘직립 공간에서 오브젝트 공간으로 바꾼다’는 식의 연산을 필요로 합니다. 따라서 이 클래스의 인터페이스도 두 공간의 관계를 중심으로 제공하는 편을 권합니다.
이제 처음의 질문으로 돌아가 보겠습니다. 어느 행렬을 사용해야 할까요? 저자들의 답은 ‘어느 쪽을 선택했는지 사용자가 신경 쓰지 않아도 되도록 설계해야 한다’입니다. 내부 규약을 알지 못해도 원하는 변환을 사용할 수 있게 만드는 것입니다. C++ 코드에서는 함수 이름을 바꾸는 정도의 차이일 수도 있습니다. 예를 들어 multiply() 대신 objectToUpright(), multiplyByTranspose() 대신 uprightToObject()를 사용하면 됩니다. 좌표 공간과 변환 방향이 이름에 드러나므로 코드를 읽고 작성하기가 더 쉬워집니다.
[원주 1]: 벡터를 행렬의 열에 넣을 수도 있습니다. 열벡터를 쓸 때는 당연하지만 행벡터를 선호해도 가능합니다. 회전행렬은 정규직교행렬이므로 전치하여 역행렬을 구할 수 있기 때문입니다. 8.2.1절에서 설명합니다.
[원주 2]: 여기서 사용 빈도는 실행 횟수가 아니라 그 연산을 코드로 작성하는 횟수입니다. 예를 들어 그래픽스 파이프라인에서 정점을 변환하는 연산은 매우 자주 실행되지만 해당 연산을 수행하는 코드의 줄 수는 비교적 적습니다. 레이싱, 전투, 3차원 보드게임, 슈터 등 다양한 장르에서 이런 경향을 보았지만 모든 사람의 작업 환경을 대변할 수는 없습니다.
[원주 3]: 실제로는 회전행렬이 직교행렬이므로 전치행렬을 곱할 가능성이 큽니다. 하지만 지금의 요점은 그것이 아닙니다.
8.2.2 방향코사인 행렬
자세를 행렬로 표현하는 내용을 읽다 보면 방향코사인(direction cosines)이라는 오래된 용어를 만날 수 있습니다. 방향코사인 행렬은 회전행렬과 같은 행렬입니다. 이름만 다를 뿐, 행렬을 해석하거나 구성하는 특정한 관점을 강조하는 표현입니다. 그 관점에는 배울 만한 내용이 있으므로 잠시 살펴보겠습니다. 회전행렬의 각 원소는 한 공간의 기본축과 다른 공간의 기본축 사이의 내적입니다. 예를 들어 3×3 행렬의 중앙 원소 m₂₂는 두 공간의 y축 사이의 내적을 나타냅니다.
좀 더 일반적인 경우를 생각해 보겠습니다. 첫 번째 좌표 공간의 기저벡터가 서로 수직인 단위벡터 p, q, r이라고 하겠습니다. 같은 원점을 가진 두 번째 좌표 공간에는 또 다른 정규직교기저 p′, q′, r′가 있습니다. 이 절에서는 수식을 간결하게 쓰기 위해 단위벡터 위의 모자 기호를 생략하겠습니다. 첫 번째 공간에서 두 번째 공간으로 행벡터를 회전시키는 행렬은, 각 기저벡터 쌍이 이루는 각도의 코사인으로 구성할 수 있습니다.
단위벡터 두 개의 내적은 사이각의 코사인과 정확히 같으므로 행렬 곱은 다음과 같습니다.
여기서 축은 단순한 숫자의 나열이 아니라 기하학적인 대상으로 볼 수 있습니다. 따라서 모든 축을 같은 좌표 공간의 좌표로 표현하기만 하면, 어느 좌표 공간을 기준으로 측정했는지는 중요하지 않습니다. 그에 따라 만들어지는 회전행렬은 같습니다.
예를 들어 축들을 첫 번째 기저에 대한 좌표로 표현한다고 하겠습니다. 그러면 p, q, r은 각각 단순한 [1, 0, 0], [0, 1, 0], [0, 0, 1]입니다. 두 번째 공간의 기저벡터 p′, q′, r′는 임의의 좌표를 갖습니다. 이 단순한 벡터를 식 (8.1)에 대입하여 내적을 전개하면 다음과 같습니다.
즉, 회전행렬의 행은 입력 좌표 공간으로 표현한 출력 좌표 공간의 기저벡터입니다. 이 사실은 회전행렬뿐 아니라 모든 변환행렬에 해당합니다. 4.2절에서 설명한 변환행렬 작동 원리의 핵심입니다.
이번에는 반대 경우를 보겠습니다. 첫 번째 기저에 대한 좌표 대신 모든 것을 두 번째 좌표 공간, 즉 출력 공간으로 측정합니다. 이번에는 p′, q′, r′가 단순한 형태이고 p, q, r은 임의적입니다. 방향코사인 행렬에 넣으면 다음을 얻습니다.
이 결과는 회전행렬의 열이 출력 공간의 좌표로 표현한 입력 공간의 기저벡터로 이루어진다는 뜻입니다. 다만 이 성질이 모든 변환행렬에 적용되는 것은 아닙니다. 회전행렬처럼 직교행렬인 경우에만 성립합니다.
또한 이 책에서는 행벡터를 행렬의 왼쪽에 두는 규약을 사용한다는 점을 기억해 주세요. 열벡터를 오른쪽에 두는 규약을 사용한다면, 위의 행렬과 설명을 모두 전치해서 생각해야 합니다.
제공 PDF의 방향코사인 식 정정
8.2.2절의 식 (8.1)은 행벡터를 쓴다는 설명과 행렬 원소 배열이 맞지 않습니다. 첫 공간의 기저가 p, q, r이고 둘째 공간의 기저가 p′, q′, r′이면, 첫 좌표의 행벡터 v를 둘째 좌표로 바꾸는 올바른 식은 다음과 같습니다. 모든 기저는 동일한 외부 좌표계로 표현해야 합니다.
따라서 행은 ‘출력 공간 좌표로 표현한 입력 공간 기저’다. 회전행렬에서는 열을 ‘입력 공간 좌표로 표현한 출력 공간 기저’로 볼 수 있습니다. 8.2.5절의 정리는 이 방향으로 올바르게 되어 있습니다. 8.2.2절에 실린 전치된 식은 원문 확인을 위해 번역에 보존했으며 구현에는 이 교정식을 사용합니다.
8.2.3 행렬 표현의 장점
행렬은 자세를 매우 명시적으로 표현합니다. 이런 명시성은 몇 가지 이점을 줍니다.
•
곧바로 벡터를 회전시킬 수 있습니다. 행렬 표현의 가장 중요한 성질은 오브젝트 공간과 직립 공간 사이에서 벡터를 회전시킬 수 있다는 것입니다. 다른 자세 표현은 이를 직접 허용하지 않으므로[원주 4] 벡터를 회전시키려면 자세를 행렬로 변환해야 합니다.
•
그래픽스 API에서 사용하는 형식입니다. 부분적으로 앞 항목의 이유 때문에 그래픽스 API는 자세를 행렬로 표현합니다. API는 Application Programming Interface의 약자로, 기본적으로 그래픽스 하드웨어와 통신하는 데 사용하는 코드입니다. API와 통신할 때 변환을 행렬로 표현해야 합니다. 프로그램 내부 저장 방식은 자유지만 다른 표현을 고르면 그래픽스 파이프라인의 어느 단계에서는 행렬로 바꿔야 합니다.
•
여러 각변위를 합성할 수 있습니다. 중첩된 좌표 공간 관계를 하나로 ‘접어 넣을’ 수 있습니다. 예를 들어 물체 B에 대한 A의 자세와 C에 대한 B의 자세를 알면 행렬로 C에 대한 A의 자세를 구할 수 있습니다. 중첩 좌표 공간은 3장에서, 행렬 합성은 5.6절에서 이미 다뤘습니다.
•
역행렬을 구할 수 있습니다. 각변위를 행렬로 표현하면 역행렬을 이용해 ‘반대’ 각변위를 구할 수 있습니다. 더욱이 회전행렬은 직교행렬이므로 전치만 하면 되는 간단한 계산입니다.
[원주 4]: 쿼터니언 곱으로 회전할 수 있다는 점은 쿼터니언의 장점으로 자주 언급됩니다(8.5.7절). 그러나 수학을 들여다보면 이 ‘지름길’은 대응하는 회전행렬을 곱하는 것과 같습니다.
8.2.4 행렬 표현의 단점
앞에서 살펴본 것처럼, 행렬은 자세를 명시적으로 표현한다는 장점이 있습니다. 그러나 자세를 표현하는 데는 숫자 세 개면 충분한데, 행렬은 숫자 아홉 개를 저장합니다. 이처럼 추가로 저장하는 숫자 때문에 몇 가지 문제가 생길 수 있습니다.
•
메모리를 더 사용합니다. 애니메이션 시퀀스의 키프레임처럼 많은 자세를 저장하면 세 개 대신 아홉 개의 숫자를 저장하는 비용이 누적됩니다. 소박한 예로, 신체 부위별로 15개 조각으로 나뉜 사람 모델을 애니메이션한다고 하겠습니다. 각 부위의 부모 부위에 대한 자세를 조절하는 것만으로 애니메이션합니다. 프레임마다 부위별 자세 하나를 저장하고, 낮은 편인 15Hz로 데이터를 저장하면 초당 자세 225개가 됩니다. 32비트 부동소수점 행렬을 사용하면 각 프레임에 8,100바이트가 듭니다. 다음 8.3절의 오일러 각으로는 같은 데이터에 2,700바이트만 듭니다. 겨우 30초짜리 애니메이션에서도 행렬은 오일러 각보다 162K를 더 차지합니다!
•
사람이 쓰기 어렵습니다. 행렬은 사람이 직접 다루기에 직관적이지 않습니다. 숫자가 많을 뿐 아니라 회전행렬의 원소는 모두 −1과 +1 사이에 있습니다. 사람은 보통 자세를 각도로 생각하지만, 행렬은 벡터를 통해 표현합니다. 연습하면 주어진 행렬에서 자세를 읽어 낼 수 있고, 4.2절의 행렬 시각화 기법도 도움이 됩니다. 그래도 오일러 각을 해석하는 것보다는 훨씬 어렵습니다. 반대로 원하는 자세의 행렬을 손으로 구성하는 일은 더 어렵습니다. 자세가 조금만 복잡해져도 많은 시간이 필요합니다. 전반적으로 행렬은 사람이 자세를 자연스럽게 생각하는 방식과 잘 맞지 않습니다.
•
잘못된 형식의 행렬이 가능합니다. 세 수면 충분한데 아홉 수를 사용하므로 행렬에는 여섯 자유도만큼 중복이 있습니다. 행렬이 자세를 나타내는 데 ‘유효’하려면 여섯 제약을 만족해야 합니다. 행들이 단위벡터여야 하고 서로 수직이어야 합니다(6.3.2절).
마지막 문제를 조금 더 자세히 살펴보겠습니다. 숫자 아홉 개를 무작위로 골라 3×3 행렬을 만들면, 앞에서 말한 여섯 제약을 만족할 가능성은 매우 낮습니다. 따라서 그것이 유효한 회전행렬이 된다고 기대할 수는 없습니다. 즉, 일반 행렬로서 사용할 수 있어도 자세를 표현하는 목적에는 맞지 않을 수 있습니다. 이런 행렬을 회전행렬로 사용하면 수치 예외나 이상하게 늘어난 그래픽 등 예상하지 못한 동작이 생길 수 있습니다.
그렇다면 잘못된 행렬은 어떻게 만들어질까요? 다음과 같은 경로를 생각할 수 있습니다.
•
행렬에 스케일, 기울이기, 반사, 투영이 들어 있을 수 있습니다. 이런 연산의 영향을 받은 물체의 ‘자세’는 무엇일까? 명확하게 정의하기 어렵습니다. 비직교행렬은 올바른 회전행렬이 아닙니다(직교행렬은 6.3절 참조). 반사행렬은 직교행렬이지만 역시 유효한 회전행렬은 아닙니다.
•
외부에서 잘못된 데이터를 받을 수 있습니다. 예를 들어 모션 캡처 같은 물리적 데이터 수집 시스템에서는 취득 과정의 오류가 생길 수 있습니다. 많은 모델링 패키지가 잘못된 행렬을 만드는 것으로 악명 높습니다.
•
부동소수점 반올림 오차가 누적되어 잘못된 데이터를 만들 수도 있습니다. 게임이나 시뮬레이션에서 사용자가 물체의 자세를 조절할 때처럼, 회전에 작은 변화를 반복해서 적용하는 상황을 생각해 보겠습니다. 부동소수점 정밀도에는 한계가 있으므로 수많은 행렬 곱셈을 반복하면 회전행렬의 조건에서 조금씩 벗어날 수 있습니다. 이런 현상을 행렬 크리프(matrix creep)라고 부릅니다. 6.3.3절에서 설명한 행렬의 직교화를 통해 대응할 수 있습니다.
원문 표현과 계산 조건
8.2.4절의 메모리 예에서 8,100바이트와 2,700바이트는 ‘프레임당’이 아니라 ‘초당’입니다. 15부위 × 초당 15프레임 × 9성분 × 4바이트 = 초당 8,100바이트입니다. 30초 차이 162,000바이트는 맞습니다.
8.2.5 행렬 표현 정리
8.2절에서 설명한 내용을 정리하겠습니다.
•
행렬은 자세를 명시적으로 표현하는 방법입니다. 한 공간의 기저벡터를 다른 공간의 좌표로 직접 나열하여 두 공간의 관계를 나타냅니다.
•
방향코사인 행렬이라는 이름은 회전행렬의 각 원소가 입력 기저벡터 하나와 출력 기저벡터 하나의 내적이라는 점을 가리킵니다. 모든 변환행렬처럼 행렬의 행은 출력 공간 좌표로 표현한 입력 공간의 기저벡터입니다. 또한 회전행렬의 열은 입력 공간 좌표로 표현한 출력 공간의 기저벡터이며, 이 추가 성질은 회전행렬이 직교행렬이기 때문에 성립합니다.
•
행렬 자세 표현이 유용한 주된 이유는 좌표 공간 사이에서 벡터를 회전시킬 수 있기 때문입니다.
•
현대 그래픽스 API는 자세를 행렬로 표현합니다.
•
행렬 곱셈으로 중첩 좌표 공간의 행렬들을 하나로 합칠 수 있습니다.
•
역행렬은 ‘반대’ 각변위를 구하는 수단입니다.
•
행렬은 다른 기법보다 메모리를 두세 배 쓸 수 있습니다. 애니메이션 데이터처럼 많은 자세를 저장할 때는 큰 차이가 됩니다.
•
행렬의 숫자는 사람이 직관적으로 다루기 어렵습니다.
•
모든 행렬이 자세 표현에 유효한 것은 아닙니다. 어떤 행렬에는 반사나 기울이기가 있습니다. 외부의 잘못된 데이터나 행렬 크리프 때문에 잘못된 행렬이 생길 수 있습니다.
8.3 오일러 각 (Euler Angles)
자세를 표현하는 또 다른 일반적인 방법은 오일러 각(Euler angles)입니다. 이 기법의 이름은 유명한 수학자 레온하르트 오일러(1707–1783)에서 유래했습니다. Euler는 ‘율러’가 아니라 ‘오일러’로 읽습니다. 8.3.1절에서는 오일러 각의 작동 원리와 가장 일반적인 규약을 설명합니다. 이어 8.3.2절에서는 고정축 체계를 포함한 다른 규약을 살펴보겠습니다. 장점과 단점은 8.3.3절과 8.3.4절에서 검토하고, 8.3.5절에서 중요한 개념을 정리합니다.
이 절은 7.3.2절의 구면좌표에 관한 생각, 용어, 규약을 많이 활용합니다.
8.3.1 오일러 각이란 무엇인가?
오일러 각의 기본 생각은 서로 수직인 세 축을 중심으로 세 번의 회전을 순서대로 적용하여 각변위를 정의하는 것입니다. 처음에는 복잡하게 들릴 수 있지만, 실제로는 상당히 직관적인 방법입니다. 사람이 쉽게 이해하고 사용할 수 있다는 점도 오일러 각의 주요 장점입니다.
그렇다면 어떤 축을 중심으로, 어떤 순서로 회전해야 할까요? 세 축을 사용하는 회전 순서에는 여러 규약이 있습니다. 많은 경우 기본축을 특정한 순서로 사용하는 방식이 실용적입니다. 가장 일반적인 규약이자 이 책에서 사용하는 규약은 헤딩–피치–뱅크(heading-pitch-bank)입니다. 이 체계에서는 헤딩각, 피치각, 뱅크각으로 물체의 자세를 정의합니다.
헤딩, 피치, 뱅크를 정확히 정의하기 전에 이 책의 좌표계 규약부터 복습해 보겠습니다. 이 책은 +x가 오른쪽, +y가 위쪽, +z가 앞쪽을 가리키는 왼손 좌표계를 사용합니다(인쇄본 19쪽 그림 1.15). 양의 회전 방향을 정하는 왼손 법칙이 기억나지 않는다면, 인쇄본 17쪽 그림 1.14를 다시 확인해 주세요.
헤딩, 피치, 뱅크가 주어지면 간단한 네 단계로 자세를 결정할 수 있습니다.
1.
항등 자세에서 시작합니다. 오브젝트 공간 축과 직립 공간 축이 나란히 정렬된 상태입니다.
그림 8.4. 1단계: 항등 자세인 물체
그림 8.4. 1단계: 항등 자세인 물체
1.
헤딩 회전을 수행합니다. 그림 8.5처럼 y축을 중심으로 회전합니다. 양의 회전은 오른쪽으로, 위에서 보면 시계 방향으로 돌아갑니다.
그림 8.5. 2단계: 첫 번째 회전인 헤딩은 수직축을 중심으로 돈다
그림 8.5. 2단계: 첫 번째 회전인 헤딩은 수직축을 중심으로 돈다
1.
피치 회전을 수행합니다. 헤딩을 적용한 다음, 오브젝트 공간의 x축을 중심으로 회전하는 양이 피치입니다. 이때 사용하는 축은 고정된 직립 공간의 x축이 아니라, 헤딩과 함께 움직인 물체의 x축입니다. 왼손 법칙에 따르면 양의 피치는 아래쪽으로 회전합니다. 따라서 이 책의 피치는 실제로 부각, 즉 아래로 기울어진 각도를 나타냅니다(그림 8.6).
그림 8.6. 3단계: 두 번째 회전인 피치는 물체의 가로축을 중심으로 돈다
그림 8.6. 3단계: 두 번째 회전인 피치는 물체의 가로축을 중심으로 돈다
1.
뱅크 회전을 수행합니다. 헤딩과 피치를 적용한 다음, 오브젝트 공간의 z축을 중심으로 회전하는 양이 뱅크입니다. 여기서도 원래의 직립 공간 z축이 아니라, 앞의 두 회전과 함께 움직인 물체의 z축을 사용합니다. 왼손 법칙에 따르면 원점에서 +z 방향을 바라볼 때 양의 뱅크는 반시계 방향으로 회전합니다(그림 8.7).
그림 8.7. 4단계: 세 번째이자 마지막 회전인 뱅크는 물체의 세로축을 중심으로 돈다
그림 8.7. 4단계: 세 번째이자 마지막 회전인 뱅크는 물체의 세로축을 중심으로 돈다
양의 헤딩은 시계 방향인데 양의 뱅크는 반시계 방향이라니, 서로 모순되는 것처럼 느껴질 수 있습니다. 그러나 두 회전을 바라보는 위치가 다릅니다. 헤딩을 시계 방향이라고 설명할 때는 y축의 양의 끝에서 원점을 내려다보고 있습니다. 뱅크를 설명할 때는 반대로 원점에서 +z 방향을 바라봅니다. 원점에서 y축의 양의 끝을 바라보면 양의 헤딩도 반시계 방향으로 보입니다. 반대로 z축의 양의 끝에서 원점을 바라보면, 즉 물체의 앞에서 뒤를 보면 양의 뱅크는 시계 방향으로 보입니다. 어느 경우든 같은 왼손 법칙을 따릅니다.
이 네 단계를 거치면 주어진 오일러 각이 나타내는 자세에 도달합니다. 특히 1~3단계는 7.3.2절에서 구면좌표계의 두 각도로 방향을 구했던 절차와 비슷합니다. 헤딩과 피치가 물체가 바라보는 방향을 정하고, 여기에 뱅크가 더해져 비틀림의 정도까지 정한다고 이해하면 됩니다.
8.3.2 다른 오일러 각 규약
앞에서 설명한 헤딩–피치–뱅크 체계만이 세 각도로 회전을 정의하는 유일한 방법은 아닙니다. 다른 규약도 많습니다. 어떤 차이는 단순히 용어의 차이지만, 어떤 차이는 회전의 의미를 바꿉니다. 이 책의 규약에 익숙해졌더라도 이 절을 건너뛰지 않는 편이 좋습니다. 많은 혼란이 생기는 부분을 이해하고, 그 차이를 정확히 구분하는 데 필요한 개념을 다루기 때문입니다.
먼저 단순한 이름 문제가 있습니다. 가장 흔히 만날 변형은 항공우주 분야에서 널리 쓰이게 된 요–피치–롤(yaw-pitch-roll)입니다.[원주 5] 롤은 뱅크와 완전한 동의어이며 모든 면에서 같습니다. 요–피치–롤이라는 제한된 맥락에서는 요도 사실상 헤딩과 같습니다.
다만 더 넓은 맥락에서는 요의 뜻이 미묘하게 다르고, 이것이 우리가 헤딩이라는 용어를 선호하는 이유입니다. 그 다소 까다로운 구별은 잠시 뒤에 다루기로 하고 지금은 요와 헤딩이 같다고 하겠습니다. 따라서 본질적으로 요–피치–롤과 헤딩–피치–뱅크는 같은 체계입니다.
덜 흔한 다른 용어도 있습니다. 헤딩은 방위각(azimuth), 피치는 애티튜드(attitude)나 고도각(elevation), 마지막 뱅크는 틸트(tilt)나 트위스트(twist)라고 부르기도 합니다.
물론 칠판 공간을 아끼려는 것인지, 그리스 문자 무더기로 독자의 눈을 공격하려는 짓궂은 수학자들도 있습니다. 다음 표기 중 무엇이든 볼 수 있습니다.
온통 그리스어라 알아듣기 어렵습니다.
지금까지 살펴본 표기의 차이는 주로 겉모양의 차이입니다. 더 흥미로운 것은 같은 세 단어를 반대 순서로 나열한 롤–피치–요라는 표현도 자주 사용된다는 점입니다. 원문에서는 ‘roll pitch yaw’와 ‘yaw pitch roll’을 검색하면 두 표현 모두 많은 결과가 나온다고 설명합니다. 회전 순서가 그렇게 중요하다면, 왜 사람들은 이름을 반대로 나열하는 걸까요?
이 차이는 단순한 용어 문제가 아닙니다. 용어의 순서가 암시하는 사고방식은 오일러 각을 회전행렬로 바꿀 때 실제로 도움이 됩니다. ‘거꾸로’ 나열하는 데에는 합리적인 이유가 있습니다. 컴퓨터에서 벡터에 회전을 적용하는 순서와 연결되기 때문입니다.
고정축 체계(fixed-axis system)는 오일러 각 체계와 아주 밀접합니다. 오일러 각에서는 매 회전 뒤에 바뀌는 몸체 축을 중심으로 회전합니다. 예를 들어 뱅크의 물리적 축은 항상 몸체의 세로축이지만, 직립 공간에서는 일반적으로 임의의 방향을 가리킵니다. 반면 고정축 체계는 항상 고정된 직립 축을 중심으로 회전합니다. 그런데 회전 순서를 반대로 적용하면 두 체계는 실제로 동등합니다.
간단한 예를 머릿속에 그려 보겠습니다. 헤딩(요)을 h, 피치를 p라고 하고, 지금은 뱅크(롤)를 무시하겠습니다. 오일러 각 규약에서는 먼저 수직 y축을 중심으로 h만큼 회전하고, 이어 회전한 물체의 가로 x축을 중심으로 p만큼 회전합니다. 예를 들어 h = 30°, p = 20°라고 생각하면 과정을 그리기 쉽습니다. 고정축 체계에서는 반대 순서로 회전해도 같은 최종 자세에 도달합니다.
고정축 방식에서는 먼저 직립 공간의 x축을 중심으로 p만큼 피치 회전하고, 이어 직립 공간의 y축을 중심으로 h만큼 헤딩 회전합니다. 머릿속에서는 물체와 함께 움직이는 축을 기준으로 오일러 각을 상상하더라도, 컴퓨터에서는 고정축 회전으로 계산할 수 있습니다. 원문은 직립 공간 벡터와 오브젝트 공간 벡터 사이의 회전을 설명할 때 이 관계를 사용합니다. 변환 방향과 실제 행렬 곱셈 순서는 8.7.1절에서 더 자세히 살펴보겠습니다.
고정축 규약을 외재적(extrinsic), 보통의 오일러 각 규약을 내재적(intrinsic)이라고도 부릅니다.
오일러 각은 몸체 축 중심으로 회전하므로 각 단계의 회전축이 앞서 적용한 각도에 따라 달라진다. 고정축 체계의 회전축은 언제나 같은 직립 축이다. 회전 순서를 반대로 적용하면 두 체계는 동등하다.
이제 ‘요’라는 말을 더 정확히 사용하자는 작고 겸손한 캠페인을 펼쳐 보겠습니다. 항공 용어 상당수는 항해 용어를 물려받았습니다.[원주 6] 항해에서 요의 원래 뜻은 절대각으로도 각의 변화로도 본질적으로 헤딩과 같았습니다. 하지만 비행기 등 자유롭게 회전하는 물체에서는 요와 헤딩이 같지 않다고 생각합니다.
요 운동은 오브젝트의 y축 중심 회전을 만들지만, 헤딩 변화는 직립 공간의 y축 중심 회전을 만듭니다. 예를 들어 비행기 조종사가 페달로 방향타를 조작하면 요 회전을 수행합니다. 방향타가 일으키는 회전은 언제나 비행기 오브젝트 공간의 y축 중심이기 때문입니다. 비행기가 수직으로 급강하한다고 상상해 보겠습니다.
비행기가 수직으로 급강하하는 상태에서 조종사가 90° 요 회전을 하면, 비행기는 더 이상 아래를 향하지 않습니다. 대신 뱅크가 90°인 채 수평선을 바라보며, 옆으로 누운 듯한 자세가 됩니다(그림 8.8).
그림 8.8. 헤딩과 요의 차이
그림 8.8. 헤딩과 요의 차이
반면 1인칭 슈터에서 플레이어가 마우스를 좌우로 움직일 때는 헤딩 회전을 수행합니다. 이 회전은 항상 위쪽을 향하는 수직축, 즉 직립 공간의 y축을 중심으로 이루어집니다. 아래를 바라보는 상태에서 마우스를 수평으로 움직여도, 계속 아래를 바라본 채 제자리에서 돌게 됩니다. 여기서 강조하려는 것은 FPS가 헤딩을 사용하므로 헤딩이 요보다 더 좋다는 주장이 아닙니다.
요 운동은 오일러 각 하나만 조정해서 만들 수 없지만 헤딩 운동은 가능하다는 점입니다. 첫 오일러 각을 조금 바꿨을 때 생기는 행동이 헤딩이므로, 헤딩이 더 나은 용어라고 생각합니다.
안타깝게도 같은 논리를 ‘피치’에도 적용할 수 있습니다. 뱅크가 0이 아니면 가운데 오일러 각을 조금 바꿔도 물체 가로축 중심의 회전이 되지 않습니다. 그렇지만 물체 세로축과 수평면 사이의 각도, 즉 가운데 오일러 각의 실제 의미를 표현할 간단하고 좋은 단어가 마땅치 않습니다. inclination은 오른손 규약에 한정되므로 적절하지 않습니다.
저자들의 이러한 의견은 용어를 반드시 바꾸어야 한다는 주장보다, 규약의 차이를 자세히 살펴보자는 제안으로 받아들여 주세요. 겉보기에는 사소한 차이라도 살펴보면 원리를 더 깊이 이해할 수 있습니다. 물론 때로는 단순히 말꼬리를 잡는 일이 될 수도 있습니다. 여러 세대의 항공우주 엔지니어들은 ‘요’와 ‘롤’이라는 용어를 사용하면서 사람을 달에 보내고 로봇을 화성에 착륙시켰으며, 저자들을 먼 도시까지 안전하게 실어 나르는 비행기도 만들었습니다.
그 엔지니어들 가운데 일부는 저자들이 누구인지조차 모를지도 모릅니다! 직접 용어를 선택할 수 있다면 가능한 한 ‘헤딩’을 사용하기를 권합니다. 그렇다고 다른 사람이 ‘요’라고 말할 때마다 지나치게 문제 삼을 필요는 없습니다. 특히 그 사람이 자신보다 더 잘 아는 분야라면 더욱 그렇습니다.
이 책은 항공우주의 오른손 좌표 규약을 따르지 않고 용어에 약간 이견도 있습니다. 그러나 물리적인 관점에서 오일러 각의 기본 전략만큼은, 적어도 세계에 ‘지면’이라는 개념이 있다면 항공우주 선구자들의 지혜를 그대로 따르는 것이 옳다고 믿습니다. 이론적으로는 어느 세 축을 어느 순서로 사용해도 됩니다.
하지만 각 각도를 유용하고 의미 있게 만들려면 실제로 그들이 선택한 규약만이 실용적으로 타당합니다. 축 이름을 어떻게 붙이든 첫 각은 수직축, 둘째는 몸체 가로축, 셋째는 몸체 세로축을 중심으로 회전해야 합니다.
오일러 각의 규약에는 다른 차이도 있습니다. 지금까지 설명한 체계에서는 각 회전이 서로 다른 물체 축을 중심으로 이루어졌습니다. 그러나 오일러가 처음 제안한 것은 첫 번째와 마지막 회전에 같은 축을 사용하는 ‘대칭’ 체계였습니다. 세 각도가 각각 세차, 장동, 자전에 대응하는 팽이의 운동처럼, 이런 방식이 더 편리한 상황도 있습니다.
비대칭 체계에 ‘오일러 각’이라는 이름을 붙이는 데 반대하는 엄격한 사람도 만날 수 있습니다. 하지만 이 용법은 여러 분야에서 널리 쓰이니 우리 쪽이 수적으로 우세하다는 점을 안심해도 됩니다. 두 체계를 구별하려고 대칭 오일러 각을 정통(proper) 오일러 각, 더 흔한 규약을 앞서 언급한 항공우주 선구자들이 처음 문서화한 테이트–브라이언 각(Tait-Bryan angles)이라고 부르기도 한다[10].
O’Reilly[51]는 정통 오일러 각, 로드리게스 벡터, 케일리–클라인 매개변수 같은 다른 회전 표현과 흥미로운 역사를 다룹니다. James Diebel의 정리[13]는 이 장처럼 다양한 오일러 각 규약과 주요 회전 표현을 비교하지만 더 높은 수학적 배경을 전제로 합니다.
선호하는 것과 다른 규약의 오일러 각을 다뤄야 한다면 두 가지를 권합니다.
•
다른 체계가 정확히 어떻게 작동하는지 이해해 보세요. 양의 회전 정의와 회전 순서 같은 작은 차이가 큰 차이를 만듭니다.
•
자기 형식으로 바꾸는 가장 쉬운 방법은 먼저 행렬로 바꾼 다음 그 행렬을 자기 규약의 오일러 각으로 되돌리는 것입니다. 이 변환은 8.7절에서 배웁니다. 각도를 직접 조작하는 일은 생각보다 훨씬 어렵습니다. 자세한 내용은 [63]을 참고해 주세요.
[원주 5]: 요–피치–롤을 ‘변형’이라고 부르는 것은 조금 건방질 수도 있습니다. 요–피치–롤에는 위키피디아 문서가 있지만 헤딩–피치–뱅크에는 없습니다. 우리의 선택이 변형이 아니라고 누가 장담하겠는가? 그 건방짐을 인정하며 곧 우리 선택을 변호하겠습니다.
[원주 6]: aeronautical이라는 단어 자체가 대표적인 예입니다.
8.3.3 오일러 각의 장점
오일러 각은 숫자 세 개로 자세를 표현하며, 그 숫자는 모두 각도입니다. 숫자가 적고 각도로 해석할 수 있다는 두 특성은 다른 자세 표현에 비해 몇 가지 장점을 제공합니다.
•
사람이 쓰기 쉽습니다. 행렬이나 쿼터니언보다 훨씬 쉽습니다. 세 숫자가 사람이 자세를 자연스럽게 생각하는 방식인 각도이기 때문일 것입니다. 상황에 가장 적합한 규약을 고르면 가장 실용적인 각도를 직접 표현할 수 있습니다. 예를 들어 헤딩–피치–뱅크는 부각을 직접 나타냅니다. 이러한 편리함은 큰 장점입니다. 자세를 숫자로 표시하거나 키보드로 입력해야 할 때 오일러 각은 사실상 유일한 선택입니다.
•
가능한 최소 크기의 표현입니다. 자세 하나에 수 세 개를 씁니다. 세 개보다 적은 수로 3차원 자세를 매개화하는 체계는 없습니다. 메모리가 귀할 때 가장 경제적입니다. 공간을 아껴야 할 때 오일러 각을 택할 또 다른 이유는 저장한 수를 압축하기 쉽다는 점입니다. 단순한 고정 정밀도 방식으로 더 적은 비트에 넣기 쉽습니다. 각도이므로 양자화 손실도 균등하게 퍼집니다. 행렬과 쿼터니언은 각도의 사인·코사인을 저장하므로 매우 작은 수를 다뤄야 합니다. 두 값 사이의 절대적인 수치 차이는 오일러 각과 달리 눈에 보이는 차이에 비례하지 않습니다. 일반적으로 행렬과 쿼터니언은 고정소수점 형식에 쉽게 담기지 않습니다. 애니메이션 데이터에서 흔하듯 많은 3차원 회전 데이터를 가능한 적은 메모리에 저장해야 한다면 오일러 각 또는 8.4절의 지수 사상이 가장 좋은 선택입니다.
•
어떤 세 숫자든 유효합니다. 무작위로 고른 세 수도 자세로 해석할 수 있는 유효한 오일러 각입니다. ‘유효하지 않은’ 오일러 각은 없습니다. 물론 값이 의도와 다를 수는 있지만 적어도 유효합니다. 행렬과 쿼터니언은 그렇지 않습니다.
8.3.4 오일러 각의 단점
오일러 각으로 자세를 표현할 때의 주요 단점은 두 가지입니다. 하나의 자세를 나타내는 표현이 유일하지 않다는 점, 그리고 두 자세 사이를 보간하는 과정에 문제가 생길 수 있다는 점입니다. 이제 두 문제를 차례로 살펴보겠습니다.
먼저, 같은 자세를 나타내는 오일러 각 삼중항이 여러 개 존재할 수 있습니다. 이를 중복 표현(aliasing)이라고 부릅니다. 7.3.4절에서 구면좌표계를 다룰 때 만났던 문제와 비슷합니다. 중복 표현이 있으면 ‘이 두 오일러 각 삼중항이 같은 각변위를 나타내는가?’처럼 기본적인 질문에도 바로 답하기 어렵습니다.
가장 단순한 중복 표현은 극좌표계에서 이미 보았습니다. 각도에 360°의 배수를 더하면 숫자는 달라지지만, 최종 자세는 달라지지 않습니다. 또 다른 유형은 세 각도가 서로 완전히 독립적이지 않기 때문에 생깁니다. 예를 들어 아래로 135° 피치하는 것은, 헤딩 180°를 적용한 뒤 아래로 45° 피치하고 이어 뱅크 180°를 적용하는 것과 같은 자세를 만듭니다.
구면좌표에서는 표준 집합을 정하는 방법이 유용했습니다. 각 점을 표현하는 ‘공식적인’ 방법이 표준 집합 안에 유일하게 존재하게 만드는 것입니다. 오일러 각에도 비슷한 기법을 씁니다. 모든 자세의 오일러 각 표현을 유일하게 만들려고 각도의 범위를 제한합니다. 흔히 헤딩과 뱅크는 (−180°, +180°], 피치는 [−90°, +90°]로 제한합니다.
그러면 각 자세를 나타내는 표준 오일러 각은 하나뿐입니다. 정확히는 곧 설명할 성가신 특이점 하나를 더 처리해야 합니다. 표준 오일러 각을 쓰면 ‘대략 동쪽을 보고 있는가?’ 같은 기본 검사가 쉬워집니다.
가장 유명하고 성가신 중복 표현은 다음 예로 설명할 수 있습니다. 오른쪽으로 헤딩 45°를 적용하고 아래로 피치 90°를 적용하면, 아래로 피치 90°를 적용하고 뱅크 45°를 적용한 것과 같습니다. 피치를 ±90°로 고르는 순간 수직축 중심 회전만 가능해집니다. 두 번째 회전의 각도가 ±90°일 때 첫째와 셋째 회전이 같은 축 중심 회전이 되는 현상을 짐벌 락이라고 합니다.
표준 집합에서 이 중복을 없애려면 짐벌 락일 때 수직축 회전을 모두 헤딩에 맡깁니다. 즉 표준 집합에서는 피치가 ±90°이면 뱅크가 0입니다.
이 규칙까지 더하면 표준 오일러 각 집합의 규칙이 완성됩니다.
오일러 각을 인수로 받는 C++ 코드는 대체로 어떤 범위의 각도든 처리하도록 만드는 것이 좋습니다. 다행히 이는 보통 쉽습니다. 특히 각도를 삼각함수에 넣는 경우에는 별도 조치 없이도 잘 작동하는 경우가 많습니다. 반면 오일러 각을 계산하거나 반환하는 코드에서는 표준 삼중항을 반환하는 것이 좋습니다. 8.7절의 변환 방법이 이런 원칙을 보여 줍니다.
짐벌 락 때문에 오일러 각으로 표현할 수 없는 자세가 있다는 흔한 오해가 있다. 실제로 자세를 표현한다는 목적에서는 중복 표현이 문제를 일으키지 않는다. 3차원의 모든 자세를 오일러 각으로 표현할 수 있으며 표준 집합 안에서는 그 표현이 유일하다. 또한 ‘유효하지 않은’ 오일러 각은 없다. 보통 범위를 벗어나도 어떤 자세를 나타내는지 언제나 합의할 수 있다.
단순히 자세를 나타내는 목적이라면 중복 표현을 정리해서 사용할 수 있습니다. 그렇다면 중복 표현과 짐벌 락이 왜 큰 문제가 되는 걸까요? 두 자세 R₀와 R₁ 사이를 보간하려는 상황을 생각해 보겠습니다. 0 ≤ t ≤ 1인 매개변수 t가 0에서 1로 변할 때, R₀에서 R₁으로 부드럽게 바뀌는 중간 자세 R(t)를 구하는 것입니다. 캐릭터 애니메이션이나 카메라 제어에서 자주 필요한 연산입니다.
그림 8.9. 단순한 보간은 불필요하게 많은 회전을 만들 수 있다
그림 8.9. 단순한 보간은 불필요하게 많은 회전을 만들 수 있다
가장 단순한 접근은 각 각도에 표준 선형 보간(lerp)을 독립적으로 적용하는 것입니다.
그러나 각도를 각각 보간하면 여러 문제가 생깁니다. 첫 번째 문제는 표준 오일러 각을 사용하지 않을 때 나타납니다. 예를 들어 R₀의 헤딩 h₀가 720°이고 R₁의 헤딩 h₁이 45°라고 하겠습니다. 720° = 2×360°이므로 시작 자세는 0°와 같습니다. 실제로 필요한 회전은 45°뿐입니다. 하지만 숫자를 그대로 선형 보간하면, 그림 8.9처럼 반대 방향으로 거의 두 바퀴를 돌아가게 됩니다.
해결 방법은 표준 오일러 각을 쓰는 것입니다. 보간할 두 입력이 언제나 표준형이라고 가정하거나 보간 함수 안에서 표준형으로 바꿀 수 있습니다. 각도를 (−180°, +180°]로 감싸는 것은 쉽지만 [−90°, +90°] 밖의 피치를 처리하는 것은 더 까다롭습니다.
표준 각도를 사용하더라도 문제가 모두 해결되지는 않습니다. 회전각이 주기적으로 반복되기 때문입니다. 예를 들어 h₀ = −170°, h₁ = 170°라고 하겠습니다. 둘 다 (−180°, +180°] 안에 있는 표준 헤딩입니다. 두 방향은 짧은 경로로 보면 20°밖에 떨어져 있지 않습니다. 그러나 단순 선형 보간은 그림 8.10처럼 반시계 방향으로 20° 도는 대신, 시계 방향으로 340°나 돌아가게 만듭니다.
그림 8.10. 단순한 보간은 더 긴 길로 회전할 수 있다
그림 8.10. 단순한 보간은 더 긴 길로 회전할 수 있다
두 번째 문제를 해결하려면 보간식에 사용하는 각도 차를 (−180°, +180°] 범위로 감싸 최단 호를 찾아야 합니다. 이를 위해 다음 표기를 도입합니다.
⌊·⌋는 바닥 함수입니다. wrapPi는 모든 게임 프로그래머가 알아 두면 유용한 작은 도구입니다. 적절한 360°의 배수를 더하거나 빼서 각도의 주기성을 처리합니다. 따라서 서로 같은 방향을 나타내는 각도를 정리하거나, 두 각도 사이의 짧은 차이를 구할 때 사용할 수 있습니다. 목록 8.1은 원문의 C 구현을 보여 줍니다.
float wrapPi(float theta) {
// 이미 범위 안인지 확인한다. 필수 검사는 아니지만 아주 흔한 경우다.
// 불필요한 속도 손해와 부동소수점 정밀도 손실을 피하고 싶다.
if (fabs(theta) <= PI) {
// 한 바퀴는 2 PI다.
const float TWOPPI = 2.0f * PI;
// 범위를 벗어났다. 몇 바퀴를 더해야 하는지 결정한다.
float revolutions = floor((theta + PI) * (1.0f / TWOPPI));
// 그만큼 뺀다.
theta -= revolutions * TWOPPI;
}
return theta;
}
C++
복사
목록 8.1: 각도를 ±180° 사이로 감싸기. 위 코드는 제공 PDF 원문을 보존했습니다. 원문의 조건문 오타와 끝점 규약은 별도 보충 설명에서 교정합니다.
오일러 각으로 돌아가겠습니다. 예상대로 wrapPi를 사용하면 두 각도 사이의 보간에서 최단 호를 쉽게 택할 수 있습니다.
이 두 가지 방법을 적용해도 오일러 각 보간은 여전히 짐벌 락의 영향을 받습니다. 많은 상황에서 회전 경로가 끊기거나 부자연스러워지고, 물체가 갑자기 휙 돌아 마치 어딘가에 걸린 것처럼 보일 수 있습니다. 보간 과정에서 각속도가 일정하게 유지되지 않는 것이 핵심 문제입니다. 짐벌 락을 실제로 본 적이 없다면, 이것이 왜 큰 문제인지 쉽게 와닿지 않을 수도 있습니다.
책의 그림만으로는 충분히 느끼기 어렵고 실시간으로 경험해야 합니다. 다행히 문제를 시연하는 애니메이션은 쉽게 찾을 수 있습니다. youtube.com에서 ‘gimbal lock’을 검색하면 됩니다.
앞의 두 보간 문제는 성가시지만 비교적 간단히 우회할 수 있었습니다. 표준 오일러 각과 wrapPi를 사용하면 됩니다. 반면 짐벌 락은 사소한 불편을 넘어서는 근본적인 문제입니다. 그렇다면 회전을 다르게 정의해서 이런 문제가 없는 체계를 만들 수 있을까요? 숫자 세 개만으로 3차원 자세를 표현하는 한, 이러한 문제를 완전히 없앨 수는 없습니다.
문제가 나타나는 형태를 바꿀 수는 있지만, 특이점 자체를 완전히 없애기는 어렵습니다. 숫자 세 개로 3차원 자세를 매개변수화하는 체계에는 매개변수 공간의 특이점이 생기므로, 짐벌 락과 같은 문제가 남습니다. 역시 숫자 세 개를 사용하는 지수 사상 표현(8.4절)은 특이점을 대척점 쪽으로 모으는 방식입니다.
지수 사상은 일부 실제 상황에서 오일러 각보다 다루기 편하지만, 특이점을 완전히 제거하지는 못합니다. 이 문제를 피하는 다른 표현으로 8.5절에서는 숫자 네 개를 사용하는 쿼터니언을 살펴보겠습니다.
wrapPi의 부등호와 끝점
목록 8.1의 if (fabs(theta) <= PI)는 조건이 반대입니다. 원문은 범위 밖에서만 감싸려는 의도이므로 > PI여야 합니다. 또 원문의 바닥 함수 식은 일반적으로 [−π, π) 규약이며, 본문의 표준 범위 (−π, π]와 끝점이 반대입니다. 두 규약 중 하나를 일관되게 선택하면 됩니다. 아래는 (−π, π]를 명시적으로 지키는 예입니다.
double wrapPiCanonical(double angle) {
constexpr double pi = 3.14159265358979323846;
constexpr double tau = 2.0 * pi;
double wrapped = std::remainder(angle, tau);
if (wrapped <= -pi) wrapped += tau;
return wrapped;
}
C++
복사
입력은 유한한 실수 라디안이라고 가정합니다. −170°에서 +170°로 가는 차이는 +340°가 아니라 −20°다. wrapPiCanonical(end - start)를 사용하면 그 짧은 방향을 얻습니다.
8.3.5 오일러 각 정리
•
오일러 각은 세 각도로 자세를 저장합니다. 이들은 세 오브젝트 공간 축을 중심으로 순서대로 적용하는 회전입니다.
•
가장 일반적인 체계는 헤딩–피치–뱅크입니다. 헤딩은 나침반에서 읽는 방위에 해당하고, 피치는 아래로 기울어진 각도인 부각을 나타냅니다. 두 각도가 물체가 바라보는 방향을 정하고, 뱅크가 여기에 비틀림을 더합니다.
•
고정축 체계에서는 움직이는 몸체 축 대신 직립 축 중심으로 회전합니다. 회전 순서를 반대로 적용하면 오일러 각과 동등합니다.
•
많은 똑똑한 사람들이 여러 용어를 쓰며 다른 규약을 택한 좋은 이유가 있을 수 있습니다.[원주 7] 용어에만 의존하지 않는 편이 좋습니다. 항상 정확한 작동 정의를 확인하지 않으면 크게 혼란스러워질 수 있습니다.
•
대부분의 상황에서 사람에게는 다른 자세 표현보다 오일러 각이 더 직관적입니다.
•
메모리가 귀할 때 3차원 자세 저장에 가능한 최소량의 데이터를 쓰며 쿼터니언보다 압축하기 쉽습니다.
•
유효하지 않은 오일러 각은 없습니다. 어떤 세 숫자든 의미 있게 해석할 수 있습니다.
•
회전각의 주기성과 각 회전이 완전히 독립적이지 않다는 점 때문에 중복 표현이 발생합니다.
•
표준형은 기본적인 질의를 쉽게 만듭니다. 헤딩과 뱅크가 (−180°, +180°], 피치가 [−90°, +90°]에 있고, 피치가 ±90°일 때 뱅크가 0이면 표준형입니다.
•
피치가 ±90°이면 짐벌 락이 생깁니다. 헤딩과 뱅크가 모두 수직축 중심으로 회전하므로 자유도 하나를 잃습니다.
•
흔한 오해와 달리 3차원의 모든 자세를 표현할 수 있고 표준 집합에서는 유일한 표현을 정할 수 있습니다.
•
wrapPi는 각도의 주기성을 다루는 상황을 단순화하는 유용한 도구입니다. 이런 상황은 오일러 각에서 특히 흔하지만 다른 곳에서도 발생합니다.
•
단순한 중복 표현은 성가셔도 우회할 수 있습니다. 짐벌 락은 쉬운 해결책이 없는 더 근본적인 문제입니다. 자세의 매개변수 공간에 불연속이 있어서 작은 자세 변화가 개별 각도의 큰 변화로 나타날 수 있습니다. 따라서 오일러 각으로 자세를 보간하면 갑자기 이상하게 움직이거나 흔들리는 경로를 택할 수 있습니다.
[원주 7]: 자신의 선택에 좋은 이유가 없는 어리석은 사람들도 많습니다. 하지만 결국 결과는 같습니다.
8.4 축–각과 지수 사상 표현 (Axis–Angle and Exponential Map)
오일러의 이름은 회전과 관련된 여러 개념에 등장합니다. 앞 절에서 다룬 오일러 각에 이어, 이번에는 오일러의 회전 정리를 살펴보겠습니다. 이 정리는 어떤 3차원 각변위라도 적절히 선택한 하나의 축을 중심으로 한 번 회전하여 만들 수 있다고 말합니다. 더 정확히는 임의의 두 자세 R₁과 R₂가 주어졌을 때, 그 축을 중심으로 한 번 회전하면 R₁에서 R₂로 이동할 수 있는 축 n̂이 존재한다는 뜻입니다.
오일러 각에서는 기본축만을 중심으로 회전하기 때문에 자세를 표현하려면 세 번의 회전이 필요합니다. 하지만 회전축을 자유롭게 고를 수 있다면 회전 한 번만으로 충분한 축을 찾을 수 있습니다. 게다가 이 절에서 보겠지만 몇 가지 작은 사항을 제외하면 이 회전축은 유일하게 결정됩니다.
이 정리에서 서로 밀접하게 연결된 두 가지 자세 표현이 나옵니다. 먼저 표기부터 정해 보겠습니다. 회전각을 θ라고 하고, 원점을 지나면서 단위벡터 n̂에 평행한 회전축을 선택하겠습니다. 이 책에서 양의 회전 방향은 왼손 법칙으로 정합니다(1.3.3절).
회전축 n̂과 회전각 θ를 그대로 저장하면 각변위의 축–각(axis-angle) 표현이 됩니다. 다른 방법도 있습니다. n̂은 단위벡터이므로 θ를 곱해 하나의 벡터 e = θn̂으로 만들 수 있습니다. 이때 정보는 사라지지 않습니다. 이렇게 회전을 표현하는 방식을 지수 사상(exponential map)이라고 부릅니다.[원주 8] 처음에는 다소 낯설게 느껴지는 이름이지만, 벡터 e의 길이에서 회전각을 다시 얻을 수 있습니다.
즉 θ = ‖e‖이며, 축은 e를 정규화하여 구합니다. 지수 사상은 축–각보다 작을 뿐 아니라(네 수 대신 세 수) 특정 특이점을 깔끔하게 피하고 보간과 미분 성질도 더 좋습니다.
축–각과 지수 사상은 다른 표현만큼 자세히 다루지는 않겠습니다. 쓰임이 다소 특수하기 때문입니다. 축–각은 특히 회전을 이해하는 개념적 도구로 유용하지만, 다른 형식보다 직접 사용하는 경우는 적습니다. 이 표현의 장점 중 하나는 각변위의 임의 배수를 곧바로 구할 수 있다는 것입니다. 회전각 θ에 원하는 배수를 곱하면, 원래 회전의 1/3이나 2.65배에 해당하는 회전을 쉽게 표현할 수 있습니다.
지수 사상으로도 같은 연산을 쉽게 수행할 수 있습니다. 쿼터니언에서는 거듭제곱을 통해 가능한데, 그 수학을 살펴보면 내부적으로 축–각과 연결된 생각을 사용합니다. 지수 사상을 사용한다고 설명할 수도 있습니다. slerp로도 비슷한 연산을 수행할 수 있지만 좀 더 우회적인 방법이며, 쿼터니언의 중간 결과만으로는 180도를 넘는 회전 경로를 구별해서 저장할 수 없다는 제약이 있습니다.
쿼터니언은 8.5절에서 다룹니다.
지수 사상은 축–각보다 더 널리 사용됩니다. 먼저 오일러 각보다 보간 특성이 좋습니다. 특이점이 남아 있기는 하지만, 오일러 각에서 나타나는 것만큼 다루기 어렵지는 않습니다. 회전 보간이라고 하면 보통 쿼터니언을 떠올리지만, 애니메이션 데이터 저장 같은 용도에서는 지수 사상도 실용적인 대안이 될 수 있습니다.[27]
하지만 가장 중요하고 흔한 용도는 각변위가 아니라 각속도를 저장하는 것입니다. 지수 사상은 미분이 잘되고, 이는 더 좋은 보간 성질과도 어느 정도 관련이 있으며, 여러 바퀴의 회전을 쉽게 표현하기 때문입니다.
오일러 각처럼 축–각과 지수 사상에도 중복 표현과 특이점이 있습니다. 다만 나타나는 범위와 영향은 더 제한적입니다. 먼저 항등 자세, 즉 각변위가 없는 경우를 생각해 보겠습니다. θ = 0이면 어느 축을 선택해도 결과가 같으므로 축–각 표현에는 특이점이 생깁니다. 반면 지수 사상에서는 어떤 n̂을 선택하든 θ를 곱하면 e = 0이 됩니다. 따라서 이 경우의 모호함을 자연스럽게 처리할 수 있습니다.
축–각에서 θ와 n̂의 부호를 모두 바꾸는 것도 단순한 중복 표현입니다. 지수 사상에서는 둘의 부호를 동시에 바꿔도 e = θn̂이 같으므로 이 문제 역시 피합니다.
나머지 중복은 그렇게 쉽게 없앨 수 없습니다. 오일러 각과 마찬가지로 θ에 360°의 배수를 더하면 최종 자세가 같은 각변위가 됩니다. 이 중복은 축–각과 지수 사상 모두에 영향을 줍니다. 다만 이것이 언제나 단점은 아닙니다. 각속도를 표현할 때 ‘추가’ 회전을 담는 능력은 중요하고 유용합니다.
예를 들어 x축을 중심으로 초당 720° 회전하는 것과 초당 1080° 회전하는 것은 반드시 구별할 수 있어야 합니다. 정수 초 동안 적용한 최종 자세는 같더라도 말입니다. 쿼터니언 형식은 이 차이를 담을 수 없습니다.
회전행렬로 표현할 수 있는 각변위가 주어지면 지수 사상 표현은 유일하게 결정됩니다. 같은 회전행렬을 만드는 지수 사상은 여럿일 수 있지만, 지수 사상의 부분집합, 즉 ‖e‖ < 2π인 것들을 택하면 회전행렬과 일대일 대응을 만들 수 있습니다. 이것이 오일러의 회전 정리의 핵심입니다.
보충·원문 정정
바로 앞의 ‖e‖ < 2π만으로 일대일 대응이라는 원문 주장은 잘못입니다. θn̂과 −(2π−θ)n̂이 같은 회전이고 둘 다 그 범위에 들어갈 수 있습니다. 보통 회전각을 0 ≤ θ ≤ π로 제한하고, θ = π에서 축의 부호에 대한 별도 규약을 정합니다. 경계의 중복과 불연속까지 없어지는 것은 아닙니다.
여러 회전의 합성을 생각해 보겠습니다. e₁과 e₂가 지수 사상 형식의 두 회전이라고 하겠습니다. e₁ 뒤에 e₂를 수행한 결과는 e₁ + e₂를 한 번 수행한 결과와 같지 않습니다. 일반 벡터 덧셈은 교환법칙이 성립하지만 3차원 회전은 성립하지 않으므로 같을 수 없습니다. e₁ = [90°, 0, 0], e₂ = [0, 90°, 0]이라고 하겠습니다.
이 책의 규약에서 두 회전은 각각 아래로 90° 피치하는 회전과 동쪽으로 90° 헤딩하는 회전입니다. e₁ 다음에 e₂를 적용하면 머리가 동쪽을 향한 채 아래를 보게 됩니다. 반대 순서로 적용하면 옆으로 누운 채 동쪽을 바라보는 자세가 됩니다. 그렇다면 각도가 90°가 아니라 2°처럼 훨씬 작으면 어떨까요? 두 순서가 만드는 최종 자세는 더 비슷해집니다. 각도가 작아질수록 순서에 따른 차이도 줄어들며, 무한소 회전의 극한에서는 순서의 영향이 사라집니다.
즉 무한소 회전에서는 지수 사상을 벡터로 더할 수 있습니다.
무한소는 변화율을 정의할 때 사용하는 미적분의 중요한 개념으로, 11장에서 다룹니다. 여기서는 다음의 구분을 기억해 주세요. 각변위나 자세처럼 회전의 ‘양’을 나타내는 지수 사상은 일반 벡터처럼 더할 수 없습니다. 반면 회전의 변화율을 나타낼 때는 벡터처럼 더할 수 있습니다. 이러한 성질 때문에 지수 사상은 각속도를 표현하는 데 잘 맞습니다.
이 절을 마치기 전에 용어에 관해 한 가지 주의할 점이 있습니다. 간단해 보이는 두 개념이지만, 사용하는 이름은 다양하고 합의도 뚜렷하지 않습니다. 어떤 저자는 두 표현을 모두 축–각이라고 부르며 구분하지 않습니다. 또 ‘오일러 축(Euler axis)’이라는 말을 둘 중 하나를 뜻하는 데 사용하기도 합니다. 이 이름이 오일러 각을 뜻하는 것은 아니므로 문맥을 확인해야 합니다.
이 책에서 지수 사상이라고 부르는 표현을 ‘회전벡터(rotation vector)’라고 하는 자료도 있습니다. 마지막으로 이 용어가 유래한 리 대수의 더 넓은 맥락에서는, 지수 사상이 하나의 수량이 아니라 연산, 즉 ‘사상’을 뜻합니다. 이름만으로 판단하기보다 해당 자료에서 무엇을 정의하고 있는지 확인하는 것이 좋습니다.
[원주 8]: 마찬가지로 위압적이고 모호한 수학 분야인 리 대수(Lie algebra)에서 나온 개념이기 때문입니다. Lie는 사람 이름이므로 ‘리’로 발음합니다. 이 맥락에서 지수 사상은 더 넓게 정의되며, 때때로 SO(3)이라고 쓰는 3차원 회전의 공간은 리 군의 한 종류일 뿐입니다. 용어에 대한 유감은 뒤에서 더 이야기합니다.
8.5 쿼터니언 (Quaternions)
쿼터니언(quaternion, 사원수)은 3차원 수학에서 유행어 같은 존재입니다. 묘한 신비감을 두르고 있습니다. 이는 많은 사람이 복잡하고 혼란스럽게 느낀다는 사실을 좋게 표현한 말입니다. 우리는 많은 교재의 설명 방식도 혼란에 한몫한다고 생각하며, 조금 다른 접근으로 그 ‘신비감’을 걷어 내기를 바랍니다.
오일러 각에서 보았듯이, 숫자 세 개만으로 3차원 자세를 표현하면 짐벌 락과 같은 문제가 생깁니다. 여기에는 ‘다양체’ 같은 고급 수학 개념과 연결된 이유가 있습니다.[원주 9] 쿼터니언은 자세를 표현하는 데 숫자 네 개를 사용하여 이 문제를 피합니다. 쿼터니언이라는 이름도 네 개라는 뜻에서 유래했습니다.
이 절에서는 쿼터니언으로 각변위를 정의하는 방법을 설명합니다. 다만 복소수에서 출발하는 전통적인 설명 대신, 먼저 기하학적인 의미를 중심으로 살펴보겠습니다. 복소수 해석도 흥미롭지만, 회전을 이해하고 사용하는 데 반드시 먼저 알아야 하는 내용은 아니라고 보기 때문입니다. 8.5.1절에서 표기법을 소개한 뒤, 가장 중요한 8.5.2절에서 네 숫자의 기하학적 의미를 설명합니다. 8.5.3~8.5.11절에서는 기본 성질과 연산을 살펴보고, 8.5.12절에서 두 쿼터니언을 보간하는 slerp를 다룹니다.
slerp는 쿼터니언의 주요 장점 가운데 하나입니다. 이어 8.5.13절에서는 장점과 단점을 정리하고, 8.5.14절에서는 선택적으로 읽을 수 있는 4차원 복소수 해석을 설명합니다. 마지막으로 8.5.15절에서 중요한 성질을 정리하겠습니다.
복잡해 보이는 수식이 많이 나오더라도 부담을 느낄 필요는 없습니다. 가장 중요한 것은 8.5.15절에 정리한 개념입니다. 자세한 유도 과정은 각 결과가 어디에서 나왔는지 직접 확인할 수 있도록 제시합니다. 쿼터니언을 사용하기 위해 모든 유도를 먼저 이해해야 하는 것은 아닙니다.[원주 10] 다만 어떤 연산을 할 수 있고, 언제 유용한지는 알아야 합니다.
쉬운 보충 ① · 허수에서 출발해도 괜찮아요
기존 본문은 회전의 기하학적 의미부터 설명합니다. 처음 읽는다면 이 보충 설명에서 허수와 복소수를 먼저 익힌 뒤, 본문의 공식과 연결해 보세요. 필요한 출발점은 제곱, 좌표, 피타고라스 정리, 사인과 코사인입니다.
허수는 왜 필요할까요? 3²도 9이고 (−3)²도 9입니다. 실수를 제곱하면 음수가 될 수 없으므로, 실수만으로는 x² = −1을 풀 수 없습니다. 그래서 기존 실수의 계산 규칙을 확장하면서, 제곱하면 −1이 되는 새로운 수 i를 도입합니다. 이것이 허수 단위이며 i² = −1입니다. 이 확장된 수 체계에서는 x² = −1의 해가 i와 −i입니다.
음수도 처음에는 낯설 수 있습니다. 사과의 개수를 셀 때는 −3개가 이상해 보이지만, 빚이나 반대 방향의 이동을 나타낼 때는 유용하죠. 마찬가지로 ‘허수’라는 이름이 계산이 가짜라는 뜻은 아닙니다. 실수만으로 표현하기 어려웠던 관계를 더 넓은 수 체계에서 다루는 것입니다.
복소수는 무엇일까요? 실수 a와 허수 단위 i를 이용해 a + bi로 쓰는 수를 복소수라고 합니다. 예를 들어 2 + 3i에서 실수부는 2, 허수부는 3입니다. 허수부는 3i 전체가 아니라 i 앞의 계수 3이라는 점도 함께 기억해 주세요. 실수 2도 2 + 0i이므로 복소수에 포함됩니다.
복소수의 계산은 평소의 덧셈과 곱셈을 그대로 하다가 i²가 나오면 −1로 바꾸는 것부터 시작하면 됩니다. 예를 들어 (2 + i) + (1 + 2i) = 3 + 3i입니다. 실수부끼리, 허수부끼리 더한 것이죠.
쉬운 보충 ② · i를 곱하면 실제로 무엇이 바뀔까요?
종이에 가로축과 세로축을 그려 보세요. 가로는 실수부, 세로는 허수부로 정하면 2 + i는 점 (2, 1), 또는 원점에서 그 점으로 향하는 화살표로 볼 수 있습니다. 이제 복소수는 숫자이면서 동시에 평면의 두 좌표를 묶어 쓰는 방법이 됩니다.
여기에 i를 곱해 보겠습니다. 계산 자체는 짧습니다.
좌표로 읽으면 (a, b)가 (−b, a)로 바뀝니다. 예를 들어 오른쪽으로 2칸, 위로 1칸 향하던 화살표 (2, 1)는 왼쪽으로 1칸, 위로 2칸 향하는 (−1, 2)가 됩니다. 길이는 둘 다 √5로 같습니다. 이 종이 그림에서 i를 곱하는 것은 화살표를 원점 중심으로 반시계 방향 90° 돌리는 것입니다.
오른쪽을 가리키는 1에 i를 반복해서 곱하면 1 → i → −1 → −i → 1로 돌아옵니다. 그림으로는 오른쪽 → 위 → 왼쪽 → 아래 → 오른쪽입니다. 따라서 i² = −1은 이 그림에서 ‘90° 회전을 두 번 하면 반대 방향인 180° 회전이 된다’고 이해할 수 있습니다.
여기서는 실수축을 오른쪽, 허수축을 위쪽으로 그렸기 때문에 반시계 방향이라고 말했습니다. 뒤의 3차원 예시는 본문에서 정한 좌표축과 회전 규약을 따릅니다. 화면에서 시계 방향인지 판단하려면 바라보는 방향까지 함께 정해야 합니다.
쉬운 보충 ③ · 90° 말고 원하는 각도로 돌리려면?
단위원에서 각도 θ에 해당하는 점의 좌표는 (cos θ, sin θ)입니다. 이 좌표를 복소수로 쓰면 cos θ + i sin θ가 됩니다. 이 길이 1인 복소수를 화살표에 곱하면, 화살표의 길이를 유지하면서 θ만큼 회전시킬 수 있습니다.
θ = 90°이면 cos 90° = 0, sin 90° = 1이므로 회전용 복소수는 i입니다. θ = 180°이면 −1이고, θ = 0°이면 1입니다. 앞에서 본 i와 −1이 회전 공식의 특별한 경우였던 것이죠.
게임 화면의 화살표가 오른쪽을 보고 있다고 해 봅시다. 위를 보게 하려면 i를 곱하고, 왼쪽을 보게 하려면 −1을 곱합니다. 같은 평면에서 30° 돌린 뒤 60° 더 돌리면 총 90°가 됩니다. 회전용 복소수 두 개를 곱하는 계산이 이러한 회전의 합성을 해 줍니다.
복소수를 아무렇게나 곱한다고 항상 회전만 하는 것은 아닙니다. 예를 들어 2i를 곱하면 90° 돌리는 동시에 길이가 2배가 됩니다. 회전만 원해서 길이 1인 복소수를 쓰는 생각은, 잠시 뒤 단위 쿼터니언에서도 다시 등장합니다.
8.5.1 쿼터니언 표기
쿼터니언은 스칼라 성분과 3차원 벡터 성분으로 이루어집니다. 스칼라 성분은 보통 w라고 합니다. 벡터 성분을 한 덩어리 v로 쓰거나 개별 성분 x, y, z로 쓸 수 있습니다.
어떤 때는 v를 쓰는 짧은 표기가 편하고 어떤 때는 전개한 표기가 더 분명합니다. 이 장에서는 대부분의 식을 두 형태로 제시합니다. 전개한 쿼터니언을 세로로 쓸 수도 있습니다.
일반 벡터와 달리 ‘행’ 쿼터니언과 ‘열’ 쿼터니언에 의미 있는 구별은 없습니다. 순전히 보기 좋은 쪽을 고르면 됩니다. 변수는 벡터와 같은 굵은 소문자, 예를 들어 q로 나타냅니다. 벡터와 쿼터니언이 함께 나오면 대개 문맥과 선택한 변수 글자로 구별할 수 있습니다.
쉬운 보충 ④ · 3차원 회전에는 ‘어느 축인가’가 필요해요
평면의 화살표는 종이 위에서 돌기만 하면 됩니다. 하지만 스마트폰을 손에 들면 고개를 끄덕이듯 기울일 수도 있고, 좌우로 방향을 바꿀 수도 있고, 화면을 세로에서 가로로 돌릴 수도 있습니다. 모두 회전이지만 회전축이 서로 다릅니다.
문이 돌아가는 모습을 떠올려 보세요. 경첩을 잇는 선이 회전축이고, 문이 얼마나 열렸는지가 회전각입니다. 3차원 회전도 ‘어느 축을 중심으로 몇 도 돌릴까?’라는 두 정보로 생각하면 훨씬 쉽습니다.
쿼터니언은 그 회전 정보를 계산에 편리한 네 숫자짜리 묶음으로 담습니다. 네 숫자를 저장한다고 게임 속 물체가 4차원으로 움직이는 것은 아닙니다. 전화번호에 숫자가 여러 개 들어 있어도 그만큼의 공간 방향을 뜻하지 않는 것처럼, 저장하는 숫자의 개수와 우리가 돌리는 공간의 차원은 구분해야 합니다.
본문의 [w, (x, y, z)]에서 w는 숫자 하나, (x, y, z)는 숫자 세 개입니다. 여기의 x, y, z는 물체의 위치 좌표도, 각각의 축으로 도는 세 오일러 각도도 아닙니다. 다음 공식에서 회전축에 회전각 정보를 섞어 넣은 값이라는 사실을 확인하겠습니다.
8.5.2 네 숫자는 무엇을 뜻할까?
쿼터니언은 8.4절의 축–각과 지수 사상 표현에 밀접하게 연결되어 있습니다. 같은 표기법을 사용하므로 잠시 복습해 보겠습니다. 단위벡터 n̂은 회전축을 나타내고, 스칼라 θ는 그 축을 중심으로 회전하는 양을 나타냅니다. 따라서 (θ, n̂)이 하나의 축–각 각변위를 정의합니다. 양의 회전 방향을 정할 때는 왼손 또는 오른손 법칙이 필요합니다.[원주 11]
쿼터니언에도 회전축과 회전각의 정보가 들어 있습니다. 그러나 축–각 표현처럼 n̂과 θ를 네 숫자에 그대로 저장하지는 않습니다. 처음에는 조금 이상하게 보일 수 있지만, 실제 연산에 유용하도록 다른 방식으로 표현합니다. 식 (8.2)는 쿼터니언의 값이 θ와 n̂에 어떻게 연결되는지를 두 가지 표기법으로 보여 줍니다.
w는 θ와 관련되지만 θ 자체는 아닙니다. 마찬가지로 v와 n̂도 관련은 있으나 같지 않습니다. 이어지는 절에서는 여러 연산을 수학과 기하 관점에서 살펴봅니다.
쉬운 보충 ⑤ · 캐릭터가 오른쪽으로 돌아보는 90° 회전을 만들어 봅시다
본문의 좌표 규약대로 +x는 오른쪽, +y는 위쪽, +z는 정면이라고 하겠습니다. 캐릭터가 똑바로 선 채 정면에서 오른쪽으로 돌아보게 하려면, 위쪽 축인 +y를 중심으로 90° 헤딩 회전을 주면 됩니다. 따라서 회전축은 n̂ = (0, 1, 0), 회전각은 θ = 90°입니다.
공식에는 θ/2가 들어가므로 먼저 45°를 계산합니다. cos 45°와 sin 45°는 모두 √2/2이고, 약 0.7071입니다. 본문의 표기 순서는 [w, (x, y, z)]입니다.
읽는 순서: ‘위쪽 축으로 90° 돌린다’ → ‘각도의 절반인 45°를 쓴다’ → ‘w에는 cos 45°, 축 벡터에는 sin 45°를 곱해 담는다’입니다. 어려운 네 숫자였지만, 출발점은 회전축과 회전각 두 정보입니다.
이 회전에서는 y 성분만 0이 아닙니다. 그렇다고 y = 0.7071이 ‘y축으로 0.7071도 회전’이라는 뜻은 아닙니다. 축 (0, 1, 0)에 sin 45°를 곱한 결과입니다. w 역시 각도 자체가 아닙니다.
쉬운 보충 ⑥ · 왜 90°를 돌리는데 45°를 저장할까요?
쿼터니언은 회전을 저장하는 방식과 실제 벡터에 적용하는 방식이 한 쌍으로 설계되어 있습니다. 뒤에서 벡터 p를 돌릴 때 q p q⁻¹처럼 양쪽에서 곱하는 계산을 합니다. 이 계산에 맞춰 회전용 q에는 반각을 넣어야 최종적으로 원하는 θ 회전이 나옵니다.
예를 들어 ‘cos 90°와 sin 90°를 넣으면 90°겠지’ 하고 q를 만들면, 이 양쪽 곱셈 방식에서는 180° 회전이 됩니다. 90°를 원하면 cos 45°와 sin 45°를 넣어야 합니다. 저장값에 45°를 쓴다고 캐릭터가 45°만 도는 것은 아닙니다.
한쪽 곱셈만 한 중간 결과는 일반적인 3차원 회전 결과가 아닐 수 있으므로, 각각을 단순히 ‘물체가 45°씩 두 번 돈다’고 생각하면 혼란스러워집니다. 지금은 반각으로 저장하고, 양쪽 곱셈으로 실제 회전을 만든다는 관계를 잡으세요. 8.5.14절의 보충 예시에서 두 계산을 실제 숫자로 확인합니다.
[원주 9]: 여기서 ‘고급’이라는 말은 ‘저자의 전문 영역 밖’이라는 뜻입니다.
[원주 10]: 클래스 라이브러리가 잘 설계되어 있다면 말입니다.
[원주 11]: 인도의 독자들에게는 미안하지만 우리는 왼손을 선호합니다.
8.5.3 쿼터니언의 부호 반전
각 성분의 부호를 바꾸는 자연스러운 방식으로 쿼터니언의 부호를 반전할 수 있습니다.
놀랍게도 적어도 각변위의 맥락에서는 이 연산이 실제로 아무것도 바꾸지 않습니다.
q와 −q는 같은 각변위를 나타낸다. 모든 3차원 각변위에는 쿼터니언 표현이 정확히 두 개 있으며 서로 부호가 반대다.
이유는 어렵지 않습니다. θ에 360°를 더하면 나타내는 각변위는 그대로지만 q의 네 성분 부호는 모두 반전됩니다.
쉬운 보충 ⑦ · 부호를 전부 바꾸는 것과 반대로 도는 것은 달라요
앞의 오른쪽 90° 회전은 q = [0.7071, (0, 0.7071, 0)]입니다. 네 성분을 모두 바꾼 −q = [−0.7071, (0, −0.7071, 0)]도 같은 오른쪽 90° 회전을 나타냅니다. 여기의 소수는 설명을 위한 근삿값입니다.
반대로 돌아오는 회전은 뒤에서 배울 q⁻¹ = [0.7071, (0, −0.7071, 0)]입니다. w는 그대로 두고 벡터 부분만 반전했습니다. 이 예시에서는 오른쪽 90°를 되돌리는 왼쪽 90° 회전입니다.
즉 ‘마이너스가 붙었으니 반대 회전’이라고 판단하면 안 됩니다. 전체 부호 반전 −q는 같은 회전의 다른 표현이고, 역원 q⁻¹은 회전을 되돌리는 연산입니다. 같은 도착 자세를 표현하는 숫자 묶음이 두 개 있다는 성질을 먼저 기억해 주세요.
8.5.4 항등 쿼터니언(들)
기하적으로 ‘각변위 없음’을 나타내는 항등 쿼터니언은 두 개입니다.
굵은 0은 영벡터입니다. θ가 360°의 짝수 배이면 cos(θ/2) = 1로 첫째 형태, 홀수 배이면 cos(θ/2) = −1로 둘째 형태입니다. 둘 다 sin(θ/2) = 0이므로 n̂의 값은 무관합니다. 어떤 축이든 정수 번 완전히 돌면 자세는 실질적으로 바뀌지 않으니 직관적으로도 맞습니다.
대수적인 의미의 항등 쿼터니언은 [1, 0] 하나입니다. 어떤 q에 곱해도 그대로 q가 나오기 때문입니다. 곱셈은 8.5.7절에서 소개합니다. 반면 또 다른 ‘기하적 항등’인 [−1, 0]을 곱하면 −q가 나옵니다. q와 −q는 같은 각변위를 나타내므로 기하적인 결과는 같지만, 쿼터니언의 값 자체가 같은 것은 아닙니다. 따라서 [−1, 0]은 곱셈의 항등원이 아닙니다.
쉬운 보충 ⑧ · 회전이 0°라고 네 숫자가 모두 0은 아니에요
0°를 공식에 넣으면 cos 0° = 1, sin 0° = 0이므로 q = [1, (0, 0, 0)]입니다. 이것을 적용하면 캐릭터는 원래 바라보던 방향을 그대로 유지합니다. 다른 회전에 곱해도 그 회전을 그대로 남기는, 실수 곱셈의 1과 같은 역할입니다.
반면 [0, (0, 0, 0)]은 유효한 단위 회전 쿼터니언이 아닙니다. ‘회전 없음’을 만들고 싶다면 항등 쿼터니언을 써야 합니다. [−1, (0, 0, 0)]은 같은 무회전을 표현하지만, 숫자 계산에서 곱셈의 1 역할을 하는 값은 [1, (0, 0, 0)]입니다.
8.5.5 쿼터니언의 크기
벡터나 복소수처럼 크기를 계산할 수 있습니다. 표기와 식도 벡터와 비슷합니다.
회전 쿼터니언에서는 기하적으로 무엇을 뜻할까?
이는 중요한 관찰입니다. 자세를 표현하려는 우리의 목적에서 모든 쿼터니언은 크기가 1인 단위 쿼터니언입니다. 정규화하지 않은 쿼터니언은 Dam 등의 기술 보고서[11]를 참고하겠습니다.
쉬운 보충 ⑨ · ‘단위’는 왜 길이가 1이라는 뜻일까요?
피타고라스 정리에서 좌표 (a, b)의 길이를 √(a² + b²)로 구했죠. 네 숫자의 크기는 같은 생각으로 √(w² + x² + y² + z²)로 계산합니다. ‘단위 쿼터니언’은 이 값이 1인 쿼터니언입니다.
앞의 90° 회전에는 0이 아닌 성분이 √2/2 두 개 있습니다. 제곱하면 각각 1/2이므로 합은 1, 크기는 √1 = 1입니다. 일반 공식에서도 cos²(θ/2) + sin²(θ/2) = 1이므로 크기가 1이 됩니다.
이는 네 숫자를 독립적으로 아무렇게나 정하는 것이 아니라, 크기 1이라는 관계를 지키며 회전을 담는다는 뜻입니다. 회전을 합성하다 계산 오차로 크기가 조금 어긋나면, 각 성분을 현재 크기로 나누어 다시 1로 맞추는 정규화를 합니다. 회전각을 1°로 만드는 작업은 아닙니다.
8.5.6 쿼터니언의 켤레와 역원
q*로 쓰는 켤레는 벡터 부분의 부호를 바꾸어 구합니다.
‘켤레’라는 말은 쿼터니언의 복소수 해석에서 왔습니다. 8.5.14절에서 자세히 봅니다. q⁻¹로 쓰는 역원은 켤레를 크기로 나눈 것으로 정의합니다.
보충·원문 정정
일반 쿼터니언의 올바른 역원은 q⁻¹ = q*/‖q‖²입니다. 원문 식 (8.6)은 분모의 제곱이 빠졌습니다. 이 절 이후처럼 단위 쿼터니언만 다루면 ‖q‖ = 1이므로 결론 q⁻¹ = q*에는 영향이 없습니다.
쿼터니언 역원은 실수의 곱셈 역원과 흥미롭게 대응합니다. 실수에서는 a⁻¹ = 1/a이며 a(a⁻¹) = a⁻¹a = 1입니다. 쿼터니언도 같습니다. q에 q⁻¹을 곱하면 항등 쿼터니언 [1, 0]이 됩니다. 곱셈은 8.5.7절에서 다룹니다.
식 (8.6)은 역원의 공식 정의입니다. 다만 이 책처럼 순수 회전을 나타내는 쿼터니언에만 관심이 있다면 모두 단위 쿼터니언이므로 켤레와 역원이 같습니다.
켤레, 즉 역원이 흥미로운 이유는 q와 q가 반대 각변위를 나타내기 때문입니다. v의 부호를 바꾸면 회전축 n̂의 부호가 바뀝니다. n̂과 −n̂은 평행하므로 물리적인 축 자체는 같지만, 양의 회전으로 간주하는 방향이 뒤집힙니다. 따라서 q가 축을 중심으로 θ만큼 회전한다면 q는 같은 양만큼 반대로 회전합니다.
쉬운 보충 ⑩ · 역원은 ‘방금 한 회전 되돌리기’입니다
캐릭터를 오른쪽으로 90° 돌린 뒤 원래 방향으로 돌려놓고 싶다면, 같은 축을 중심으로 왼쪽으로 90° 돌리면 됩니다. 이것이 회전 q의 역원 q⁻¹이 하는 일입니다. q⁻¹q는 ‘회전 후 되돌리기’이므로 결과가 항등 회전입니다.
단위 쿼터니언에서는 역원이 켤레와 같아서, [w, (x, y, z)]를 [w, (−x, −y, −z)]로 바꾸면 됩니다. 예시의 q = [√2/2, (0, √2/2, 0)]는 q⁻¹ = [√2/2, (0, −√2/2, 0)]가 됩니다.
복소수의 90° 회전 i를 되돌릴 때 −i를 곱하면 i(−i) = 1이 되는 것과 연결해 보세요. 다만 쿼터니언에서 ‘켤레만으로 역원을 구한다’는 간단한 규칙은 크기가 1인 경우에 적용됩니다.
우리의 목적만 생각하면 v와 n̂을 그대로 두고 w만 부호 반전하는 방법으로 켤레를 정의할 수도 있었습니다. 축을 뒤집어 양의 회전 방향을 반대로 만드는 대신 회전량 θ를 반대로 만드는 것입니다. 적어도 기하적인 목적에서는 식 (8.5)와 동등하며 조금 더 직관적인 해석이 가능합니다. 하지만 켤레라는 말에는 복소수에서 특별한 의미가 있으니 원래 정의를 따르겠습니다.
쿼터니언 구현에서 확인할 조건
일반 역원은 q*/‖q‖²다. 단위 회전 쿼터니언에서만 역원과 켤레가 같습니다. 외부에서 읽은 데이터나 회전을 여러 번 합성한 결과는 먼저 길이를 확인하고 정규화합니다. slerp의 매우 가까운 경우에 사용한 단순 선형 보간 결과도 정확히 단위 길이인 것은 아니므로, 단위 길이가 필요한 다음 연산에 넣기 전에 정규화하는 방법이 실용적입니다.
8.5.7 쿼터니언 곱셈
두 쿼터니언을 곱하면 새로운 쿼터니언을 얻습니다. 결과가 스칼라가 아니라는 점, 그리고 일반적으로 교환법칙이 성립하지 않는다는 점에서는 벡터의 외적과 비슷합니다. 다만 곱셈 표기는 다릅니다. 두 피연산자를 나란히 적어 곱을 나타냅니다. 복소수로서의 정의에서 공식을 유도할 수 있지만(연습문제 6), 여기서는 두 가지 표기법으로 곱셈 공식을 먼저 제시하겠습니다.
이를 해밀턴 곱(Hamilton product)이라고도 합니다. 8.5.14절에서 역사를 읽으면 이유를 알게 됩니다.
이 정의에서 얻을 수 있는 세 가지 성질을 살펴보겠습니다. 먼저 결합법칙은 성립하지만, 교환법칙은 일반적으로 성립하지 않습니다.
둘째, 곱의 크기는 크기의 곱과 같습니다(연습문제 9).
단위 쿼터니언 둘을 곱해도 단위 쿼터니언임을 보장하므로 매우 중요합니다. 마지막으로 곱의 역원은 역원들을 역순으로 곱한 것과 같습니다.
이제 곱셈이 실제로 왜 유용한지 보겠습니다. 일반적인 3차원 점 (x, y, z)를 p = [0, (x, y, z)]로 정의하여 쿼터니언 공간으로 확장합니다. p의 크기는 임의적이므로 일반적으로 유효한 회전 쿼터니언이 아닙니다. q는 지금까지의 회전 쿼터니언 [cos(θ/2), n̂ sin(θ/2)]이며 n̂은 단위 회전축, θ는 회전각이라고 하겠습니다. 놀랍게도 다음의 낯선 곱셈으로 3차원 점 p를 n̂ 중심으로 회전할 수 있습니다.
곱을 전개하고 n̂과 θ를 대입한 다음 인쇄본 144쪽의 임의축 회전행렬 식과 비교하면 증명할 수 있습니다. 실제로 대부분의 쿼터니언 교재가 이 방법을 씁니다. 잘 작동하는지 검증하는 데는 효과적이지만, 도대체 누가 처음에 이런 기법을 생각해 냈을까 하는 의문은 남습니다. 8.7.3절에서는 qpq⁻¹을 거치지 않고 회전의 기하만으로 쿼터니언에서 행렬로의 변환을 직접 유도합니다.
실제로 이 관계가 어떻게 발견되었는지는 확신할 수 없지만, 8.5.14절에서 이 이상한 곱과 회전 사이의 관계를 발견하도록 이끄는 사고 과정을 제안하겠습니다. 왜 성분에 회전각의 절반을 쓰면 좋다는 생각에 도달할 수 있는지도 설명합니다.
쉬운 보충 ⑪ · q p q⁻¹의 입력과 출력을 실제로 확인해요
다시 정면을 보는 캐릭터로 돌아가 보겠습니다. 정면 방향 벡터는 (0, 0, 1)입니다. 회전할 대상을 쿼터니언 계산에 넣기 위해 p = [0, (0, 0, 1)]로 쓰고, 오른쪽 90° 회전 q = [√2/2, (0, √2/2, 0)]를 적용합니다.
결과의 벡터 부분은 (1, 0, 0), 즉 오른쪽입니다. 따라서 눈에 보이는 변화는 캐릭터의 정면 방향이 오른쪽 방향으로 바뀐 것입니다. 회전용 q는 ‘무엇을 할지’를 담고, p는 ‘무엇에 적용할지’를 담는다고 구별하면 쉽습니다. p는 회전 정보를 나타내는 q와 용도가 다릅니다.
이 회전에서는 위쪽 축에 놓인 벡터 (0, 1, 0)는 그대로이고, 정면 방향과 오른쪽 방향이 도는 평면에서 변화가 생깁니다. 문을 열어도 경첩의 축은 움직이지 않는 것과 같은 원리입니다.
이 식으로 점을 돌리면 기본적으로 원점을 중심으로 돕니다. 캐릭터의 월드 위치까지 원점 주위로 돌릴 필요는 없습니다. 물체 자체의 자세를 바꿀 때는 로컬 방향과 자세에 회전을 적용하며, 별도의 회전 중심 c 주위로 점 p를 돌리려면 먼저 p − c를 돌린 뒤 c를 다시 더합니다.
쿼터니언 곱셈과 3차원 벡터 회전 사이의 관계는 이론적으로 흥미롭습니다. 일부 쿼터니언 애호가들은 식 (8.7)로 벡터를 곧바로 회전시킬 수 있다는 점을 실용적인 장점으로 꼽습니다. 저자들도 표기가 간결해진다는 점은 인정하지만, 계산량에서 특별한 이점이 있는지는 따져 볼 필요가 있다고 설명합니다.
실제 수학을 전개해 보면 8.7.3절에서 유도할 식 (8.20)으로 동등한 회전행렬을 만든 뒤 벡터를 곱하는 것과 연산 수가 거의 같습니다. 그래서 적어도 컴퓨터의 실용적인 관점에서는 쿼터니언에 벡터를 직접 회전시키는 특별한 능력이 있다고 보지 않습니다.
직접적인 실무 중요성은 낮아도 이론적으로는 매우 중요합니다. 프로그래밍에서 매우 실용적인 곱셈의 다른 용도로 이어지기 때문입니다. 벡터 p를 쿼터니언 a로 회전하고 그 결과를 b로 다시 회전해 보겠습니다.
a 다음 b로 회전하는 것은 곱 ba로 한 번 회전하는 것과 같습니다. 핵심적인 관찰입니다.
쉬운 보충 ⑫ · 카메라를 돌리는 순서가 왜 중요할까요?
이번에는 월드에 고정된 축 두 개를 사용하겠습니다. A는 위쪽 y축으로 오른쪽 90° 회전, B는 오른쪽 x축으로 아래쪽 90° 회전입니다. 카메라의 시작 방향은 정면인 +z입니다. 여기서는 회전할 때마다 카메라와 함께 움직이는 축으로 바꾸지 않습니다.
•
A 다음 B: 정면 +z → 오른쪽 +x → 그대로 +x입니다. 두 번째 회전의 축이 x이므로, 이미 그 축을 가리키는 방향은 바뀌지 않습니다.
•
B 다음 A: 정면 +z → 아래쪽 −y → 그대로 −y입니다. 두 번째 회전의 축이 y이므로 아래쪽 방향은 바뀌지 않습니다.
둘 다 90° 회전을 두 번 했지만 한 결과는 오른쪽, 다른 결과는 아래쪽입니다. ‘90 + 90’만으로 설명할 수 없는 이유가 여기에 있습니다. 3차원에서는 회전축과 적용 순서가 함께 중요합니다.
본문의 해밀턴 곱과 q p q⁻¹ 규약에서는 A 다음 B를 qB qA로 적습니다. 오른쪽 qA가 먼저, 왼쪽 qB가 나중입니다. 곱은 회전을 차례로 적용한 결과를 하나의 회전으로 묶어 주며, 네 성분을 각각 곱하는 계산과는 다릅니다.
행렬 곱셈처럼 쿼터니언 곱셈도 여러 회전을 합성할 수 있다.
‘행렬 곱셈처럼’이라고 했지만 조금 성가신 차이는 있습니다. 행렬에서는 행벡터를 왼쪽에 두는 이 책의 선택 덕분에 합성 회전을 왼쪽에서 오른쪽으로 변환 순서대로 읽을 수 있습니다. 쿼터니언은 이런 선택의 여지가 없어서 여러 회전의 합성은 언제나 ‘안에서 밖으로’, 오른쪽에서 왼쪽으로 읽습니다.[원주 12]
회전을 합칠 때 좌표 공간까지 말하기
행벡터 규약의 v * B * P * H는 고정된 축을 중심으로 뱅크, 피치, 헤딩을 적용합니다. 같은 물리 회전을 해밀턴 쿼터니언 곱으로 합치면 h * p * b이며 오른쪽부터 적용됩니다. 식을 외우기 전에 입력 공간, 출력 공간, 축이 물체와 함께 움직이는지, 벡터를 어느 쪽에 곱하는지를 확인하겠습니다.
8.5.8 쿼터니언의 ‘차이’
곱셈과 역원을 이용하면 두 쿼터니언 사이의 ‘차이’를 계산할 수 있습니다. 여기서 차이는 성분별 뺄셈을 뜻하지 않습니다. 한 자세에서 다른 자세로 이동하는 각변위입니다. 자세 a와 b가 주어졌을 때, a에서 b로 회전시키는 쿼터니언 d를 구하려는 것입니다. 이를 간결하게 쓰면 다음과 같습니다.
쿼터니언 곱은 오른쪽에서 왼쪽 순서로 적용된다는 점을 기억하면서 d를 구해 보겠습니다. 스칼라라면 a로 나누면 되지만, 쿼터니언에서는 같은 효과를 역원의 곱으로 표현합니다. 곱셈에 교환법칙이 성립하지 않으므로 어느 쪽에 곱하는지가 중요합니다. 여기서는 양변의 오른쪽에 a⁻¹을 곱합니다.
이제 한 자세에서 다른 자세로의 각변위를 나타내는 쿼터니언을 만들 수 있습니다. 8.5.12절의 slerp에서 사용합니다. 수학적으로 이 각도 차는 실제 뺄셈보다는 나눗셈과 더 닮았습니다.
쉬운 보충 ⑬ · 두 자세의 ‘차이’는 얼마나 더 돌아야 하느냐입니다
같은 위쪽 축을 기준으로 카메라가 이미 오른쪽 30°를 보고 있고, 목표는 오른쪽 90°라고 해 봅시다. 우리가 원하는 차이는 각 숫자 칸의 차이가 아니라 현재에서 목표까지 필요한 추가 회전, 이 경우 오른쪽 60°입니다.
현재 자세를 a, 목표 자세를 b라고 하면 d a = b를 만족하는 d를 찾습니다. 그래서 d = b a⁻¹입니다. a⁻¹로 현재 자세의 효과를 계산상 제거하고 목표 b와 연결하면, 필요한 추가 회전 d가 남습니다. 카메라가 실제로 원점 방향으로 돌아갔다가 목표를 보게 하는 애니메이션을 뜻하지는 않습니다.
이 예시는 같은 축이라 90° − 30°로 생각할 수 있지만, 서로 다른 축으로 기울어진 자세는 그렇게 뺄 수 없습니다. b a⁻¹은 그런 경우에도 회전의 차이를 표현합니다. 단, 본문과 같은 좌표 공간과 합성 규약에서 계산해야 합니다.
[원주 12]: 기존 체계에 반항할 생각이라면 선택의 여지가 있긴 합니다. 어떤 괴짜 저자들[16]은 피연산자 순서를 뒤집은 쿼터니언 곱을 대안으로 정의했습니다. 더 읽기 쉬운 코드가 될 수 있으므로 자기 코드에서는 고려할 가치가 있습니다. 하지만 이 책은 표준을 따르겠습니다.
8.5.9 쿼터니언 내적
쿼터니언에도 내적이 정의됩니다. 표기와 정의는 벡터 내적과 매우 비슷합니다.
벡터 내적처럼 결과는 스칼라입니다. 단위 쿼터니언 a, b에 대해서는 −1 ≤ a·b ≤ 1입니다.
적어도 게임 프로그래밍에서 아주 자주 쓰는 쿼터니언 연산은 아닐지 모르지만 흥미로운 기하 해석이 있습니다. 8.5.8절에서 a에서 b로의 각변위를 나타내는 차이 쿼터니언 d = ba*를 보았습니다. 단위 쿼터니언을 가정하여 역원 대신 켤레를 썼습니다. 곱을 전개하여 d를 살펴보면 w 성분은 내적 a·b와 같습니다!
이 내적은 기하학적으로 무엇을 뜻할까요? 오일러의 회전 정리에 따르면 적절한 축을 중심으로 한 번 회전하여 자세 a에서 b로 이동할 수 있습니다. 축의 부호에 따른 표현을 제외하면, 이 회전축과 회전각이 차이 쿼터니언 d에 들어 있습니다. w와 회전각 θ의 관계를 떠올리면 a·b = cos(θ/2)임을 알 수 있습니다. 여기서 θ는 자세 a에서 b로 이동하는 데 필요한 회전량입니다.
따라서 쿼터니언 내적도 벡터 내적과 비슷하게 해석할 수 있습니다. |a·b|가 클수록 나타내는 각변위가 더 ‘비슷하다’. 벡터 내적은 벡터 사이 각도의 코사인을 주지만 쿼터니언 내적은 한 자세를 다른 자세로 회전시킬 각도의 절반의 코사인을 줍니다. 비슷함을 측정할 때는 보통 절댓값만 봅니다. b와 −b는 같은 각변위인데 a·b = −(a·(−b))이기 때문입니다.
게임 코드에서 직접 쓸 일은 적더라도 내적은 8.5.12절의 slerp 계산 첫 단계입니다.
쉬운 보충 ⑭ · 내적은 두 자세 사이의 회전량을 알아보는 단서예요
시작 자세를 a = [1, (0, 0, 0)], 목표를 오른쪽 90° 회전 b = [√2/2, (0, √2/2, 0)]라고 해 봅시다. 성분끼리 곱해 더하면 a·b = √2/2 = cos 45°입니다. 여기의 45°는 실제 회전량 90°의 절반입니다.
같은 단위 쿼터니언을 자기 자신과 내적하면 1입니다. 그런데 b와 −b를 내적하면 −1이 나옵니다. 두 값이 같은 자세를 나타낸다는 사실을 떠올리면, 내적이 −1이라고 ‘완전히 반대 자세’라고 해석해서는 안 된다는 것을 알 수 있습니다.
단위 회전 쿼터니언 a와 b 사이의 가장 짧은 회전각을 알고 싶을 때는 부호 중복을 고려하여 2 arccos(|a·b|)로 구할 수 있습니다. arccos는 ‘코사인 값에서 각도를 되찾는 함수’입니다. 우선은 내적이 보간 경로와 회전량을 판단하는 재료라는 점을 잡으면 충분합니다.
8.5.10 쿼터니언 log, exp와 스칼라 곱
직접 쓰는 경우는 드물어도 중요한 여러 연산의 토대가 되는 세 연산, 로그, 지수함수, 스칼라 곱을 살펴보겠습니다. 먼저 반각 θ/2를 뜻하는 α를 도입하여 정의를 다시 씁니다.
q의 로그는 다음과 같이 정의합니다.
≡는 정의에 의해 같다는 뜻입니다. 일반적으로 log q는 단위 쿼터니언이 아닙니다. 쿼터니언의 로그를 취하는 것과 8.4절의 지수 사상 표현이 비슷하다는 점에 주목하겠습니다.
지수함수는 정확히 반대 방식으로 정의합니다. 먼저 p = [0, αn̂], ‖n̂‖ = 1이라고 놓으면
정의상 exp p는 항상 단위 쿼터니언을 반환합니다. 로그와 지수함수는 스칼라의 대응 함수와 관련됩니다. 스칼라 a에 대해 e^(ln a) = a인 것처럼, 쿼터니언 exp는 log의 역함수로 정의됩니다.
마지막으로 각 성분에 스칼라를 곱하는 자연스러운 방식으로 스칼라 곱을 정의합니다. 스칼라 k와 쿼터니언 q에 대해
대개 단위 쿼터니언이 아니게 되므로 각변위 표현의 맥락에서는 별로 유용하지 않습니다. 하지만 8.5.11절에서 쓸모를 찾을 것입니다.
쉬운 보충 ⑮ · log와 exp는 회전 정보를 풀고 다시 담는 과정으로 봐요
이 절의 log와 exp는 정의가 낯설지만, 같은 축의 90° 회전 한 가지로 따라가면 됩니다. log는 단위 회전 쿼터니언에서 축과 반각을 꺼내 벡터 형태로 담고, exp는 그 벡터에서 다시 회전 쿼터니언을 만듭니다.
앞의 오른쪽 90° 회전에서 축은 (0, 1, 0), 반각은 45°입니다. 실제 수식에서는 각도를 라디안으로 쓰므로 45° = π/4입니다. 라디안은 각도의 단위이며, 180° = π라는 대응으로 읽으면 됩니다.
log q의 벡터를 절반으로 줄이면 반각이 π/8, 즉 22.5°가 됩니다. exp로 다시 회전용 쿼터니언에 담으면 실제 회전각은 45°입니다. 회전 정보를 풀기 → 각도 비율 조절하기 → 다시 회전용 값으로 만들기가 다음 절의 거듭제곱으로 이어집니다.
여기서는 항등에서 오른쪽 90°까지의 같은 축 회전으로 범위를 정했습니다. 전체 회전 범위에서는 대표 부호와 각도 범위를 정해야 하는 문제가 있으므로, 본문의 보간 규약도 함께 확인해 주세요.
8.5.11 쿼터니언 거듭제곱
쿼터니언은 스칼라 지수로 거듭제곱할 수 있습니다. qᵗ로 표기하는 거듭제곱(exponentiation)과 지수함수 exp q는 구별해야 합니다. 지수함수는 쿼터니언 하나를 인수로 받습니다. 반면 거듭제곱은 쿼터니언 q와 스칼라 지수 t, 두 값을 인수로 받습니다.
의미는 실수의 거듭제곱과 비슷합니다. 0이 아닌 스칼라 a에 대해 a⁰ = 1, a¹ = a이며 t가 0에서 1로 변하면 aᵗ는 1에서 a로 변합니다. 쿼터니언도 t가 0에서 1로 변하면 qᵗ가 [1, 0]에서 q로 변합니다.
각변위의 ‘일부분’을 추출할 수 있기 때문에 유용합니다. 예를 들어 q가 나타내는 각변위의 1/3을 얻으려면 q^(1/3)을 계산합니다.
[0, 1] 밖의 지수도 대체로 예상대로 작동하지만 중요한 주의점 하나가 있습니다. 예를 들어 q²는 두 배의 각변위입니다. q가 x축 중심의 시계 방향 30° 회전이라면 q²는 같은 축 중심의 시계 방향 60°, q^(−1/3)은 반시계 방향 10° 회전입니다. 특히 역원 표기 q⁻¹도 이런 맥락으로 해석할 수 있으며 결과는 같은 반대 회전 쿼터니언입니다.
주의점은 쿼터니언이 각변위를 최단 호로 표현하며 여러 바퀴를 표현할 수 없다는 점입니다. 앞 예에서 q⁸은 기대한 시계 방향 240°가 아니라 반시계 방향 120° 회전입니다. 물론 한쪽 240°와 반대쪽 120°의 최종 결과는 같습니다. 바로 이것이 요점입니다. 쿼터니언은 실제로 최종 결과만 담습니다. 일반적으로 스칼라 거듭제곱 항등식, 예를 들어 (aˢ)ᵗ = aˢᵗ 등은 쿼터니언에서 성립하지 않습니다.
최종 결과뿐 아니라 총 회전량이 중요한 경우도 있습니다. 가장 중요한 예는 각속도입니다. 그때는 쿼터니언이 적합하지 않으며 지수 사상이나 가까운 친척인 축–각을 사용해야 합니다.
용도를 알았으니 수학적 정의를 보겠습니다. 앞 절의 보조 연산으로 정의합니다.
스칼라에도 비슷한 식 aᵗ = e^(t ln a)가 성립합니다.
t가 0에서 1로 변할 때 qᵗ가 항등에서 q로 보간되는 이유는 어렵지 않습니다. log는 계수 2를 제외하면 본질적으로 쿼터니언을 지수 사상으로 바꿉니다. 이어 지수 t를 스칼라로 곱하면 각도가 t배가 됩니다. 마지막 exp는 log를 되돌려 지수벡터에서 새 w와 v를 계산합니다.
쉬운 보충 ⑯ · ‘회전의 절반’과 ‘네 숫자의 절반’은 다릅니다
q가 같은 축 중심의 오른쪽 90° 회전이라면, 이 예시에서 q⁰는 0°, q¹⁄⁴는 22.5°, q¹⁄²는 45°, q¹은 90° 회전입니다. 거듭제곱의 지수 t가 회전량의 비율을 조절하는 것이죠.
중요한 차이가 있습니다. q¹⁄²는 회전각을 절반으로 만든 새 단위 쿼터니언입니다. 반면 0.5q는 네 성분을 그냥 절반으로 줄인 값이라 크기가 0.5입니다. 이것을 정규화하면 원래 q로 돌아오므로, 45° 회전을 만든 것이 아닙니다.
우리 예시의 45° 회전은 [cos 22.5°, (0, sin 22.5°, 0)]입니다. 원래의 0.7071을 반으로 나눈 숫자와 다르다는 것을 확인해 보세요. 회전각이 달라지면 사인과 코사인도 새로 계산됩니다.
또한 하나의 최종 자세만으로 회전한 횟수까지 알 수는 없습니다. 캐릭터가 한 바퀴 돌아 원래 방향이 되어도 정지해 있던 캐릭터와 최종 자세는 같습니다. 몇 바퀴 도는 연출이 필요하면 회전 경로나 누적 각도도 별도로 다뤄야 합니다.
적어도 수학식에서는 그렇습니다. 식 (8.8)은 공식 정의로 이론적으로 우아하지만 그대로 코드로 옮기면 필요 이상으로 복잡합니다. 목록 8.2는 C 구현입니다. 식처럼 지수 사상과 비슷한 하나의 수량을 다루는 대신 축과 반각을 따로 꺼냅니다.
// 쿼터니언: 입력과 출력
float w, x, y, z;
// 입력 지수
float exponent;
// 항등 쿼터니언인지 확인하여 0으로 나누는 일을 막는다.
if (fabs(w) < .9999f) {
// 반각 alpha = theta / 2를 추출한다.
float alpha = acos(w);
// 새 alpha와 w를 계산한다.
float newAlpha = alpha * exponent;
w = cos(newAlpha);
// 새 x, y, z를 계산한다.
float mult = sin(newAlpha) / sin(alpha);
x *= mult;
y *= mult;
z *= mult;
}
C++
복사
목록 8.2: 쿼터니언 거듭제곱.
몇 가지를 주목하겠습니다. 첫째, w = ±1이면 mult 계산에서 0으로 나누므로 항등 검사가 필요합니다. 항등 쿼터니언을 어떤 지수로 거듭제곱해도 항등이므로 입력이 항등이면 지수를 무시하고 원래 쿼터니언을 반환합니다. 둘째, alpha를 구할 때 항상 양의 각도를 반환하는 arccos를 사용해도 일반성을 잃지 않습니다. 어떤 축 중심의 음의 회전은 반대 방향을 가리키는 축 중심의 양의 회전과 같으므로 어떤 쿼터니언이든 양의 회전각으로 해석할 수 있습니다.
8.5.12 쿼터니언 보간, 일명 Slerp
오늘날 게임과 그래픽스에서 쿼터니언이 널리 사용되는 중요한 이유는 slerp, 즉 구면 선형 보간(Spherical Linear interpolation)입니다. 두 자세 사이를 매끄럽게 보간하면서, 8.3.4절에서 살펴본 오일러 각 보간의 문제를 피할 수 있습니다.
slerp는 세 값을 인수로 받습니다. 첫 번째와 두 번째는 시작 자세 q₀와 끝 자세 q₁이고, 세 번째는 보간 매개변수 t입니다. t가 0에서 1로 변하면 slerp(q₀, q₁, t)는 q₀에서 q₁으로 변화하는 중간 자세를 반환합니다.
쉬운 보충 ⑰ · Slerp는 카메라가 중간에 바라볼 자세를 구합니다
카메라가 정면을 보다가 1초 동안 오른쪽 90°를 향해 부드럽게 돌아가는 상황을 생각해 보세요. 시작 q₀는 항등 자세이고, 끝 q₁은 앞에서 만든 오른쪽 90° 자세입니다. t를 ‘지난 시간 ÷ 1초’로 정하면, slerp가 그 시점의 중간 자세를 알려 줍니다.
•
0초, t = 0: 정면, 0°.
•
0.25초, t = 0.25: 오른쪽 22.5°.
•
0.5초, t = 0.5: 오른쪽 45°.
•
0.75초, t = 0.75: 오른쪽 67.5°.
•
1초, t = 1: 오른쪽 90°.
이 같은 축 예시에서는 시간이 일정하게 늘 때 회전각도 일정하게 늘어납니다. 화면에서는 갑자기 방향이 튀지 않고 카메라가 고르게 돌아가는 모습이 됩니다. Slerp의 출력은 중간 자세이며, 시작값과 끝값의 위치를 섞어 카메라를 이동시키는 함수는 아닙니다.
캐릭터가 바라보는 방향, 카메라의 자세, 물체의 기울기를 연결할 때 이런 중간 자세가 필요합니다. 실제 연출에서 천천히 출발하고 멈추게 하려면 시간에 따른 t의 변화도 조절할 수 있습니다. 이때는 t를 선형으로 늘렸을 때의 일정한 회전 속도와 구분합니다.
지금까지의 도구로 식을 유도해 보겠습니다. 스칼라 a₀와 a₁을 보간한다면 표준 선형 보간을 씁니다.
a₀에서 시작하여 a₁과 a₀의 차이의 t배를 더하는 방식입니다. 기본 단계는 세 가지입니다. 두 값의 차이를 구하고, 차이의 일부를 취하고, 원래 값에 그 일부만큼의 변화를 적용합니다. 자세에도 같은 생각을 쓸 수 있습니다. 쿼터니언 곱은 오른쪽에서 왼쪽으로 읽는다는 점을 기억하겠습니다.
1.
두 값의 차이: 8.5.8절에 따라 q₀에서 q₁로의 각변위는 Δq = q₁q₀⁻¹입니다.
2.
차이의 일부: 8.5.11절의 거듭제곱으로 (Δq)ᵗ를 구합니다.
3.
원래 값에 적용: 쿼터니언 곱으로 각변위를 합성하여 (Δq)ᵗq₀를 구합니다.
따라서 이론적인 slerp 식은 다음과 같습니다.
실제 계산에서는 같은 결과를 더 효율적으로 구하는 식을 사용합니다. 이를 유도하기 위해 쿼터니언을 4차원 유클리드 공간의 점으로 생각해 보겠습니다. 회전을 나타내는 단위 쿼터니언은 모두 4차원 초구의 표면에 놓입니다. 이 표면에서 두 쿼터니언을 잇는 호를 따라 보간한다는 것이 핵심 생각입니다. ‘구면 선형 보간’이라는 이름도 여기에서 나옵니다.
이 생각은 평면에서도 시각화할 수 있습니다(그림 8.11). 길이가 1인 두 2차원 벡터 v₀, v₁을 떠올려 보세요. v₀에서 v₁까지의 호를 따라 전체 거리의 t만큼 이동한 벡터 vₜ를 구하려는 것입니다. 그 호가 만드는 각을 ω[원주 13]라고 하면, vₜ는 v₀를 tω만큼 회전시킨 결과입니다.
그림 8.11. 회전 보간
그림 8.11. 회전 보간
vₜ는 v₀, v₁의 선형결합으로 표현할 수 있습니다. 즉 vₜ = k₀v₀ + k₁v₁인 음이 아닌 상수 k₀, k₁이 존재합니다. 그림 8.12처럼 초등 기하로 이 값들을 구합니다.
그림 8.12. 호를 따라 벡터 보간하기
그림 8.12. 호를 따라 벡터 보간하기
k₁v₁을 빗변으로 하는 직각삼각형에 삼각함수를 적용하고 v₁이 단위벡터임을 사용하면
같은 방식으로 k₀를 구하면
따라서
이를 쿼터니언 공간으로 확장하면 실제 계산에 쓰는 식이 됩니다.
이제 두 쿼터니언 사이의 ‘각도’ ω를 구하기만 하면 됩니다. 2차원 벡터와의 유추가 그대로 이어집니다. 쿼터니언 내적이 cos ω를 반환한다고 생각할 수 있습니다.
작은 복잡함이 두 가지 있습니다. 첫째, q와 −q는 같은 자세지만 slerp의 인수로 넣으면 다른 결과가 나올 수 있습니다. 이런 문제는 2차원이나 3차원에서는 생기지 않지만 4차원 초구의 표면은 유클리드 공간과 위상이 다릅니다. 해결책은 q₀·q₁ ≥ 0이 되도록 부호를 고르는 것입니다.
그러면 항상 최단 회전 호를 택합니다. 둘째, 두 쿼터니언이 매우 가까우면 ω와 sin ω가 매우 작아 나눗셈에 문제가 생깁니다. sin ω가 아주 작으면 단순 선형 보간을 사용하여 피합니다. 목록 8.3은 이를 모두 반영합니다.
쉬운 보충 ⑱ · 곧장 숫자를 섞으면 안 될까요?
두 쿼터니언의 성분을 비율대로 섞는 선형 보간은 이해하기 쉽지만, 섞은 값의 크기가 1이 아닐 수 있습니다. 정규화해서 단위 길이를 복원할 수는 있어도, 일반적으로 t가 일정하게 늘 때 회전각까지 일정하게 늘어나는 것은 아닙니다. Slerp는 회전 자세를 잇는 호를 따라 일정한 비율로 진행하도록 계산합니다.
부호를 고르는 문제도 화면에서 이해할 수 있습니다. 같은 축의 방향 각도가 350°에서 10°로 바뀐다면, 짧게는 20°만 더 돌아도 도착합니다. 길게 반대 방향으로 340° 돌아도 도착하죠. q와 −q 중 어느 표현을 연결하느냐에 따라 같은 끝 자세를 향하는 경로가 달라질 수 있습니다.
그래서 아래 코드에서는 내적이 음수이면 끝 쿼터니언의 네 성분을 모두 반전합니다. 도착 자세는 그대로 보존하면서 더 짧은 회전 경로를 고르는 것입니다. 숫자 부호를 바꿨다고 카메라가 반대 자세를 보게 되는 것이 아닙니다.
두 자세가 매우 가까울 때는 선형 보간을 근사로 사용합니다. 그 결과를 단위 회전 쿼터니언으로 사용할 때의 정규화 조건은 본문 아래의 구현 보충도 함께 확인해 주세요.
// 두 입력 쿼터니언
float w0, x0, y0, z0;
float w1, x1, y1, z1;
// 보간 매개변수
float t;
// 출력 쿼터니언
float w, x, y, z;
// 내적으로 쿼터니언 사이 '각도의 코사인'을 구한다.
float cosOmega = w0*w1 + x0*x1 + y0*y1 + z0*z1;
// 내적이 음수이면 한 입력의 부호를 뒤집어 더 짧은 4차원 호를 택한다.
if (cosOmega < 0.0f) {
w1 = -w1;
x1 = -x1;
y1 = -y1;
z1 = -z1;
cosOmega = -cosOmega;
}
// 너무 가까운지 검사하여 0으로 나누는 일을 막는다.
float k0, k1;
if (cosOmega > 0.9999f) {
// 매우 가까우므로 선형 보간한다.
k0 = 1.0f - t;
k1 = t;
} else {
// sin²(omega) + cos²(omega) = 1로 사인을 구한다.
float sinOmega = sqrt(1.0f - cosOmega*cosOmega);
// 사인과 코사인으로 각도를 구한다.
float omega = atan2(sinOmega, cosOmega);
// 한 번만 나누도록 분모의 역수를 구한다.
float oneOverSinOmega = 1.0f / sinOmega;
// 보간 계수를 구한다.
k0 = sin((1.0f - t) * omega) * oneOverSinOmega;
k1 = sin(t * omega) * oneOverSinOmega;
}
// 보간한다.
w = w0*k0 + w1*k1;
x = x0*k0 + x1*k1;
y = y0*k0 + y1*k1;
z = z0*k0 + z1*k1;
C++
복사
목록 8.3: 쿼터니언 slerp.
[원주 13]: 그리스 문자 오메가이며 영어로 ‘oh-MAY-guh’라고 발음합니다.
쿼터니언 구현에서 확인할 조건
원문의 ‘쿼터니언은 항상 최단 호를 쓴다’는 설명에는 선택 규약이 생략되어 있습니다. q와 −q는 같은 자세지만 분수 거듭제곱이나 보간의 경로는 다를 수 있습니다. 원문의 목록 8.2는 w < 0을 양수로 바꾸지 않으므로 자동으로 최단 회전을 선택한다고 일반화할 수 없습니다. 가장 짧은 회전량의 거듭제곱을 원하면 먼저 대표 부호를 정합니다. slerp의 목록 8.3은 내적이 음수일 때 한 입력을 반전하여 이 선택을 명시적으로 합니다.
8.5.13 쿼터니언의 장점과 단점
각변위의 다른 표현에 비해 다음 장점이 있습니다.
•
매끄러운 보간: slerp가 자세 사이의 매끄러운 보간을 제공합니다. 다른 표현법은 매끄러운 보간을 제공하지 않습니다.
•
빠른 합성과 역변위 계산: 쿼터니언 곱으로 여러 각변위를 하나로 합칠 수 있습니다. 행렬의 같은 연산은 스칼라 연산이 더 많지만, 특정 아키텍처에서 무엇이 더 빠른지는 단순하지 않습니다. SIMD 벡터 명령은 행렬 곱을 매우 빠르게 처리할 수 있습니다. 켤레를 쓰면 반대 각변위도 효율적으로 구합니다. 회전행렬은 전치로 가능하지만 오일러 각에서는 쉽지 않습니다.
•
행렬과의 빠른 상호 변환: 8.7절에서 보듯 오일러 각보다 조금 빠르게 행렬로, 또 행렬에서 변환할 수 있습니다.
•
숫자가 네 개뿐: 행렬의 아홉 수보다 훨씬 경제적입니다. 그래도 오일러 각보다 33% 큽니다.
이점에는 비용도 따릅니다. 행렬의 문제 중 일부가 정도는 덜하지만 쿼터니언에도 있습니다.
•
오일러 각보다 조금 크다: 숫자 하나가 별것 아닌 듯해도 애니메이션처럼 많은 각변위를 저장하면 33%가 차이를 만듭니다. 성분 값은 [−1, +1]에 ‘균등하게’ 분포하지 않습니다. 자세가 매끄럽게 보간돼도 성분은 같은 식으로 매끄럽게 보간되지 않습니다. 따라서 오일러 각이나 지수 사상보다 고정소수점 수로 압축하기 어렵습니다.
•
유효하지 않게 될 수 있다: 잘못된 입력이나 부동소수점 반올림 오차의 누적으로 생깁니다. 크기가 1이 되도록 정규화하면 대응할 수 있습니다.
•
사람이 다루기 어렵다: 세 표현법 가운데 사람이 직접 다루기 가장 어렵습니다.
8.5.14 복소수로 보는 쿼터니언
이제 대부분의 교재가 쿼터니언 설명을 시작하는 지점, 복소수 해석으로 돌아가 보겠습니다. 회전을 사용하는 것만이 목적이라면 이 절은 건너뛰어도 됩니다. 쿼터니언을 더 깊이 이해하고 싶거나, 수학적 기원과 발명 당시의 생각이 궁금하다면 흥미롭게 읽을 수 있습니다. 여기서는 드폴대학교 John McDonald의 접근[45]을 따릅니다. 이 방법으로 θ 대신 θ/2가 등장하는 이유와, 벡터를 회전시킬 때 독특한 qvq⁻¹ 형태를 사용하는 이유를 설명하겠습니다.
쉬운 보충 ⑲ · 뒤의 행렬은 같은 계산을 다른 언어로 적은 것입니다
앞의 쉬운 보충에서는 2 + i를 좌표 (2, 1)로 읽었습니다. 이제 본문은 복소수와 쿼터니언의 계산을 행렬로도 똑같이 표현할 수 있는지 살펴봅니다. 숫자의 이름이 바뀌는 것이 아니라, 덧셈과 곱셈 결과가 같은 표현을 찾는 과정입니다.
예를 들어 실수 3을 2×2 행렬로 적을 때 대각에 3을 두고 나머지를 0으로 둡니다. 이 행렬을 평면 벡터에 적용하면 두 좌표를 모두 3배 합니다. 원래의 실수 3을 곱하는 역할과 연결되는 것이죠.
뒤에 나오는 모든 행렬의 칸을 외울 필요는 없습니다. 복소수는 평면 회전과 연결된다 → 쿼터니언은 회전축을 포함한다 → 한쪽 곱셈만으로는 안 된다 → 양쪽 곱셈과 반각이 필요하다라는 질문의 흐름을 따라가 보세요. 여기의 쿼터니언 4×4 표현은 쿼터니언 곱셈을 행렬로 적은 것이며, 게임 속 3차원 점에 바로 적용하는 회전행렬과는 역할을 구분해야 합니다.
실수 집합을 2×2 행렬 집합에 넣는 것부터 시작하겠습니다. 각 스칼라 a를 대각 원소 둘에 a가 있는 행렬 하나와 대응시킵니다.
2×2 행렬의 부분집합을 골라 모든 실수와 일대일 대응을 만들었습니다. 다른 방법도 가능하지만 이 방식은 덧셈, 뺄셈, 곱셈의 일반적인 대수 법칙, 결합법칙, 분배법칙, 영인수가 없다는 성질 등을 보존하므로 중요합니다. 나눗셈을 역원 곱셈으로 취급하면 나눗셈도 포함됩니다. 예를 들면
복소수에도 비슷한 대응을 만들 수 있을까? 이미 복소수를 배웠다면 쌍 (a, b)가 a + bi를 나타낸다는 사실을 기억할 것입니다. i는 i² = −1인 특별한 수입니다. 일반 스칼라, 즉 ‘실수’에는 이런 성질이 없으므로 흔히 허수라고 부릅니다. ‘허수(imaginary)’는 실제로 존재하지 않는다는 인상을 주므로, 여기서는 그 말 대신 더 설명적인 ‘복소수(complex)’라는 표현을 사용하겠습니다.
복소수는 더하고 빼고 곱할 수 있습니다. 보통 산술 규칙을 따르다가 i²가 나올 때마다 −1로 바꾸면 됩니다.
이제 2×2 행렬 공간에 수를 넣는 체계를 복소수까지 확장하려면 어떻게 할까? 이전에는 a라는 자유도 하나였지만 이제 a, b 둘입니다. 다음 대응을 씁니다.
왼쪽 복소수와 오른쪽 행렬의 동작이 정확히 같음을 쉽게 확인할 수 있습니다. 어떤 의미에서는 같은 수량의 두 표기법입니다.
i² = −1도 여전히 성립하는지 확인합니다. 제공 PDF의 이 문장에는 i² = 1이라고 오식되어 있지만 바로 아래 계산은 −1입니다.
2×2 형태에서는 i가 그다지 ‘허구적’으로 보이지 않습니다. 5장의 기하적 관점을 적용하겠습니다. 열[원주 14] [0, 1], [−1, 0]을 좌표 공간의 기저벡터로 보면 이 행렬은 90° 회전입니다.
i를 곱하는 것을 90° 회전으로 해석할 수 있다.[원주 15]
여기에는 ‘허구적’인 것이 없습니다. i를 −1의 제곱근으로만 생각하지 말고 a + bi를 두 자유도를 가지며 곱할 때 특정 규칙을 따르는 수학적 대상으로 보겠습니다. 보통 ‘실수부’라고 부르는 a가 주된 자유도이고 b가 부차적인 자유도를 측정합니다. 두 자유도는 어떤 의미에서 서로 ‘직교’합니다.
더 나아가 임의각 θ의 회전도 이 체계로 표현할 수 있습니다. 5.1.1절의 기본 2×2 회전행렬은 복소수와 대응시킨 특별한 행렬 집합에 속하며 cos θ + i sin θ에 대응합니다.
복소수부 부호를 반전하는 켤레가 행렬 전치에 대응한다는 점도 보겠습니다. 매우 만족스러운 관계입니다. 쿼터니언 켤레는 반대 각변위를 나타냅니다. 회전행렬은 직교행렬이므로 전치가 역행렬이라는 대응 사실이 있습니다.
일반 2차원 벡터는 어떻게 넣을까? [x, y]를 x + iy라는 복소수로 해석하면 다음 곱을 회전으로 해석할 수 있습니다.
이는 다음 행렬 곱과 동등합니다.
지금까지는 수학 상식 이상의 것이 아니지만, 목적은 쿼터니언으로 이어갈 유사 관계를 쌓는 것입니다. 핵심을 반복하겠습니다.
2차원 벡터 [x, y]를 복소수 x + yi로 해석하고, (cos θ + i sin θ)(x + iy)라는 복소수 곱으로 회전시킬 수 있다.
쿼터니언과 3차원 벡터를 곱할 때도 일반 벡터를 복소수로 바꾸는 비슷한 과정이 필요합니다.
2차원을 떠나기 전에 정리하겠습니다. 복소수는 두 자유도를 가지며 곱할 때 특정 규칙을 따르는 수학적 대상입니다. 보통 a + bi로 쓰지만 2×2 행렬로도 동등하게 표현할 수 있습니다. 행렬로 쓰면 i를 곱하는 것이 90° 회전이라는 기하 해석이 자연스럽게 따라옵니다. i² = −1은 90° 회전 둘이 180° 회전이 된다는 뜻이며 마음이 포근해집니다.
더 일반적으로 단위 길이 복소수는 cos θ + i sin θ로 쓰여 θ만큼의 회전으로 해석됩니다. 2차원 벡터를 복소수로 바꿔 여기에 곱하면 회전합니다.
이를 3차원으로 확장하고 싶은 유혹이 듭니다. 하지만 안타깝게도 단순한 확장은 불가능합니다. 아일랜드 수학자 윌리엄 해밀턴(1805–1865)도 바로 그 유혹에 빠져 여러 해 동안 2차원 복소수를 3차원으로 확장하려 했던 모양입니다. 그는 새로운 수가 실수부 하나, 허수부 둘을 가질 것이라 생각했습니다.
그러나 허수부 둘인 유용한 수 체계를 만들지 못했습니다. 전해지는 이야기에 따르면 1843년 왕립 아일랜드 아카데미의 연설에 가던 길에 허수부가 둘이 아니라 셋 필요하다는 사실을 갑자기 깨달았습니다. 그는 이 새로운 수의 성질을 정의하는 식을 브룸 다리에 새겼습니다. 원래 자국은 전설 속으로 사라졌지만 기념 명판이 자리를 대신합니다.
이렇게 쿼터니언이 발명되었습니다.
우리는 1843년 브룸 다리에 있지 않았으므로 해밀턴이 무엇 때문에 3차원 복소수 체계가 불가능하다고 깨달았는지는 확언할 수 없습니다. 다만 이런 집합을 3×3 행렬과 회전에 쉽게 대응시킬 수 없는 이유는 보일 수 있습니다. 3차원 복소수에는 i² = j² = −1인 복소 성분 i와 j 둘이 있을 것입니다.
ij와 ji도 정의해야 합니다. 어떤 규칙이 좋을지는 확실하지 않습니다. 해밀턴도 이것이 막다른 길임을 알았을지 모릅니다. 어쨌든 지금의 논의에는 중요하지 않습니다. 2차원 아이디어를 단순 확장한다면 1, i, j를 일반 대수 법칙을 모두 보존하도록 3×3 행렬에 대응시켜야 합니다. 1은 분명 3차원 항등행렬 I₃, −1은 대각이 −1인 −I₃여야 합니다.
이제 제곱이 −I₃인 i, j 행렬을 찾으려 하면 문제가 생깁니다. det(−I₃) = −1이므로 불가능함을 금방 알 수 있습니다. 행렬 곱의 행렬식은 행렬식의 곱이므로 이 행렬의 제곱근 i나 j의 행렬식은 −1의 제곱근이어야 합니다. 그러려면 행렬 원소 자체가 복소수여야 합니다.
즉 일관된 3차원 복소수 체계는 없어 보입니다. 적어도 보통 복소수와 2차원 회전처럼 우아하게 회전에 대응하는 체계는 없습니다. 그러려면 쿼터니언이 필요합니다.
쿼터니언은 i, j, k라는 세 허수 단위를 두어 복소수 체계를 확장합니다. 이들은 해밀턴이 브룸 다리에 새긴 유명한 관계를 따릅니다.
[w, (x, y, z)]는 w + xi + yj + zk에 대응합니다. 8.5.7절의 곱셈 정의는 이 규칙에서 나온다(연습문제 6). 반면 내적은 i, j, k 이야기를 무시하고 피연산자를 단순한 4차원 벡터로 취급합니다.
쉬운 보충 ⑳ · i, j, k는 계산 규칙이 정해진 세 허수 단위예요
복소수에는 i 하나가 있었지만, 쿼터니언에는 i, j, k가 있습니다. w + xi + yj + zk에서 x, y, z는 이 세 허수 단위 앞의 계수입니다. 2 + 3i에서 실수부와 허수부를 구별했던 것처럼, 숫자 네 개를 구별해서 읽으면 됩니다.
계산은 분배법칙으로 전개한 뒤 본문의 규칙을 적용합니다. 예를 들어 ij = k인데 ji = −k입니다. 순서가 다르면 결과도 다릅니다. 이 규칙 덕분에 쿼터니언은 앞에서 확인한 순서에 따라 결과가 달라지는 3차원 회전을 표현할 수 있습니다.
주의할 점은 복소수 i의 ‘평면에서 90° 회전’ 해석을 세 허수 단위에 그대로 옮기면 안 된다는 것입니다. 회전용 쿼터니언으로 i = [0, (1, 0, 0)]를 읽으면 cos(θ/2) = 0이므로, 양쪽 곱셈에서는 x축 중심 180° 회전을 나타냅니다. j와 k도 각각 해당 축의 180° 회전입니다. 실제 회전각을 읽을 때는 반각 공식과 적용 방식까지 함께 보아야 합니다.
행렬로 돌아가겠습니다. 식 (8.10)을 보존하며 쿼터니언 집합을 행렬 집합에 넣을 수 있을까? 가능합니다. 예상대로 4×4 행렬에 대응시킵니다. 실수 a는 전처럼 모든 대각 원소가 a인 행렬입니다.
복소 단위는 다음 행렬에 대응합니다.
계속 읽기 전에 이 대응이 해밀턴의 모든 규칙을 보존하는지 직접 확인해 보기를 권합니다. 결합하면 임의의 쿼터니언은 다음과 같습니다.
다시 켤레, 즉 x, y, z의 부호 반전이 전치에 대응함을 볼 수 있습니다.
지금까지는 크기가 어떤 쿼터니언에도 적용됩니다. 이제 회전으로 돌아가겠습니다. 식 (8.11)의 i, j, k 행렬은 축을 순열로 바꾸고 부호를 뒤집으므로 90° 회전이나 반사와 비슷합니다. 2차원의 간단한 생각을 이어갈 수 있을까? k 행렬의 왼쪽 위 2×2 부분은 처음 i에 대응시킨 2×2 행렬과 같습니다.
즉 k의 일부는 z축 중심 90° 회전입니다. 2차원과 유사하게 cos θ + k sin θ가 z축 중심 θ만큼의 회전이라 예상해 볼 만합니다. 벡터 [1, 0, 0]에 곱해 보겠습니다. 2차원에서처럼 벡터를 복소 영역으로 ‘승격’해야 하는데, 여기서는 수가 하나 더 있습니다. [x, y, z]를 0 + xi + yj + zk에 대응시키면 [1, 0, 0]은 i다.
전개하면
[cos θ, sin θ, 0]에 대응하므로 x축을 z축 중심으로 회전한 기대 결과와 정확히 같습니다. 지금까지는 좋습니다. 조금 더 일반적인 [1, 0, 1], 즉 i + k를 시험하겠습니다.
w가 0이 아니므로 이 결과는 벡터에 대응하지도 않습니다. xy 평면 회전은 기대대로지만 z 성분이 잘못되었습니다. zw 초평면에서 원하지 않는 회전이 생깁니다. 4×4 표현을 보면 명확합니다.
쉬운 보충 ㉑ · 한 번만 곱했을 때 무엇이 잘못될까요?
본문의 벡터 (1, 0, 1)를 i + k로 적었다고 해 봅시다. z축을 중심으로 90° 돌리면, xy 평면의 (1, 0)은 (0, 1)이 되고 회전축에 평행한 z 성분 1은 그대로여야 합니다. 따라서 기대하는 결과는 (0, 1, 1)입니다.
그런데 복소수처럼 90°를 그대로 넣어 cos 90° + k sin 90° = k를 만들고 한 번만 곱하면 다음 결과가 나옵니다.
j − 1은 [−1, (0, 1, 0)]입니다. y는 기대한 1이 되었지만 z는 0으로 사라졌고, 원래 0이어야 하는 w가 −1이 되었습니다. 벡터만 회전시키려던 계산이 벡터 밖의 성분까지 만들어 버린 것입니다. 그래서 2차원 복소수의 곱셈 한 번을 그대로 옮겨 쓸 수 없습니다.
이제 필요한 해결책이 선명해집니다. 원하는 평면 회전은 살리되, 회전축 방향의 성분을 망가뜨리는 효과는 없애야 합니다. 다음의 양쪽 곱셈이 그 역할을 합니다.
왼쪽 위 2×2 회전행렬은 원하던 것이지만 오른쪽 아래 회전행렬은 원하지 않던 것입니다.
우리가 뭔가 잘못했을까? i, j, k에 다른 4×4 형태의 −1의 제곱근을 사용하여 쿼터니언을 행렬에 넣는 다른 방법이 있을까? 실제로 대안들이 있고 이는 2차원과 무언가 다르다는 단서입니다. 그러나 모든 대안도 본질적으로 같은 현상을 조금씩 변형하여 보입니다. 혹시 곱셈 순서가 잘못된 것은 아닐까? i, j, k 곱은 가환적이지 않으니 벡터를 왼쪽, 회전 쿼터니언을 오른쪽에 두어 보겠습니다.
식 (8.13)과 비교하면 차이는 y좌표의 부호뿐입니다. 얼핏 더 나빠 보입니다. 원하는 평면의 회전이 뒤집혀 −θ가 되었고 원치 않는 추가 회전은 그대로입니다. 하지만 해결책이 보일지도 모릅니다. 켤레를 사용해 반대 회전을 적용하면 두 문제가 해결됩니다.
보충·원문 정정
원문에서 이 결과의 −k cos θ는 +k cos θ가 맞습니다. 또한 앞 문장의 ‘xz 평면’은 ‘xy 평면’의 오타입니다. 위에 설명한 원치 않는 zw 회전의 상쇄라는 결론은 그대로 성립합니다.
왼쪽에서 cos θ + k sin θ를 곱하면 원하는 회전과 원치 않는 회전이 함께 생깁니다. 오른쪽에서 켤레를 곱하면 같은 원하는 회전과 반대의 원치 않는 회전이 생깁니다. 둘을 합치면 원치 않는 회전은 상쇄되고 원하는 회전만 남습니다. 정확히는 원하는 것의 두 배지만 θ 대신 θ/2를 사용하면 간단히 해결됩니다. θ/2가 어딘가에서 나올 줄은 알고 있었지만 이제 이유를 알았습니다. 앞의 발견을 정리하겠습니다.
2차원 복소수와 회전의 생각을 쿼터니언으로 확장하려면 먼저 [x, y, z]를 v = [0, (x, y, z)]로 바꾼다. n̂ 중심으로 θ만큼 회전시키려고 q = [cos θ, sin θ n̂]을 만들고 qv를 수행하는 단순한 접근은 실패한다. 원하는 회전 외에 w 쪽으로의 불필요한 회전도 포함하기 때문이다. vq 역시 원하는 회전과 불필요한 회전을 만들지만 불필요한 부분은 qv의 것과 정확히 반대다. 따라서 반각을 써서 q = [cos(θ/2), sin(θ/2)n̂]로 놓고 두 곱을 모두 수행한 qvq를 사용한다. 첫 회전은 목표의 절반과 w 관련 불필요한 회전을 만들고, 둘째는 목표 회전을 완성하면서 불필요한 회전을 상쇄한다.
쉬운 보충 ㉒ · 반각과 양쪽 곱셈으로 같은 예시를 완성해요
바로 앞의 실패한 예시를 끝까지 계산해 보겠습니다. 목표는 z축 중심 90° 회전이고, 대상은 p = i + k입니다. 이번에는 반각을 써서 q = (1 + k)/√2, q⁻¹ = (1 − k)/√2로 만듭니다. 두 값 모두 단위 쿼터니언입니다.
한쪽을 곱한 중간 결과에는 여전히 w 성분이 있습니다. 하지만 오른쪽에서 q⁻¹까지 곱하면 w는 0이 되고, 최종 결과는 j + k = [0, (0, 1, 1)]입니다. xy 평면은 90° 돌아갔고, z 성분 1은 그대로 남았습니다. 우리가 원하던 결과와 일치하죠.
회전축 위에 있는 p = k만 넣어도 q k q⁻¹ = k로 그대로입니다. 축에 평행한 성분은 보존하고, 축에 수직인 평면만 돌린다는 3차원 회전의 조건을 만족합니다.
앞의 복소수에서는 길이 1인 수를 한 번 곱해 평면의 화살표를 돌렸습니다. 쿼터니언에서는 반각으로 만든 길이 1인 q를 사용해 양쪽에서 곱합니다. 눈에 보이는 물체는 3차원에서 회전하고, 네 숫자와 양쪽 곱셈은 그 회전을 계산하는 수단입니다.
마지막 세부사항 하나를 정리하겠습니다. 쿼터니언을 4×4 행렬 집합에 넣는 방법은 식 (8.11), (8.12)뿐이 아닙니다. McDonald[45]가 이를 자세히 다룹니다. 여기서는 이것도 qvq⁻¹이 필요한 근본 이유 중 하나라는 점만 짚겠습니다. 곱셈 한 번만 쓰면 대응 방식의 차이가 회전 결과의 차이를 만듭니다. 양쪽 곱셈이 있으면 한 방식에서 다른 방식으로 바꿀 때 왼쪽에서 생긴 변화가 오른쪽의 대응하는 변화로 정확히 상쇄됩니다.
[원주 14]: 보통은 행벡터를 쓰지만 여기서는 회전 적용 순서가 쿼터니언과 더 잘 맞는 오른쪽→왼쪽이 되도록 열벡터를 씁니다.
[원주 15]: 시계냐 반시계냐는 규약의 문제이지 복소수 자체에 내재한 것은 아닙니다. 식 (8.9)의 다른 b에 음수를 붙여도 유효한 복소수–행렬 대응이 된다는 것을 이미 눈치챘을 수 있습니다. 지금의 임의 선택은 잠시 뒤에 유용해집니다.
8.5.15 쿼터니언 정리
8.5절에는 수학이 많았지만 대부분을 외울 필요는 없습니다. 기억할 중요한 사실은 다음과 같습니다.
•
개념적으로 쿼터니언은 회전축과 그 축 중심의 회전량으로 각변위를 표현합니다.
•
스칼라 w와 벡터 v로 이루어지며 회전각 θ, 회전축 n̂과 w = cos(θ/2), v = n̂ sin(θ/2)의 관계가 있습니다.
•
모든 3차원 각변위에는 서로 부호가 반대인 표현이 정확히 두 개 있습니다.
•
각변위 없음을 뜻하는 항등 쿼터니언은 [1, 0]입니다.
•
각변위를 나타내는 모든 쿼터니언은 크기가 1인 단위 쿼터니언입니다.
•
켤레는 벡터 v의 부호를 반전하여 구하며 반대 각변위입니다. 역원은 켤레를 크기로 나눈 것입니다. 이 마지막 문장은 제공 PDF의 서술이며 일반식의 정확한 분모는 크기의 제곱입니다. 이 책처럼 단위 회전 쿼터니언만 쓰면 켤레와 역원은 같습니다.
•
곱셈으로 여러 회전을 하나로 합성합니다. 이론적으로는 3차원 벡터 회전도 가능하지만 직접적인 실용성은 적습니다.
•
거듭제곱으로 각변위의 배수를 계산합니다. 최종 결과는 정확하지만 쿼터니언이 최단 호를 택하므로 여러 바퀴는 표현할 수 없습니다.
•
4차원 복소수로 해석하면 수학과 기하 사이에 흥미롭고 우아한 대응이 생깁니다.
쿼터니언에 관해 여기서 다룬 것보다 훨씬 많은 내용이 쓰여 있습니다. Dam 등의 보고서[11]는 좋은 수학적 정리입니다. Kuiper의 책[41]은 항공우주 관점에서 쓰였으며 쿼터니언과 오일러 각의 연결을 잘 설명합니다. Hanson의 겸손한 제목을 가진 Visualizing Quaternions[30]는 리만 기하, 복소수, 리 대수, 이동 프레임 등 여러 분야의 도구로 분석하며 흥미로운 공학·수학 이야기도 담았습니다.
시각화 방법도 다룹니다. 더 짧은 시각화 설명은 Hart 등의 글[31]에서 볼 수 있습니다.
쉬운 보충 ㉓ · 게임에서 쓰는 흐름과 이해 점검
게임에서 쿼터니언을 다룰 때는 숫자 네 개를 직접 추측하기보다 원하는 회전을 먼저 말로 정하는 것부터 시작하면 좋습니다. ‘캐릭터를 위쪽 축으로 오른쪽 90° 돌린다’면 축–각에서 q를 만들고, ‘현재 자세에서 다른 회전을 추가한다’면 규약에 맞게 곱하며, ‘현재 카메라와 목표 카메라 사이를 부드럽게 잇는다’면 Slerp로 중간 자세를 구합니다.
에디터에 보이는 오일러 각은 사람이 자세를 입력하고 확인하기 편한 표현입니다. 쿼터니언은 회전을 합성하고 보간하기 편한 표현입니다. 월드 위치를 이동시키는 값과는 역할이 다릅니다. 필요에 따라 같은 자세를 행렬이나 오일러 각으로 바꾸는 방법은 8.7절로 연결됩니다.
스스로 확인해 보세요. ① 복소수 (2 + i)에 i를 곱하면 화살표의 좌표는 어떻게 바뀔까요? ② q가 오른쪽 90° 회전일 때 −q는 어느 회전일까요? ③ 90° 회전의 절반을 만들려면 q¹⁄²와 0.5q 중 무엇을 써야 할까요?
답과 이유: ① (−1, 2)입니다. i² = −1을 적용하면 −1 + 2i가 되어 90° 돌아갑니다. ② 같은 오른쪽 90° 회전입니다. 반대로 되돌리는 값은 q⁻¹입니다. ③ 이 예시에서는 q¹⁄²입니다. 0.5q는 회전각이 아니라 성분의 크기를 절반으로 줄입니다.
8.6 표현 방법 비교 (Comparison of Representations)
지금까지 살펴본 표현 방법들을 비교해 보겠습니다. 표 8.1은 각 방법의 차이를 정리합니다. 어느 하나가 모든 상황에 가장 좋은 것은 아닙니다. 필요한 연산과 사용 목적에 따라 선택이 달라집니다. 다음의 지침을 기준으로 어떤 표현이 적합한지 생각해 보세요.
•
사람이 다루기에는 오일러 각이 가장 쉽습니다. 세계 안에서 물체의 자세를 지정하는 사람과의 상호작용이 훨씬 간단해집니다. 키보드로 직접 입력하기, 렌더링용 카메라 배치처럼 코드에 직접 자세를 지정하기, 디버거에서 확인하기 등이 포함됩니다. 이 장점을 과소평가하지 말아 주세요. 효과가 확실하기 전에는 ‘최적화’라는 이름으로 사용 편의성을 희생하지 말아야 합니다.
•
벡터 좌표 공간 변환이 필요하면 결국 행렬이 필요합니다. 그렇다고 다른 형식으로 자세를 저장했다가 필요할 때 회전행렬을 만들 수 없다는 뜻은 아닙니다. 흔한 전략은 자세의 ‘원본’을 오일러 각이나 쿼터니언으로 저장하면서 회전용 행렬도 유지하고, 원본이 바뀔 때마다 행렬을 다시 계산하는 것입니다.
•
많은 자세를 저장할 때는 서로 다른 절충점이 있습니다. 애니메이션 같은 데이터에서 오일러 각과 지수 사상 성분은 대체로 쿼터니언보다 양자화하기 좋습니다. 회전 쿼터니언도 수 세 개에 저장할 수 있습니다. 네 번째 성분을 버리기 전에 부호를 검사하고 음수면 전체 쿼터니언의 부호를 바꿉니다. 이후 단위 길이를 가정하면 버린 성분을 복원할 수 있습니다.
•
안정적인 고품질 보간은 쿼터니언으로 수행합니다. 다른 형식을 사용하더라도 쿼터니언으로 변환하여 보간한 뒤 원래 형식으로 되돌릴 수 있습니다. 경우에 따라 지수 사상의 직접 보간도 실용적인 대안입니다. 특이점이 극단적인 자세에 있어 실제로 피하기 쉬운 경우가 많기 때문입니다.
•
각속도처럼 ‘추가 바퀴’를 표현해야 하면 지수 사상이나 축–각을 씁니다.
표 8.1: 행렬, 오일러 각, 지수 사상, 쿼터니언 비교.
작업·특성 | 행렬 | 오일러 각 | 지수 사상 | 쿼터니언 |
오브젝트 직립 공간 사이 점 회전 | 가능. SIMD로 크게 최적화할 수 있습니다. | 불가. 회전행렬로 변환해야 합니다. | 불가. 회전행렬로 변환해야 합니다. | 칠판에서는 가능. 컴퓨터의 실무에서는 회전행렬로 바꾸는 것과 다름없습니다. |
여러 회전 합성 | 가능. SIMD 최적화 가능. 행렬 크리프에 주의합니다. | 불가. | 불가. | 가능. 행렬 곱보다 스칼라 연산이 적지만 SIMD 활용은 덜 쉬울 수 있습니다. 누적 오차에 주의합니다. |
회전 역변위 | 전치로 쉽고 빠르게. | 쉽지 않습니다. | 벡터 부호 반전으로 쉽고 빠르게. | 켤레로 쉽고 빠르게. |
보간 | 매우 문제가 많습니다. | 가능하나 짐벌 락으로 이상 동작이 생깁니다. | 가능. 특이점이 있지만 오일러 각보다는 덜 성가시다. | slerp가 매끄러운 보간을 제공합니다. |
사람의 직접 해석 | 어렵습니다. | 가장 쉽습니다. | 매우 어렵습니다. | 매우 어렵습니다. |
메모리·파일 저장 효율 | 숫자 아홉 개. | 쉽게 양자화하는 숫자 세 개. | 쉽게 양자화하는 숫자 세 개. | 양자화하기 어려운 숫자 네 개. 넷째 성분이 음이 아니며 단위 길이라고 가정하면 세 개로 줄입니다. |
회전당 유일한 표현 | 그렇습니다. | 중복 표현 때문에 아니다. | 중복 표현 때문에 아니지만 오일러 각만큼 복잡하지 않습니다. | 정확히 두 표현이며 서로 부호가 반대입니다. |
유효하지 않게 될 가능성 | 직교행렬에 여섯 자유도의 중복이 있습니다. 행렬 크리프가 가능합니다. | 어떤 세 수도 모호함 없이 해석 가능합니다. | 어떤 세 수도 모호함 없이 해석 가능합니다. | 누적 오차가 가능합니다. |
원문 표현과 계산 조건
‘행렬로 바꿔야만 벡터를 회전할 수 있다’, ‘다른 표현은 매끄럽게 보간할 수 없다’는 원문의 문장은 저자의 구현 선호를 강하게 표현한 것입니다. 회전행렬을 실제로 저장하지 않는 쿼터니언 벡터 회전식도 있으며, 지수 사상 등의 보간법도 있습니다. 필요한 연산과 수치 조건을 기준으로 선택합니다.
8.7 표현 사이의 변환 (Converting Representations)
지금까지는 상황에 따라 어떤 자세 표현이 적합한지 살펴보았습니다. 이번 절에서는 실제로 한 표현을 다른 표현으로 바꾸는 방법을 설명합니다. 8.7.1절은 오일러 각에서 행렬로, 8.7.2절은 행렬에서 오일러 각으로 변환합니다. 이어 8.7.3절과 8.7.4절에서 쿼터니언과 행렬 사이의 변환을 다룹니다. 마지막으로 8.7.5절과 8.7.6절에서는 오일러 각과 쿼터니언 사이를 직접 변환합니다. 더 자세한 내용은 James Diebel의 논문[13]을 참고해 주세요.
8.7.1 오일러 각을 행렬로 변환하기
오일러 각은 세 번의 회전을 어떤 순서로 적용할지 정의합니다. 각 회전은 기본축을 중심으로 한 단순한 회전이므로, 개별 회전행렬을 만드는 것은 어렵지 않습니다. 세 행렬을 합성하면 전체 각변위를 나타내는 행렬을 얻습니다. 많은 책과 웹사이트가 이 과정을 설명합니다. 그런데 그런 자료를 읽고도 ‘이 행렬을 벡터에 곱하면 정확히 어떤 변환이 일어나는 걸까?’라는 의문이 남을 수 있습니다.
그 이유는 자료에서 오브젝트 공간에서 직립 공간으로 가는 변환인지, 반대 방향의 변환인지 명확히 설명하지 않는 경우가 있기 때문입니다. 실제로 필요한 행렬은 두 종류입니다. 서로 전치이므로 같은 정보를 담고 있지만, 벡터에 적용하는 방향은 다릅니다. 여기서는 두 행렬을 모두 구해 보겠습니다.
일부 독자[원주 16]는 너무 오래 설명한다고 생각할지 모릅니다. 이미 자료 속 행렬의 사용법을 알아내 지금은 당연하게 느낄 수도 있습니다. 하지만 너무 많은 프로그래머에게 걸림돌이 되었기에 강조하기로 했습니다. 흔한 예로 혼란을 설명하겠습니다.
물체들이 움직이는 일반적인 실시간 환경에서 각 물체의 자세를 오일러 각 상태 변수로 유지한다고 하겠습니다. 카메라도 물체 중 하나이고, 다른 물체와 같은 오일러 각 체계로 자세를 표현합니다. 어느 시점에는 이 기준틀들을 그래픽스 API에 전달해야 합니다. 바로 여기서 혼란이 생깁니다.
물체의 자세를 그래픽스 API에 전달할 때 사용하는 행렬과, 카메라의 자세를 처리할 때 사용하는 행렬은 같지 않습니다. API에는 두 종류의 변환이 필요합니다(10.3.1절에서 자세히 다룹니다). 모델 변환은 오브젝트 공간의 벡터를 월드 공간으로 옮기고, 뷰 변환은 월드 공간의 벡터를 카메라의 오브젝트 공간으로 옮깁니다.
모델 변환의 회전 부분은 오브젝트→직립, 뷰 변환의 회전 부분은 직립→오브젝트 행렬입니다. 따라서 그냥 ‘오일러 회전행렬’이라고만 말하면 중요한 실무 세부사항이 빠집니다.
먼저 오브젝트 공간의 점을 직립 공간으로 회전시키는 행렬을 유도하겠습니다. 5.1.2절의 기본 회전행렬을 사용할 텐데 이는 능동 변환 관점에서 유도했습니다. 능동·수동 변환 차이는 3.3.1절을 참고해 주세요. 물체 위의 임의의 점을 상상하겠습니다. 물체는 항등 자세로 시작합니다. 두 공간이 정렬되어 있으므로 우리가 아는 몸체 좌표는 이 순간 직립 좌표와 같습니다.
물체에 오일러 회전을 순서대로 적용하면 점이 움직여, 세 번째 회전 뒤에는 오일러 각이 나타내는 자세에 도달합니다. 그동안 측정에 쓰는 직립 좌표 공간은 고정되어 있습니다. 따라서 계산의 최종 결과는 임의의 자세에 놓인 점의 직립 좌표입니다.
마지막 주의점이 있습니다. 구성 요소로 쓰려는 기본 행렬은 각각 기본축 중심의 회전입니다. 오일러 각의 회전축은 첫 회전 뒤에는 임의의 방향이 되는 몸체 축입니다. 그래서 몸체 축 중심의 오일러 회전 대신 직립 축 중심의 고정축 회전을 합니다. 실제 순서는 거꾸로 뱅크→피치→헤딩입니다. 고정축 회전은 8.3.2절을 떠올려 보겠습니다.
따라서 세 기본 행렬을 단순 합성하면 됩니다.
B, P, H는 각각 z, x, y축 중심의 뱅크, 피치, 헤딩 회전행렬입니다. 5.1.2절에서 구한 식은 다음과 같습니다.
복잡한 실제 행렬 곱의 전개를 생략하고 결과를 쓰면
여기서 c_h = cos h, c_p = cos p, c_b = cos b, s_h = sin h, s_p = sin p, s_b = sin b라는 축약을 도입했습니다.
직립 공간에서 오브젝트 공간으로 회전하려면 이 행렬의 역행렬을 씁니다. 회전행렬이 직교행렬이므로 전치가 역행렬임은 알지만 직접 확인해 보겠습니다. 고정축 회전을 되돌린다고 상상합니다. 헤딩, 피치, 뱅크를 차례로 취소합니다. 전처럼 물체와 점은 움직이고 모든 측정은 직립 좌표로 합니다. 차이는 이번에는 직립 좌표에서 시작한다는 것입니다. 끝나면 몸체 축과 직립 축이 정렬되며 결과는 오브젝트 공간 좌표입니다.
식 (8.14)와 (8.15)를 비교하면 예상대로 서로 전치입니다. 또한 H⁻¹, P⁻¹, B⁻¹은 원래 행렬의 역행렬로도, 반대 각도를 사용한 일반 회전행렬로도 생각할 수 있습니다.
[원주 16]: 아마 이미 아는 독자일 것입니다. 어이, 어린이 풀에서는 뭘 하고 있는가? 지금쯤 다이빙대에서 뒤로 공중제비라도 돌아야 하는 것 아닌가?
8.7.2 행렬을 오일러 각으로 변환하기
몇 가지를 고려해야 합니다.
•
행렬이 오브젝트→직립인지 직립→오브젝트인지 알아야 합니다. 여기서는 전자를 사용합니다. 두 행렬이 서로 전치이므로 후자도 과정이 매우 비슷합니다.
•
중복 표현 때문에 주어진 각변위의 오일러 각 표현은 무한히 많다(8.3.4절). 여기서는 헤딩과 뱅크 ±180°, 피치 ±90°의 표준형을 항상 반환합니다.
•
행렬 형식이 조금 어긋날 수 있으므로 부동소수점 정밀도 오차에 관대해야 합니다. 그러나 스케일, 반사, 기울이기 등 회전 외 변환을 담은 행렬도 있습니다. 이 방법은 정상적인 회전행렬에만 적용하며, 보통의 부동소수점 오차 정도는 허용해도 직교성에서 크게 벗어나면 안 됩니다. 비직교행렬에 적용한 결과는 예측할 수 없습니다.
식 (8.14)에서 직접 각도를 구하겠습니다. 편의를 위해 전개한 행렬을 다시 씁니다.
m₃₂에서 p를 바로 구합니다.
C 표준 라이브러리 asin()은 라디안 [−π/2, +π/2], 즉 [−90°, +90°]를 반환합니다. 표준 피치 범위와 정확히 같습니다.
p를 아니 cos p도 압니다. 먼저 cos p ≠ 0이라 하겠습니다. −90° ≤ p ≤ +90°이므로 cos p > 0입니다. m₃₁과 m₃₃을 cos p로 나누면 sin h, cos h를 얻습니다. 제공 PDF의 설명 문장에는 m₁₃이라고 되어 있으나 식 (8.16)의 m₃₁이 맞습니다.
사인과 코사인을 알면 C의 atan2()로 각도를 구합니다. 반환 범위 [−π, +π], 즉 [−180°, +180°] 역시 원하는 범위입니다. 이 범위 전체에서 각도를 유일하게 정하려면 사인이나 코사인 하나만으로는 부족하므로 asin()이나 acos()만 쓸 수는 없습니다. 대입하면
atan2(y,x)는 y/x의 아크탄젠트와 두 인수의 부호로 사분면을 고릅니다. cos p > 0으로 나눠도 부호와 비가 바뀌지 않으므로 불필요한 나눗셈을 생략할 수 있습니다.
뱅크도 m₁₂와 m₂₂에서 같은 방식으로 구합니다.
세 각도를 모두 얻었습니다. 하지만 cos p = 0이면 위 방식은 0으로 나누므로 쓸 수 없습니다. 그때 p = ±90°이므로 정확히 위나 아래를 봅니다. 헤딩과 뱅크가 사실상 같은 물리적 수직축을 중심으로 회전하는 짐벌 락입니다. 수학적 특이점과 기하적 특이점이 동시에 생깁니다.
이때 수직축 회전을 모두 헤딩에 맡기고 뱅크를 0으로 정합니다. 이제 피치와 뱅크는 아니까 헤딩만 구하면 됩니다. cos p = 0, b = 0, sin b = 0, cos b = 1을 식 (8.14)에 넣으면 다음처럼 단순해집니다.
따라서 각각 헤딩의 사인과 코사인인 −m₁₃, m₁₁로 h를 구합니다. 목록 8.4는 이 기법으로 오브젝트→직립 행렬에서 오일러 각을 추출합니다.
// 입력 행렬이 다음 변수에 저장되어 있다고 가정한다.
float m11, m12, m13;
float m21, m22, m23;
float m31, m32, m33;
// 라디안 단위의 출력 오일러 각
float h, p, b;
// m32에서 피치를 추출한다. 부동소수점 계산 때문에 약간 범위를
// 벗어날 수 있으므로 asin()의 정의역 오류를 조심한다.
float sp = -m32;
if (sp <= -1.0f) {
p = -1.570796f; // -pi/2
} else if (sp >= 1.0f) {
p = 1.570796f; // pi/2
} else {
p = asin(sp);
}
// 수치 오차를 조금 허용하며 짐벌 락을 검사한다.
if (fabs(sp) > 0.9999f) {
// 정확히 위나 아래를 본다. 뱅크를 0으로 하고 헤딩만 구한다.
b = 0.0f;
h = atan2(-m13, m11);
} else {
// m31과 m33에서 헤딩을 구한다.
h = atan2(m31, m33);
// m12와 m22에서 뱅크를 구한다.
b = atan2(m12, m22);
}
C++
복사
목록 8.4: 오브젝트→직립 행렬에서 오일러 각 추출하기. 원문 코드 주석의 m13/m21 오기는 실제 코드와 일치하도록 각각 m31/m12로 명시적으로 교정했습니다.
8.7.3 쿼터니언을 행렬로 변환하기
쿼터니언을 행렬로 바꾸는 방법은 여러 가지입니다. 흔히 사용하는 방법은 qvq⁻¹을 전개하는 것입니다. 계산하면 올바른 행렬이 나오고, 쿼터니언을 조작하는 과정도 연습할 수 있습니다(연습문제 10). 다만 결과가 왜 회전을 나타내는지 기하학적으로 이해하기는 어려울 수 있습니다. 여기서는 성분의 기하학적 의미에서 출발하겠습니다. 쿼터니언은 본질적으로 축–각 회전을 표현하므로, 5.1.3절의 임의축 회전행렬과 연결해서 유도할 수 있습니다.
이 행렬은 n̂, θ로 쓰였지만 쿼터니언 성분은 다음과 같습니다.
w, x, y, z를 대입할 수 있는 형태로 행렬을 바꿔 보겠습니다. 아홉 원소 모두에 필요하지만 행렬 구조 덕분에 큰 경우는 대각과 비대각 둘뿐입니다.
유도가 까다롭지만 행렬을 사용하는 데 유도 과정을 이해할 필요는 없다. 수학에 관심이 없다면 식 (8.20)으로 건너뛰어도 된다.
대각부터 시작하겠습니다. m₁₁을 다루며 m₂₂와 m₃₃은 비슷하게 구합니다.
먼 길처럼 보이는 몇 조작을 먼저 합니다. 목적은 잠시 뒤에 드러납니다.
cos θ를 없애고 쿼터니언 성분에 있는 cos(θ/2)나 sin(θ/2)로 바꾸고 싶습니다. α = θ/2로 놓고 1.4.5절의 코사인 배각 공식을 쓰면
대입하면
n̂이 단위벡터이므로 n_x² + n_y² + n_z² = 1, 따라서 1 − n_x² = n_y² + n_z²를 사용합니다.
m₂₂와 m₃₃도 같은 방식으로 유도하며 결과는 절 끝의 식 (8.20)에 제시합니다.
비대각 원소는 더 쉽습니다. m₁₂를 예로 보겠습니다.
사인 배각 공식을 거꾸로 쓴다(1.4.5절).
식 (8.17), (8.19)를 (8.18)에 대입하고 정리합니다.
다른 비대각 원소도 비슷합니다. 최종 회전행렬은 다음과 같습니다.
다른 자료[원주 17]에는 변형된 식도 있습니다. 원문에는 w² + x² + y² + z² = 1이므로 m11 = −1 + 2w² + 2z²도 된다고 쓰여 있습니다. 보충 정정: 마지막 z²는 x²여야 하며, 올바른 대체식은 m11 = −1 + 2w² + 2x²다. 저자들은 컴퓨터 그래픽스 분야에 쿼터니언을 알린 Shoemake에게 경의를 표하여, 초기의 권위 있는 자료[62]와 같은 결과가 나오도록 유도 방식을 맞췄습니다.
[원주 17]: 이 책의 1판[16]도 포함됩니다.
원문 표현과 계산 조건
8.4절의 지수 사상 일대일 범위, 8.5.6절 역원 분모, 8.5.14절 복소수 전개 부호, 8.7.3절 대각 원소의 대체식 오타는 각 번역 위치에도 별도 보충으로 명시했습니다.
8.7.4 행렬을 쿼터니언으로 변환하기
식 (8.20)을 거꾸로 풀어 쿼터니언을 추출합니다. 대각 원소의 합인 대각합(trace)을 보면
따라서
나머지 셋도 대각합의 세 항 중 둘의 부호를 바꾸어 비슷하게 구합니다.
안타깝게도 네 성분 모두에 이 방법을 쓸 수는 없습니다. 제곱근은 항상 양의 결과를 주기 때문입니다. 더 정확히는 양의 근과 음의 근 중 무엇을 고를 근거가 없습니다. 하지만 q와 −q가 같은 자세이므로 네 성분 중 하나는 임의로 음이 아닌 근을 골라도 언제나 올바른 쿼터니언을 얻을 수 있습니다. 네 값 모두에 쓸 수 없을 뿐입니다.
다른 접근은 대각선 맞은편 원소의 합과 차이를 살펴보는 것입니다.
이 식들로 두 단계 전략을 세웁니다. 먼저 대각 성분의 식으로 네 성분 중 하나를 구합니다. 다음 그 값을 식 (8.27)~(8.32)에 넣어 나머지 셋을 구합니다. 본질적으로 표 8.2의 한 행을 골라 왼쪽에서 오른쪽으로 푸는 것입니다.
그렇다면 표의 어느 행을 선택해야 할까요? 다시 말해, 네 성분 중 무엇을 먼저 구하는 것이 좋을까요? 하나를 임의로 선택해 항상 같은 절차를 사용하는 방법에는 문제가 있습니다. 예를 들어 첫 행을 사용하면 대각합에서 w를 구한 뒤 나머지 세 성분을 구합니다. 그런데 w = 0이면 뒤의 나눗셈을 정의할 수 없습니다. w > 0이어도 값이 매우 작으면 수치적으로 불안정해집니다.
Shoemake[62]는 제곱근을 계산하지 않고도 알 수 있는 절댓값이 가장 큰 성분을 먼저 정한 뒤, 대각 원소로 그 성분을 구하고 표 8.2에 따라 나머지를 구하라고 제안합니다.
표 8.2: 회전행렬에서 쿼터니언 추출하기.
먼저 구할 성분 | 그다음 구할 세 성분 |
w = √(m₁₁+m₂₂+m₃₃+1)/2 | x=(m₂₃−m₃₂)/(4w), y=(m₃₁−m₁₃)/(4w), z=(m₁₂−m₂₁)/(4w) |
x = √(m₁₁−m₂₂−m₃₃+1)/2 | w=(m₂₃−m₃₂)/(4x), y=(m₁₂+m₂₁)/(4x), z=(m₃₁+m₁₃)/(4x) |
y = √(−m₁₁+m₂₂−m₃₃+1)/2 | w=(m₃₁−m₁₃)/(4y), x=(m₁₂+m₂₁)/(4y), z=(m₂₃+m₃₂)/(4y) |
z = √(−m₁₁−m₂₂+m₃₃+1)/2 | w=(m₁₂−m₂₁)/(4z), x=(m₃₁+m₁₃)/(4z), y=(m₂₃+m₃₂)/(4z) |
목록 8.5는 이 전략을 직접 구현합니다.
// 입력 행렬
float m11, m12, m13;
float m21, m22, m23;
float m31, m32, m33;
// 출력 쿼터니언
float w, x, y, z;
// w, x, y, z 중 절댓값이 가장 큰 것을 찾는다.
float fourWSquaredMinus1 = m11 + m22 + m33;
float fourXSquaredMinus1 = m11 - m22 - m33;
float fourYSquaredMinus1 = m22 - m11 - m33;
float fourZSquaredMinus1 = m33 - m11 - m22;
int biggestIndex = 0;
float fourBiggestSquaredMinus1 = fourWSquaredMinus1;
if (fourXSquaredMinus1 > fourBiggestSquaredMinus1) {
fourBiggestSquaredMinus1 = fourXSquaredMinus1;
biggestIndex = 1;
}
if (fourYSquaredMinus1 > fourBiggestSquaredMinus1) {
fourBiggestSquaredMinus1 = fourYSquaredMinus1;
biggestIndex = 2;
}
if (fourZSquaredMinus1 > fourBiggestSquaredMinus1) {
fourBiggestSquaredMinus1 = fourZSquaredMinus1;
biggestIndex = 3;
}
// 제곱근과 나눗셈을 수행한다.
float biggestVal = sqrt(fourBiggestSquaredMinus1 + 1.0f) * 0.5f;
float mult = 0.25f / biggestVal;
// 표에 따라 쿼터니언 성분을 계산한다.
switch (biggestIndex) {
case 0:
w = biggestVal;
x = (m23 - m32) * mult;
y = (m31 - m13) * mult;
z = (m12 - m21) * mult;
break;
case 1:
x = biggestVal;
w = (m23 - m32) * mult;
y = (m12 + m21) * mult;
z = (m31 + m13) * mult;
break;
case 2:
y = biggestVal;
w = (m31 - m13) * mult;
x = (m12 + m21) * mult;
z = (m23 + m32) * mult;
break;
case 3:
z = biggestVal;
w = (m12 - m21) * mult;
x = (m31 + m13) * mult;
y = (m23 + m32) * mult;
break;
}
C++
복사
목록 8.5: 회전행렬을 쿼터니언으로 변환하기.
8.7.5 오일러 각을 쿼터니언으로 변환하기
8.7.1절에서 행렬을 만들 때와 비슷한 기법을 씁니다. 세 회전을 개별 쿼터니언으로 바꾸는 간단한 작업을 한 뒤 올바른 순서로 합성합니다. 행렬처럼 오브젝트→직립과 직립→오브젝트 두 경우를 고려해야 합니다. 서로 켤레이므로 전자만 유도합니다.
오일러 각을 h, p, b라고 하고 y, x, z축 중심 회전 쿼터니언을 각각 굵은 h, p, b라고 하겠습니다.
올바른 순서로 합성합니다. ‘거꾸로’가 되는 이유가 두 가지여서 서로 상쇄됩니다. 고정축 회전을 사용하므로 실제 회전 순서는 뱅크, 피치, 헤딩입니다. 하지만 쿼터니언 곱은 오른쪽에서 왼쪽으로 적용합니다. 첫 이유는 8.7.1절, 둘째는 8.5.7절을 참고해 주세요.
직립→오브젝트 쿼터니언은 단순히 그 켤레입니다.
8.7.6 쿼터니언을 오일러 각으로 변환하기
식 (8.33)을 직접 풀 수도 있지만 앞 절의 결과를 활용하면 노력을 줄일 수 있습니다. 8.7.2절에서 행렬에서 오일러 각을 추출했고 쿼터니언을 행렬로 바꾸는 방법도 압니다. 따라서 행렬→오일러 각 기법에 식 (8.20)을 대입하겠습니다.
오브젝트→직립 행렬에서 추출하는 방법을 다시 정리하면
식 (8.20)에서 필요한 원소는
대입하고 단순화하면 다음과 같습니다.
이를 그대로 코드로 옮긴 목록 8.6은 오브젝트→직립 쿼터니언을 오일러 각으로 바꿉니다.
// 입력 쿼터니언
float w, x, y, z;
// 라디안 단위의 출력 오일러 각
float h, p, b;
// sin(pitch)를 추출한다.
float sp = -2.0f * (y*z - w*x);
// 수치 오차를 조금 허용하며 짐벌 락을 검사한다.
if (fabs(sp) > 0.9999f) {
// 정확히 위나 아래를 본다.
p = 1.570796f * sp; // pi/2
// 헤딩을 구하고 뱅크는 0으로 한다.
h = atan2(-x*z + w*y, 0.5f - y*y - z*z);
b = 0.0f;
} else {
// 각도를 구한다.
p = asin(sp);
h = atan2(x*z + w*y, 0.5f - x*x - y*y);
b = atan2(x*y + w*z, 0.5f - x*x - z*z);
}
C++
복사
목록 8.6: 오브젝트→직립 쿼터니언을 오일러 각으로 변환하기.
직립→오브젝트 쿼터니언은 거의 같은 코드를 쓰되 x, y, z의 부호만 뒤집습니다. 두 쿼터니언이 서로 켤레라고 가정하기 때문입니다.
// sin(pitch)를 추출한다.
float sp = -2.0f * (y*z + w*x);
// 수치 오차를 조금 허용하며 짐벌 락을 검사한다.
if (fabs(sp) > 0.9999f) {
// 정확히 위나 아래를 본다.
p = 1.570796f * sp; // pi/2
// 헤딩을 구하고 뱅크는 0으로 한다.
h = atan2(-x*z - w*y, 0.5f - y*y - z*z);
b = 0.0f;
} else {
// 각도를 구한다.
p = asin(sp);
h = atan2(x*z - w*y, 0.5f - x*x - y*y);
b = atan2(x*y - w*z, 0.5f - x*x - z*z);
}
C++
복사
목록 8.7: 직립→오브젝트 쿼터니언을 오일러 각으로 변환하기.
쿼터니언 구현에서 확인할 조건
목록 8.6·8.7의 짐벌 락 분기 p = (π/2) * sp는 원문 그대로이나, sp가 수치 오차로 ±1을 넘으면 표준 범위를 벗어날 수 있습니다. 경계 자세로 처리하려는 의도라면 p = copysign(π/2, sp)처럼 부호만 사용합니다. 큰 오차가 있다면 입력의 단위 길이부터 검사해야 합니다.
8.8 연습문제 (Exercises)
정답은 인쇄본 772쪽에 있습니다.
그림 8.13. 연습문제 1, 2, 4, 5에 사용하는 자세 예
그림 8.13. 연습문제 1, 2, 4, 5에 사용하는 자세 예
연습문제 1
다음 회전행렬을 그림 8.13의 대응하는 자세와 연결해 보세요. 이 행렬들은 왼쪽의 행벡터를 오브젝트 공간에서 직립 공간으로 변환합니다.
연습문제 2
다음 오일러 각 삼중항을 그림 8.13의 대응하는 자세와 연결하고, 표준 오일러 각 집합에 속하는지 판단해 보세요. 아니라면 이유를 설명해 보세요.
항목 | h | p | b |
(a) | 180° | 45° | 180° |
(b) | −135° | −45° | 0° |
(c) | 0° | −90° | −45° |
(d) | 123° | 33.5° | −32.7° |
(e) | 0° | 0° | 0° |
(f) | 0° | 135° | 0° |
(g) | −45° | −90° | 0° |
(h) | 180° | −180° | 180° |
(i) | −30° | 30° | 70° |
연습문제 3
(a) x축을 중심으로 30° 회전하는 쿼터니언을 만들어 보세요. (b) 그 크기는 얼마인가요? (c) 켤레는 무엇인가요? (d) 이 쿼터니언이 물체의 오브젝트 공간 점을 직립 공간으로 회전시킨다고 가정하겠습니다. 그 물체의 자세를 오일러 각으로 나타내 보세요.
연습문제 4
다음 쿼터니언을 그림 8.13의 대응하는 자세와 연결해 보세요. 각각 오브젝트 공간의 벡터를 직립 공간으로 변환하는 쿼터니언입니다. 숫자만 보고 자세를 읽어 내기는 쉽지 않다는 것을 느낄 수 있을 것입니다. 먼저 행렬이나 오일러 각으로 바꿔서, 앞에서 구한 결과를 활용해 보세요.
항목 | [w ; (x, y, z)] |
(a) | [−1.000 ; (0.000, 0.000, 0.000)] |
(b) | [0.653 ; (−0.653, −0.271, −0.271)] |
(c) | [0.364 ; (−0.106, 0.848, −0.372)] |
(d) | [0.383 ; (0.924, 0.000, 0.000)] |
(e) | [1.000 ; (0.000, 0.000, 0.000)] |
(f) | [−0.364 ; (0.106, −0.848, 0.372)] |
(g) | [0.354 ; (−0.146, −0.853, −0.354)] |
(h) | [0.726 ; (0.061, −0.348, 0.590)] |
(i) | [−0.383 ; (−0.924, 0.000, 0.000)] |
연습문제 5
다음 축–각 자세를 그림 8.13의 대응하는 자세와 연결해 보세요. 힌트: 축–각 표현만 보고 자세를 머릿속에 그리기는 꽤 어렵습니다. 먼저 쿼터니언으로 변환하고 앞에서 구한 결과를 활용해 보세요. 그런 다음 어떤 자세인지 시각화해 보세요.
항목 | 각도 | 축 |
(a) | 98.4° | [−0.863, −0.357, −0.357] |
(b) | 0° | [0.707, −0.707, 0.000] |
(c) | 87.0° | [0.089, −0.506, 0.857] |
(d) | 137° | [−0.114, 0.910, 0.399] |
(e) | 135° | [1.000, 0.000, 0.000] |
(f) | 261.6° | [0.863, 0.357, 0.357] |
(g) | 139° | [−0.156, −0.912, −0.378] |
(h) | 7200° | [0.000, −1.000, 0.000] |
(i) | −135° | [−1.000, 0.000, 0.000] |
연습문제 6
쿼터니언을 4차원 복소수로 해석하고 식 (8.10)의 규칙을 적용하여 곱셈 공식을 유도해 보세요.
연습문제 7
쿼터니언 [0.965 ; (0.149, −0.149, 0.149)]의 회전을 두 배로 수행하는 쿼터니언을 계산해 보세요.
연습문제 8
a = [0.233 ; (0.060, −0.257, −0.935)], b = [−0.752 ; (0.286, 0.374, 0.459)]에 대해 (a) 내적 a·b, (b) 곱 ab, (c) a에서 b로의 차이를 구해 보세요.
연습문제 9
8.5.7절의 주장, ‘두 쿼터니언 곱의 크기는 두 크기의 곱과 같다’를 증명해 보세요.
연습문제 10
qvq⁻¹을 전개하여 식 (8.20)의 행렬이 맞는지 검증해 보세요.
연습문제 11
게임 엔진과 오픈소스 코드를 몇 개 조사하여 행·열벡터, 회전행렬, 오일러 각에 어떤 규약을 쓰는지 알아보세요.
나는 목걸이를 한다. 내가 거꾸로 되어 있을 때 알고 싶으니까. — 미치 헤드버그(1968–2005)
직접 확인하기
헤딩·피치·뱅크를 바꾸고 짐벌 락 부근에서 축이 겹치는 모습을 비교해 보세요.
Fletcher Dunn · Ian Parberry, 《3D Math Primer for Graphics and Game Development, 2nd Edition》, 8장. 원서 그림과 자료.


















직립 공간 사이 점 회전