3D에서 “어느 쪽을 보고 있는가”라는 자세는 하나인데 숫자로 적는 방법이 셋이에요. 우열 관계가 아니라 용도가 다릅니다. 사람이 읽을 때, 벡터에 적용할 때, 저장하고 보간할 때 각각 유리한 표기가 정해져 있어요.
| 표현 | 숫자 | 강점 | 약점 | 쓰는 자리 |
|---|---|---|---|---|
| 오일러각 | 3개 | 사람이 직관적으로 읽음 | 짐벌락, 순서 혼란 | 로그, 파라미터 입력 |
| 회전행렬 | 9개 | 적용·합성이 행렬곱 | 눈으로 못 읽음 | 변환 계산 내부 |
| 쿼터니언 | 4개 | 특이점 없음, 보간 안정 | 직관 없음 | 저장·통신·보간 |
오일러각은 회전을 세 축 회전의 합으로 쪼개 적어요. 좌우로 도는 요, 앞뒤로 숙이는 피치, 옆으로 기우는 롤. 사람 머릿속 모델과 같아서 “요 90도”라고 하면 바로 그림이 그려져요.
회전행렬은 벡터에 곱하면 회전된 벡터가 나와요. 회전 둘을 이어 하는 것도 행렬곱 하나로 끝나고, 역회전은 역행렬 대신 전치로 얻어요. 회전행렬이 직교행렬이라 둘이 같거든요.
// z축 회전 — 평면 성분만 섞이고 z는 그대로
double xr = x * std::cos(th) - y * std::sin(th);
double yr = x * std::sin(th) + y * std::cos(th);라이다가 몸통에 30도 틀어져 달려 있으면, 라이다가 잰 점을 몸통 기준으로 옮길 때 이 곱을 점마다 해요. 센서 좌표의 점을 로봇 좌표로 돌린다가 로봇 코드에서 회전행렬이 하는 일의 대부분이에요.
오일러각의 문제는 두 겹이에요
첫째는 순서예요. 오일러각 세 숫자는 세 축 회전을 순서대로 적용한 결과인데, 그 순서가 표기에 안 적혀 있어요. 어느 축부터 도는지, 고정된 세계 축 기준인지 매번 회전된 몸통 축 기준인지에 따라 같은 세 숫자가 다른 자세가 되고, 반대로 같은 자세가 소스마다 다른 숫자로 적혀요.
센서 제조사 문서, 시뮬레이터, 논문이 서로 다른 관례를 써서 “그쪽 순서가 뭐냐”가 실제로 오가는 질문이에요. ROS는 표준 문서로 못 박아 뒀지만, 외부와 값을 주고받는 순간 확인이 필수예요. 게임 엔진 중에는 왼손 좌표계에 y가 위인 것도 있어서 축 자체가 다릅니다.
둘째가 짐벌락이에요. 피치가 ±90도가 되면 롤 축과 요 축이 같은 방향으로 겹쳐요. 세 개의 손잡이 중 둘이 같은 문을 여는 셈이라, 3자유도를 적겠다고 만든 표기가 그 지점에서 2자유도밖에 못 적어요. 겹친 상태 근처에서는 자세가 조금만 변해도 숫자가 크게 널뛰고, 오일러각으로 자세를 적분하거나 보간하던 코드는 여기서 튀어요.
로봇에서 실제로 만나는 자리는 정해져 있어요. 바닥을 달리는 주행 로봇은 피치가 90도 갈 일이 없어 안전하지만, 팔의 손목이 수직을 향할 때, 짐벌 카메라가 바로 아래를 볼 때가 정확히 그 지점이에요. 쿼터니언에서 피치를 뽑는 공식에 아크사인이 들어가는데 인자가 ±1 근처면 수치가 불안정해지는 것도 같은 현상의 코드 쪽 얼굴이에요.
그래서 규칙은 한 줄이에요. 자세를 저장·통신·연산하는 경로에는 오일러각을 두지 않고, 사람이 보는 마지막 순간에만 변환해요. “오일러는 UI, 쿼터니언은 데이터”로 기억하면 됩니다.
쿼터니언은 규칙 다섯 개면 써요
수학적 정체는 몰라도 되고 실전 규칙만 알면 로봇 코드를 읽고 쓸 수 있어요.
저장 순서. 메시지 타입은 x·y·z·w 순서로 w가 마지막인데, 수학 교재와 일부 라이브러리는 w를 앞에 써요. 라이브러리를 오갈 때 순서 사고가 나요. 항등 회전은 (0,0,0,1)이고, 축 u로 θ만큼 도는 회전은 (u·sin(θ/2), cos(θ/2))인데 절반 각이 들어간다는 게 놓치기 쉬운 부분이에요.
// yaw만 있는 평면 자세 → 쿼터니언. 절반 각에 주의
Quat q{0.0, 0.0, std::sin(yaw / 2.0), std::cos(yaw / 2.0)}; // (x, y, z, w)곱이 곧 합성. 회전을 이어 하는 게 곱 하나예요. 먼저 하는 회전이 오른쪽이고 순서를 바꾸면 결과가 달라요. 관성 센서가 준 자세에 장착 오프셋을 합성해 몸통 자세를 얻는 자리, 접근 자세에 “손목을 90도 비틀어서”를 얹는 자리가 전부 이 곱이에요.
정규화가 필수. 회전을 뜻하는 쿼터니언은 크기가 정확히 1이어야 해요. 곱을 거듭하면 부동소수 오차가 쌓여 1에서 벗어나고, 그 상태로 벡터를 돌리면 회전이 아니라 크기까지 바꾸는 변환이 돼요. 프레임워크들이 크기가 벗어난 값을 받으면 경고나 거부로 답해요. 곱 몇 번마다, 그리고 외부에서 받은 값은 믿지 말고 한 번 정규화하는 습관이 방어선이에요.
켤레가 역회전. 벡터 부호만 뒤집으면 단위 쿼터니언에서는 역원이라, 비싼 계산 없이 반대로 돌리기를 얻어요. 목표 자세와 현재 자세의 차이를 구할 때 쓰는 조합이 자세 제어의 기본형이에요.
yaw 뽑기. 평면 로봇은 헤딩 하나만 필요한 때가 많아요.
double yaw = std::atan2(2.0 * (q.w * q.z + q.x * q.y),
1.0 - 2.0 * (q.y * q.y + q.z * q.z));아크사인이 들어가는 피치 추출과 달리 이건 전 구간에서 안정적이고 결과가 처음부터 −π에서 π 범위로 나와요.
상식 하나 더. 부호를 전부 뒤집은 쿼터니언은 같은 회전이에요. 두 쿼터니언을 성분으로 비교하다 “숫자는 다른데 자세는 같은” 상황을 만나면 이걸 떠올리면 돼요.
359도 빼기 1도는 2도
각도는 한 바퀴마다 같은 방향으로 되돌아오는데 숫자는 그냥 커져요. 이 틈에서 나는 사고가 각도 계산 버그의 대부분이에요.
헤딩 359도인 로봇에게 1도를 향하라고 하면, 오차를 그냥 빼는 코드는 −358도를 내놓고 제어기는 시계 방향으로 거의 한 바퀴를 돌아요. 실제로는 반시계 2도면 되는데요.
// 임의의 각도를 [-π, π) 범위로 정규화
double wrap_angle(double a) {
a = std::fmod(a + PI, 2.0 * PI); // [-2π, 2π) 사이 어딘가로
if (a < 0) a += 2.0 * PI; // [0, 2π)로 올림
return a - PI; // [-π, π)로 내림
}
// from에서 to까지의 최단 회전량. +면 반시계, -면 시계
double angle_diff(double from, double to) { return wrap_angle(to - from); }나머지 연산이 부호를 보존해서 음수 입력에 한 번 더 보정이 필요하고 그게 가운데 줄이에요. 삼각함수를 한 바퀴 태우는 방법도 같은 일을 하는데, 조금 느리지만 경계 처리 실수가 원천적으로 없어서 성능이 급하지 않은 자리에는 그쪽도 좋아요.
이 두 함수가 필요한 자리는 생각보다 많아요. 헤딩 오차를 만드는 모든 제어 루프, 누적 각을 자세로 바꾸는 오도메트리 — 틱에서 좌표까지, 그리고 드리프트, 목표 방향 도달 판정, 각도의 이동 평균까지요. 각도끼리 빼기·비교·평균이 나오는 코드에서 랩핑이 안 보이면 일단 의심하는 게 맞아요.
자세의 중간은 직선 보간이 아니에요
두 자세 사이의 중간이 필요한 자리가 있어요. 궤적의 웨이포인트 사이를 잇거나 그리퍼가 접근 자세에서 파지 자세로 넘어가는 과정을 잘게 쪼갤 때요. 위치는 직선 보간이면 되는데 자세는 사정이 달라요.
쿼터니언 성분을 그냥 선형 보간하면 중간값의 크기가 1에서 벗어나요. 단위 쿼터니언은 4차원 구면 위의 점인데, 구면 위 두 점을 직선으로 이으면 현이 구면 안쪽을 지나가거든요. 정규화로 크기는 되돌릴 수 있지만, 그래도 매개변수가 일정하게 흘러도 회전 속도가 일정하지 않은 문제가 남아요. 구간 가운데에서 빨라졌다 느려지는 왜곡이에요.
구면 선형 보간은 최단 호를 따라 일정한 각속도로 가요. 매개변수를 균일하게 주면 회전도 균일하게 진행되니 자세 보간의 정답이에요. 직접 구현할 일은 거의 없고 라이브러리 함수를 부르면 되는데, 알아둘 건 선택 기준이에요. 각도 차가 몇 도 이내로 작으면 정규화한 선형 보간도 사실상 구분이 안 가지만, 로봇 궤적처럼 큰 각을 쪼개는 자리는 구면 보간이 기본이에요.
메시지 기본값이 지뢰예요
자세가 실려 오는 메시지는 세 갈래예요. 쿼터니언 원자 타입이 자세 메시지와 변환 메시지에 박혀서 오도메트리 토픽과 좌표 트리를 흘러다녀요. TF2 좌표변환 — lookup 한 줄 밑의 수학의 변환에서 회전부가 바로 이거예요.
메시지 타입 자체에는 연산이 없어서 계산할 때는 전용 타입으로 옮겨요. 왕복 변환 함수가 있고, 오일러각으로 만드는 함수와 요를 뽑는 함수가 앞 절 공식을 대신해 줘요.
함정 하나만 상기할게요. 메시지 쿼터니언을 기본 생성하면 네 필드가 전부 0인데, 이건 크기가 0이라 회전이 아니에요. 항등 자세를 보내려면 w를 1로 손수 넣어야 해요. 목표 자세를 채우다 방향을 잊으면 “길이가 0에 가깝다”며 거부당하는, 입문 단계에서 한 번씩 밟는 지뢰예요. 외부에서 받은 값은 한 번 정규화한다는 규칙이 이 지뢰의 방어선이기도 해요.
회전은 결국 세 표기를 자리에 맞게 오가는 것이 전부예요. 저장과 통신은 쿼터니언, 오일러는 표시용으로만, 쿼터니언은 정규화를 유지, 각도끼리의 연산은 항상 랩핑 위에서.