摄像机系统

目前,我们的软件渲染流程已经全部实现完毕。后续,我们会继续在此软件渲染的基础上实现几个案例,验证功能的正确性。

在本篇文章中,我们实现摄像机功能。操作方式和 UE 编辑器中的视口漫游类似:W/S/A/D/Q/E 键控制摄像机的位置平移;按住鼠标右键并移动,控制摄像头的旋转。

1. 添加 Windows 输入事件

因为现在需要响应键盘和鼠标事件,所以需要在之前的 TBasicWindow 类中添加相应的消息处理。

不过在此之前,我们先“抽象”一下逻辑。如代码清单 1.1 所示,我们定义鼠标和键盘的相关事件输入接口。

代码清单 1.1 事件接口
  1. class IInputHandler {
  2. public:
  3.     virtual void OnKeyDown(int virtualKeyCode) = 0;
  4.     virtual void OnKeyUp(int virtualKeyCode) = 0;
  5.     virtual void OnMouseMove(
  6.         int posX,
  7.         int posY,
  8.         bool leftButton,
  9.         bool rightButton,
  10.         bool middleButton) = 0;
  11. };

接着,如代码清单 1.2 所示,我们添加事件注册方法。

代码清单 1.2 添加事件
  1. class TBasicWindow
  2. {
  3. public:
  4.     void AddInputHandler(IInputHandler* handler);
  5.  
  6. private:
  7.     static std::vector<IInputHandler*> m_inputHandlers;
  8. };
  1. void TBasicWindow::AddInputHandler(IInputHandler* handler)
  2. {
  3.     m_inputHandlers.push_back(handler);
  4. }

这样,我们就可以在 WindowProc 函数里调用注册好的事件处理函数。如代码清单 1.3 所示,我们添加键盘按下(WM_KEYDOWN)和弹起(WM_KEYUP)的处理,以及鼠标移动(WM_MOUSEMOVE)的处理。

代码清单 1.3 响应事件
  1. LRESULT CALLBACK TBasicWindow::WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
  2. {
  3.     int posX;
  4.     int posY;
  5.  
  6.     bool leftButton = false;
  7.     bool rightButton = false;
  8.     bool middleButton = false;
  9.  
  10.     switch (uMsg)
  11.     {
  12.     case WM_DESTROY:
  13.         PostQuitMessage(0);
  14.         return 0;
  15.  
  16.     case WM_CLOSE:
  17.         DestroyWindow(hwnd);
  18.         return 0;
  19.  
  20.     case WM_PAINT:
  21.     {
  22.         PAINTSTRUCT ps;
  23.         HDC hdc = BeginPaint(hwnd, &ps);
  24.  
  25.         // 不在这里使用 BitBlt,因为 WM_PAINT 消息处理可能导致绘制延迟。
  26.         // 为了保证实时渲染,我们会直接在渲染函数中调用 BitBlt。
  27.         //BitBlt(hdc, 0, 0, ps.rcPaint.right - ps.rcPaint.left, ps.rcPaint.bottom - ps.rcPaint.top, m_hMemDC, ps.rcPaint.left, ps.rcPaint.top, SRCCOPY);
  28.  
  29.         EndPaint(hwnd, &ps);
  30.  
  31.         return 0;
  32.     }
  33.  
  34.     case WM_KEYDOWN:
  35.         for (auto handler : m_inputHandlers)
  36.             handler->OnKeyDown(wParam);
  37.         break;
  38.  
  39.     case WM_KEYUP:
  40.         for (auto handler : m_inputHandlers)
  41.             handler->OnKeyUp(wParam);
  42.         break;
  43.  
  44.     case WM_MOUSEMOVE:
  45.         posX = GET_X_LPARAM(lParam);
  46.         posY = GET_Y_LPARAM(lParam);
  47.  
  48.         leftButton = wParam & MK_LBUTTON;
  49.         rightButton = wParam & MK_RBUTTON;
  50.         middleButton = wParam & MK_MBUTTON;
  51.  
  52.         for (auto handler : m_inputHandlers)
  53.             handler->OnMouseMove(posX, posY, leftButton, rightButton, middleButton);
  54.         break;
  55.  
  56.     default:
  57.         return DefWindowProc(hwnd, uMsg, wParam, lParam);
  58.     }
  59. }

2. 摄像机前向量

摄像机前向量可以通过旋转得到。但这节介绍一种新的方式,可以使用 yaw 和 pitch 角决定一个向量方向。即用 yaw 和 pitch 角定义摄像机的前向量。

和欧拉角的三轴旋转有点不同。

yaw 是偏航角的意思,可以理解成,人左右转头;pitch 是俯仰角的意思,可以理解成,人上下点头。

