이전 챕터에서 view 행렬을 scene 주변으로 움직이기 위해 어떻게 사용하는지 알아봤다. OpenGL 그 자체로는 카메라라는 개념을 잘 모른다. 그러나 scene에 있는 모든 물체를 반대 방향으로 움직임으로서 view의 움직였다.
이번 챕터에서는 OpenGL에서 어떻게 카메라를 설정하는지 알아보고 3차원 공간에서 카메라가 어떻게 자유롭게 움직이게 할 수 있는지 알아본다. 또한 카메라 클래스 생성과 키보드, 마우스 입출력을 다룰 것.
view 행렬이 모든 world 좌표들을 카메라의 위치와 방향과 연관된 view 좌표로 변환하는 것. 카메라를 정의하기 위해서 world-space에서 카메라의 위치, 카메라로부터 바라보는 방향, 우측을 가리키는 벡터와 상단을 가리키는 벡터가 필요하다. 실제로 수직인 세 단위 축과 원점인 카메라의 위치로 좌표계를 생성.

카메라 위치를 갖는 것은 쉽다. 카메라 위치는 카메라 위치를 가리키는 world-space 공간의 하나인 벡터. 이전 챕터와 같이 설정
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
양의 방향인 z축으로 움직이면 카메라는 뒤쪽으로 움직인다.
그 다음 벡터는 카메라의 방향을 요구한다. 지금은 카메라를 scene의 원점(0, 0, 0)을 향하게 했다. scene의 원점과 카메라의 위치 벡터 간의 차를 구하여 방향을 구한다. view 행렬의 좌표계에 관해 z축을 양의 방향이길 원하고 OpenGL 관습에 의해 카메라는 음의 z축을 가리키기 때문에 그 방향 벡터의 부호를 바꾸길 원한다. 벡터 간의 뻴셈의 순서를 바꾸면 카메라의 양의 z축을 가리키는 벡터를 갖는다.
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);
Camera-space의 양의 x축을 나타내는 right 벡터를 필요로 한다. 이 벡터를 갖기 위해서 world-space에서 위를 가르키는 up 벡터를 먼저 지정함으로 약간의 트릭을 사용한다. 이 up 벡터와 카메라의 방향 벡터를 외적한다. 외적의 결과는 두 벡터와 수직인 벡터이고 이 벡터는 양의 x축이다. 외적의 순서가 바뀌면 축의 부호도 바뀐다.
glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f);
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));
카메라의 양의 y축을 가르키는 벡터를 갖는 것은 조금 쉽다. right 벡터와 방향벡터를 외적하면 된다.
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);
외적을 통하여 view/camera space에 관한 모든 벡터를 생성할 수 있다. Gram-Schmidt process로 알려져있다.
이러한 카메라 벡터들을 사용하여 LookAt 행렬을 생성할 수 있다.
세 수직인 축을 사용하여 좌표 공간을 정의하면 그 세 축과 이동 벡터와 함께 행렬을 만들 수 있고 이 행렬을 곱하는 것으로 그 좌표 공간에 어느 벡터도 변환할 수 있다. 이것이 LookAt 행렬의 역할이고 지금 세 축과 카메라 벡터를 정의하는 지점으로 LookAt 행렬을 만들 수 있다.
R은 right 벡터, U는 up 벡터, D는 방향 벡터, P는 카메라의 위치 벡터이다. 회전(왼쪽 행렬)과 이동(오른쪽 행렬) 부분은 도치된다. world를 회전 시키고 이동시키기 때문에 카메라를 반대 반향으로 이동 시킨다. view 행렬로서 LookAt 행렬을 사용하는 것은 모든 world 좌표를 정의된 view 공간으로 효율적으로 변환시킨다. 그러면 LookAt 행렬은 정확하게 주어진 타겟을 보는 view 행렬을 생성하는 것.
GLM은 이미 이러한 작업을 가능하게 한다. 카메라의 위치, 타겟 지점, world-space의 up을 나타내는 벡터만 명시하면 된다. 그러면 GLM은 view 행렬을 사용할 수 있는 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 함수는 위치, 타겟, up 벡터를 각각 요구. 위 예시는 view 행렬을 생성하는데 이전 챕터에서 생성한 view 행렬과 같다.
사용자 입력을 좀 더 알아보기 전에 scene 주위를 도는 카메라를 생성. 타겟을 (0, 0, 0)으로 둔다. 원에서 지점을 나타내는 각 프레임, x, z 좌표를 생성하기 위해 삼각법을 사용하고 이것을 카메라의 지점으로 둔다. 시간이 지남에 따라 x와 y 좌표를 재계산하는 것으로 원을 모든 지점을 순회할 것. 그렇게 하여 카메라가 scene 주위를 돈다. 이 원을 미리 지정한 반지름만큼 원을 확대하고 GLFW의 glfwGetTime 함수를 사용하여 새로운 view 행렬을 생성한다.
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));
아래와 같이 반지름 기준으로 계속 회전하는 카메라를 볼 수 있다.

