Unity渲染矩阵变换全流程:从模型空间到屏幕空间的五步链路

发布时间:2026/10/18 1:18:53
Unity渲染矩阵变换全流程:从模型空间到屏幕空间的五步链路 1. 从一次画面撕裂说起为什么矩阵变换值得单独拎出来讲前阵子帮一个朋友排查他项目里的一个怪现象角色跑动的时候影子跟本体之间总有一小段延迟像是影子被“甩”在后面。他一开始怀疑是动画状态机的问题又去查物理更新频率折腾了两天没结果。我让他把角色渲染相关的矩阵打印出来一看问题当场就露馅了——他在自定义的渲染管线里手动拼了一个模型矩阵但漏掉了父级节点的缩放补偿导致阴影投射用的矩阵和主相机用的矩阵差了那么一点点。这种问题靠看代码是看不出来的只有把矩阵变换这条链路完整地捋一遍才知道哪一环掉了链子。Unity渲染的矩阵变换全流程说白了就是回答一个问题一个模型文件里的顶点坐标从它自己的“小世界”出发经过多少次坐标系的转换最终变成屏幕上那个像素。这条链路里藏着模型矩阵、视图矩阵、投影矩阵、视口变换还有法线矩阵、逆转置矩阵这些容易被忽略的配角。任何一个环节理解不到位轻则画面错位重则整个渲染结果崩掉。这篇文章适合已经写过一些Shader、用过Unity但总觉得矩阵这块“知其然不知其所以然”的开发者也适合想自己动手写渲染管线、做后处理或者搞自定义阴影的人。我会把这条链路拆成几个可操作的阶段配上能直接跑的代码和参数计算过程尽量让每个环节都能“抄作业”。2. 矩阵变换的整体链路设计为什么是这几个矩阵串联2.1 从局部空间到屏幕空间的五步走Unity的渲染管线里一个顶点从模型文件到屏幕像素标准流程要经过五次坐标系变换。这个顺序不是随便定的每一步都在解决一个特定的问题。第一步是模型变换把顶点从模型自己的局部坐标系Local Space搬到世界坐标系World Space。模型文件里的顶点坐标是相对于模型原点的比如一个立方体的八个顶点可能是(±0.5, ±0.5, ±0.5)。但场景里这个立方体可能被放在(3, 2, 5)的位置旋转了45度还缩放了2倍。模型矩阵就是把这些信息打包成一个4x4矩阵一次性完成平移、旋转、缩放。第二步是视图变换把世界坐标系里的点搬到相机坐标系View Space。这一步的本质是“把相机放到原点让相机看向-Z方向”。很多人在这里会绕晕因为视图矩阵其实是相机世界矩阵的逆矩阵。为什么用逆矩阵因为相机在世界里有一个位置和朝向我们要做的是把整个世界反向移动让相机“感觉”自己没动是场景在动。第三步是投影变换把相机坐标系里的点搬到裁剪空间Clip Space。这一步决定了视野范围FOV、近裁剪面、远裁剪面以及是透视投影还是正交投影。投影矩阵会把视锥体“压”成一个标准的立方体方便后续裁剪。第四步是裁剪与透视除法GPU会自动完成。裁剪空间里的坐标是齐次坐标(x, y, z, w)除以w之后得到NDCNormalized Device Coordinates范围是[-1, 1]。这一步是硬件固定的但理解它对于排查深度问题很关键。第五步是视口变换把NDC坐标映射到屏幕像素坐标。这一步由Unity的视口设置决定比如Screen.width、Screen.height以及相机的viewport rect。注意这五步里前三步是开发者可以完全控制的后两步是GPU和引擎自动完成的。出问题的地方九成都在前三步。2.2 为什么Unity要区分内置管线和SRP的矩阵Unity的内置渲染管线和SRPScriptable Render Pipeline包括URP和HDRP在矩阵的获取方式上有明显差异。内置管线里Shader里直接用UNITY_MATRIX_MVP就能拿到模型-视图-投影的合并矩阵方便但黑盒。SRP里更推荐用TransformObjectToHClip这样的函数底层还是矩阵乘法但把每一步拆开了方便你在中间插入自定义逻辑。这个设计变化的背后逻辑是内置管线追求“开箱即用”SRP追求“可控可扩展”。如果你在做自定义渲染比如要画一个跟随相机的天空盒或者做基于深度的后处理就必须知道每个矩阵单独是什么而不是一个合并后的MVP。我个人的习惯是即使在SRP里也会把UNITY_MATRIX_M、UNITY_MATRIX_V、UNITY_MATRIX_P分别打印出来确认每一步的数值符合预期。2.3 行主序与列主序一个容易踩的坑Unity的C#端Matrix4x4是列主序存储的但Shader里mul(M, v)的写法要求矩阵在左边、向量在右边。如果你从C#传一个矩阵到Shader用SetMatrix传进去Shader里用mul(_MyMatrix, v)结果是对的。但如果你手写矩阵元素比如float4x4 m float4x4(1,0,0,0, 0,1,0,0, ...)就要注意Unity的构造函数是按行填充的而内存里是列主序。这个细节在写自定义投影矩阵时特别容易出错我见过不止一个人在这里卡了半天。3. 核心矩阵的数学原理与Unity中的获取方式3.1 模型矩阵平移、旋转、缩放的组合顺序模型矩阵的构建顺序直接影响最终结果。Unity的Transform组件里position、rotation、lossyScale三个属性组合起来对应的矩阵是Matrix4x4.TRS(position, rotation, scale)。这个函数的内部顺序是先缩放再旋转最后平移。为什么是这个顺序因为缩放和旋转都是相对于模型原点的如果先平移再旋转模型会绕着世界原点转而不是绕自己的原点转。用数学表达就是M T * R * S。其中T是平移矩阵R是旋转矩阵S是缩放矩阵。矩阵乘法不满足交换律所以顺序不能乱。如果你在Shader里手动拼这个矩阵代码大概长这样float4x4 BuildModelMatrix(float3 pos, float4 rot, float3 scale) { float4x4 T float4x4( 1, 0, 0, pos.x, 0, 1, 0, pos.y, 0, 0, 1, pos.z, 0, 0, 0, 1 ); float4x4 R quaternion_to_matrix(rot); float4x4 S float4x4( scale.x, 0, 0, 0, 0, scale.y, 0, 0, 0, 0, scale.z, 0, 0, 0, 0, 1 ); return mul(T, mul(R, S)); }提示Unity的Matrix4x4.TRS内部用的就是这个顺序但如果你自己拼记得用mul(T, mul(R, S))而不是mul(S, mul(R, T))。3.2 视图矩阵相机世界矩阵的逆视图矩阵的推导是很多人觉得绕的地方。假设相机在世界空间的位置是eye朝向是forward上方向是up。那么相机的世界矩阵就是把相机从局部空间搬到世界空间。视图矩阵要做的是反过来把世界空间搬到相机空间。所以视图矩阵等于相机世界矩阵的逆矩阵。Unity里可以用Camera.worldToCameraMatrix直接拿到视图矩阵也可以用Matrix4x4.Inverse(camera.transform.localToWorldMatrix)手动算。两者结果应该一致但worldToCameraMatrix会额外处理一些相机特有的参数比如立体渲染的偏移。如果你在做自定义相机建议直接用worldToCameraMatrix省得自己处理这些边界情况。视图矩阵的另一个理解方式是它把相机的position变成原点把相机的forward变成-Z把相机的up变成Y。这样在相机空间里所有可见物体的z值都是负数因为相机看向-Z方向。这个约定在写深度相关逻辑时很重要。3.3 投影矩阵透视与正交的数学差异投影矩阵分两种透视投影和正交投影。透视投影模拟人眼近大远小正交投影保持物体大小不变常用于2D游戏或工程制图。透视投影矩阵的构建需要四个参数FOV垂直视场角、aspect宽高比、near近裁剪面、far远裁剪面。Unity里用Matrix4x4.Perspective(fov, aspect, near, far)可以生成。手动推导的话矩阵的大致形式是f 1 / tan(fov/2) [ f/aspect, 0, 0, 0 ] [ 0, f, 0, 0 ] [ 0, 0, (farnear)/(near-far), (2*far*near)/(near-far) ] [ 0, 0, -1, 0 ]这个矩阵会把视锥体里的点映射到裁剪空间w分量变成-z相机空间里的z是负的所以w是正的。透视除法之后x和y的范围是[-1, 1]z的范围是[-1, 1]在Unity里z的范围是[0, 1]或[-1, 1]取决于平台这个差异后面会讲。正交投影矩阵更简单没有透视除法w分量始终是1。Unity里用Matrix4x4.Ortho(left, right, bottom, top, near, far)生成。注意Unity在不同图形API下投影矩阵的z范围可能不同。DirectX平台下NDC的z是[0, 1]OpenGL平台下是[-1, 1]。Unity的GL.GetGPUProjectionMatrix会自动处理这个差异如果你手动传投影矩阵到Shader记得用这个函数转换一下。3.4 法线矩阵为什么不能直接用模型矩阵法线变换是一个容易被忽略但很关键的环节。法线是垂直于表面的向量它的变换不能用模型矩阵直接乘。为什么因为法线表示的是方向不是位置平移对它没有影响。更重要的是如果模型有非均匀缩放直接用模型矩阵乘法线会导致法线不再垂直于表面。正确的做法是用模型矩阵的逆转置矩阵Inverse Transpose来变换法线。Unity里可以用UNITY_MATRIX_IT_M拿到这个矩阵或者手动算transpose(inverse(modelMatrix))。在Shader里如果模型没有非均匀缩放直接用模型矩阵的3x3部分也能凑合但一旦有非均匀缩放就必须用逆转置。float3 normalWS normalize(mul((float3x3)UNITY_MATRIX_IT_M, normalOS));这个细节在做光照计算时特别重要法线错了光照结果就会怪怪的但又不至于完全黑掉所以很容易被忽略。4. 实操在Unity中完整走一遍矩阵变换链路4.1 环境准备与基础场景搭建先建一个干净的Unity项目版本建议2021 LTS以上URP或内置管线都行。场景里放一个Cube位置设为(2, 1, 3)旋转设为(30, 45, 0)缩放设为(1.5, 1, 1)。再放一个Camera位置设为(0, 2, -5)朝向Cube。这些参数后面会用来验证矩阵计算的结果。创建一个C#脚本MatrixDebugger挂到Camera上。脚本里在OnPreRender或OnDrawGizmos里打印各个矩阵。我习惯用OnDrawGizmos因为可以在编辑器里实时看到不用运行游戏。void OnDrawGizmos() { var cam GetComponentCamera(); var cube GameObject.Find(Cube).transform; Matrix4x4 model Matrix4x4.TRS(cube.position, cube.rotation, cube.lossyScale); Matrix4x4 view cam.worldToCameraMatrix; Matrix4x4 proj GL.GetGPUProjectionMatrix(cam.projectionMatrix, false); Matrix4x4 mvp proj * view * model; Debug.Log($Model:\n{model}); Debug.Log($View:\n{view}); Debug.Log($Proj:\n{proj}); Debug.Log($MVP:\n{mvp}); }运行之后把打印出来的矩阵和手动计算的结果对比。手动计算可以用Python或Excel把TRS的每一步拆开算确认Unity的Matrix4x4.TRS确实等价于T * R * S。4.2 在Shader中验证每一步的变换结果写一个简单的Unlit Shader把顶点变换的每一步都输出到颜色上用颜色来可视化坐标。比如把模型空间的坐标映射到红色通道世界空间的坐标映射到绿色通道裁剪空间的坐标映射到蓝色通道。这样一眼就能看出哪一步出了问题。v2f vert (appdata v) { v2f o; float4 posOS v.vertex; float4 posWS mul(UNITY_MATRIX_M, posOS); float4 posVS mul(UNITY_MATRIX_V, posWS); float4 posCS mul(UNITY_MATRIX_P, posVS); o.vertex posCS; o.color float4(posOS.x, posWS.y, posCS.z, 1); return o; }这个Shader跑起来之后Cube的颜色会随着位置变化。如果你移动Cube红色通道不变因为模型空间坐标不变绿色通道变化世界空间坐标变了蓝色通道也变化裁剪空间坐标变了。这个简单的测试能帮你建立对每一步变换的直觉。4.3 参数计算手算一个透视投影矩阵拿一个具体的例子来算。假设FOV60度aspect16/9near0.3far1000。先算f 1 / tan(30°) 1 / 0.5774 ≈ 1.732。然后f/aspect 1.732 / 1.7778 ≈ 0.974(farnear)/(near-far) (1000.3) / (0.3-1000) 1000.3 / -999.7 ≈ -1.0006(2*far*near)/(near-far) (2*1000*0.3) / -999.7 600 / -999.7 ≈ -0.6002所以投影矩阵大概是[ 0.974, 0, 0, 0 ] [ 0, 1.732, 0, 0 ] [ 0, 0, -1.0006, -0.6002 ] [ 0, 0, -1, 0 ]拿一个相机空间里的点比如(0, 0, -5)乘这个矩阵得到裁剪空间坐标。w分量是5除以w之后x和y是0z是(-1.0006 * -5 - 0.6002) / 5 (5.003 - 0.6002) / 5 ≈ 0.8806。这个z值在[0, 1]范围内说明点在视锥体内。如果z算出来是负数或大于1说明点在近裁剪面之前或远裁剪面之后会被裁掉。4.4 视口变换与屏幕坐标的最终映射裁剪空间坐标除以w之后得到NDC坐标范围是[-1, 1]。视口变换把它映射到屏幕像素screenX (ndcX * 0.5 0.5) * screenWidth viewportX screenY (ndcY * 0.5 0.5) * screenHeight viewportYUnity里可以用Camera.WorldToScreenPoint直接拿到这个结果但理解背后的计算过程有助于排查问题。比如如果你发现物体的屏幕位置偏了可以先用WorldToViewportPoint看视口坐标对不对再用WorldToScreenPoint看像素坐标对不对逐步缩小问题范围。提示WorldToScreenPoint返回的z值是相机空间里的深度不是NDC的z。这个深度值在写自定义深度排序时很有用。5. 常见问题与排查技巧实录5.1 模型显示在错误位置从矩阵打印开始排查模型位置不对是最常见的问题。排查思路是先确认模型矩阵对不对再确认视图矩阵对不对最后确认投影矩阵对不对。具体操作是在C#里打印Matrix4x4.TRS的结果和Shader里UNITY_MATRIX_M的值对比。如果两者不一致说明Shader里用的矩阵不是你以为的那个。我遇到过一个案例某开发者在Shader里用了UNITY_MATRIX_M但他在C#里手动改了一个子物体的localToWorldMatrix没有同步到Shader。结果Shader里用的还是旧的矩阵模型就“飘”了。解决办法是用Graphics.DrawMesh时显式传入矩阵或者用MaterialPropertyBlock把矩阵传进去。5.2 深度冲突与Z-Fighting投影矩阵参数的锅Z-Fighting是远近裁剪面设置不当导致的。如果near设得太小比如0.01而far设得很大比如10000深度缓冲的精度会严重下降远处的物体会闪烁。解决办法是尽量把near设大一点far设小一点或者用反向ZReversed Z技术。Unity的URP默认用反向Z投影矩阵的z范围是[1, 0]而不是[0, 1]。这个差异在写自定义Shader时要注意如果你手动拼投影矩阵记得用GL.GetGPUProjectionMatrix转换一下。5.3 法线变换错误导致光照异常法线变换错误的典型表现是物体表面光照不均匀或者某些角度下完全黑掉。排查方法是在Shader里把法线可视化出来用颜色表示法线的方向。如果法线看起来“歪”了大概率是用了模型矩阵而不是逆转置矩阵。float3 normalWS normalize(mul((float3x3)UNITY_MATRIX_IT_M, normalOS)); o.color float4(normalWS * 0.5 0.5, 1);这个可视化技巧在调试光照问题时非常有用能一眼看出法线是否正确。5.4 常见问题速查表问题现象可能原因排查方法解决方案模型位置偏移模型矩阵错误打印UNITY_MATRIX_M和C#端矩阵对比检查TRS顺序确认父子节点关系模型旋转不对旋转矩阵顺序错误用单位旋转测试确认T * R * S顺序深度闪烁near/far比例过大检查相机参数增大near减小far或用反向Z光照异常法线矩阵错误可视化法线用UNITY_MATRIX_IT_M屏幕位置偏移视口变换错误用WorldToViewportPoint对比检查viewport rect和屏幕分辨率裁剪空间z异常投影矩阵平台差异打印GL.GetGPUProjectionMatrix结果用GL.GetGPUProjectionMatrix转换注意这个表里的排查方法都是我在实际项目中反复用过的尤其是矩阵打印和法线可视化能解决八成以上的矩阵相关问题。5.5 一个容易被忽略的坑动态批处理与矩阵Unity的动态批处理会把多个小物体合并成一个Draw Call但合并之后每个物体的模型矩阵会被“烘焙”到顶点坐标里。如果你在Shader里依赖UNITY_MATRIX_M做某些计算比如基于世界坐标的纹理采样动态批处理之后结果可能会错。解决办法是禁用动态批处理或者用UNITY_MATRIX_M的替代方案比如把世界坐标存到顶点色里。这个坑我在做草地渲染时踩过草地的世界坐标采样在批处理之后全乱了排查了半天才发现是动态批处理的问题。后来改成用Graphics.DrawMeshInstanced每个实例的矩阵单独传问题就解决了。6. 矩阵变换在自定义渲染中的扩展应用6.1 用矩阵做屏幕空间的后处理后处理本质上是在屏幕空间做一次全屏的渲染但有时候需要把屏幕像素反投影回世界空间。比如做屏幕空间的反射或者基于深度的雾效。这时候需要用到投影矩阵和视图矩阵的逆矩阵。float4 clipPos float4(uv * 2 - 1, depth, 1); float4 viewPos mul(unity_CameraInvProjection, clipPos); viewPos / viewPos.w; float4 worldPos mul(unity_CameraToWorld, viewPos);这段代码在URP和内置管线里都能用关键是unity_CameraInvProjection和unity_CameraToWorld这两个矩阵。理解它们的来源就能灵活地在屏幕空间和世界空间之间切换。6.2 自定义阴影投射中的矩阵处理阴影投射的核心是从光源的角度渲染一遍场景把深度存到阴影贴图里。这个过程中光源的视图矩阵和投影矩阵决定了阴影贴图的内容。如果阴影位置不对先检查光源的矩阵是否正确。Unity里可以用light.worldToCameraMatrix和light.projectionMatrix拿到光源的矩阵。如果是自定义阴影比如点光源的立方体阴影需要为六个面分别构建视图矩阵。这个过程中矩阵的构建顺序和相机类似但要注意光源的朝向和相机的朝向定义不同。6.3 矩阵在GPU Instancing中的角色GPU Instancing的核心是把多个实例的矩阵打包成一个数组一次性传给GPU。每个实例的矩阵存在UNITY_INSTANCING_BUFFER里Shader里用UNITY_ACCESS_INSTANCED_PROP读取。这个过程中矩阵的存储格式和访问方式会影响性能。UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float4x4, _InstanceMatrix) UNITY_INSTANCING_BUFFER_END(Props)用Instancing的时候模型矩阵不再是UNITY_MATRIX_M而是从Instancing Buffer里读。这个差异在写Shader时要注意否则所有实例会叠在一起。7. 我个人的一些实操体会矩阵变换这块我最大的体会是不要怕打印矩阵。很多人觉得矩阵是一堆数字打印出来也看不懂。但实际上只要你把每一步的矩阵和预期结果对比很快就能定位问题。我习惯在项目里留一个MatrixDebugger脚本随时可以打开看各个矩阵的值。另一个体会是理解比记忆重要。矩阵的公式可以查但理解每一步在做什么才能在出问题时快速反应。比如知道视图矩阵是相机世界矩阵的逆就能理解为什么相机移动时视图矩阵会反向变化。最后分享一个小技巧如果你在写自定义Shader时不确定某个矩阵是什么可以在Shader里把矩阵的每一行输出到颜色上用颜色来可视化。比如把UNITY_MATRIX_M的第一行输出到红色通道第二行到绿色通道第三行到蓝色通道。这样一眼就能看出矩阵的大致结构比看数字直观多了。这个内容后续还可以这样扩展把矩阵变换和四元数结合起来讲因为旋转的底层实现就是四元数到矩阵的转换或者把矩阵变换和骨骼动画结合起来讲蒙皮矩阵的计算过程。这两个方向都是矩阵变换的延伸理解了基础链路之后再看这些会轻松很多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询