Skip to content

원본: Camera

카메라

이전 장에서는 뷰 행렬과 뷰 행렬을 사용하여 장면을 이동하는 방법에 대해 이야기했습니다. OpenGL 자체는 카메라 개념을 지원하지 않지만, 장면의 모든 객체를 반대 방향으로 움직여 마치 움직이는 것처럼 보이게 할 수 있습니다.

이 장에서는 OpenGL에서 카메라를 설정하는 방법을 살펴보겠습니다. 3D 장면에서 자유롭게 이동할 수 있는 플라이 스타일 카메라에 대해 논의하고, 키보드 및 마우스 입력에 대해서도 살펴본 후, 사용자 정의 카메라 클래스를 만드는 것으로 마무리하겠습니다.

카메라 또는 뷰 공간

카메라 또는 뷰 공간에 대해 이야기할 때, 우리는 장면의 원점인 카메라의 시점에서 바라본 모든 정점 좌표를 의미합니다. 뷰 행렬은 모든 월드 좌표를 카메라의 위치와 방향을 기준으로 하는 뷰 좌표로 변환합니다. 카메라를 정의하려면 월드 공간에서의 위치, 카메라가 바라보는 방향, 오른쪽을 가리키는 벡터, 그리고 위쪽을 가리키는 벡터가 필요합니다. 주의 깊게 읽는 사람은 우리가 실제로 카메라의 위치를 ​​원점으로 하는 세 개의 수직 단위 축으로 이루어진 좌표계를 만들 것이라는 점을 알아차릴 수 있을 것입니다.

1. 카메라 위치

카메라 위치를 구하는 것은 쉽습니다. 카메라 위치는 카메라의 위치를 ​​가리키는 월드 공간상의 벡터입니다. 이전 장에서 설정했던 것과 동일한 위치에 카메라를 설정합니다.

glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);  

양의 z축은 화면을 통과하여 사용자 쪽으로 향한다는 점을 잊지 마세요. 따라서 카메라를 뒤로 이동시키려면 양의 z축을 따라 이동해야 합니다.

2. 카메라 방향

다음으로 필요한 벡터는 카메라의 방향, 즉 카메라가 가리키는 방향입니다. 지금은 카메라가 장면의 원점인 (0,0,0)을 가리킨다고 가정합니다. 두 벡터를 빼면 두 벡터의 차이가 되는 벡터가 나온다는 것을 기억하세요. 따라서 장면의 원점 벡터에서 카메라 위치 벡터를 빼면 우리가 원하는 방향 벡터를 얻을 수 있습니다. 뷰 행렬의 좌표계에서 z축은 양수여야 하고, OpenGL에서는 관례적으로 카메라가 음의 z축을 가리키므로 방향 벡터의 부호를 반대로 해야 합니다. 따라서 빼는 순서를 바꾸면 카메라의 양의 z축을 가리키는 벡터를 얻을 수 있습니다.

glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);

왜 빼야하죠?

벡터 \(\vec{a}\)와 \(\vec{b}\)가 있다고 가정해 봅시다. (\(\vec{a}\)는 카메라의 위치이고, \(\vec{b}\)는 카메라가 보고 있는 목표점입니다.) 이때 \(\vec{a}\)와 \(\vec{b}\) 사이의 방향을 구하고 싶다면 둘을 빼면 됩니다.

\[ \vec{ab} = \vec{b} - \vec{a} \]

만약 \((0,0,0)\)을 바라보는 카메라의 위치 \((0,0,3)\)이 있다고 가정합시다. 이 때 위 공식을 사용해본다면

\[ (0,0,0) - (0,0,3) = (0,0,-3) \]

이므로 방향은 \((0,0,-3)\)이 됩니다. 또한 이 방향을 단위벡터로 만든다면 완전히 방향만을 나타내게 됩니다. 위 코드블록에서 glm::normalize를 사용해 그렇게 만들었습니다. 아직 이해가 되지 않았다면 다른 예시도 상상해 보세요.

참고로 '방향 벡터'라는 이름은 그다지 적절하지 않습니다. 왜냐하면 실제로는 목표로 하는 것과 정반대 방향을 가리키기 때문입니다.

