拓冰建站拓冰建站
首页 / 资讯中心 / 正文

相机变换核心拆解:从向量矩阵到视图投影的实现指南

不知道你有没有过这种经历自己动手写一个渲染引擎网格加载好了模型也转起来了结果一到相机部分屏幕上什么都出不来或者出现各种扭曲、翻转、错位的画面。我最早实现一个最小引擎时就卡在这一步最后发现只是一行矩阵乘法顺序写反了于是花了两个通宵排查。Camera Transformations相机变换看起来只是一个摆好相机位置再投影的过程但背后牵扯到的数学基础一旦没打牢后续所有东西都会出问题。这篇文章我就想把这些数学基础拆开聊透从向量、矩阵、齐次坐标到视图矩阵、透视投影再到最终屏幕映射带你从零搭起一套能用的相机变换流程。这篇文章适合谁如果你正在写自己的小引擎或者刚接触图形学不想只停留在调用API的层面想搞清楚坐标变换背后的原理那它应该对你挺有用。我会尽量把公式背后的直觉讲清楚也会把实际写代码时容易踩的坑一个一个列出来让你在动手前就避开。1. 为什么先从数学基础聊起1.1 一个相机视角牵扯出整个渲染管线简单引擎要做的事压缩成一句话就是把三维场景里的几何体通过一系列变换映射到二维屏幕上。这一系列变换通常被拆成几段模型变换Model、视图变换View、投影变换Projection以及最后的视口变换Viewport。平时大家喜欢叫它MVP矩阵指的就是Model、View、Projection三者相乘。相机变换在其中承担确定观察角度和成像方式的职责。模型变换负责把物体放到世界场景中投影变换负责把三维坐标压成二维而视图变换则回答了一个更基本的问题如果场景里站了一个人相机他看到的世界到底长什么样这三个变换是串在一起的前面的输出就是后面的输入任何一个环节出错屏幕上都会出现诡异的结果。很多人一开始会觉得相机不就是站在某个位置往前看吗有什么好研究的但真正动手时就会发现光是往前看这个动作就需要回答几个问题眼睛在哪里、看向哪里、哪个方向是上这三个问题看似简单一旦把它们变成数学运算再让场景里成千上万个顶点都经过这套运算中间任何一步错了画面就是错的。不少教程在这一步习惯一笔带过直接丢一个gluLookAt或者glm::lookAt让你去调结果就是代码敲出来了但完全不知道它在干什么。这也是我想专门写一篇数学基础的原因。1.2 向量和矩阵别急着背公式先建立空间直觉3D引擎里最基础的工具就是向量和矩阵。向量表示点或者方向矩阵表示对这些点、方向的变换。你可以把矩阵理解成一种空间操作说明书它告诉空间里每个顶点该怎么移动、旋转、缩放。一旦你建立了这个直觉后面的代码看起来就不会那么像天书。举个例子有一个向量v和一个矩阵M计算M乘以v得到v可以理解为把v按照M的规则操作了一遍。多个矩阵连乘就是按顺序执行多个操作。这里我想特别强调顺序问题矩阵乘法不满足交换律先旋转再平移和先平移再旋转结果通常不一样。你在引擎里看到MVP这类表达式时建议养成从右往左读的习惯因为最右边的矩阵最先作用于顶点。我见过不少初学者在向量和矩阵上反复摔跤问题往往不是公式记不住而是没有建立矩阵是变换的直觉。回到相机变换的场景当你看到一个视图矩阵你要能在脑子里想象出它做的事情是把整个世界搬到相机面前然后把相机摆回原点再去计算所有物体相对相机的位置。有了这个画面感后面很多问题会迎刃而解。1.3 齐次坐标到底解决什么问题说到矩阵变换就绕不开齐次坐标。很多图形学教材一上来就丢出一个4x4矩阵看得人一头雾水其实它的存在有非常实际的原因3x3矩阵根本处理不了平移。平移是加法操作而线性变换只包含乘法和加法中的乘法部分所以如果只用一个3x3矩阵你没法把把一个点从(1,2,3)移动到(4,5,6)这件事表示成矩阵乘法。解决办法就是升维把坐标从(x, y, z)写成(x, y, z, w)也就是所谓的齐次坐标这样平移操作也能被塞进矩阵乘法里。在齐次坐标里一个点通常是(x, y, z, 1)方向则是(x, y, z, 0)。这个约定很关键对点做平移它会真正移动位置对方向做平移它不应该有任何变化。w分量等于0就相当于告诉矩阵我是方向你的平移部分别碰我。写引擎时你会频繁用法线方向、视线方向这些都不该被平移影响所以这个约定非常实用。到这里你可以理解为什么引擎里几乎所有的数学库都统一用4x4矩阵和四维向量来处理顶点。它不是为了故意复杂化而是把旋转、缩放、平移、透视投影全部统一到了同一种计算框架里。搞清楚这个背景后我们就能正式开始拆解相机变换的每一步了。2. 相机变换的核心从世界空间到屏幕空间2.1 视图变换把相机摆正相机变换的第一步是视图矩阵View Matrix。它的作用非常简单把世界坐标系中的顶点变换到以相机为中心的坐标系中。你可以这么想象世界场景里有一台相机它朝某个方向倾斜位于某个位置在World Space里描述它的坐标、朝向都要牵涉很多数值。视图矩阵要做的事情就是重新描述整个场景以相机为参照系让相机位于原点让相机的前方指向某个固定轴。最常见的构建视图矩阵的方法是lookAt。你需要提供三个参数眼睛位置eye、目标点center、上方向up。它的思路分三步先用normalize(center - eye)算出相机的前方向forward再用forward和up做叉积得到右方向right最后用right和forward做叉积得到真正的上方向trueUp。这样相机的三个基向量就齐了。这三个基向量教会我们相机坐标系在世界坐标系中是怎么摆放的。然后视图矩阵就是把这个摆放关系反过来用——也就是把世界坐标系的点映射到以相机为原点的坐标系里。很多教程给出的lookAt矩阵是一个3x3旋转矩阵和一个平移向量的合并。从直觉上说你先把场景整体平移到相机位置再旋转到相机的姿态这样场景里每个顶点就变成了相对相机的位置也就是View Space。这一变换用到的数学不复杂就是向量的点积、叉积加上矩阵求逆的概念。不过实际写的时候叉积方向容易搞反导致相机左右手性颠倒、画面被镜像这些都是新手常遇到的问题。2.2 投影变换近大远小是怎么算出来的有了视图矩阵世界坐标就变成了相机坐标。但相机看到的是三维空间最终屏幕只是二维平面所以还需要一个投影变换Projection Matrix把三维坐标压到二维的平面上。投影分为两种正交投影和透视投影。正交投影的特点是不管物体距离相机多远它在屏幕上的大小都不变。这种投影适合工程制图、UI渲染、以及部分2D游戏。透视投影则更像人眼近大远小越远的东西看起来越小。游戏和3D渲染里用的绝大多数是透视投影因为它更能模拟真实观察感知。透视投影矩阵的输入参数一般是field of view视场角通常用垂直方向的角度、aspect ratio宽高比、near plane近裁剪面和far plane远裁剪面。这四个参数一起定义了一个视锥体Frustum。视锥体内部的东西会被渲染出来外部会被裁剪掉。而投影矩阵的任务就是把视锥体里的坐标映射到一个标准立方体中这个立方体叫NDCNormalized Device Coordinatesx、y、z范围通常是[-1, 1]。透视投影的推导核心是利用相似三角形。想象一个物体点P在相机空间里它的深度是z。它投影到近裁剪面上时x坐标会按比例缩小缩小的比例就是near / z。这也是近大远小的数学来源。如果你把每个顶点的x、y都乘上这个比例再把结果除以w分量就得到了NCC坐标。投影矩阵做的就是把这套计算塞进4x4矩阵里。看完公式后你会发现矩阵里很多元素并不是随便放的它们和fov、aspect、near、far有明确的对应关系。2.3 视口变换最后一步映射投影矩阵输出的是NDC坐标范围在[-1, 1]之间。但屏幕上的像素坐标可不是[-1, 1]比如一个窗口宽度是1280高度是720x坐标应该在0到1280之间y坐标在0到720之间。这就需要一个视口变换Viewport Transform把NDC坐标映射到实际的屏幕像素坐标。视口变换本身不复杂公式也比较直观屏幕x坐标等于NDC的x1再乘上一半的窗口宽度屏幕y坐标等于NDC的y1再乘上一半的窗口高度。如果你的渲染目标是窗口还要考虑DPI缩放如果目标是纹理或离屏缓冲则要指定对应的宽高。很多小的图形库会内置这个步骤你只需要传一个viewport矩形进去就行。但这里有一个值得注意的点NDC坐标系里y轴是向上还是向下不同图形API的约定不一样。OpenGL默认NDC y轴向上而屏幕像素习惯的y轴是向下。在写视口变换时如果换算不对画面就会上下颠倒。这也是新手经常遇到的明明MVP全对但画面翻转的常见原因之一。3. 实操用一个最小引擎实现相机变换3.1 数据结构和初始化动手写代码之前我先定义几个基础数据结构。你可以用现成的数学库比如glm、DirectXMath但我建议至少在纸上或代码里自己推一遍加深理解。下面是我在最小引擎里常用的几种类型struct Vec3 { float x, y, z; }; struct Vec4 { float x, y, z, w; }; struct Mat4 { float m[4][4]; // 行优先存储m[row][col] };存储方式上你要先决定用行主序还是列主序。这个决定会影响后续所有矩阵乘法、以及和图形API对接时的写法。如果你用OpenGL驱动默认按列主序读取矩阵如果你用DirectX习惯是行主序。自己写引擎的话我建议选一种并坚持到底中间不要频繁换否则容易把自己绕晕。我这里先用行主序做演示因为可读性更好如果对接OpenGL注意在最终传给glUniformMatrix4fv时转置或换读取方式。矩阵乘法是引擎里最频繁的运算实现时要避免多余的分配和拷贝。我会直接放在一个函数里Mat4 multiplyMat4(const Mat4 a, const Mat4 b) { Mat4 result; for (int row 0; row 4; row) { for (int col 0; col 4; col) { float sum 0.0f; for (int k 0; k 4; k) { sum a.m[row][k] * b.m[k][col]; } result.m[row][col] sum; } } return result; }这段代码很直白就是按矩阵乘法的定义来的。不过每次调用都返回一个Mat4如果项目规模变大性能上会有些压力。自己写引擎可以先用它保证正确性等跑通之后再用引用参数和内存复用来优化。3.2 生成视图矩阵的代码实现接下来是lookAt的实现。这里我会给出一个完整的函数并且把每一步的数学直觉标出来Mat4 lookAt(const Vec3 eye, const Vec3 center, const Vec3 up) { Vec3 forward normalize(center - eye); // 相机前方 Vec3 right normalize(cross(forward, up)); // 右侧方向 Vec3 trueUp cross(right, forward); // 真正的向上方向 Mat4 result identityMat4(); // 旋转部分把世界坐标映射到相机坐标系 result.m[0][0] right.x; result.m[0][1] right.y; result.m[0][2] right.z; result.m[1][0] trueUp.x; result.m[1][1] trueUp.y; result.m[1][2] trueUp.z; result.m[2][0] -forward.x; result.m[2][1] -forward.y; result.m[2][2] -forward.z; // 平移部分将相机位置移到原点 result.m[0][3] -dot(right, eye); result.m[1][3] -dot(trueUp, eye); result.m[2][3] dot(forward, eye); return result; }你可能会困惑为什么旋转矩阵里放的是right、trueUp、forward这些向量而平移部分却是它们和eye的点积。这是因为视图矩阵的本质是把世界坐标系旋转到相机坐标系再平移对齐的组合你可以先把它当成一个经验结果来用跑通之后再对着矩阵乘法公式验证几遍。代码里有几个细节容易翻车。叉积的顺序如果写反了相机就会左右翻转。这里我用的顺序是cross(forward, up)得到right然后cross(right, forward)得到trueUp。如果你用左手坐标系叉积规则、forward的取负方式都会不同所以一定要先确定自己用的坐标系。我在调试时经常加一些临时日志比如把某个已知点的视图坐标打出来看看它在相机的左侧还是右侧来验证是否镜像。3.3 生成投影矩阵的代码实现透视投影矩阵的推导看起来复杂但最后落地的代码其实很规整。先确认几个参数fov是垂直视场角弧度制aspect是窗口宽高比width/heightnear和far是近远裁剪面到相机的距离。下面是常见实现Mat4 perspective(float fov, float aspect, float near, float far) { Mat4 result zeroMat4(); float f 1.0f / tanf(fov / 2.0f); result.m[0][0] f / aspect; result.m[1][1] f; result.m[2][2] (far near) / (near - far); result.m[2][3] (2.0f * far * near) / (near - far); result.m[3][2] -1.0f; return result; }这里有几个地方要解释一下。m[0][0]是f / aspect这是为了让水平方向的缩放和垂直方向保持一致从而保持画面不变形。m[3][2]等于-1它的作用是让每个顶点在矩阵乘法之后w分量变成原始的-z。后面做透视除法把xyz都除以w时就自然实现了远的东西被缩小的效果。如果你看过网上别的版本的透视矩阵可能符号会有差异一般是因为坐标系方向或者行主序/列主序不同造成的本质上是一样的。ortho正交投影矩阵就更简单了它不做透视除法只是把视锥体线性映射到NDC。你的引擎如果暂时用不到可以先不做。但在调试场景里正交投影往往很有用因为它不会受到深度精度和透视变形的影响问题更容易定位。3.4 把顶点送进渲染管线有了视图矩阵和投影矩阵最后一步就是把它们和模型矩阵结合起来。假设场景里有一个立方体每个顶点都要经过如下变换Mat4 mvp multiplyMat4(projection, multiplyMat4(view, model)); Vec4 clipPos multiplyMat4Vec4(mvp, Vec4(vertexPos, 1.0f)); // 透视除法 Vec3 ndcPos Vec3(clipPos.x / clipPos.w, clipPos.y / clipPos.w, clipPos.z / clipPos.w); // 视口变换 float screenX (ndcPos.x 1.0f) * 0.5f * screenWidth; float screenY (1.0f - ndcPos.y) * 0.5f * screenHeight;这里你会看到模型矩阵model把物体的本地坐标变成世界坐标view把世界坐标变成相机坐标projection把相机坐标变成裁剪坐标裁剪坐标再除以w变成NDC最后视口变换映射到屏幕。全程只有一次矩阵乘法和一次除法其他都是固定的线性变换。如果你在写OpenGL着色器上面这些流程会被拆分到CPU端和GPU端。CPU负责计算MVP矩阵GPU的顶点着色器把MVP和每个顶点做乘法再交给硬件做透视除法和视口变换。代码结构不一样但数学本质完全一样。我一般建议先在一个纯CPU的小程序里把顶点坐标手算一遍打印出来验证确认无误后再搬到GPU上这样调试效率会高很多。4. 常见问题与排查技巧实录4.1 矩阵乘法顺序搞反画面直接没了这是我最常遇到的错误也是排查时间最长的问题。MVP矩阵的乘法顺序是projection * view * model也就是说渲染一个顶点时先经过model再经过view最后经过projection。表面上看三个矩阵连乘只要顺序别乱就行但很多数学库的乘法实现还有行主序和列主序的差别导致同一个乘法在不同库里看起来方向正好反过来。一个非常有效的排查技巧是写一个已知点比如原点(0,0,0)通过变换后的结果来验证矩阵是否正常。如果你的相机在原点看向-z方向那么原点经过view变换后应该还是接近原点如果结果混乱就说明矩阵乘法顺序有问题。另一个技巧是在CPU端先用简单数据做单元测试把矩阵和顶点都打印出来逐行核对别一上来就丢给GPU否则连报错都很难读。4.2 坐标系统一左手还是右手OpenGL传统上使用右手坐标系相机看向-z方向DirectX和部分引擎使用左手坐标系相机看向z方向。你说的前方到底是z还是-z直接决定了叉积公式、投影矩阵里zn的大小符号、还有深度缓冲的存储方式。一个常见的症状是模型在Maya或Blender里看着正常导进引擎后左右镜像其实就是坐标系统一手性问题没处理好。我的建议是从第一天起就明确写下本引擎使用右手坐标系相机前方为-z然后所有相关计算都向这个约定看齐。如果你需要对接外部资源就在加载阶段做一次坐标转换比如把z取反而不要在相机矩阵里偷偷修正。这样虽然前期会多几行代码但能省掉大量后期排查时间。4.3 万向锁和四元数相机绕三个轴旋转时如果用欧拉角Pitch、Yaw、Roll累加很容易遇到一个叫万向锁的问题当俯仰角接近90度时方位角和翻滚角会出现退化相机姿态变得奇怪甚至无法平滑旋转。很多人在做自由相机时发现镜头突然卡住或乱转原因就在这里。要解决这个问题更稳妥的方案是用四元数来表示旋转。四元数的数学基础不复杂你可以把它理解成带三个虚部的复数也是一种旋转表示方式。相机旋转时用四元数做插值和累积再把四元数转换成旋转矩阵最后作为视图矩阵的旋转部分使用。对于灯光、天空盒、角色骨骼这类频繁旋转的组件四元数几乎是最佳实践。虽然四元数的公式初看有点绕但它们能绕开万向锁而且代码实现也非常固定值得花时间去掌握。4.4 深度精度问题深度缓冲的精度是相机相关的一个经典坑。投影矩阵计算出的深度值在近裁剪面附近变化剧烈在远裁剪面附近变化平缓这导致near和far的比例过大的时候会出现z-fighting也就是物体表面闪烁、破面。典型场景是near设为0.001far设为10000结果画面远景处开始疯狂闪烁那就是深度精度不够了。解决办法有几个一是把near尽量调大比如改成0.1或者更大能有效改善近处精度二是提高深度缓冲位数从16位换成24位或32位三是如果场景特别大可以考虑使用对数深度缓冲或反向z技巧。反向z就是把深度比较逻辑反过来让远平面附近的精度更高这在现代渲染引擎里很常用。平时调试时如果看到闪烁先检查near和far的比例不要急着怀疑渲染逻辑。最后再分享一个调试小技巧相机变换写完之后你最好准备一条永远不应该出错的测试数据。比如我做的第一件事是在原点放一个三角形相机放在(0,0,3)朝原点看投影矩阵用一个合理的fov。如果画面能正确显示这个三角形再逐步增加复杂度。这个测试比任何理论检查都来得快因为当你的引擎开始出问题时你手里有一个确定的基准能很快定位是哪一步变了。我自己在最开始实现相机变换时踩得最多的是坐标系统一和矩阵顺序这两个坑几乎每个小引擎项目都会遇到一次。后来我养成一个习惯把所有矩阵相关的工具函数集中到一个文件里并且写上注释说明当前用的是行主序还是列主序、是左手还是右手相当于给自己立规矩。这个习惯推荐给你等你的引擎越写越大你会发现它能在关键时刻救你一命。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门