개요

   마야나 블랜더, 혹은 언리얼 같은 3d 모델과 관련된 에디터에서는 특정한 물체를 바라보고 마우스를 드래그해서 해당 물체를 바라보는 방향으로 카메라가 가상의 구 궤적으로 카메라를 이동시킨다. 이러한 이동을 궤적(orbit)이나 아크볼(arcball) 카메라라고 하는 듯하다.

   굉장히 많은 소프트웨어에서 기본적인 기능으로 들어가 있는데, 나는 이 기능의 이름을 찾는 것도 생각보다 오래걸렸고, 이에 대한 자료도 찾기 쉽지 않았다. 특히 directx의 경우는 거의 없었다. 내가 구현한 방법이 정석적인 방법이 아닐지라도 정리하고 공유하는 것이 좋겠다 싶었다.

 

과정

  1. 마우스의 드래그를 구현한다.
  2. 마우스의 변화량을 구한다.
  3. 카메라를 변화시킬 변환 행렬을 구한다.
  4. 카메라를 이동시킨다.
 

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차원을 그림으로 표현한다고 고생했는데, 이 글이 쓸모가 있는 순간이 왔으면 좋겠다.

   구현한 내용의 일부는 직관에 의존한 구현이었기 때문에 엄밀하게 계산하기 위해서는 증명이 필요할지도 모르겠다. 다만, 현재 충분히 잘 작동하는 모습에서 충분히 만족감을 느낀다. 어디에나 있는 간단한 기능을 직접 구현하려면 생각보다 쉽지 않구나를 항상 느낀다.

 

참고 자료