3. 우측 축

다음으로 필요한 벡터는 카메라 공간의 양의 x축을 나타내는 오른쪽 벡터입니다. 오른쪽 벡터를 얻기 위해 약간의 트릭을 사용하는데, 먼저 위쪽 방향(월드 공간 기준)을 가리키는 상향 벡터를 지정합니다. 그런 다음 이 상향 벡터와 2단계에서 구한 방향 벡터의 외적을 수행합니다. 외적의 결과는 두 벡터 모두에 수직인 벡터이므로 양의 x축 방향을 가리키는 벡터를 얻게 됩니다(외적 순서를 바꾸면 음의 x축 방향을 가리키는 벡터를 얻게 됩니다).

glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f); 
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));

glm::cross

glm::cross 함수는 두 벡터의 외적을 수행하는 함수입니다.

4. 위쪽 축

이제 x축 벡터와 z축 벡터를 모두 얻었으므로 카메라의 양의 y축을 가리키는 벡터를 찾는 것은 비교적 쉽습니다. 오른쪽 벡터와 방향 벡터의 외적을 취하면 됩니다.

glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);

외적과 몇 가지 트릭을 활용하여 뷰/카메라 공간을 구성하는 모든 벡터를 생성할 수 있었습니다. 수학에 좀 더 익숙한 독자분들을 위해 설명드리자면, 이 과정은 선형대수학에서 그람-슈미트 과정으로 알려져 있습니다. 이러한 카메라 벡터를 사용하여 카메라를 생성하는 데 매우 유용한 LookAt 행렬을 만들 수 있습니다.

Look At

행렬의 가장 큰 장점은 서로 수직인 3개의 축(또는 비선형 축)을 사용하여 좌표 공간을 정의하면, 이 3개의 축과 변환 벡터를 포함하는 행렬을 만들 수 있고, 이 행렬을 곱하여 어떤 벡터든 해당 좌표 공간으로 변환할 수 있다는 것입니다. LookAt 행렬이 바로 이러한 원리를 이용하며, 이제 카메라 공간을 정의하는 3개의 수직 축과 위치 벡터를 사용하여 LookAt 행렬을 직접 만들어 보겠습니다.

\[ LookAt = \begin{bmatrix} \textcolor{red}{R_x} & \textcolor{red}{R_y} & \textcolor{red}{R_z} & 0 \\ \textcolor{green}{U_x} & \textcolor{green}{U_y} & \textcolor{green}{U_z} & 0 \\ \textcolor{blue}{D_x} & \textcolor{blue}{D_y} & \textcolor{blue}{D_z} & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} * \begin{bmatrix} 1 & 0 & 0 & -\textcolor{purple}{P_x} \\ 0 & 1 & 0 & -\textcolor{purple}{P_y} \\ 0 & 0 & 1 & -\textcolor{purple}{P_z} \\ 0 & 0 & 0 & 1 \end{bmatrix} \]

여기서 \(\textcolor{red}{R}\)은 오른쪽 벡터, \(\textcolor{green}{U}\)는 위쪽 벡터, \(\textcolor{blue}{D}\)는 방향 벡터, \(\textcolor{purple}{P}\)는 카메라의 위치 벡터입니다. 회전(왼쪽 행렬)과 이동(오른쪽 행렬) 부분은 반전(각각 전치 및 부호 반전)되는데, 이는 카메라 이동 방향의 반대 방향으로 월드 좌표계를 회전시키고 이동시키기 위함입니다. 이 LookAt 행렬을 뷰 행렬로 사용하면 모든 월드 좌표계가 방금 정의한 뷰 공간으로 변환됩니다. LookAt 행렬은 이름 그대로 주어진 대상을 바라보는 뷰 행렬을 생성합니다.

다행히 GLM은 이 모든 작업을 이미 수행해 줍니다. 우리는 카메라 위치, 목표 위치, 그리고 월드 공간에서의 업 벡터(오른쪽 벡터를 계산하는 데 사용했던 업 벡터)를 나타내는 벡터만 지정하면 됩니다. 그러면 GLM이 뷰 행렬로 사용할 수 있는 LookAt 행렬을 생성합니다.

