开头部分,我先说个一直想聊透的话题。很多同学在UE4里做渲染相关的东西,模型、材质、光照玩得挺6,但只要一碰到“投影矩阵”四个字,要么绕道走,要么直接拿现成函数一顿调用,压根不管底层发生了什么。一直到某天遇到一个需要非标准视角的输出,或者一个手写Shader的显示错误,才第一次意识到,原来投影矩阵不只是引擎替我们算好的一个黑盒,它直接决定了摄像机看到的画面是“正常”还是“诡异”。这篇文章我就以实际项目里的经验出发,把UE4投影矩阵从约定、源码到自定义改造整个链路讲一遍,适合正在做自定义渲染、AR/VR相关功能,或者单纯想把图形学基础补齐的开发者参考。
1. 为什么搞懂了投影矩阵,才敢说入门了UE4渲染
1.1 投影矩阵在渲染管线里的位置
整个GPU渲染流程可以压缩成一句话:把世界里的三维顶点,一步步变换到屏幕上的二维像素。中间经历的大致变换顺序是:模型空间→世界空间→观察空间→裁剪空间→归一化设备坐标→屏幕坐标。投影矩阵干的事情,就是把观察空间里的顶点推入裁剪空间,顺带把视锥体压缩成一个标准立方体。
这么说有点抽象,我打个比方。你站在一个房间里看窗外,房间的窗户框就是你的视野范围。投影矩阵做的事情,就是把窗外所有景物“压扁”到你面前这块窗户玻璃上,然后再由后续的视口变换决定最终画到屏幕哪个区域。如果没有这个压扁的过程,GPU根本不知道哪些东西应该显示、哪些东西在视野外需要被裁掉。
在UE4里,这一步发生在场景渲染的顶点着色阶段。引擎通过FSceneView携带的ProjectionMatrix,把传入的顶点位置从观察空间变换到裁剪空间。你在材质编辑器里看到的ScreenPosition节点,在Common Shader里算出来的东西,底层依赖的就是这个矩阵。搞懂它,意味着你不再被Shader里那些莫名其妙的坐标计算牵着鼻子走,而是清楚每一步数值变化的原因。
1.2 透视、正交与反向Z,UE4默认用的是哪种
很多人以为投影矩阵只有“透视”和“正交”两种,在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对深度缓冲的处理方式有细微差别,最省事的做法是先在编辑器默认平台调试通过,再逐步切到其他平台对比验证。