본문으로 건너뛰기
뒤로가기

[Emberlit] Camera: worldUp hint로 right를 복원할 수 있는 이유

TL;DRlookAt 함수의 worldUp hint는 “대충 비슷한 방향”을 주는 것이 아닙니다. roll이 없는 카메라 모델에서 cross(forward, worldUp)을 정규화한 값은 수학적으로 정확한 right 벡터와 일치합니다. pitch가 외적 결과의 크기만 스케일링하고 방향은 바꾸지 않기 때문입니다.

Table of contents

Open Table of contents

들어가며

카메라를 만들 때 glm::lookAt(eye, target, worldUp) 같은 함수를 사용합니다. 세 번째 인자 worldUp에는 보통 (0,1,0)(0, 1, 0)을 넣는데, 이것이 카메라의 실제 up 벡터가 아니라는 점은 잘 알려져 있습니다. 카메라가 위를 보고 있으면 실제 up은 뒤쪽을 향하겠지만, 여전히 (0,1,0)(0, 1, 0)을 넣어도 됩니다. 그래서 이 인자를 up hint라고 부릅니다.

lookAt 내부에서는 이 hint로 right를 먼저 구하고, 그 right에서 실제 up을 다시 계산합니다. 그런데 hint 하나로 구한 right가 왜 정확한 값인지 — “대충 비슷한 방향”이 아니라 수학적으로 맞는 이유가 있는지 궁금했습니다. 이 글에서는 카메라 모델을 정의한 뒤, cross(forward, worldUp)을 정규화한 값이 해당 모델의 정확한 right 벡터와 일치한다는 것을 증명합니다.

이 글에서 다루는 내용:

사전 지식: 벡터 외적(cross product), 회전행렬의 기본 개념을 전제로 합니다.


1. 문제 설정 — lookAt의 세 번째 인자는 무엇을 하는가

카메라를 만들 때 보통 이런 함수를 사용합니다:

glm::mat4 view = glm::lookAt(eye, target, worldUp);

세 번째 인자 worldUp(0,1,0)(0, 1, 0)을 넣는 것이 관례입니다. 이것은 카메라의 실제 up 벡터가 아닙니다. 카메라가 위를 보고 있으면 실제 up은 뒤쪽을 향할 텐데, 여전히 (0,1,0)(0, 1, 0)을 넣어도 됩니다.

그래서 이것을 up hint라고 부릅니다. lookAt 내부에서는 이 hint로 right를 구하고, 그 right에서 실제 up을 다시 계산합니다:

right = normalize(cross(forward, worldUp));
up    = normalize(cross(right, forward));

질문은 이것입니다: hint 하나로 구한 right가 왜 정확한 값입니까? 대충 비슷한 값이 아니라 수학적으로 정확히 맞는 이유가 있는지 확인해 보겠습니다.


2. 카메라 모델 정의

증명에 앞서 카메라 모델을 고정합니다. 이 글에서 다루는 카메라는 다음과 같습니다:

yawpitch가 모두 0일 때 카메라는 Z-Z 방향을 바라봅니다.

중요한 점은, 이 값들이 “모든 카메라의 보편적 기본값”이라는 뜻이 아니라는 것입니다. 이 글에서는 yawpitch를 해석할 기준 자세(reference basis) 를 이렇게 정하겠다는 뜻입니다. 뒤에서 나오는 forward, right_true, up 관련 식은 모두 이 기준 자세에서 출발한 같은 카메라 모델의 결과입니다.


3. right_true — 올바른 right는 무엇인가

이 카메라 모델에서 right에 영향을 줄 수 있는 회전은 세 가지입니다: yaw, pitch, roll. 이 중 어떤 것이 실제로 right를 바꾸는지 살펴보겠습니다.

결론: 올바른 right는 초기 rightyaw 회전만 적용한 것입니다. 이것이 이 카메라 모델에서 “실제로 있어야 할 right” — rtrue\mathbf{r}_{true}입니다. 즉 rtrue\mathbf{r}_{true}는 임의로 정한 정답이 아니라, 이 모델의 회전 규칙에서 직접 나온 정답입니다.

따라서:

rtrue=Ry(yaw)r0\mathbf{r}_{true} = R_y(\text{yaw}) \cdot \mathbf{r}_0

+Y+Y축 기준 회전행렬 RyR_y는:

Ry(y)=(cosy0siny010siny0cosy)R_y(y) = \begin{pmatrix} \cos y & 0 & \sin y \\ 0 & 1 & 0 \\ -\sin y & 0 & \cos y \end{pmatrix}