glm::mat4 view;
view = glm::lookAt(glm::vec3(0.0f, 0.0f, 3.0f), 
           glm::vec3(0.0f, 0.0f, 0.0f), 
           glm::vec3(0.0f, 1.0f, 0.0f));

glm::lookAt 함수는 위치, 대상 및 상향 벡터를 각각 필요로 합니다. 이 예제는 이전 장에서 생성한 것과 동일한 뷰 행렬을 생성합니다.

사용자 입력을 처리하기 전에, 먼저 카메라를 장면 주위로 회전시켜 보겠습니다. 장면의 기준점은 (0,0,0)으로 유지합니다. 삼각법을 이용하여 매 프레임마다 원 위의 한 점을 나타내는 x, z 좌표를 생성하고, 이 좌표를 카메라 위치로 사용합니다. x, y 좌표를 시간에 따라 다시 계산함으로써 원 위의 모든 점을 순회하게 되고, 따라서 카메라가 장면을 중심으로 회전하게 됩니다. 이 원을 미리 정의된 반지름만큼 확장하고, GLFW의 glfwGetTime 함수를 사용하여 매 프레임마다 새로운 뷰 행렬을 생성합니다.

const float radius = 10.0f;
float camX = sin(glfwGetTime()) * radius;
float camZ = cos(glfwGetTime()) * radius;
glm::mat4 view;
view = glm::lookAt(glm::vec3(camX, 0.0, camZ), glm::vec3(0.0, 0.0, 0.0), glm::vec3(0.0, 1.0, 0.0)); 

이 코드를 실행하면 다음과 같은 결과가 나올 것입니다. (클릭해서 확인하세요.)

이 간단한 코드 조각을 사용하면 카메라가 시간이 지남에 따라 장면 주위를 회전합니다. 반지름과 위치/방향 매개변수를 변경하면서 LookAt 행렬이 어떻게 작동하는지 직접 경험해 보세요. 또한, 막히는 부분이 있으면 소스 코드를 참고하세요.

걸어 보기

나는 걷는 방법을 알지 못해 - 컴퓨터

장면에서 카메라를 이리저리 움직이는 것도 재미있지만, 모든 움직임을 직접 구현하는 것이 훨씬 더 재미있습니다! 먼저 카메라 시스템을 설정해야 하므로 프로그램 상단에서 몇 가지 카메라 변수를 정의하는 것이 유용합니다.

glm::vec3 cameraPos   = glm::vec3(0.0f, 0.0f,  3.0f);
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 cameraUp    = glm::vec3(0.0f, 1.0f,  0.0f);

LookAt 함수는 이제 다음과 같이 됩니다.

view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);

먼저 카메라 위치를 이전에 정의한 cameraPos로 설정합니다. 방향은 현재 위치에 방금 정의한 방향 벡터를 더한 값입니다. 이렇게 하면 카메라가 어떻게 움직이든 항상 목표 방향을 바라보게 됩니다. 이제 키를 누를 때 cameraPos 벡터를 업데이트하여 이러한 변수들을 좀 더 다양하게 활용해 보겠습니다.

GLFW의 키보드 입력을 관리하는 processInput 함수를 이미 정의했으므로 몇 가지 추가 키 명령을 추가해 보겠습니다.

void processInput(GLFWwindow *window)
{
    ...
    const float cameraSpeed = 0.05f; // 적절히 변경하세요
    if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
        cameraPos += cameraSpeed * cameraFront;
    if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
        cameraPos -= cameraSpeed * cameraFront;
    if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
        cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
    if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
        cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
}

WASD 키를 누를 때마다 카메라 위치가 그에 따라 업데이트됩니다. 앞뒤로 이동하려면 위치 벡터에 속도 값을 곱한 값을 더하거나 빼면 됩니다. 좌우로 이동하려면 외적을 계산하여 우측 벡터를 만들고, 이 우측 벡터를 따라 이동합니다. 이렇게 하면 카메라를 사용할 때 익숙한 좌우 이동 효과가 나타납니다.

