
1. 项目概述为什么Unity Shader里的矩阵变换是绕不开的坎如果你在Unity里写过Shader或者哪怕只是看过一些复杂的特效代码大概率会碰到一堆以“_”开头的矩阵变量比如_World2Object、UNITY_MATRIX_MVP。第一次见的时候是不是感觉头都大了心想我不就是想改个颜色、挪个顶点吗怎么还得跟这些数学玩意儿打交道这感觉就像你想开车结果4S店让你先学一遍内燃机原理。但现实是在Shader的世界里矩阵变换就是那台“发动机”不理解它你的车可能连车库都出不去或者开起来歪七扭八。简单来说矩阵变换是Shader将3D模型从它自己的小天地模型空间摆放到游戏世界大舞台世界空间再经过摄像机“拍照”观察空间最终投影到2D屏幕裁剪空间和屏幕空间这一系列复杂操作的核心数学工具。每一个顶点、每一束光线其位置、方向、甚至法线的正确性都依赖于矩阵计算的准确性。很多新手Shader效果出错比如模型位置不对、光照计算诡异、阴影贴图错位十有八九是矩阵用错了地方。所以今天我们不谈枯燥的数学证明就从一名图形程序员的实战视角把Unity Shader里这些关键的矩阵一个个拆开揉碎讲清楚它们是谁、从哪来、到哪去、以及怎么用。目标是让你下次再看到mul(UNITY_MATRIX_MVP, v.vertex)时心里门儿清知道它在干什么甚至能自信地把它拆成几步来写。2. 核心概念空间、坐标系与矩阵的三角关系在深入矩阵之前必须把三个核心概念绑在一起理解空间、坐标系和矩阵。这是所有混乱的源头也是所有理解的起点。2.1 空间顶点数据的“居住地”一个3D模型从建模软件如Blender, Maya里诞生时它的所有顶点位置数据都是相对于模型自身的某个中心点通常是原点(0,0,0)来定义的。这个“小天地”就是模型空间Model Space也叫局部空间Local Space。在这里一个立方体的某个顶点坐标可能是(1, 1, 1)意思是距离模型中心点向右1单位、向上1单位、向前1单位。当这个模型被放入Unity的Scene场景中时它有了位置Position、旋转Rotation和缩放Scale属性。此时我们需要知道这个顶点在世界空间World Space中的坐标。世界空间是整个游戏场景统一的、绝对的坐标系所有物体都共享这个空间。摄像机也有自己的坐标系即观察空间View Space或摄像机空间Camera Space。在这个空间里摄像机自身是原点其看向的方向通常是-Z轴在Unity中这样便于进行后续的投影计算。最终为了在2D屏幕上显示我们需要一个裁剪空间Clip Space的坐标。这是一个标准化的立方体空间通常范围是[-1, 1]或[0, 1]在这个空间外的顶点会被“裁剪”掉。最后通过视口变换裁剪空间坐标被转换为屏幕空间Screen Space的像素坐标。2.2 坐标系空间的“度量衡”每个空间都对应着一个坐标系它定义了三个轴向X, Y, Z和原点的位置。变换空间本质上就是将一个坐标系下的点转换到另一个坐标系下去表示。这里的关键在于坐标系之间可能存在旋转、平移和缩放。例如模型空间的原点和轴向与世界空间的原点和轴向通常是不对齐的。2.3 矩阵空间变换的“万能公式”矩阵就是完成这个坐标系转换的数学工具。一个4x4的矩阵在齐次坐标下可以完美地表示旋转、平移、缩放以及它们的任意组合。当你用一个矩阵左乘一个齐次坐标表示的顶点时你就得到了这个顶点在新坐标系下的坐标。它们三者的关系可以这样概括空间定义了数据的上下文环境坐标系是这个环境的标尺而矩阵则是将数据从一个标尺体系转换到另一个标尺体系的转换器。理解Shader中的矩阵变换就是理解Unity如何为我们预置了这些关键的“转换器”以及如何在正确的时机使用它们。3. Unity内置矩阵全解析从MVP到逆转置Unity在Shader中通过内置变量和宏提供了一系列关键的变换矩阵。我们按渲染流水线的顺序来逐一攻克。3.1 模型变换矩阵从局部到世界这是变换的第一步。unity_ObjectToWorld矩阵在较老版本中可能是_Object2World负责将顶点从模型空间变换到世界空间。它编码了该游戏对象GameObject的Transform组件上的位置、旋转和缩放信息。// 在顶点着色器中将模型空间顶点变换到世界空间 float4 worldPos mul(unity_ObjectToWorld, v.vertex);重要注意事项缩放与非均匀缩放如果模型有缩放特别是非均匀缩放即X、Y、Z缩放系数不同这个矩阵会包含缩放信息。这会影响到后续的法线变换我们后面会详细说。矩阵的第四行在齐次坐标中点的w分量通常为1。unity_ObjectToWorld矩阵的第四列表示的是平移量。当与顶点(x, y, z, 1)相乘后平移部分会直接加到结果中从而实现移动。3.2 观察变换矩阵从世界到摄像机UNITY_MATRIX_V矩阵代表了观察矩阵View Matrix它将世界空间坐标变换到观察空间摄像机空间。这个矩阵本质上是由摄像机的位置和旋转即其Transform的逆矩阵构成的。在观察空间中摄像机位于原点通常面朝-Z方向。// 将世界空间位置变换到观察空间 float4 viewPos mul(UNITY_MATRIX_V, worldPos);实操心得在自定义的后期处理或屏幕空间效果中我们有时需要世界空间位置。这时可以利用UNITY_MATRIX_V的逆矩阵即摄像机的世界变换矩阵来反推但更常用的方法是利用深度纹理和_CameraInvProjection矩阵在片元着色器中重建世界坐标。3.3 投影变换矩阵从观察到裁剪UNITY_MATRIX_P是投影矩阵Projection Matrix。它负责将观察空间中的3D坐标“挤压”到裁剪空间。这个矩阵决定了透视近大远小或正交投影的视锥体形状。// 将观察空间位置变换到裁剪空间传统方式 float4 clipPos mul(UNITY_MATRIX_P, viewPos);核心细节投影变换后顶点的w分量不再为1而是存储了观察空间中的深度值即-Z值。这个w分量在后续的透视除法clipPos.xyz / clipPos.w中起到关键作用正是它实现了透视效果。在Shader中我们通常不直接进行透视除法而是由GPU固定管线完成。3.4 MVP矩阵一步到位的组合拳UNITY_MATRIX_MVP是一个组合矩阵它是Model-View-Projection的缩写。在顶点着色器中我们经常用它直接将模型空间顶点变换到裁剪空间。// 最经典的顶点着色器变换 o.pos mul(UNITY_MATRIX_MVP, v.vertex); // 等价于 // float4 worldPos mul(unity_ObjectToWorld, v.vertex); // float4 viewPos mul(UNITY_MATRIX_V, worldPos); // o.pos mul(UNITY_MATRIX_P, viewPos);注意在Unity较新的渲染管线如URP/HDRP中UNITY_MATRIX_MVP可能被废弃或行为有变。官方推荐使用更明确的变换步骤例如在URP中使用TransformObjectToHClip(v.vertex)宏来代替它内部会处理不同渲染管线的差异。3.5 逆转置矩阵法线变换的救星这是最容易出错的地方。顶点位置可以用unity_ObjectToWorld变换到世界空间但法线Normal不能直接使用同一个矩阵。法线是一个方向向量不是位置点。当模型发生缩放尤其是非均匀缩放时直接用模型变换矩阵去变换法线会导致法线方向不再垂直于表面光照计算会完全错误。正确的做法是使用逆转置矩阵Inverse Transpose Matrix。Unity提供了unity_WorldToObject矩阵它是unity_ObjectToWorld的逆矩阵。对于方向向量如法线、切线我们需要使用这个逆矩阵的转置来变换。// 正确变换法线到世界空间的方法 float3 worldNormal normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 或者使用等价的写法更常见 float3 worldNormal normalize(mul((float3x3)unity_ObjectToWorld, v.normal)); // 注意上面第二种写法在数学上只适用于没有缩放的纯旋转矩阵。 // 在存在缩放时严格来说应该使用逆转置。但Unity的unity_ObjectToWorld在构建时 // 如果用于变换法线其内置的逆转置计算通常已通过UnityObjectToWorldNormal宏处理。最佳实践永远使用Unity提供的宏来变换法线它们会帮你处理缩放等复杂情况。在Built-in管线或标准着色器中WorldNormalVector(v.normal)或直接使用UnityObjectToWorldNormal(v.normal)。在URP中使用TransformObjectToWorldNormal(v.normal)。踩坑实录我曾在一个带有非均匀缩放的角色模型上发现其高光总是错位。排查了半天最终发现是在自定义Shader中图省事直接用unity_ObjectToWorld矩阵去乘了法线。换成UnityObjectToWorldNormal宏后问题立刻解决。这个坑几乎每个图形新手都会踩一次。4. 矩阵变换在Shader中的实战应用理解了理论我们来看几个实战场景看看这些矩阵是如何协同工作的。4.1 场景一构建一个世界空间下的顶点动画假设我们想做一个“草地随风摆动”的效果。简单的模型空间顶点偏移会让整个模型作为一个整体移动不真实。我们希望在世界空间里根据顶点的世界坐标来决定偏移量这样相邻的草叶可以有不同的摆动相位。// 在顶点着色器中 v2f vert (appdata v) { v2f o; // 1. 先将顶点变换到世界空间 float4 worldPos mul(unity_ObjectToWorld, v.vertex); // 2. 在世界空间计算偏移例如基于XZ坐标的正弦波 float waveOffset sin(_Time.y * _WaveSpeed worldPos.x * _WaveFrequencyX worldPos.z * _WaveFrequencyZ) * _WaveAmplitude; worldPos.y waveOffset; // 在世界空间Y轴上进行偏移 // 3. 将偏移后的世界坐标变换回裁剪空间输出 // 注意不能直接用UNITY_MATRIX_MVP了因为顶点已经变了。 // 需要手动组合世界坐标 - 观察坐标 - 裁剪坐标 float4 viewPos mul(UNITY_MATRIX_V, worldPos); o.pos mul(UNITY_MATRIX_P, viewPos); // 4. 其他数据如UV、法线照常处理 o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos worldPos; // 传递世界坐标给片元着色器用于光照计算 return o; }关键点当你修改了顶点位置后后续的变换链必须与你修改的位置所处的空间匹配。这里我们在世界空间修改了worldPos所以后续必须使用UNITY_MATRIX_V和UNITY_MATRIX_P来继续变换而不能用已经不合时宜的UNITY_MATRIX_MVP。4.2 场景二在片元着色器中重建世界坐标很多高级效果如屏幕空间深度、雾效、动态全局光照探针采样都需要知道当前片元对应的世界坐标。顶点着色器可以传递但精度可能不足尤其是在透视投影下线性插值的世界坐标会有误差。更精确的方法是利用深度纹理和投影矩阵在片元着色器中重建。// 片元着色器中需要启用深度纹理_CameraDepthTexture float4 frag (v2f i) : SV_Target { // 1. 获取当前片元的深度值来自深度纹理范围通常为[0,1] float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth); // 2. 将深度值转换到线性观察空间深度即距离摄像机平面的Z值 float linearDepth LinearEyeDepth(depth); // 3. 获取当前片元在裁剪空间中的位置范围[-1,1] // i.screenPos是经过计算的屏幕位置其xy分量在[0,1]zw分量可能包含深度信息。 // 我们需要构建一个裁剪空间下的方向向量。 float4 clipVec float4(i.uv * 2.0 - 1.0, depth, 1.0); // 非标准做法仅示意 // 更标准的做法是使用ComputeScreenPos或类似的宏来获取NDC坐标 // 4. 利用投影矩阵的逆矩阵将裁剪空间坐标变换回观察空间坐标 float4 viewPos mul(unity_CameraInvProjection, clipVec); viewPos.xyz / viewPos.w; // 透视除法逆过程 // 5. 再利用观察矩阵的逆矩阵即摄像机世界矩阵将观察坐标变换回世界坐标 float4 worldPos mul(unity_CameraToWorld, float4(viewPos.xyz, 1.0)); // 现在 worldPos.xyz 就是精确的该片元对应的世界空间位置 // ... 使用 worldPos 进行后续计算 }注意事项实际项目中Unity提供了更简洁的辅助函数如ComputeWorldSpacePosition或通过DecodeDepthNormal等。理解背后的矩阵原理能让你在自定义管线或遇到问题时有能力自己推导和调试。4.3 场景三正确变换切线空间数据对于法线贴图我们需要构建一个从切线空间Tangent Space到世界空间的变换矩阵即TBN矩阵Tangent, Bitangent, Normal。这个矩阵用于将法线贴图中读取的切线空间法线变换到世界空间以便与世界空间的光照方向进行计算。// 在顶点着色器中构建TBN矩阵并传递给片元着色器 v2f vert (appdata_tan v) // 使用带切线的输入结构 { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); // 变换顶点位置到世界空间 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldPos worldPos; // 变换法线到世界空间使用正确处理缩放的宏 float3 worldNormal UnityObjectToWorldNormal(v.normal); // 变换切线到世界空间注意切线是方向向量但通常不考虑缩放对方向的影响不需要考虑 // 实际上对于构建TBN矩阵更常见的做法是 // 1. 世界空间法线 (N) // 2. 世界空间切线 (T) 使用UnityObjectToWorldDir(v.tangent.xyz)它处理了缩放 // 3. 世界空间副切线/双切线 (B) cross(N, T) * v.tangent.w // v.tangent.w 是用于决定副切线方向的符号通常为1或-1 float3 worldTangent UnityObjectToWorldDir(v.tangent.xyz); float tangentSign v.tangent.w; float3 worldBitangent cross(worldNormal, worldTangent) * tangentSign; // 将TBN矩阵的三个轴按行排列传递给片元着色器 // 这样在片元着色器中我们可以用 mul(TBN, tangentNormal) 将切线空间法线变换到世界空间 o.worldT worldTangent; o.worldB worldBitangent; o.worldN worldNormal; // 或者更紧凑地传递整个3x3矩阵的每一行 o.tbnMatrix float3x3(worldTangent, worldBitangent, worldNormal); return o; }核心原理UnityObjectToWorldDir这个宏内部已经考虑了模型变换矩阵中的旋转和均匀缩放确保方向向量的正确性。对于非均匀缩放情况更复杂通常需要显式使用逆转置矩阵或者依赖Unity在导入模型时生成的切线如果勾选了“计算切线”选项。5. 常见问题排查与深度优化技巧即使理解了原理在实际编码中依然会遇到各种诡异问题。下面是一些典型问题及其排查思路。5.1 问题一模型显示位置/旋转/缩放完全错误症状模型不在预期的游戏世界位置或者旋转、缩放不对。排查步骤检查顶点变换代码确认在顶点着色器中使用的起始坐标是模型空间坐标v.vertex并且第一个变换矩阵是unity_ObjectToWorld或URP中的对应宏。检查矩阵乘法顺序在HLSL/GLSL中矩阵乘法是左乘。mul(UNITY_MATRIX_MVP, v.vertex)是正确的。如果顺序反了结果会完全错误。检查坐标系手性Unity使用的是左手坐标系。如果你从其他来源如某些数学库或教程复制了矩阵需要确认其坐标系是否匹配。不匹配会导致轴向反转例如Z轴方向相反。5.2 问题二光照效果异常高光错位、明暗相反症状漫反射光照看起来还行但高光点不在正确位置或者模型某些部分该亮的地方暗该暗的地方亮。排查步骤首要怀疑对象法线变换99%的此类问题源于法线变换错误。确保使用UnityObjectToWorldNormal(v.normal)或对应渲染管线的宏来变换法线。检查法线纹理采样和变换如果使用了法线贴图确保从纹理中读取的法线向量范围正确通常是[0,1]映射到[-1,1]normal tex2D(_BumpMap, uv).xyz * 2 - 1。正确构建了从切线空间到世界空间的TBN矩阵见4.3节。在片元着色器中将采样得到的切线空间法线用TBN矩阵变换到世界空间后必须进行归一化worldNormal normalize(mul(tbnMatrix, tangentNormal))。检查光照方向确保光照方向如_WorldSpaceLightPos0.xyz是在世界空间中并且与你变换后的世界空间法线进行点积计算。5.3 问题三深度相关效果出错雾、软粒子、屏幕空间反射症状基于深度的效果出现错位、拉伸或完全不显示。排查步骤确认深度来源效果使用的是顶点插值的世界坐标还是从深度纹理重建的世界坐标前者在透视投影下对于大三角形精度不足。检查重建公式如果自己重建世界坐标双重检查矩阵乘法的顺序和使用的矩阵是否正确。特别是投影矩阵的逆矩阵unity_CameraInvProjection和摄像机世界矩阵unity_CameraToWorld。检查空间一致性确保参与计算的所有向量和位置都在同一个空间。例如计算视线方向View Dir应该是世界空间摄像机位置 - 世界空间片元位置。5.4 性能优化技巧减少矩阵运算在顶点着色器中尽量只做必要的矩阵乘法。如果片元着色器不需要世界坐标就不要传递。UNITY_MATRIX_MVP在内部可能是合并的比分开乘三个矩阵效率高取决于平台和驱动。善用宏和内置函数Unity提供的宏如UnityObjectToClipPos,TransformObjectToWorldNormal不仅是代码规范它们还针对不同平台和渲染后端进行了优化有时会使用更高效的指令。避免在片元着色器中进行复杂矩阵运算像重建世界坐标这样的操作虽然功能强大但计算量较大。如果每个像素都要做一次矩阵乘法开销可观。考虑是否可以通过顶点着色器插值一个近似值或者利用线性深度代替。注意精度在移动平台或VR中使用float进行矩阵运算可能开销较大。对于位置计算float通常是必须的但对于方向向量法线、切线在保证质量的前提下可以考虑使用half精度。同时在片元着色器中对变换后的向量进行normalize()是必要的但也是耗时的需权衡。矩阵变换是Shader编程的基石初看复杂但一旦理清各个空间的关系和矩阵的职责就会变得清晰而强大。最好的学习方法就是动手实践写一个最简单的Shader然后尝试用不同的矩阵去变换顶点和法线观察渲染结果的变化。遇到问题时再回头对照本文的思路进行排查。记住图形学中的很多知识都是在调试一个个“黑屏”或“花屏”的过程中才真正刻进脑子里的。