초기 right r0=(1,0,0)\mathbf{r}_0 = (1, 0, 0)을 곱하면 첫 번째 열이 그대로 나옵니다:

rtrue=(cosy0siny)\mathbf{r}_{true} = \begin{pmatrix} \cos y \\ 0 \\ -\sin y \end{pmatrix}

검산해 보면:

카메라가 왼쪽(양의 yaw)으로 돌면, right도 같은 방향으로 따라 돕니다. YY 성분은 항상 0입니다 — yaw는 XZ 평면 위의 회전이라 right가 수평면을 벗어나지 않습니다.

이제 목표가 명확해졌습니다. lookAt이 내부에서 사용하는 cross(forward, worldUp)을 정규화한 값 — rhint\mathbf{r}_{hint} — 이 이 rtrue\mathbf{r}_{true}와 정확히 같다는 것을 보이면, worldUp hint로 구한 right는 “대충 비슷한 후보”가 아니라 **이 모델에서의 정답 right**라는 결론이 나옵니다.


4. right_hintforward ×\times worldUp에서 복원한 right

이제 같은 카메라 모델에서 forward를 봅니다. 이것도 섹션 2에서 정한 기준 자세에서 yawpitch를 적용해 얻은 벡터입니다. 즉, 아래 식의 forward와 섹션 3의 right_true는 서로 다른 출처의 값이 아니라 같은 모델에서 나온 두 결과입니다.

같은 카메라 모델에서 유도한 forward는:

f=(cospsinysinpcospcosy)\mathbf{f} = \begin{pmatrix} -\cos p \sin y \\ \sin p \\ -\cos p \cos y \end{pmatrix}

여기에 worldUp=(0,1,0)\text{worldUp} = (0, 1, 0)을 외적하면:

f×worldUp=(cospsinysinpcospcosy)×(010)\mathbf{f} \times \text{worldUp} = \begin{pmatrix} -\cos p \sin y \\ \sin p \\ -\cos p \cos y \end{pmatrix} \times \begin{pmatrix} 0 \\ 1 \\ 0 \end{pmatrix}

외적을 전개하면:

=((sinp)(0)(cospcosy)(1)(cospcosy)(0)(cospsiny)(0)(cospsiny)(1)(sinp)(0))=(cospcosy0cospsiny)= \begin{pmatrix} (\sin p)(0) - (-\cos p \cos y)(1) \\ (-\cos p \cos y)(0) - (-\cos p \sin y)(0) \\ (-\cos p \sin y)(1) - (\sin p)(0) \end{pmatrix} = \begin{pmatrix} \cos p \cos y \\ 0 \\ -\cos p \sin y \end{pmatrix}

세 성분 모두에 cosp\cos p가 공통 계수로 붙어 있습니다.


5. 왜 같은가 — pitch는 방향을 바꾸지 않습니다

외적 결과를 다시 보겠습니다:

f×worldUp=(cospcosy0cospsiny)\mathbf{f} \times \text{worldUp} = \begin{pmatrix} \cos p \cos y \\ 0 \\ -\cos p \sin y \end{pmatrix}

이 벡터에서 cosp\cos p를 묶어내면:

=cosp(cosy0siny)= \cos p \begin{pmatrix} \cos y \\ 0 \\ -\sin y \end{pmatrix}

cosp\cos p를 빼면 남는 (cosy,  0,  siny)(\cos y,\; 0,\; -\sin y)는 섹션 3에서 구한 rtrue\mathbf{r}_{true}와 정확히 같은 방향입니다. 즉 pitch 연산의 결과는 외적 벡터의 방향 자체에는 영향을 주지 않고, cosp\cos p만큼 크기를 스케일링한 것입니다.

cosp\cos p가 붙는지 살펴보겠습니다. forwardpitch를 적용하면 벡터가 위/아래로 기울어지면서 XZ 수평 성분의 길이가 cos(pitch)\cos(\text{pitch})만큼 줄어듭니다. cosp\cos p010 \sim 1 사이의 값이므로 사실상 축소 스케일링이고, 그 축소가 외적 결과에 그대로 전달된 것입니다.

right는 방향 벡터입니다. 정규화하면 이 축소 스케일링이 사라져 순수하게 방향만 남습니다:

rhint=normalize(cosp(cosy0siny))=(cosy0siny)\mathbf{r}_{hint} = \text{normalize}\left(\cos p \begin{pmatrix} \cos y \\ 0 \\ -\sin y \end{pmatrix}\right) = \begin{pmatrix} \cos y \\ 0 \\ -\sin y \end{pmatrix} rhint=rtrue\mathbf{r}_{hint} = \mathbf{r}_{true}