참고로, 우리는 결과로 나오는 오른쪽 벡터를 정규화합니다. 만약 이 벡터를 정규화하지 않으면, 카메라 전면(cameraFront) 변수에 따라 외적의 벡터 크기가 달라질 수 있습니다. 벡터를 정규화하지 않으면 카메라 방향에 따라 움직임이 느려지거나 빨라져서 일정한 속도로 움직이지 못하게 됩니다.

이제 카메라를 어느 정도 움직일 수 있을 것입니다. 다만, 움직임 속도는 시스템에 따라 다르므로 cameraSpeed ​​값을 조정해야 할 수도 있습니다.1

이동 속도

현재 우리는 걸어 다닐 때 이동 속도를 고정된 값으로 사용하고 있습니다. 이론적으로는 문제가 없어 보이지만, 실제로는 사용자마다 컴퓨터 처리 능력이 다르기 때문에 초당 렌더링할 수 있는 프레임 수가 다릅니다. 사용자가 다른 사용자보다 더 많은 프레임을 렌더링할수록 processInput 함수를 더 자주 호출하게 됩니다. 결과적으로 사용자의 컴퓨터 사양에 따라 어떤 사람은 매우 빠르게 움직이고 어떤 사람은 매우 느리게 움직이는 현상이 발생합니다. 애플리케이션을 출시할 때는 모든 종류의 하드웨어에서 동일하게 작동하는지 확인해야 합니다.

그래픽 애플리케이션과 게임은 일반적으로 마지막 프레임을 렌더링하는 데 걸린 시간을 저장하는 델타타임 변수를 사용합니다. 그런 다음 모든 속도에 이 델타타임 값을 곱합니다. 결과적으로 프레임의 델타타임 값이 클수록, 즉 마지막 프레임을 렌더링하는 데 평균보다 시간이 더 오래 걸렸을 경우, 해당 프레임의 속도도 약간 높아져 전체적인 균형을 맞춥니다. 이 방식을 사용하면 PC 사양이 빠르든 느리든 상관없이 카메라 속도가 적절하게 조정되어 모든 사용자가 동일한 경험을 할 수 있습니다.

deltaTime을 계산하려면 아래 두 개의 전역 변수를 계속 관리해야 합니다.

float deltaTime = 0.0f; // 현재 프레임과 이전 프레임 사이의 시간
float lastFrame = 0.0f; // 마지막 프레임의 시간

각 프레임 내에서 나중에 사용할 새로운 deltaTime 값을 계산합니다.

float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;  

이제 deltaTime을 알았으니 속도를 계산할 때 이를 사용할 수 있습니다.

void processInput(GLFWwindow *window)
{
    float cameraSpeed = 2.5f * deltaTime;
    [...]
}

deltaTime을 사용했으므로 이제 카메라는 초당 2.5 단위의 일정한 속도로 움직입니다. 이전 섹션과 함께 고려하면 이제 장면을 이동하는 데 훨씬 더 부드럽고 일관된 카메라 시스템을 갖게 되었습니다.

결과물을 확인하려면 클릭하세요.

둘러보기

키보드 키만으로 이동하는 건 그다지 재미있지 않아요. 특히 몸을 돌릴 수 없으니 움직임이 상당히 제한적이죠. 바로 이럴 때 마우스가 필요한 겁니다!

장면을 둘러보려면 마우스 입력에 따라 cameraFront 벡터를 변경해야 합니다. 하지만 마우스 회전에 따라 방향 벡터를 변경하는 것은 다소 복잡하며 삼각법을 필요로 합니다. 삼각법을 이해하지 못하더라도 걱정하지 마세요. 코드 섹션으로 바로 넘어가서 코드를 붙여넣기만 하면 됩니다. 나중에 더 자세히 알고 싶으면 언제든지 다시 돌아와서 확인할 수 있습니다.

오일러 각

오일러 각은 1700년대경 레온하르트 오일러가 정의한 3차원 공간에서의 모든 회전을 나타낼 수 있는 세 가지 값입니다. 오일러 각에는 피치, 요, 롤의 세 가지가 있습니다. 다음 이미지는 오일러 각의 시각적 의미를 보여줍니다.