如图 1 左手坐标系所示,我们设 pitch 角相对于 x-z 平面,即在 x-z 平面上“点头”。可以看到 z 坐标只和 pitch 角相关,我们可以先得到向量的 z 坐标:

  • forward.z = sin(pitch)
图1 pitch

如图 2 所示,设向量在 x-z 平面上的投影,与 x 轴的夹角为 yaw 角。投影的长度在图 1 中已经计算得到为 cos(pitch),所以可以得到向量的 x 和 y 坐标:

  • forward.x = cos(pitch)cos(yaw)
  • forward.y = cos(pitch)sin(yaw)
图2 yaw

3. 摄像机代码实现

摄像机的前向量定位,是本章中唯一涉及到推导的地方。所以在了解如何定位之后,我们就可以开始实现摄像机相关的代码了。

如代码清单 2.1 所示,我们先大致看一下设计的摄像机类。摄像机类继承 IInputHandler 接口,后续需要实现键盘和鼠标的处理。最终想要获取的是视图矩阵(通过 GetViewMatrix 函数获取),其他变量都是为了获取视图矩阵记录的中间变量。

我们逐帧调用 GetViewMatrix 函数,更新视图矩阵。

代码清单 2.1 摄像机类
  1. class TCameraController : public IInputHandler
  2. {
  3. public:
  4.     virtual void OnKeyDown(int virtualKeyCode) override;
  5.     virtual void OnKeyUp(int virtualKeyCode) override;
  6.     virtual void OnMouseMove(
  7.         int posX,
  8.         int posY,
  9.         bool leftButton,
  10.         bool rightButton,
  11.         bool middleButton) override;
  12.  
  13.     TCameraController(const tmath::Vec3f& initialPosition, const tmath::Vec3f& eyePosition);
  14.     tmath::Mat4f GetViewMatrix();
  15. private:
  16.     tmath::Vec3f m_position;
  17.     tmath::Vec3f m_forward;
  18.     tmath::Vec3f m_up = { 0.0f, 1.0f, 0.0f };
  19.  
  20.     float m_yaw;
  21.     float m_pitch;
  22.     float m_mouseSensitivity = 0.05f;
  23.     float m_lastX, m_lastY;
  24.     bool  m_mouseDown = false;
  25.  
  26.     float m_moveSpeed = 0.01f;
  27.  
  28.     std::unordered_map<int, bool> m_keyState;
  29.     tmath::Mat4f m_viewMatrix;
  30. };

我们首先看键盘和鼠标的输入处理。如代码清单 2.2 所示,键盘的输入,我们只先记录当前帧下按下了什么键,最后获取视图矩阵的时候再统一处理。需要记录多个按键的原因是,可能会同时按下多个键。

鼠标移动的逻辑是,只在按下鼠标右键的情况下进行状态更新。鼠标在左、右方向上的移动偏移,我们作为 yaw 角的增量;上、下方向上的偏移,我们作为 pitch 角的增量。

在 windows 窗体中,鼠标坐标系 y 轴向下增长,和图 2 中预定的方向相反。所以 y 的增量要取相反数。

同样看图 2,往 x 轴正方向移动,yaw 角会变小。所以 x 的增量也要取相反数。

代码清单 2.2 输入
  1. void TCameraController::OnKeyDown(int virtualKeyCode)
  2. {
  3.     if (virtualKeyCode == 'W' || virtualKeyCode == 'S' ||
  4.         virtualKeyCode == 'A' || virtualKeyCode == 'D' ||
  5.         virtualKeyCode == 'Q' || virtualKeyCode == 'E')
  6.     {
  7.         m_keyState[virtualKeyCode] = true;
  8.     }
  9. }
  10. void TCameraController::OnKeyUp(int virtualKeyCode)
  11. {
  12.     if (virtualKeyCode == 'W' || virtualKeyCode == 'S' ||
  13.         virtualKeyCode == 'A' || virtualKeyCode == 'D' ||
  14.         virtualKeyCode == 'Q' || virtualKeyCode == 'E')
  15.     {
  16.         m_keyState[virtualKeyCode] = false;
  17.     }
  18. }
  1. void TCameraController::OnMouseMove(
  2.     int posX,
  3.     int posY,
  4.     bool leftButton,
  5.     bool rightButton,
  6.     bool middleButton)
  7. {
  8.     if (rightButton == false)
  9.         m_mouseDown = false;
  10.     else
  11.     {
  12.         if (m_mouseDown == false)
  13.         {
  14.             m_lastX = posX;
  15.             m_lastY = posY;
  16.             m_mouseDown = true;
  17.         }
  18.  
  19.         float offsetX = m_lastX - posX;
  20.         float offsetY = m_lastY - posY;
  21.  
  22.         m_lastX = posX;
  23.         m_lastY = posY;
  24.  
  25.         m_yaw += offsetX * m_mouseSensitivity;
  26.         m_pitch += offsetY * m_mouseSensitivity;
  27.  
  28.         m_pitch = std::clamp(m_pitch, -89.0f, 89.0f);
  29.     }
  30. }