cross(forward, worldUp)으로 얻은 값은 “hint에서 대충 만든 right”가 아니라, 이 카메라 모델에서 실제로 있어야 하는 right와 정확히 같은 방향입니다. 그래서 이 값을 정규화해서 카메라의 right로 사용해도 됩니다.

정리하면:

  1. pitchright축 자체를 중심으로 도는 회전이라 right방향을 바꾸지 않습니다
  2. forward의 XZ 수평 성분이 cosp\cos p만큼 축소되고, 외적 결과에 그 축소가 공통 계수로 전달됩니다
  3. right는 방향 벡터이므로 정규화하면 cosp\cos p가 제거되어, yaw만 반영된 rtrue\mathbf{r}_{true}와 정확히 일치합니다

따라서 roll이 없고, worldUp(0,1,0)(0, 1, 0)으로 고정되어 있으면, cross(forward, worldUp)을 정규화한 것만으로 실제 right를 정확히 복원할 수 있습니다. 이것은 근사값이 아니라 수학적으로 정확한 값입니다.

여기서 rtrue\mathbf{r}_{true}는 편의상 붙인 이름이 아닙니다. roll이 없는 이 카메라 모델에서, 현재 yaw/pitch 상태의 카메라가 실제로 가져야 하는 올바른 right 방향입니다. 그리고 normalize(cross(forward, worldUp))이 그 rtrue\mathbf{r}_{true}와 정확히 같다는 것을 보였으므로, worldUp hint로 구한 right는 “대충 맞는 후보”가 아니라 **이 모델에서의 정답 right**입니다. 따라서 이 값을 그대로 카메라 basis의 right로 사용해도 됩니다.

단, 이 결론은 forwardworldUp과 평행하지 않은 구간, 즉 pitch<90°|pitch| < 90°범위에서 성립합니다. pitch=±90°pitch = \pm 90°이면 forward(0,±1,0)(0, \pm 1, 0)이 되어 cross(forward, worldUp) = \mathbf{0}$이므로 right`를 복원할 수 없습니다.


6. roll이 들어가면 — hint만으로는 부족합니다

roll이 없으면 같은 forward에 대해 카메라 자세가 하나로 정해집니다. “세계의 위쪽은 (0,1,0)(0,1,0)이고, 카메라는 그 기준을 유지한다”는 제약이 자유도를 잡아주기 때문입니다.

하지만 roll이 생기면 상황이 달라집니다. 같은 forward를 보더라도:

가 모두 가능합니다.

roll은 forward축 주위의 비틀림 정보인데, worldUp hint 방식은 이 정보를 저장하지 않습니다. cross(forward, worldUp)은 항상 “세계의 위쪽 기준으로 수평인 right”를 만들어버리기 때문에, roll이 적용된 카메라의 기울어진 right를 복원할 수 없습니다.

조건right 복원 방법
roll 없음cross(forward, worldUp) hint로 정확히 복원 가능
roll 있음orientation 전체(회전행렬 또는 quaternion)를 직접 보존해야 함

7. 주의사항 — convention의 함정

카메라 구현 과정에서 가장 혼란스러웠던 것은 LearnOpenGL 등에서 흔히 보는 forward 공식이었습니다:

forward.x = cos(pitch) * cos(yaw)
forward.y = sin(pitch)
forward.z = cos(pitch) * sin(yaw)

이 식은 yaw = 0일 때 +X+X를 기준으로 하는 heading 식입니다. 이 글에서 사용하는 RH + Z-Z forward convention과는 기준축과 양의 yaw 방향이 다릅니다.

이것을 그대로 가져오면 카메라가 엉뚱한 방향을 보거나, 부호를 하나하나 뒤집다가 더 꼬이게 됩니다. 해결은 외워서 쓰는 공식을 버리고, 카메라 모델을 먼저 정의한 뒤 그 정의에서 forward를 유도하는 것이었습니다:

  1. 초기 basis를 정합니다: forward_0 = (0, 0, -1), right_0 = (1, 0, 0)
  2. yaw/pitch 회전 규칙을 정의합니다
  3. 그 정의에서 forward/right/up을 유도합니다

이렇게 하면 convention이 다른 자료를 참고하더라도 기준이 흔들리지 않습니다.


정리하며

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[Emberlit] Normal Transform: 비균일 스케일에서 역전치행렬이 필요한 이유
다음 글
[Emberlit] CPU 레이트레이서 (2): 삼각형 충돌과 그림자