피치(pitch)는 첫 번째 이미지에서 볼 수 있듯이 위 또는 아래로 얼마나 기울어지는지를 나타내는 각도입니다. 두 번째 이미지는 좌우로 얼마나 기울어지는지를 나타내는 요(yaw) 값을 보여줍니다. 롤(roll)은 주로 우주 비행 카메라에서 사용되는 것처럼 회전하는 정도를 나타냅니다. 오일러 각도는 각각 하나의 값으로 표현되며, 이 세 값을 모두 조합하면 3차원 공간에서 모든 회전 벡터를 계산할 수 있습니다.

우리 카메라 시스템에서는 요(yaw)와 피치(pitch) 값만 중요하므로 롤(roll) 값은 여기서 다루지 않겠습니다. 피치 값과 요 값이 주어지면 이를 새로운 방향 벡터를 나타내는 3D 벡터로 변환할 수 있습니다. 요 값과 피치 값을 방향 벡터로 변환하는 과정에는 삼각법이 필요하며, 기본적인 경우부터 시작하겠습니다.

먼저 간단한 복습으로, 한 각이 직각인 일반적인 직각삼각형의 경우를 살펴보겠습니다.

빗변의 길이를 1로 정의하면 삼각법에 따라 인접변의 길이는 \(\cos \ \textcolor{red}x/\textcolor{purple}h = \cos \ \textcolor{red}x/\textcolor{purple}1 = \cos\ \textcolor{red}x\)이고, 마주보는 변의 길이는 \(\sin \ \textcolor{green}y/\textcolor{purple}h = \sin \ \textcolor{green}y/\textcolor{purple}1 = \sin\ \textcolor{green}y\)입니다. 이를 통해 주어진 각도에 따라 직각삼각형의 x변과 y변의 길이를 구하는 일반적인 공식을 얻을 수 있습니다. 이제 이 공식을 이용하여 방향 벡터의 성분을 계산해 보겠습니다.

같은 삼각형을 상상해 봅시다. 하지만 이번에는 인접한 변과 마주보는 변이 장면의 x축과 z축에 평행한, 위에서 내려다보는 시점으로 봅니다(마치 y축을 따라 내려다보는 것처럼).

만약 요각을 x축에서 시작하여 반시계 방향으로 회전하는 각도로 시각화한다면, x축 변의 길이는 cos(요각)과 관계가 있음을 알 수 있습니다. 마찬가지로 z축 변의 길이는 sin(요각)과 관계가 있습니다.

이러한 정보와 주어진 요(yaw) 값을 이용하면 카메라 방향 벡터를 생성할 수 있습니다.

glm::vec3 direction;
direction.x = cos(glm::radians(yaw)); // 각도를 먼저 라디안으로 변환해야 한다는 점에 유의하세요.
direction.z = sin(glm::radians(yaw));

이로써 요(yaw) 값에서 3D 방향 벡터를 얻는 방법을 알아냈지만, 피치(pitch) 값도 포함해야 합니다. 이제 xz 평면에 앉아 있는 것처럼 y축 측면을 살펴보겠습니다.

마찬가지로, 이 삼각형에서 방향의 y 성분은 sin(피치)와 같다는 것을 알 수 있으므로, 해당 부분을 채워 넣겠습니다.

direction.y = sin(glm::radians(pitch));  

하지만 피치 삼각형에서 xz 변이 cos(피치)의 영향을 받는다는 것을 알 수 있으므로 이것 또한 방향 벡터에 포함시켜야 합니다. 이를 포함시키면 요(yaw)와 피치(pitch) 오일러 각도를 변환한 최종 방향 벡터를 얻을 수 있습니다.

direction.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
direction.y = sin(glm::radians(pitch));
direction.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));

이를 통해 우리는 요(yaw) 및 피치(pitch) 값을 3차원 방향 벡터로 변환하는 공식을 얻을 수 있으며, 이 벡터를 사용하여 주변을 둘러볼 수 있습니다.