获取视图矩阵的逻辑,如代码清单 2.3 所示,我们通过最新状态的 yaw 和 pitch 角,计算得到摄像机新的前向量。接着可以计算得到新的右向量和上向量。这样我们就可以更新摄像机的位置。

有了摄像机的新位置和新坐标基之后,我们可以通过 LookAtMatrix 函数得到新的视图矩阵。LookAtMatrix 函数在之前的 《空间变换》 文章中已经实现。

代码清单 2.3 视图矩阵
  1. tmath::Mat4f TCameraController::GetViewMatrix()
  2. {
  3.     m_forward.x() = cos(tmath::degToRad(m_yaw)) * cos(tmath::degToRad(m_pitch));
  4.     m_forward.y() = sin(tmath::degToRad(m_pitch));
  5.     m_forward.z() = sin(tmath::degToRad(m_yaw)) * cos(tmath::degToRad(m_pitch));
  6.     m_forward.Normalize();
  7.     //printf("m_forward=%f %f %f\n", m_forward.x(), m_forward.y(), m_forward.z());
  8.  
  9.     tmath::Vec3f right = tmath::cross(tmath::Vec3f(0.0f, 1.0f, 0.0f), m_forward).Normalize();
  10.  
  11.     m_up = tmath::cross(m_forward, right).Normalize();
  12.     //printf("m_up=%f %f %f\n", m_up.x(), m_up.y(), m_up.z());
  13.     if (m_keyState['W'])
  14.         m_position += m_forward * m_moveSpeed;
  15.     if (m_keyState['S'])
  16.         m_position -= m_forward * m_moveSpeed;
  17.     if (m_keyState['A'])
  18.         m_position -= right * m_moveSpeed;
  19.     if (m_keyState['D'])
  20.         m_position += right * m_moveSpeed;
  21.     if (m_keyState['Q'])
  22.         m_position -= m_up * m_moveSpeed;
  23.     if (m_keyState['E'])
  24.         m_position += m_up * m_moveSpeed;
  25.  
  26.     tmath::Vec3f eye = m_position + m_forward;
  27.     //printf("m_position=%f %f %f\n", m_position.x(), m_position.y(), m_position.z());
  28.     return tmath::LookAtMatrix(m_position, eye, tmath::Vec3f(0.0f, 1.0f, 0.0f));
  29. }

最后我们看一下相关变量的初始化。我们通过摄像机的初始位置和看向的位置,得到前向量。然后根据前向量,可以反推得到 yaw 和 pitch 角。

代码清单 2.4 初始化
  1. TCameraController::TCameraController(const tmath::Vec3f& initialPosition, const tmath::Vec3f& eyePosition)
  2.     : m_position(initialPosition), m_lastX(0), m_lastY(0)
  3. {
  4.     m_forward = (eyePosition - initialPosition).Normalize();
  5.     m_yaw = tmath::radToDeg(atan2(m_forward.z(), m_forward.x()));
  6.     m_pitch = tmath::radToDeg(asin(m_forward.y()));
  7. }

4. 测试

我们在之前绘制立方体的测试用例基础上,增加摄像机功能。如代码清单 3 所示,我们首先初始化 TCameraController 变量,然后使用 AddInputHandler 注册即可。

在这个示例中,摄像机在 z 轴 -4 的位置上,看向 z 轴正方向。

此情况下,初始化对应的 yaw 角是 90 度,pitch 角是 0 度。

代码清单 3 测试用例
  1. TCameraRenderTask::TCameraRenderTask(TBasicWindow& win)
  2.     : m_camera(tmath::Vec3f(0.0f, 0.0f, -4.0f), tmath::Vec3f(0.0f, 0.0f, 0.0f))
  3. {
  4.     win.AddInputHandler(&m_camera);
  5. }
  6.  
  7. void TCameraRenderTask::Render(TSoftRenderer& sr)
  8. {
  9.     m_shader.viewMatrix = m_camera.GetViewMatrix();
  10.     sr.ClearColor({ 0,0,0 });
  11.     sr.ClearDepth(1.0f);
  12.  
  13.     sr.DrawElements(TDrawMode::Triangles, 36, 0);
  14. }

示例的运行效果如视频 1 所示。可以看到,我们能够控制摄像机上下、左右、前后移动,也能够控制上下、左右方向上的旋转。。

本章的完整代码见 tag/camera_control

视频 1 摄像机控制