카메라의 움직임을 조작하자. 먼저 카메라 시스템을 설정해야하므로 프로그램 상단에 카메라 변수를 정의하면 유용하다.
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를 설정, 방향은 현재 position과 방향 벡터의 합. 이는 움직임에도 불구하고 카메라는 계속 목표지점을 바라보게 한다. 키를 입력했을 때 cameraPos 벡터가 업데이트된다.
앞에 정의해놓은 processInput 함수에 아래 코드를 추가
void processInput(GLFWwindow *window)
{
...
const float cameraSpeed = 0.05f; // adjust accordingly
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 키 중 하나를 누를때마다 카메라의 위치는 키에 맞게 업데이트된다. 카메라를 앞이나 뒤로 움직일 때 speed 값에 의해 크기 조정된 위치 벡터로부터 방향 벡터를 더하거나 뺀다. 양쪽으로 움직일 때 right 벡터를 생성하기 위해서 외적하고 right 벡터따라 움직인다. 이는 카메라를 이용할 때 친숙한 strafe 효과를 생성한다.
여기서 right 벡터를 normalize한다. 만약 하지 않으면 외적의 결과는 cameraFront 변수를 토대로 다른 크기의 벡터들을 반환할지도 모른다. 그 벡터를 normalize하지 않으면 카메라의 방향을 근거로 느리거나 빠르게 움직일 것이다.
현재 움직임에 관해 상수값을 사용하고 있다. 이론적으로 문제없지만 실제로 컴퓨터들은 다른 처리 출력을 가지고 있으며 그 결과 몇몇은 매초 다른 것들보다 더 많은 프레임들을 렌더링 할 수 있다. 사용자가 다른 사용자보다 더 많은 프레임을 그릴때마다 더 자주 processInput을 호출한다. 그 결과는 설정에 따라 속도가 상이하다. 모든 하드웨어에 똑같은 속도로 애플리케이션을 작동할 수 있어야한다.
그래픽 애플리케이션과 게임은 마지막 프레임을 그린 시간을 저장하는 deltatime 변수를 기록한다. 그리고 모든 속도들을 deltatime과 곱한다. 하나의 프레임에서 큰 deltatime을 가질 때(마지막 프레임이 평균보다 더 많이 걸릴 때), 그 프레임에 관한 속도는 균형을 맞추기 위해 더 높을 것이다. 이를 이용하여 어느 pc에 상관없이 각 유저들이 똑같이 느낄 수 있도록 카메라의 속도 균형을 맞출 것.
deltatime을 계산하기 위해서 2개의 전역변수를 기록
float deltaTime = 0.0f; // Time between current frame and last frame
float lastFrame = 0.0f; // Time of last frame
매 프레임 안에서 새로운 deltatime을 계산한다.
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
속도를 계산할 때 deltatime을 계산에 넣는다.
void processInput(GLFWwindow *window)
{
float cameraSpeed = 2.5f * deltaTime;
[...]
}
deltatime을 사용함으로 카메라는 초당 2.5 단위의 일정 속도로 움직일 것이다. 더불어 scene 주위를 도는 부드럽고 변함없이 일정한 카메라 시스템을 가졌다.
제한된 움직임에서 마우스 입력으로 회전 기능을 추가한다. 이를 위해서 마우스 입력을 토대로 cameraFront벡터를 바꿔야한다. 다만 마우스 회전에 맞게 방향 벡터를 바꾸는 것은 조금 복잡하고 삼각법을 요구한다.
오일러 각은 1700년대 오일러 레온하르트가 정의한 3D에서 회전을 표현할 수 있는 3가지 값이다. 3가지 오일러 각이 있다: pitch, yaw 그리고 roll.

pitch 각은 어느 정도로 아래와 위를 볼 것인지 묘사하고 yaw는 좌우의 정도를 나타내고 roll은 빙글 빙글 도는 정도를 나타낸다. 각각의 오일러각은 단일 변수에 의해 표현되며 세가지 모두 조합하여 3D에서 회전 벡터를 계산할 수 있다.
현재 카메라 시스템에서 pitch와 yaw 값들만 신경쓰고 roll 값은 배제한다. 새로운 방향 벡터를 나타내는 3D 벡터에 주어진 pitch와 yaw 값을 변환한다. pitch와 yaw 값을 방향벡터로 변환하는 과정은 삼각함수를 요구.

yaw의 값을 구한다.
glm::vec3 direction;
direction.x = cos(glm::radians(yaw)); // convert to radians first
direction.z = sin(glm::radians(yaw));
pitch의 값을 구한다.
direction.y = sin(glm::radians(pitch));
xz평면은 cos pitch에 영향을 받으므로 xz 각 축에 cos 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));
위 식에 대해서 약간의 설명
모든 설정은 끝났다. 주의할 것은 -z축 방향을 바라보도록 맞춰준다.
yaw = -90.0f;
마우스의 수직 움직임으로 yaw와 수평 움직임으로 pitch 값을 갖게 한다. 이런 마우스의 움직임을 카메라에 적용.
먼저 GLFW가 커서를 숨기게하고 캡쳐하게 한다. 커서를 캡쳐하는 것은 애플리케이션이 초점을 가지는 순간 마우스 커서를 화면 창의 센터에 위치하게 하는 것.
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
이 함수 호출 이후, 마우스를 움직이는 곳은 보이지 않고 창 밖으로 나갈 수 없다. 이는 완벽한 FPS 카메라 시스템이다.
pitch와 yaw 값을 계산하기 위해서 GLFW이 마우스 이벤트를 인식할 수 있게 해야한다. 이를 아래와 같은 프로토타입을 가진 함수를 생성함으로 수행한다.
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
여기서 xpos와 ypos는 현재 마우스의 위치를 나타낸다. 마우스가 움직일 때마다 mouse_callback함수가 호출되게 GLFW에 콜백함수를 등록한다.
glfwSetCursorPosCallback(window, mouse_callback);
fly style 카메라에 관한 마우스 입력을 처리할 때, 카메라의 방향 벡터를 완전히 계산할 수 있기전에 몇 가지 해야하는 스텝들이 있다.
첫번째 단계에서 먼저 애플리케이션에서 마지막 마우스 위치를 저장해야한다. 이는 스크린의 중앙에 위치하게 초기화 한다. 여기서 800 x 600이므로 아래와 같이 설정
float lastX = 400, lastY = 300;
이후 마우스 콜백 함수에서 바로 이전의 프레임과 현재 프레임 사이의 offset movement 계산한다.
float xoffset = xpos - lastX;
float yoffset = lastY - ypos; // reversed: y ranges bottom to top
lastX = xpos;
lastY = ypos;
const float sensitivity = 0.1f;
xoffset *= sensitivity;
yoffset *= sensitivity;
여기서 각 offset 값에 sensitivity를 곱한다. 만약 이를 생략하면 마우스의 움직임이 엄청 강할 것이다. 이는 sensitivity 값을 기호에 알맞게 조절할 수 있다.
다음에 전역으로 선언된 pitch와 yaw 값에 offset 값을 더한다.
yaw += xoffset;
pitch += yoffset;
세번째로는 카메라에 몇가지 제약을 추가하는데 사용자들이 이상한 카메라 움직임을 가지지 못하게 할 수 있다. LookAt을 한번 뒤집어 방향벡터가 world의 up 방향과 평행이 되는 것도 못하게 할 수 있다. pitch가 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 함수에 cameraFront 벡터가 이미 포함되어 있기에 준비는 다 되어있다.
지금 바로 실행하면 창이 생기자마자 커서에 위치에 따라 카메라가 바로 큰 급격한 변화가 생긴다. 이는 커서가 창에 들어서자마자 마우스 콜백 함수가 호출되고 xpos와 ypos 위치가 스크린으로부터 들어선 마우스 위치로 되기 때문이다. 이는 스크린의 중앙으로부터 많이 벗어난 위치가 될 수도 있고 이러한 큰 offset의 결과로 이같은 급격한 창 안의 scene의 변화를 가진다. 이러한 이슈를 피하는 방식은 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);
}
이제 실행하면 마우스 입력을 통해서 회전하는 것을 볼 수 있다.
zooming 인터페이스를 구현할 것이다. 이전 챕터에서 FoV(Field of View)가 scene을 얼마나 볼 수 있는지 대해서 알아봤고 FOV가 작아질 때 scene의 투영된 공간도 작아진다. 이 더 작아진 공간은 같은 NDC로 투영, zooming in 된 것처럼 된다. 줌인을 위해서 마우스 스크롤 휠을 이용. 마우스 이동과 키보드 입력과 비슷하게 마우스 스크롤에 관한 콜백함수를 가질 것.
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
Zoom -= (float)yoffset;
if (Zoom < 1.0f)
Zoom = 1.0f;
if (Zoom > 45.0f)
Zoom = 45.0f;
}
스크롤할 때 yoffset 값은 수직으로 스크롤된 양을 말해준다. scrool_callback 함수가 호출되었을 때 전역으로 선언된 fov 변수의 내용을 변경한다. 45.0이 디폴트 fov 값이므로 zoom level을 1.0-45.0 사이로 제약을 둔다.
이제 GPU의 각 프레임에 perspective projection matrix를 올려야한다. 이때 fov 변수와 함께 올린다.
projection = glm::perspective(glm::radians(fov), 800.0f / 600.0f, 0.1f, 100.0f);
마지막으로 스크롤 콜백 함수를 등록한다.
glfwSetScrollCallback(window, scroll_callback);
그럼 이제 줌이되는 결과를 볼 수 있다.
참고 - projection 행렬은 이제 렌더 루프 안으로 가야한다.
Learn OpenGL github에서 확인.
여기서 소개된 카메라 시스템은 오일러 각을 이용하고 대부분의 작업과 목적에 잘 맞는 fly like 카메라이다. 다만, FPS나 flight simulation(비행시뮬레이션)과 같은 다른 카메라 시스템을 생성할 때 조심해야한다. 각 카메라 시스템은 자기만의 트릭과 기이한 점들을 가지고 있으므로 그에 대해 많은 공부를 해야한다. 예를들어 fly 카메라는 pitch 값이 90도이상이 안되고 roll 값을 고려하지 않을 때 static up 벡터 (0, 1, 0)는 작동하지 않는다.