우리는 장면의 모든 요소가 음의 z축 방향으로 배치되도록 설정했습니다. 하지만 x축과 z축의 요(yaw) 삼각형을 살펴보면 \(\theta\) 값이 0일 때 카메라의 방향 벡터가 양의 x축을 향하게 되는 것을 알 수 있습니다. 카메라가 기본적으로 음의 z축을 향하도록 하려면 요(yaw) 값을 시계 방향으로 90도 회전하는 기본값으로 설정할 수 있습니다. 양의 각도는 반시계 방향 회전을 의미하므로 기본 요(yaw) 값은 다음과 같이 설정합니다.

yaw = -90.0f;

이제 여러분은 아마도 이런 생각이 드셨을 겁니다. 요(yaw) 값과 피치(pitch) 값은 어떻게 설정하고 수정하는 걸까요?

마우스 입력

요(yaw)와 피치(pitch) 값은 마우스(또는 컨트롤러/조이스틱) 움직임에서 얻어지며, 수평 마우스 움직임은 요에, 수직 마우스 움직임은 피치에 영향을 미칩니다. 핵심 아이디어는 이전 프레임의 마우스 위치를 저장하고 현재 프레임에서 마우스 값의 변화량을 계산하는 것입니다. 수평 또는 수직 변화량이 클수록 피치 또는 요 값을 더 많이 업데이트하여 카메라를 더 많이 움직이게 합니다.

먼저 GLFW에게 커서를 숨기고 고정(capture)하도록 지시하겠습니다. 커서 고정이란 애플리케이션이 포커스를 얻으면 마우스 커서가 창 중앙에 유지되는 것을 의미합니다(애플리케이션이 포커스를 잃거나 종료되지 않는 한). 간단한 설정 호출 하나로 이 작업을 수행할 수 있습니다.

glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);

이 호출 후에는 마우스 커서가 어디로 이동하든 보이지 않게 되며 창 밖으로 나가지도 않습니다. 이는 FPS 카메라 시스템에 매우 적합합니다.

피치와 요 값을 계산하려면 GLFW가 마우스 움직임 이벤트를 수신하도록 해야 합니다. 이를 위해 다음과 같은 프로토타입을 가진 콜백 함수를 생성합니다.

void mouse_callback(GLFWwindow* window, double xpos, double ypos);

여기서 xpos와 ypos는 현재 마우스 위치를 나타냅니다. GLFW에 콜백 함수를 등록하면 마우스가 움직일 때마다 mouse_callback 함수가 호출됩니다.

glfwSetCursorPosCallback(window, mouse_callback);  

플라이 스타일 카메라에서 마우스 입력을 처리할 때 카메라의 방향 벡터를 완전히 계산하기 전에 몇 가지 단계를 거쳐야 합니다.

  1. 마우스의 위치가 마지막 프레임 이후 얼마나 이동했는지 계산합니다. (오프셋 값)
  2. 오프셋 값을 카메라의 요(yaw) 및 피치(pitch) 값에 더합니다.
  3. 최소/최대 피치 값에 제약을 추가합니다.
  4. 방향 벡터를 계산하세요.

첫 번째 단계는 마지막 프레임 이후 마우스의 이동 거리를 계산하는 것입니다. 먼저 애플리케이션에 마지막 마우스 위치를 저장해야 하는데, 맨 처음에는 마우스를 화면 중앙(화면 크기는 800x600)에 위치시킵니다.

float lastX = 400, lastY = 300;

그런 다음 마우스 콜백 함수에서 이전 프레임과 현재 프레임 사이의 오프셋 이동을 계산합니다.

float xoffset = xpos - lastX;
float yoffset = lastY - ypos; // y좌표는 아래에서 위로 향하므로 반전됩니다.
lastX = xpos;
lastY = ypos;

const float sensitivity = 0.1f;
xoffset *= sensitivity;
yoffset *= sensitivity;

오프셋 값에 감도 값(sensitivity)을 곱한다는 점에 유의하세요. 이 곱셈을 생략하면 마우스 움직임이 너무 강해지므로, 감도 값을 원하는 대로 조정해 보세요.

다음으로 전역적으로 선언된 피치 및 요 값에 오프셋 값을 추가합니다.

yaw   += xoffset;
pitch += yoffset;  

