☰
UE4投影矩阵原理与实战:反向Z、深度重建与常见坑
2026/10/17 13:05:24 网站建设 项目流程

搞渲染或者引擎底层相关的东西,绕不开“投影矩阵”(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一帧一帧地比对验证。这三点做到了,你基本就能驾驭绝大部分跟投影相关的奇怪需求了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询