搞渲染或者引擎底层相关的东西,绕不开“投影矩阵”(Projection Matrix)这个东西。很多人蓝图里拖个节点就能拿到手,但一旦遇到自定义渲染、深度重建、镜像平面、特殊镜头效果,就得亲自跟矩阵打交道。这篇就按我实际摸过的路子,把UE4里的投影矩阵掰开揉碎讲一遍,原理、获取方式、实操案例和踩坑记录都在里面了。
1. 投影矩阵到底在做什么:从三维场景到屏幕的那一步
1.1 一张屏幕截图背后的完整变换链路
你先想一个问题:场景里一个三维点,凭什么最后能变成屏幕上某个像素?中间其实经历了四步:模型空间到世界空间,世界空间到视图空间(也就是相机空间),视图空间到裁剪空间,裁剪空间做透视除法变成NDC(归一化设备坐标),最后视口变换映射到屏幕像素坐标。
投影矩阵干的就是第三步:把视图空间里的视锥体(Frustum)压成一个标准裁剪立方体。透视投影和正交投影的矩阵长不一样,本质区别就在“xyz怎么压、w怎么参与”。透视投影里,w分量会被设置成视图空间的深度值,这就是近大远小的根源;正交投影里w恒为1,所以没有透视缩放。
很多人把“投影”理解成“把3D变成2D”,这个说法不严谨。投影矩阵之后还没有真正变成2D,它只是把视锥体变成了裁剪空间的立方体,真正的降维发生在透视除法(除以w)之后。
1.2 为什么UE4要单独拧出投影矩阵来讲
因为UE4在现代图形API的约定上有自己的习惯。首先,UE4是左手坐标系,Z轴朝上,相机朝+X方向看,这跟很多图形学教材里的右手坐标系不一样。其次,UE4在绝大多数平台上默认使用反向Z(Reversed-Z)深度,近裁剪面映射到深度1.0,远裁剪面映射到0.0,这样能大幅提升深度的浮点精度。
这两个习惯直接决定了矩阵长得跟教材上的公式不完全一样。你如果直接拿一篇OpenGL教程里的矩阵塞进UE4,大概率渲染出来是错的。这也是项目里经常有人“矩阵调不动”的真实原因——不是矩阵本身错了,是约定没对齐。
2. UE4里投影矩阵的实现细节与获取方式
2.1 UE4坐标系约定和矩阵存储方式,先把这个搞明白
UE4的FMatrix在内存里是一维数组,M[0][0]到M[3][3],但它在HLSL Shader里通常配合mul(float4(pos, 1.0), Matrix)这种写法,也就是行向量左乘矩阵。很多从OpenGL/OpenGL ES迁过来的人习惯mul(Matrix, vec4(pos, 1.0)),拿到UE4就发现矩阵要转置一下。
这里有一个我实战里养成的习惯:碰到从网上扒来的投影矩阵公式,先在CPU端打印一遍UE4自己的投影矩阵,跟自己构造的矩阵做对比,元素对不上就先转置再对比,别盲改Shader。UE4源码里FMatrix的存储虽然看着像行优先,但跟渲染管线的整体约定是配套的,你要改的是自己那颗“外来矩阵”。
2.2 FPerspectiveMatrix与反向Z:UE4默认投影矩阵的真相
UE4里常用的透视投影矩阵封装有几个:FPerspectiveMatrix、FPerspectiveMatrixFov、FReversedZPerspectiveMatrix等。默认情况下,场景视图的投影矩阵是带反向Z的,也就是把原来的z映射公式反过来用。
反向Z的好处在于:浮点深度缓冲在近处精度高、远处精度低,而一般的深度线性化之后分母里有far值,导致远处精度浪费严重。反向Z把1.0留给近处,0.0留给远处,配合浮点缓冲能把精度分布调得跟视觉敏感度正好匹配。理解了这一点,你就知道为什么自己手动搭一个标准透视矩阵时,场景里会出现离相机越远越容易抖动花片的诡异现象——因为你的深度范围和平台默认的深度约定反了。
2.3 C++和蓝图中获取投影矩阵的几种实用姿势
蓝图层面上,最常见的节点是“Project World Space To Screen Position”和“Get View Size”,这两个节点底层就是在用投影矩阵做换算。你不需要手动拼接,但如果你需要的是原始矩阵值,蓝图里没有现成节点,必须走C++。
在C++里,我常用的获取位置是在FSceneView的ViewMatrices结构里。具体来说:
const FViewMatrices& ViewMatrices = View.ViewMatrices; const FMatrix& ViewProjectionMatrix = ViewMatrices.GetViewProjectionMatrix(); const FMatrix& ProjectionMatrix = ViewMatrices.GetProjectionMatrix(); const FMatrix& ViewMatrix = ViewMatrices.GetViewMatrix();FViewMatrices里还提供了GetInvProjectionMatrix()和GetInvViewProjectionMatrix(),做深度重建或者反向投影时直接拿逆矩阵,省得自己求逆。这里有个提醒:FSceneView的投影矩阵默认包含了反向Z和平台差异(比如某些移动平台甚至会用不对称投影矩阵做单眼渲染),所以一定不要用自己MakeProjectionMatrix的普通矩阵去替代它。
3. 实操案例:三个能落地能跑的投影矩阵玩法
3.1 从屏幕像素反推世界坐标,自己动手写深度重建
屏幕空间特效、自定义水波纹、后处理里做体积光,经常需要从当前像素的UV和深度反推世界坐标。思路很清楚:拿到屏幕UV对应的NDC坐标,再把NDC坐标乘上“视图投影矩阵的逆”,就能得到世界空间位置。
在材质编辑器里,我常用的Custom节点写法大致长这样:
float2 UV = screenPosition * ViewSizeAndInvSize.zw; // 或者直接用 ScreenPosition 节点 float DeviceDepth = GetSceneTextureUV(Parameters, 14).r; // 这里是场景深度,非反向Z需额外处理 float4 ndc; ndc.x = UV.x * 2.0 - 1.0; ndc.y = (1.0 - UV.y) * 2.0 - 1.0; // 注意Y轴翻转 ndc.z = DeviceDepth; ndc.w = 1.0; float4 worldPos = mul(ndc, ResolvedView.InverseViewProjection); worldPos.xyz /= worldPos.w;注意几点:设备的深度在非反向Z情况下需要做Depth = Depth * 2.0 - 1.0的换算;但在反向Z管线里,深度值本身就是反的,如果直接按标准公式来,重建出来的位置会在相机身后乱跑。判断自己项目是不是反向Z,最快的办法就是C++里打印FReversedZPerspectiveMatrix之类的类型,或者看第一个深度值在近裁剪面时是接近1还是接近0。
这个案例做出来后,你可以顺便加一个“点击屏幕高亮像素对应的世界坐标点”的小工具,调试其他渲染问题时特别有用。
3.2 用自定义宽高比和FOV做“镜头畸变”风格的投影
某些演出效果或者游戏开场动画里,需要模拟鱼眼镜头的透视畸变,或者瞬间改变FOV营造速度感。直接修改主相机的FOV在蓝图里也能做,但颗粒度太粗,而且会导致UI、粒子等所有跟随相机的东西都变。更好的方式是单独构造一个投影矩阵,用在特定的网格材质上,或者做全屏后处理。
举个例子,假设你要做一个“边缘拉伸”的伪鱼眼效果,思路是改变透视除法时使用的w值。标准透视矩阵中,w分量来自视图空间的 -z,也就是深度。你可以把矩阵最后一行改成“部分乘以一个系数,这个系数依赖x或y”,让同一个像素在不同屏幕位置拥有不同的“缩放程度”。
具体的矩阵改法不唯一,我给出一个参数化的伪代码思路:
// 假设 baseFov = 90度,scaleFactor 控制畸变强度 float fovRad = FMath::DegreesToRadians(BaseFov); float cot = 1.0f / FMath::Tan(fovRad * 0.5f); FMatrix DistortionProj = FMatrix::Identity; DistortionProj.M[0][0] = cot / AspectRatio; DistortionProj.M[1][1] = cot; DistortionProj.M[2][2] = -(Far + Near) / (Far - Near); DistortionProj.M[2][3] = -1.0f; DistortionProj.M[3][2] = -2.0f * Far * Near / (Far - Near); // 关键:让 w 分量随屏幕位置变化 DistortionProj.M[3][0] = 0.3f; DistortionProj.M[3][1] = 0.3f;上面这个矩阵第4行不再只由z决定,而是掺入了x和y的信息,透视除法的分母在不同位置就会不同,从而出现桶形畸变。不过要提醒的是:这种做法会让影子和深度计算变得不可预测,只适合后处理或非光照的装饰性网格,不适合一上来就替换主相机的投影矩阵。
3.3 镜像/传送门效果里的反射视角:投影矩阵的“歪用”
做镜面反射或传送门效果时,核心不只是“画一个平面”,而是让相机相对于反射平面翻转,拿到一张正确的反射纹理。这里其实分两步:视图矩阵要翻转,投影矩阵的裁剪面也要跟着调整,才能把反射后视锥体以外的内容正确切掉。
我在某个模拟项目里实现过一个简版镜面:复制一个SceneView,然后手工修改视图矩阵的Y轴方向(把相对于镜面的观察方向翻转),再把近裁剪面设置为镜面到相机距离减去一个小的偏移,避免反射内容穿到相机背后导致画面撕裂。
投影矩阵在这一步不需要大改,但必须同步更新近远裁剪面。如果只翻转视图矩阵不调整投影矩阵,反射画面里会出现物体被“砍掉一半”或者被背后遮挡的现象,像是有一道看不见的墙。
具体代码我不全贴了,核心是:
// 在CustomDepthPass或者SceneExtension里 NewViewMatrix = SceneView->ViewMatrices.GetViewMatrix(); // 对镜面平面做反射 NewViewMatrix.M[0][2] = -NewViewMatrix.M[0][2]; NewViewMatrix.M[1][2] = -NewViewMatrix.M[1][2]; NewViewMatrix.M[2][2] = -NewViewMatrix.M[2][2]; NewViewMatrix.M[3][2] = -NewViewMatrix.M[3][2]; ProjMatrix = SceneView->ViewMatrices.GetProjectionMatrix(); // 保证近裁剪面不会比镜面还近 float DistanceToMirror = FVector::DotProduct(...); float NewNear = FMath::Max(1.0f, DistanceToMirror - 5.0f); // 重新生成投影矩阵这里面的关键教训是:不要试图手动“补”一个翻转矩阵,而是把视图矩阵里跟镜面法线相关的分量直接翻转,投影矩阵只改近裁剪面。这套路说白了就是“渲染半条命”的老玩法,但放到UE4里细节特别多,一帧里要处理的东西比想象中多。
4. 投影矩阵常见问题与排查实录
4.1 画面拉伸变形、深度闪烁、转置迷局
我整理了一张实操问题速查表,做渲染相关调试时基本都能用到。
| 现象 | 最可能的原因 | 怎么处理 |
|---|---|---|
| 场景物体横竖比例不对,圆球变椭圆 | 宽高比(Aspect)算错,投影矩阵使用了错误的ViewWidth/ViewHeight | 核对一下用到的分辨率是“视图实际渲染分辨率”还是“视口UI分辨率”,别混用 |
| 远处物体疯狂闪烁,近处正常 | 深度精度不够,常见于near/far比例过大或者改了非反向Z矩阵 | 调小Far/Near比值、开启反向Z,或者把深度缓冲改成浮点格式 |
| 自定义Shader里位置对不上,世界空间乱飞 | 矩阵乘法顺序错了,或者行列向量约定不一致 | 先打印一次引擎原始矩阵,和自定义矩阵逐元素对比,确认行优先还是列优先 |
| 渲染结果和蓝图节点算出来差一点点 | 没考虑平台特定的裁剪矩阵(比如VR单目渲染的不对称投影) | 获取ProjectionMatrix时用FSceneView平台裁剪的那一套,不要自己重建 |
深度闪烁这个我多说一点。假设你的Near是10,Far是10000,场景又比较大,传统的24位深度缓冲下,只要相机稍微移动一点,巨远处的山体交界线就会疯狂跳动。但如果你把Far改成5000,Near保持10,又会看不到很远的东西。这种情况下有的团队会改用“对数深度”或者“反向Z”。在UE4里,最简单的路径反而是保证自己不要覆盖引擎默认的投影矩阵,因为默认矩阵已经做了大部分优化。
4.2 用RenderDoc和断点调试投影矩阵
排查渲染问题,我最推荐的思路是分三路走。
第一路是抓RenderDoc。RenderDoc的Mesh Viewer可以看每一帧VS(顶点着色器)输入的常量和输出。如果发现网格跑飞到十万八千里外,直接看VS输出的Clip Position,跟CPU端的投影矩阵算出来的结果做比对,很快就能锁定是矩阵的问题还是Shader代码的问题。
第二路是在C++里断点打印。在SceneView的调DisplayDebug或自定义延迟渲染插件代码里,拿到ViewProjection矩阵然后打印关键元素,比如M[2][2]和M[3][2]。反向Z矩阵和标准矩阵这两个位置的值差异极其明显,一眼就能看出来你是在用默认矩阵还是一颗“野生矩阵”。
第三路是写一个像素调试器,把深度值和屏幕UV编码成颜色输出到屏幕。算出来之后跟“Project World Space To Screen Position”节点结果交叉验证,如果偏差超过0.5像素,那基本可以断定是矩阵或者UV翻转的问题,不用去怀疑数学公式本身。
5. 关于改动投影矩阵,我想说的几件小事
5.1 别乱动SceneView的三条军规
每一条都是从实际项目里摔出来的:
第一条,不要在渲染线程中途直接改SceneView->ViewMatrices的投影矩阵。那玩意儿是每帧同步好的,读的地方很多,你中途改了,后处理、阴影、粒子、UI全都跟着乱。真要改,就在CameraManager或者SceneViewExtension里做一个副本,然后在合适的位置覆盖渲染用的矩阵。
第二条,凡是涉及深度重建、屏幕空间光线步进、自定义ShadowProjection的,永远要用“同一份”视图投影矩阵。有时候你会因为省事分别拿GetProjectionMatrix()和GetViewMatrix()再拼一次,但里面可能有一个已经包含平台差异,一个没包含,最后差之毫厘谬以千里。
第三条,不要随手复用其他项目的投影矩阵代码。不同引擎的投影矩阵公式看着差不多,但Z的范围(0到1还是-1到1)、Y轴朝向(向上还是向下)、坐标系手性(左手还是右手)这三板斧只要有一个不一样,渲染出来就是完全不同的画面。复制粘贴前先花十分钟把MakeProjectionMatrix的参数和函数注释看清楚。
5.2 一正一反两个实际项目踩坑记录
先讲一个反面的。之前做某个跨平台系统项目,我在PC上自定义了一个投影矩阵,做某种“非均匀缩放镜头”的特效,PC上看起来一切正常。打包到某移动设备上一看,画面直接半边全绿半边全红,后来排查半天发现是移动端的旋转屏幕逻辑会重新构造视口,而我的自定义矩阵没有跟着朝向一起旋转,导致投影矩阵和视口尺寸交叉错位。从那以后,我养成了一个习惯:在项目里写一个“矩阵链路自检”的隐藏调试模式,每帧打印视口宽高、投影矩阵前两行,至少能快速看出分辨率方向变了没。
再看一个正面的经验。另一个模拟类项目里,需要在指定区域显示一种“透视透明”的预览效果,当时不想改动材质和写深度去排除物体,索性直接构造了一个带逆向深度偏移的小范围透视矩阵,把FOV故意调大,让边缘物体轻微拉伸,配合深度越小越清晰的逻辑,最后效果既省开销又可控。这个案例告诉我:投影矩阵不只是一个“把3D变2D”的数学工具,它本身就是一种可以灵活调用的表现资源,关键在于你要知道自己在改哪一行、改了之后会影响哪些依赖它的功能。
要在UE4里玩转投影矩阵,核心就是三件事:搞懂约定(手性、Z范围、行列向量)、掌握获取/备份/替换的代码路径、最后习惯用RenderDoc一帧一帧地比对验证。这三点做到了,你基本就能驾驭绝大部分跟投影相关的奇怪需求了。