개요
마야나 블랜더, 혹은 언리얼 같은 3d 모델과 관련된 에디터에서는 특정한 물체를 바라보고 마우스를 드래그해서 해당 물체를 바라보는 방향으로 카메라가 가상의 구 궤적으로 카메라를 이동시킨다. 이러한 이동을 궤적(orbit)
이나 아크볼(arcball)
카메라라고 하는 듯하다.
굉장히 많은 소프트웨어에서 기본적인 기능으로 들어가 있는데, 나는 이 기능의 이름을 찾는 것도 생각보다 오래걸렸고, 이에 대한 자료도 찾기 쉽지 않았다. 특히 directx의 경우는 거의 없었다. 내가 구현한 방법이 정석적인 방법이 아닐지라도 정리하고 공유하는 것이 좋겠다 싶었다.
과정
- 마우스의 드래그를 구현한다.
- 마우스의 변화량을 구한다.
- 카메라를 변화시킬 변환 행렬을 구한다.
- 카메라를 이동시킨다.
1. 드래그 구현하기
드래그를 구현하려면 우선 마우스의 이동과 클릭에 대한 정보를 얻어와야 한다. 마우스의 이동에는 WM_MOUSEMOVE
, 마우스 버튼이 눌릴 시에는 WM_LBUTTONDOWN
눌린 버튼이 떼어졌을 때에는 WM_LBUTTONUP
메시지가 발생한다. 해당 메시지가 발생할 때마다 별도의 클래스 msgHandler가 행동을 처리하도록 구현했다.
// 윈도우 메시지를 처리하는 함수의 일부
LRESULT WindowsWindow::MsgProc(HWND hWnd, UINT msg, WPARAM wParam,
LPARAM lParam) {
switch (msg) {
case WM_MOUSEMOVE:
msgHandler->OnMouseMove(LOWORD(lParam), HIWORD(lParam));
break;
case WM_LBUTTONDOWN:
msgHandler->OnLeftMouseDown();
break;
case WM_LBUTTONUP:
msgHandler->OnLeftMouseUp();
break;
...
}
}
이제, 사용할 수 있는 이벤트를 바탕으로 드래그를 인식하도록 해야한다. 주의해야 할 것은 WM_LBUTTONDOWN
는 마우스가 눌린 시점 한 번만 발생한다는 것이다. 따라서, i 번째 프레임에서 처음 마우스가 눌렸고, i+1 번째에도 여전히 마우스가 눌려 있다면 우리는 이것을 드래그 상태라고 가정할 것이다.
이 정보를 바탕으로 마우스가 눌린 상태를 갖는 변수 isLeftMousePress
와 마우스가 눌린 최초의 상태라는 것을 표현하는 변수 isLeftMouseDragStart
를 사용할 것이다. isLeftMouseDragStart가 true면 마우스가 눌리기 시작한 것이고, isLeftMouseDragStart를 false로 만들어주면서 드래그 상태에 돌입하는 것이다.
// 사용자 입력을 처리하는 클래스의 일부
class MessageHandler {
public:
void OnMouseMove(int x, int y) {
mouseX = x;
mouseY = y;
}
void OnLeftMouseDown() {
if (!isLeftMousePress) {
isLeftMouseDragStart = true;
}
isLeftMousePress = true;
}
void OnLeftMouseUp() {
isLeftMousePress = false;
isLeftMouseDragStart = false;
}
}
클릭과 드래그를 사용하는 코드는 다음과 같이 사용하면 된다.
// 마우스가 눌린 상태인지 확인한다.
if (msgHandler->IsLeftMousePress()) {
// 드래그 여부를 확인한다. true면 드래그가 아님.
if (msgHandler->IsLeftMouseDragStart()) {
msgHandler->OffLeftMouseDragStart(); // false로 만들어 드래그 모드에 돌입
//
// 초기 설정과 관련된 코드들
//
return;
}
//
// 드래그 중인 경우 처리될 코드들
//
}
2. 마우스의 변화량 구하기
마우스 좌표의 변화량은 현재 좌표와 이전 좌표를 빼서 벡터로 표현할 수 있다. 또, 이 변화량은 한 프레임마다 새로 측정할 것이다. 따라서, 이전 프레임에서의 마우스의 위치를 기억할 필요가 있다. 지금 당장은 이전 마우스의 좌표를 사용할 일이 따로 더 없어서 static을 이용해서 저장했다. 프레임 당 얼마나 많이 움직였는지, 또, 몇 프레임 동안 움직였는지를 통해서 카메라의 이동 변위를 구할 수 있다.
void MainApp::cameraUpdate(float dt) {
static Vector2 prevCursorPos; // 이전 마우스의 좌표를 저장하는 변수
if (msgHandler->IsLeftMousePress()) {
Vector2 currentCursorPos(0.0f);
// 조표를 0~1로 정규화
currentCursorPos.x = float(msgHandler->GetMousePosX()) / screenWidth;
currentCursorPos.y = float(msgHandler->GetMousePosY()) / screenHeight;
if (msgHandler->IsLeftMouseDragStart()) {
msgHandler->OffLeftMouseDragStart();
prevCursorPos = currentCursorPos; // 드래그 시작하기 전에 마우스의 좌표를 초기화 한다.
return;
}
// 마우스의 이동에 따른 변화량을 표현하는 벡터
Vector2 delta = (prevCursorPos - currentCursorPos);
// 변화량이 일정 수준보다 작으면 카메라를 이동시키지 않는다.
if (delta.Length() >= 1e-5) {
delta.Normalize();
camera.Move(delta, dt); // 카메라 이동 함수에 변화량 벡터를 넘겨준다.
prevCursorPos = currentCursorPos; // 마우스의 이전 좌표를 현재 좌표로 최신화 한다.
}
}
}
3. 카메라 위치 이동시키기
코드를 구현하기 위해서는 3가지를 알아야 한다. 첫 번째는 이동을 위해 변환 행렬을 사용하는데, 정확히 이 행렬은 이동행렬이 아니라 회전행렬이라는 것이다. 두 번째는 회전을 마우스 x 이동, y 이동 두 가지로 요소로 나뉘어 2개의 축이 필요하다는 것이다. 마지막은 일관된 이동을 위해서는 회전축이 카메라의 위치에 따라서 변해야 한다는 것이다.
3-1. 회전이 이동이다.
회전 행렬은 물체를 회전축 기준으로 특정한 각도만큼 회전시키는 변환행렬이다. 그런데, 이 회전 행렬이 물체의 중심에서 벗어나면 회전 축을 기준으로 원의 궤도를 도는 것이 된다. 즉, 물체를 이동시키고 회전을 적용하면 원의 궤도의 이동이 발생하는 것이다. 그림으로 보면 조금 더 명확하다.회전축이 중심이면 오리는 회전만 한다.오리를 회전축에서 이동시키면, 오리는 이동한 크기 만큼의 원을 그리면서 이동한다. 구현하고자 하는 것이 가상의 구 위를 움직이는 궤도 이동이기 때문에 이 회전을 통한 이동의 원리를 이용한다.
카메라는 항상 같은 지점(원점)을 바라본다. 카메라는 원점과의 거리의 반지름을 가진 구 위를 움직이게 된다. 따라서, 회전은 원점을 기준으로 일어나고, 카메라는 원점과의 거리를 반지름으로 하는 구 위를 움직인다.
3-2. 2개의 회전축
마우스의 변화량은 2개의 속성으로 이루어져 있다. 바로 x변화량과 y 변화량이다. 마우스가 x 좌표로 얼만큼 이동했느냐에 따라서 화면은 좌우로 이동한다. 마우스의 y 좌표가 얼만큼 이동했느냐에 따라서 화면은 위 아래로 이동한다. 하나 처럼 보이는 움직임이 2개의 회전의 합으로 나타난다는 것이고, 이는 두번의 회전이 일어남과 2개의 회전축이 필요함을 알 수 있다. 두 회전을 별도의 회전축을 이용해 별도의 변환 행렬을 구할 것이다.
3-3. 회전축 설정하기
회전축을 설정하기 위해서는 카메라의 이동 궤적에 대해서 조금 더 자세하게 살펴볼 필요가 있다.
우선 좌우 이동은 카메라가 어떤 고도에 위치하느냐에 따라서 궤적의 크기가 달라지지만, 달라진 궤적 모두 같은 축을 원 운동하는 것을 알 수 있다. 이것은 월드 좌표 내에서 위 방향으로 생각할 수 있고, 구체적인 값으로는 (0,1,0)
으로 고정된 축을 사용한다. 다만, 한 가지 추가적으로 고려해야할 사항이 있다.
그것은 바로 상하 움직임으로 인해서 위 아래가 바뀌어 회전이 반대로 일어나는 경우이다. 회전 축을 반대인 (0, -1, 0)
으로 바꿔줘야 한다.
상하 움직임은 카메라의 위치마다 회전 축이 달라지기 때문에 좌우보다 까다롭다. 카메라가 원의 어느 각도에 위치하느냐에 따라서 축의 위치가 달라진다.
위에서 구를 내려다 보면 위 사진과 같은데, 카메라의 오른쪽 방향과 평행한 회전축이 만들어지는 것을 직관적으로 확인할 수 있다.
정리하면 다음과 같다.
- 이동은 두 개의 회전으로 이루어져 있다.
- x 변화량을 이용하는 좌우 이동은 y축 방향을 회전축으로 이용한다.
- y 변화량을 이용하는 상하 이동은 현재 카메라의 오른쪽 방향을 회전축으로 이용한다.
이 내용을 Move함수에 적용해서 이동 구현을 완성하자.
void Camera::Move(Vector2 delta, float dt) {
// 위 벡터
const Vector3 yAxis{0.0f, 1.0f, 0.0f};
delta = delta * dt * orbitSpeed; // 이동 거리 계산
// 좌우 이동 반전에 대한 처리
if (yDir * up.Dot(yAxis) > 0) {
yDir = -yDir;
}
// 좌우 이동에 대한 회전 행렬
// 회전축 y(0,1,0)과 x 변화량 사용 (위 방향에 따라 x 변화량을 음수화)
Matrix yawMatrix = Matrix::CreateFromQuaternion(
Quaternion::CreateFromAxisAngle(yAxis, yDir * delta.x));
// 상하 이동에 대한 회전 행렬
// 회전축 오른쪽 벡터와 y 변화량 사용.
Matrix pitchMatrix = Matrix::CreateFromQuaternion(
Quaternion::CreateFromAxisAngle(right, -delta.y));
// 카메라 좌표에 대한 변환 적용
pos = Vector3::Transform(pos, pitchMatrix * yawMatrix);
right = Vector3::Transform(right, yawMatrix);
up = right.Cross(pos);
up.Normalize();
}
마치며
간략하게 정리만 하고 싶었는데, 더 기초적인 부분까지 설명해서 넣는게 나중에 도움이 되지 않을까? 더 자세하게 설명하는게 좋지 않을까? 라는 생각 때문에 길어지고, 길어지니까 힘빠지고 이도저도 아닌 글이 되어버린 느낌이다. 3차원을 그림으로 표현한다고 고생했는데, 이 글이 쓸모가 있는 순간이 왔으면 좋겠다.
구현한 내용의 일부는 직관에 의존한 구현이었기 때문에 엄밀하게 계산하기 위해서는 증명이 필요할지도 모르겠다. 다만, 현재 충분히 잘 작동하는 모습에서 충분히 만족감을 느낀다. 어디에나 있는 간단한 기능을 직접 구현하려면 생각보다 쉽지 않구나를 항상 느낀다.