摄像机系统
目前,我们的软件渲染流程已经全部实现完毕。后续,我们会继续在此软件渲染的基础上实现几个案例,验证功能的正确性。
在本篇文章中,我们实现摄像机功能。操作方式和 UE 编辑器中的视口漫游类似:W/S/A/D/Q/E 键控制摄像机的位置平移;按住鼠标右键并移动,控制摄像头的旋转。
1. 添加 Windows 输入事件
因为现在需要响应键盘和鼠标事件,所以需要在之前的 TBasicWindow 类中添加相应的消息处理。
不过在此之前,我们先“抽象”一下逻辑。如代码清单 1.1 所示,我们定义鼠标和键盘的相关事件输入接口。
- class IInputHandler {
- public:
- virtual void OnKeyDown(int virtualKeyCode) = 0;
- virtual void OnKeyUp(int virtualKeyCode) = 0;
- virtual void OnMouseMove(
- int posX,
- int posY,
- bool leftButton,
- bool rightButton,
- bool middleButton) = 0;
- };
接着,如代码清单 1.2 所示,我们添加事件注册方法。
- class TBasicWindow
- {
- public:
- void AddInputHandler(IInputHandler* handler);
- private:
- static std::vector<IInputHandler*> m_inputHandlers;
- };
- void TBasicWindow::AddInputHandler(IInputHandler* handler)
- {
- m_inputHandlers.push_back(handler);
- }
这样,我们就可以在 WindowProc 函数里调用注册好的事件处理函数。如代码清单 1.3 所示,我们添加键盘按下(WM_KEYDOWN)和弹起(WM_KEYUP)的处理,以及鼠标移动(WM_MOUSEMOVE)的处理。
- LRESULT CALLBACK TBasicWindow::WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
- {
- int posX;
- int posY;
- bool leftButton = false;
- bool rightButton = false;
- bool middleButton = false;
- switch (uMsg)
- {
- case WM_DESTROY:
- PostQuitMessage(0);
- return 0;
- case WM_CLOSE:
- DestroyWindow(hwnd);
- return 0;
- case WM_PAINT:
- {
- PAINTSTRUCT ps;
- HDC hdc = BeginPaint(hwnd, &ps);
- // 不在这里使用 BitBlt,因为 WM_PAINT 消息处理可能导致绘制延迟。
- // 为了保证实时渲染,我们会直接在渲染函数中调用 BitBlt。
- //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);
- EndPaint(hwnd, &ps);
- return 0;
- }
- case WM_KEYDOWN:
- for (auto handler : m_inputHandlers)
- handler->OnKeyDown(wParam);
- break;
- case WM_KEYUP:
- for (auto handler : m_inputHandlers)
- handler->OnKeyUp(wParam);
- break;
- case WM_MOUSEMOVE:
- posX = GET_X_LPARAM(lParam);
- posY = GET_Y_LPARAM(lParam);
- leftButton = wParam & MK_LBUTTON;
- rightButton = wParam & MK_RBUTTON;
- middleButton = wParam & MK_MBUTTON;
- for (auto handler : m_inputHandlers)
- handler->OnMouseMove(posX, posY, leftButton, rightButton, middleButton);
- break;
- default:
- return DefWindowProc(hwnd, uMsg, wParam, lParam);
- }
- }
2. 摄像机前向量
摄像机前向量可以通过旋转得到。但这节介绍一种新的方式,可以使用 yaw 和 pitch 角决定一个向量方向。即用 yaw 和 pitch 角定义摄像机的前向量。
和欧拉角的三轴旋转有点不同。
yaw 是偏航角的意思,可以理解成,人左右转头;pitch 是俯仰角的意思,可以理解成,人上下点头。
如图 1 左手坐标系所示,我们设 pitch 角相对于 x-z 平面,即在 x-z 平面上“点头”。可以看到 z 坐标只和 pitch 角相关,我们可以先得到向量的 z 坐标:
- forward.z = sin(pitch)

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

