开头部分我先说个一直想聊透的话题。很多同学在UE4里做渲染相关的东西模型、材质、光照玩得挺6但只要一碰到“投影矩阵”四个字要么绕道走要么直接拿现成函数一顿调用压根不管底层发生了什么。一直到某天遇到一个需要非标准视角的输出或者一个手写Shader的显示错误才第一次意识到原来投影矩阵不只是引擎替我们算好的一个黑盒它直接决定了摄像机看到的画面是“正常”还是“诡异”。这篇文章我就以实际项目里的经验出发把UE4投影矩阵从约定、源码到自定义改造整个链路讲一遍适合正在做自定义渲染、AR/VR相关功能或者单纯想把图形学基础补齐的开发者参考。1. 为什么搞懂了投影矩阵才敢说入门了UE4渲染1.1 投影矩阵在渲染管线里的位置整个GPU渲染流程可以压缩成一句话把世界里的三维顶点一步步变换到屏幕上的二维像素。中间经历的大致变换顺序是模型空间→世界空间→观察空间→裁剪空间→归一化设备坐标→屏幕坐标。投影矩阵干的事情就是把观察空间里的顶点推入裁剪空间顺带把视锥体压缩成一个标准立方体。这么说有点抽象我打个比方。你站在一个房间里看窗外房间的窗户框就是你的视野范围。投影矩阵做的事情就是把窗外所有景物“压扁”到你面前这块窗户玻璃上然后再由后续的视口变换决定最终画到屏幕哪个区域。如果没有这个压扁的过程GPU根本不知道哪些东西应该显示、哪些东西在视野外需要被裁掉。在UE4里这一步发生在场景渲染的顶点着色阶段。引擎通过FSceneView携带的ProjectionMatrix把传入的顶点位置从观察空间变换到裁剪空间。你在材质编辑器里看到的ScreenPosition节点在Common Shader里算出来的东西底层依赖的就是这个矩阵。搞懂它意味着你不再被Shader里那些莫名其妙的坐标计算牵着鼻子走而是清楚每一步数值变化的原因。1.2 透视、正交与反向ZUE4默认用的是哪种很多人以为投影矩阵只有“透视”和“正交”两种在UE4里这个说法虽然没错但过于笼统。引擎实际区分了更多的矩阵构造方式透视投影、正交投影以及UE4为了深度精度单独引入的“反向Z”变种。反向Z是UE4的一个鲜明特点。传统图形API里深度值一般是近处为0、远处为1数值精度在远处稀疏、近处密集导致远距离物体深度值跳动明显。UE4把深度范围反过来近处深度值为1、远处为0同时配合浮点深度缓冲让近处的数值精度更高、远处的深度区分更稳定。这就是为什么你从引擎里拿到的投影矩阵其Z分量相关项的符号和传统教科书里的公式经常不一样。再说到网上流传的各种投影矩阵构造公式绝大多数是DX风格的顶点列向量版本和UE4的行向量版本对不上。UE4的FMatrix约定是向量在前、矩阵在后也就是Vector * Matrix的形式省得后面排查问题的时候被“为什么我转置了还是不对”卡住。注意UE4内部使用行向量约定但传入Shader参与运算时引擎会做一次转置处理把矩阵变成列优先布局交给GPU。你在CPU侧和GPU侧看到同一份矩阵表达方式可能是不同的这个坑下面会细说。2. UE4里投影矩阵的具体形态与约定2.1 行主序与向量约定顺序搞反全盘皆错UE4的FMatrix是一个4x4矩阵源码里以M[4][4]的二维数组存储但逻辑上它遵循行主序存储配合行向量左乘约定。什么意思呢比如你要把一个观察空间下的点变换到裁剪空间代码写的是FVector4 ClipPos ViewPos * ProjMatrix;而不是FVector4 ClipPos ProjMatrix * ViewPos;这两种写法看起来只是乘法顺序不同实际结果完全不一样。前者是ViewPos作为行向量去乘矩阵后者是矩阵去乘列向量。如果你拿着网上常见的列向量公式直接套进UE4得到的矩阵需要先转置才能等价使用。实际项目里最容易踩的坑是这个你用标准图形学教材的公式手算了一个投影矩阵放进UE4的自定义Shader里画面要么拉伸、要么严重错位。原因往往不是公式算错了而是没有把列向量矩阵转置成行向量约定。只要在构造矩阵后加一句FMatrix RowMajorMatrix ColumnMajorMatrix.GetTransposed();问题就解决了大半。2.2 FPerspectiveMatrix与FReversedZPerspectiveMatrix源码细节引擎源码里透视投影矩阵的构造主要集中在FPerspectiveMatrix这个结构里。它的核心参数包括FOV角度垂直方向视野宽高比近裁剪面距离远裁剪面距离一个典型透视投影矩阵的构造在UE4里的简化实现类似这样FMatrix FPerspectiveMatrix::MakeProjectionMatrix(float FOV, float AspectRatio, float NearPlane, float FarPlane) { const float HalfFOV FOV * 0.5f; const float Cotangent FMath::Cotan(HalfFOV); return FMatrix( FPlane(Cotangent / AspectRatio, 0.0f, 0.0f, 0.0f), FPlane(0.0f, Cotangent, 0.0f, 0.0f), FPlane(0.0f, 0.0f, FarPlane / (FarPlane - NearPlane), 1.0f), FPlane(0.0f, 0.0f, -NearPlane * FarPlane / (FarPlane - NearPlane), 0.0f) ); }注意这里第三行第四列是1.0这个位置在列向量约定里通常是0。正是因为行向量约定W分量的计算方式也跟着调整了。UE4真正常用的其实是FReversedZPerspectiveMatrix它的Z方向相关项有额外的负号翻转处理用于配合反向Z深度缓冲。实操心得如果你只是想在引擎里临时验证某个矩阵公式别去手改引擎源码直接用FReversedZPerspectiveMatrix这个现成类。它的构造参数和FPerspectiveMatrix一致但深度方向已经调整好绝大多数自定义渲染场景用这个起点更稳。3. 实操在UE4中拿到并验证投影矩阵3.1 从ViewInfo获取投影矩阵的两种方式日常开发中你通常不需要自己构造投影矩阵引擎已经根据当前摄像机参数算好了。问题在于怎么在C或蓝图里拿到它。第一种方式从PlayerCameraManager获取。比如在一个Actor里写APlayerCameraManager* CamMgr GetWorld()-GetFirstPlayerController()-PlayerCameraManager; FMinimalViewInfo ViewInfo; CamMgr-GetCameraViewPoint(ViewInfo.Location, ViewInfo.Rotation);但这种方式只能拿到视图的位置、旋转投影矩阵本身不在这里直接暴露。想要拿到完整的投影矩阵比较靠谱的方法是拿到SceneView。你可以在自定义的SceneViewExtension里或者在有SceneView上下文的地方直接访问const FSceneView* SceneView ViewFamily.Views[0]; FMatrix ProjectionMatrix SceneView-ProjectionMatrix;SceneView里的ProjectionMatrix就是引擎最终用来渲染的那个矩阵已经包含反向Z和平台相关的处理也是最权威的版本。第二种方式在自定义渲染线程代码里通过FSceneView获取。这个更适合做后处理和自定义管线比如你自己写了一个SceneViewExtension想在渲染前拿到投影矩阵做点矩阵变换那么直接在Render相关方法里调用上述代码就行。提示蓝图里没有直接暴露投影矩阵的节点但你可以通过C写一个蓝图函数库把FMinimalViewInfo和FMatrix打包成自定义结构体暴露出去或者干脆把矩阵拆成三个方向向量加一个平移用Transform传给蓝图。3.2 用DrawDebug的方式验证矩阵变化拿到投影矩阵之后怎么确认它真的是自己想要的我个人的习惯是把视锥体画出来看。UE4提供了DrawDebugFrustum这个调试函数可以直接根据一个投影矩阵和观察矩阵画出对应的视锥体。具体做法是FMatrix ViewMatrix SceneView-ViewMatrices.GetViewMatrix(); FMatrix ProjMatrix SceneView-ProjectionMatrix; FMatrix ViewProjMatrix ViewMatrix * ProjMatrix; DrawDebugFrustum(GetWorld(), ViewProjMatrix, FColor::Green, 5.0f);这里有个细节DrawDebugFrustum接收的是View-Proj合并矩阵不是单独的投影矩阵。如果你只传投影矩阵画出来的视锥体位置会完全不对因为缺少观察空间的朝向和位置信息。我在实际调试中常用的流程是先不改任何东西把默认矩阵的视锥体画出来作为参照然后修改FOV或者自定义矩阵观察视锥体变化是否符合预期最后再落到画面输出确认渲染结果和视锥体一致。这个流程能帮你快速判断是矩阵算错了还是后续透视除法或者视口变换环节出了问题。// 画一个自定义视锥体用于调试 UWorld* World GetWorld(); if (World SceneView) { FMatrix ViewMatrix SceneView-ViewMatrices.GetViewMatrix(); FMatrix CustomProj BuildCustomProjectionMatrix(); FMatrix ViewProjMatrix ViewMatrix * CustomProj; DrawDebugFrustum(World, ViewProjMatrix, FColor::Orange, true, 10.0f); }4. 实战改造自定义投影矩阵实现非标准视角4.1 鱼眼畸变投影的思路与实操默认的透视投影矩阵只能生成一个平直的标准锥形视野画面边缘没有额外的拉伸变形。但在某些模拟仿真、全景展示、车机仪表盘等场景里需要人为引入畸变把画面拉伸成类似鱼眼镜头的效果。这时候就需要在拿到引擎默认投影矩阵后再手动叠加一层畸变变换。思路分两步走。第一步正常用引擎的透视矩阵把顶点变换到裁剪空间并完成透视除法得到NDC坐标第二步在顶点着色器里对NDC坐标的X或者Y方向做非线性偏移。实践中我比较推荐直接在UE4材质编辑器里实现不必改C。在材质中利用ScreenPosition节点拿到屏幕坐标然后对UV做偏移处理计算像素点到屏幕中心的距离根据距离做平方或者更高次方的偏移把偏移后的坐标作为采样UV这样改完后画面边缘会明显向中心收缩或者向外膨胀形成鱼眼效果。如果还需要真正改变顶点的投影结果而不只是后处理阶段的扭曲那就得在自定义Shader里让投影矩阵参与顶点变换后再施加非线性变换。实操心得做鱼眼效果时最容易忽略的问题是深度信息。后处理阶段的UV偏移不会影响深度缓冲只会改变画面颜色采样位置。如果场景里有物体遮挡关系单纯改UV容易导致边缘透视关系错误。这种情况下建议在顶点阶段处理让顶点位置真正发生偏移深度关系才会同步变化。4.2 投影贴花与阴影的矩阵特殊处理投影贴花是项目经理那边提需求频率比较高的一类功能。它的原理本质上也是通过一个特殊的投影矩阵把世界空间位置投射到贴花材质的UV坐标上。UE4内置的Deferred Decal已经封装得很完善但如果你需要做自定义类型的贴花比如动态投影到任意网格上就得自己处理投影矩阵。贴花投影的核心是构造一个从世界空间到贴花空间的映射矩阵。可以简单理解成在贴花位置放一台“假摄像机”这台摄像机朝贴花正面方向进行正交投影世界空间里的任何一个点变换到这台假摄像机的裁剪空间后得到的XY就对应贴花UV。具体实现上用UE4现有的数学工具就够了FMatrix DecalProjectionMatrix FReversedZOrthoMatrix( OrthoWidth, OrthoHeight, NearPlane, FarPlane ); FMatrix DecalViewMatrix FRotationTranslationMatrix(DecalRotation, DecalLocation); FMatrix WorldToDecalMatrix DecalViewMatrix * DecalProjectionMatrix;然后在Shader里把世界位置通过WorldToDecalMatrix变换到裁剪空间再做透视除法和视口变换就能得到0到1范围内的UV坐标。这里要注意贴花通常要处理正反两面即只有面向贴花前方片面的像素才应该采样否则容易出现贴花穿透到物体背面的情况。阴影的投影矩阵处理跟贴花有相似之处但更复杂一些。阴影映射的核心是让光源充当一台摄像机所有场景顶点变换到光源的裁剪空间后比较深度值来判断是否被遮挡。UE4里这部分已经高度封装但如果你在做自定义的光源类型或者要处理聚光灯边缘柔软的阴影免不了要手动构造光源空间的投影矩阵然后再应用到Shadow Depth Pass里。// 手动构造一个光源空间的投影矩阵示例 FMatrix LightProjection FPerspectiveMatrix( LightFOV, LightAspectRatio, LightNearPlane, LightFarPlane ); FMatrix LightView FRotationTranslationMatrix(LightRotation, LightLocation); FMatrix LightViewProj LightView * LightProjection;注意在自定义阴影管线里深度比较的数值方向一定要和引擎默认的反向Z保持一致。如果你手动构造的矩阵是普通正向深度而引擎的ShadowDepth Pass用的是反向深度生成的阴影贴图里远近关系会完全反掉表现就是近处物体疯狂闪烁、远处物体阴影乱飘。4.3 立体渲染的投影矩阵左右眼处理VR和立体显示项目里左右眼各需要一组略微不同的投影矩阵。区别主要在非对称的视口偏移也就是所谓的Off-axis投影。普通透视矩阵把视锥体中心对准屏幕中心但人眼在看近处物体时左右眼的视线是略微向内汇聚的两眼看到的画面中心并不重合。UE4的FMinimalViewInfo和立体渲染相关的模块其实已经处理了大部分逻辑但如果你想自己做双摄渲染需要知道左右眼矩阵是怎么产生差异的。核心在于投影矩阵最后一行行向量约定下是第四行的X分量这个分量控制着NDC坐标的横向偏移。在引擎里做左右眼投影比较常规的做法是拿到默认的单眼投影矩阵根据瞳距和近裁剪面距离计算横向偏移量把偏移量加进投影矩阵的第四行第一列float EyeOffset HalfIPD; // 半瞳距 float NearPlane 10.0f; float OffsetX EyeOffset / NearPlane; FMatrix LeftEyeProj DefaultProj; LeftEyeProj.M[3][0] OffsetX; FMatrix RightEyeProj DefaultProj; RightEyeProj.M[3][0] - OffsetX;这里偏移的正负号和瞳距方向有关系具体项目里要用实际头显设备测试确认。做过的同学应该都有印象左右眼矩阵如果偏移反了立体效果会非常难受甚至会让人产生眩晕感。5. 常见问题与排查技巧实录5.1 矩阵数值看起来不对问题到底出在哪排查投影矩阵问题最忌讳的就是瞎猜。我的固定流程是先确认矩阵取出来了没有再确认矩阵的Major类型最后拿一个已知坐标手算验证。第一步先确认矩阵是否来自正确的地方。SceneView-ProjectionMatrix在UE4不同版本里可能名字有差异有些版本是ProjectionMatrix有些则要通过ViewMatrices.GetProjectionMatrix()获取。取错了矩阵后面所有计算都白搭。第二步确认行列主序。UE4的FMatrix在C里是行主序但传给Shader前会被转置。你在C里调试和在Shader里调试看到同一份矩阵数值排列方式是不同的。建议调试时统一在C侧打印打印时把矩阵按行输出UE_LOG(LogTemp, Log, TEXT(Proj Matrix Row0: %f %f %f %f), ProjMatrix.M[0][0], ProjMatrix.M[0][1], ProjMatrix.M[0][2], ProjMatrix.M[0][3]);第三步取一个已知坐标做手算验证。比如观察空间原点(0,0,0)经过投影矩阵变换后裁剪空间坐标应该是什么根据矩阵公式原点变换后W分量等于投影矩阵第四行的W系数这个结果可以用来反推矩阵是否符合预期。只要这一步对得上说明矩阵本身没问题问题大概率出在后续处理上。下表是我整理的一个快速排查对照表问题现象可能原因排查方向画面整体严重拉伸宽高比参数不对检查传入投影矩阵的AspectRatio近处物体被裁掉近裁剪面距离设置过大检查NearPlane是否合理深度冲突闪烁反向Z没有生效确认使用的是FReversedZ系列矩阵贴花投影错位行列主序没转置检查Shader侧矩阵是否需要转置左右眼画面不重合偏移正负号搞反交换左右眼的OffsetX符号测试5.2 深度和裁剪相关问题的综合排查深度和裁剪的问题症状通常比矩阵数值错误更难察觉。我做项目时遇到过一种很典型的情况场景中所有物体看起来位置都正确但从一个特定角度看远处的物体突然消失或者被错误遮挡。这种问题的根源往往不是投影矩阵本身而是观察矩阵和投影矩阵之间的配合出了问题。在UE4里观察矩阵负责把世界坐标变换到以摄像机为中心的坐标系投影矩阵负责把观察坐标系压缩到裁剪空间。两者是串联关系任何一个环节出错都会导致最终画面不对。排查方法很直接把观察矩阵和投影矩阵合并为ViewProj矩阵然后取场景中几个已知的边界点手动计算它们在裁剪空间里的坐标再判断是否在[-w, w]范围内。如果边界点的计算结果和画面表现一致说明矩阵没问题如果不一致说明你调试用的矩阵和引擎实际渲染用的矩阵不是同一份回去查取数的地方。FMatrix ViewProj ViewMatrix * ProjMatrix; FVector4 TestPoint FVector4(100.0f, 200.0f, 300.0f, 1.0f); FVector4 ClipPos TestPoint * ViewProj; // 如果ClipPos.W 0说明点在摄像机后方或者超出裁剪范围实操心得遇到深度相关的问题先用r.DebugRenderScene或者直接在材质里输出线性深度到颜色通道把深度可视化这样能非常直观地看到深度分布是否合理。不要光盯着Shader代码猜把中间变量变成颜色输出往往一眼就能定位问题。6. 基于个人经验的一点补充投影矩阵这东西说难确实不难公式翻来覆去就那么几个参数FOV、近远裁剪面、宽高比。但说简单它又是最容易翻车的环节因为UE4在默认设置里加了很多特殊处理反向Z、行主序约定、平台差异都叠在一起光靠教科书上的公式生搬硬套十次有八次要出错。我个人一直建议团队里的渲染新人先做一个小练习自己写一个自定义投影矩阵渲染一个简单的方块然后一步步调试直到画面和默认引擎渲染完全一致。这个练习做完基本上就不会再被矩阵方向、深度方向这些概念绊住了。遇到问题也更能判断出到底是哪个环节出了问题。最后说一个小技巧。如果你发现手动构造的矩阵在某些平台表现不一样先别怀疑公式错了先确认一下平台是否强制开启了反向Z或者改了深度清除值。UE4里不同RHI对深度缓冲的处理方式有细微差别最省事的做法是先在编辑器默认平台调试通过再逐步切到其他平台对比验证。