세 번째 단계에서는 사용자가 이상한 카메라 움직임을 하지 못하도록 카메라에 몇 가지 제약을 추가하려고 합니다(방향 벡터가 월드 좌표계의 위쪽 방향과 평행할 때 LookAt 플립이 발생하기도 합니다). 피치는 사용자가 89도보다 높게(90도에서 LookAt 플립 발생) 또는 -89도보다 낮게 볼 수 없도록 제한해야 합니다. 이렇게 하면 사용자는 하늘을 올려다보거나 발을 내려다볼 수는 있지만 그 이상은 볼 수 없게 됩니다. 이 제약은 오일러 값이 제약 조건을 위반할 때마다 해당 제약 조건 값으로 대체하는 방식으로 작동합니다.

if(pitch > 89.0f)
  pitch =  89.0f;
if(pitch < -89.0f)
  pitch = -89.0f;

수평 회전에 대한 제약을 두지 않기 위해 요(yaw) 값에는 제약을 두지 않았습니다. 하지만 원한다면 요 값에도 제약을 추가하는 것은 어렵지 않습니다.

네 번째이자 마지막 단계는 이전 섹션의 공식을 사용하여 실제 방향 벡터를 계산하는 것입니다.

glm::vec3 direction;
direction.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
direction.y = sin(glm::radians(pitch));
direction.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
cameraFront = glm::normalize(direction);

이렇게 계산된 방향 벡터에는 마우스 움직임에서 계산된 모든 회전값이 포함됩니다. 카메라 전면 벡터는 이미 glm의 lookAt 함수에 포함되어 있으므로 바로 사용할 수 있습니다.

이제 코드를 실행해 보면 마우스 커서가 창에 처음 초점을 맞출 때마다 카메라가 갑자기 크게 움직이는 것을 확인할 수 있습니다. 이러한 갑작스러운 움직임의 원인은 커서가 창에 들어오는 순간 마우스 콜백 함수가 호출되는데, 이때 xpos와 ypos 좌표가 마우스 커서가 화면에 들어온 위치로 전달되기 때문입니다. 이 위치는 화면 중앙에서 상당히 멀리 떨어져 있는 경우가 많아 큰 오프셋이 발생하고, 결과적으로 움직임이 크게 튀는 현상이 나타납니다. 이 문제를 해결하기 위해 마우스 입력이 처음인지 확인하는 전역 bool 변수를 정의할 수 있습니다. 처음 입력인 경우, 초기 마우스 위치를 새로운 xpos와 ypos 값으로 업데이트합니다. 그러면 마우스 움직임은 새로 입력된 마우스 위치 좌표를 사용하여 오프셋을 계산하게 됩니다.

if (firstMouse) // initially set to true
{
    lastX = xpos;
    lastY = ypos;
    firstMouse = false;
}

최종 코드는 다음과 같습니다.

void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{
    if (firstMouse)
    {
        lastX = xpos;
        lastY = ypos;
        firstMouse = false;
    }

    float xoffset = xpos - lastX;
    float yoffset = lastY - ypos; 
    lastX = xpos;
    lastY = ypos;

    float sensitivity = 0.1f;
    xoffset *= sensitivity;
    yoffset *= sensitivity;

    yaw   += xoffset;
    pitch += yoffset;

    if(pitch > 89.0f)
        pitch = 89.0f;
    if(pitch < -89.0f)
        pitch = -89.0f;

    glm::vec3 direction;
    direction.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
    direction.y = sin(glm::radians(pitch));
    direction.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
    cameraFront = glm::normalize(direction);
} 

자, 이제 됐습니다! 돌려보시면 3D 장면 속을 자유롭게 이동할 수 있는 것을 확인하실 수 있을 거예요!

줌(Zoom)

카메라 시스템에 약간의 추가 기능으로 줌 인터페이스를 구현하겠습니다. 이전 장에서 시야각(FOV)이 장면을 얼마나 잘 볼 수 있는지를 결정한다고 설명했습니다. 시야각이 작아지면 장면이 투영되는 공간이 작아집니다. 이 작아진 공간은 동일한 NDC(Non-Defined Control) 영역에 투영되어 확대된 것처럼 보이게 합니다. 확대하려면 마우스 휠을 사용합니다. 마우스 움직임이나 키보드 입력과 마찬가지로 마우스 스크롤링에 대한 콜백 함수를 구현할 것입니다.