3. 摄像机代码实现
摄像机的前向量定位,是本章中唯一涉及到推导的地方。所以在了解如何定位之后,我们就可以开始实现摄像机相关的代码了。
如代码清单 2.1 所示,我们先大致看一下设计的摄像机类。摄像机类继承 IInputHandler 接口,后续需要实现键盘和鼠标的处理。最终想要获取的是视图矩阵(通过 GetViewMatrix 函数获取),其他变量都是为了获取视图矩阵记录的中间变量。
我们逐帧调用 GetViewMatrix 函数,更新视图矩阵。
- class TCameraController : public IInputHandler
- {
- public:
- virtual void OnKeyDown(int virtualKeyCode) override;
- virtual void OnKeyUp(int virtualKeyCode) override;
- virtual void OnMouseMove(
- int posX,
- int posY,
- bool leftButton,
- bool rightButton,
- bool middleButton) override;
- TCameraController(const tmath::Vec3f& initialPosition, const tmath::Vec3f& eyePosition);
- tmath::Mat4f GetViewMatrix();
- private:
- tmath::Vec3f m_position;
- tmath::Vec3f m_forward;
- tmath::Vec3f m_up = { 0.0f, 1.0f, 0.0f };
- float m_yaw;
- float m_pitch;
- float m_mouseSensitivity = 0.05f;
- float m_lastX, m_lastY;
- bool m_mouseDown = false;
- float m_moveSpeed = 0.01f;
- std::unordered_map<int, bool> m_keyState;
- tmath::Mat4f m_viewMatrix;
- };
我们首先看键盘和鼠标的输入处理。如代码清单 2.2 所示,键盘的输入,我们只先记录当前帧下按下了什么键,最后获取视图矩阵的时候再统一处理。需要记录多个按键的原因是,可能会同时按下多个键。
鼠标移动的逻辑是,只在按下鼠标右键的情况下进行状态更新。鼠标在左、右方向上的移动偏移,我们作为 yaw 角的增量;上、下方向上的偏移,我们作为 pitch 角的增量。
在 windows 窗体中,鼠标坐标系 y 轴向下增长,和图 2 中预定的方向相反。所以 y 的增量要取相反数。
同样看图 2,往 x 轴正方向移动,yaw 角会变小。所以 x 的增量也要取相反数。
- void TCameraController::OnKeyDown(int virtualKeyCode)
- {
- if (virtualKeyCode == 'W' || virtualKeyCode == 'S' ||
- virtualKeyCode == 'A' || virtualKeyCode == 'D' ||
- virtualKeyCode == 'Q' || virtualKeyCode == 'E')
- {
- m_keyState[virtualKeyCode] = true;
- }
- }
- void TCameraController::OnKeyUp(int virtualKeyCode)
- {
- if (virtualKeyCode == 'W' || virtualKeyCode == 'S' ||
- virtualKeyCode == 'A' || virtualKeyCode == 'D' ||
- virtualKeyCode == 'Q' || virtualKeyCode == 'E')
- {
- m_keyState[virtualKeyCode] = false;
- }
- }
- void TCameraController::OnMouseMove(
- int posX,
- int posY,
- bool leftButton,
- bool rightButton,
- bool middleButton)
- {
- if (rightButton == false)
- m_mouseDown = false;
- else
- {
- if (m_mouseDown == false)
- {
- m_lastX = posX;
- m_lastY = posY;
- m_mouseDown = true;
- }
- float offsetX = m_lastX - posX;
- float offsetY = m_lastY - posY;
- m_lastX = posX;
- m_lastY = posY;
- m_yaw += offsetX * m_mouseSensitivity;
- m_pitch += offsetY * m_mouseSensitivity;
- m_pitch = std::clamp(m_pitch, -89.0f, 89.0f);
- }
- }
获取视图矩阵的逻辑,如代码清单 2.3 所示,我们通过最新状态的 yaw 和 pitch 角,计算得到摄像机新的前向量。接着可以计算得到新的右向量和上向量。这样我们就可以更新摄像机的位置。
有了摄像机的新位置和新坐标基之后,我们可以通过 LookAtMatrix 函数得到新的视图矩阵。LookAtMatrix 函数在之前的 《空间变换》 文章中已经实现。
- tmath::Mat4f TCameraController::GetViewMatrix()
- {
- m_forward.x() = cos(tmath::degToRad(m_yaw)) * cos(tmath::degToRad(m_pitch));
- m_forward.y() = sin(tmath::degToRad(m_pitch));
- m_forward.z() = sin(tmath::degToRad(m_yaw)) * cos(tmath::degToRad(m_pitch));
- m_forward.Normalize();
- //printf("m_forward=%f %f %f\n", m_forward.x(), m_forward.y(), m_forward.z());
- tmath::Vec3f right = tmath::cross(tmath::Vec3f(0.0f, 1.0f, 0.0f), m_forward).Normalize();
- m_up = tmath::cross(m_forward, right).Normalize();
- //printf("m_up=%f %f %f\n", m_up.x(), m_up.y(), m_up.z());
- if (m_keyState['W'])
- m_position += m_forward * m_moveSpeed;
- if (m_keyState['S'])
- m_position -= m_forward * m_moveSpeed;
- if (m_keyState['A'])
- m_position -= right * m_moveSpeed;
- if (m_keyState['D'])
- m_position += right * m_moveSpeed;
- if (m_keyState['Q'])
- m_position -= m_up * m_moveSpeed;
- if (m_keyState['E'])
- m_position += m_up * m_moveSpeed;
- tmath::Vec3f eye = m_position + m_forward;
- //printf("m_position=%f %f %f\n", m_position.x(), m_position.y(), m_position.z());
- return tmath::LookAtMatrix(m_position, eye, tmath::Vec3f(0.0f, 1.0f, 0.0f));
- }
最后我们看一下相关变量的初始化。我们通过摄像机的初始位置和看向的位置,得到前向量。然后根据前向量,可以反推得到 yaw 和 pitch 角。
- TCameraController::TCameraController(const tmath::Vec3f& initialPosition, const tmath::Vec3f& eyePosition)
- : m_position(initialPosition), m_lastX(0), m_lastY(0)
- {
- m_forward = (eyePosition - initialPosition).Normalize();
- m_yaw = tmath::radToDeg(atan2(m_forward.z(), m_forward.x()));
- m_pitch = tmath::radToDeg(asin(m_forward.y()));
- }
4. 测试
我们在之前绘制立方体的测试用例基础上,增加摄像机功能。如代码清单 3 所示,我们首先初始化 TCameraController 变量,然后使用 AddInputHandler 注册即可。
在这个示例中,摄像机在 z 轴 -4 的位置上,看向 z 轴正方向。
此情况下,初始化对应的 yaw 角是 90 度,pitch 角是 0 度。
- TCameraRenderTask::TCameraRenderTask(TBasicWindow& win)
- : m_camera(tmath::Vec3f(0.0f, 0.0f, -4.0f), tmath::Vec3f(0.0f, 0.0f, 0.0f))
- {
- win.AddInputHandler(&m_camera);
- }
- void TCameraRenderTask::Render(TSoftRenderer& sr)
- {
- m_shader.viewMatrix = m_camera.GetViewMatrix();
- sr.ClearColor({ 0,0,0 });
- sr.ClearDepth(1.0f);
- sr.DrawElements(TDrawMode::Triangles, 36, 0);
- }
示例的运行效果如视频 1 所示。可以看到,我们能够控制摄像机上下、左右、前后移动,也能够控制上下、左右方向上的旋转。。
本章的完整代码见 tag/camera_control。