void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
    fov -= (float)yoffset;
    if (fov < 1.0f)
        fov = 1.0f;
    if (fov > 45.0f)
        fov = 45.0f; 
}

스크롤할 때 yoffset 값은 세로로 스크롤한 양을 나타냅니다. scroll_callback 함수가 호출되면 전역적으로 선언된 fov 변수의 내용을 변경합니다. 기본 fov 값이 45.0이므로 확대/축소 수준을 1.0에서 45.0 사이로 제한하고자 합니다.

이제 매 프레임마다 원근 투영 행렬을 GPU에 업로드해야 하는데, 이번에는 시야각(fov) 변수를 해당 행렬의 시야각으로 사용해야 합니다.

projection = glm::perspective(glm::radians(fov), 800.0f / 600.0f, 0.1f, 100.0f); 

마지막으로 스크롤 콜백 함수를 등록하는 것을 잊지 마세요.

glfwSetScrollCallback(window, scroll_callback); 

자, 이렇게 해서 3D 환경에서 자유로운 움직임을 가능하게 하는 간단한 카메라 시스템을 구현했습니다.

결과물을 확인하려면 클릭하세요.

자유롭게 실험해 보고, 막히는 부분이 있으면 소스 코드와 비교해 보세요.

카메라 클래스

앞으로 이어지는 장에서는 장면을 쉽게 둘러보고 모든 각도에서 결과를 확인하기 위해 항상 카메라를 사용할 것입니다. 하지만 카메라 코드가 각 장에서 상당한 분량을 차지할 수 있으므로, 세부적인 내용은 다소 추상화하고 몇 가지 유용한 추가 기능을 갖춘 자체 카메라 객체를 만들겠습니다. 셰이더 장과는 달리 카메라 클래스를 직접 만드는 과정은 설명하지 않겠지만, 내부 작동 방식을 알고 싶어하는 분들을 위해 (주석이 완벽하게 포함된) 소스 코드를 제공할 것입니다.

셰이더 객체와 마찬가지로 카메라 클래스도 하나의 헤더 파일에 완전히 정의되어 있습니다. 카메라 클래스 코드는 여기에서 확인할 수 있으며, 이 장을 마치면 코드를 이해할 수 있을 것입니다. 자신만의 카메라 시스템을 만드는 방법을 배우는 데 도움이 될 수 있으므로, 최소한 한 번은 이 클래스를 살펴보는 것을 권장합니다.

저희가 소개한 카메라 시스템은 대부분의 용도에 적합하고 오일러 각도와도 잘 작동하는 비행 카메라와 유사한 형태입니다. 하지만 FPS 카메라나 비행 시뮬레이션 카메라와 같은 다른 카메라 시스템을 만들 때는 주의해야 합니다. 각 카메라 시스템에는 고유한 특징과 특성이 있으므로 반드시 관련 내용을 숙지해야 합니다. 예를 들어, 이 비행 카메라는 피치 값이 90도 이상인 경우를 허용하지 않으며, 롤 값을 고려할 때 고정된 상향 벡터 (0,1,0)는 제대로 작동하지 않습니다.

새로운 카메라 객체를 사용하는 업데이트된 소스 코드는 여기에서 확인할 수 있습니다.

연습 문제

  • 카메라 클래스를 변형하여 비행은 불가능하고 xz 평면에 머물면서 주변만 둘러볼 수 있는 진정한 FPS 카메라로 만들 수 있는지 확인해 보세요. (해결 방법)
  • 이 장의 시작 부분에서 설명한 것처럼 뷰 행렬을 직접 생성하여 LookAt 함수를 직접 만들어 보세요. glm의 LookAt 함수를 직접 구현한 함수로 바꾸고 동일하게 작동하는지 확인해 보세요. (해결 방법)

  1. 우리는 아직 델타타임(FPS와 상관없이 값을 변환할 수 있게 해주는 기술) 또는 그와 유사한 기술을 사용하지 않기 때문에, 시스템 FPS에 따라 이동 속도에 차이가 생깁니다. 바로 다음 섹션에서 설명하겠습니다. ↩

Comments