
做Shader的人迟早会跟矩阵变换正面碰一次。哪怕你只想写个最简单的顶点膨胀效果也得先想清楚这个顶点在模型空间里、世界空间里、还是裁剪空间里我见过不少新手把Cg语法背得滚瓜烂熟一遇到UnityObjectToClipPos就当成固定咒语来用结果物体一挂到有旋转缩放的父节点下就开始乱飞。其实这些问题的背后全是Unity Shader里那一套变换矩阵Transform Matrix在起作用。这篇文章把模型、视图、投影这几类核心矩阵的来龙去脉讲透顺带把我踩过的好几个坑一并交代清楚适合所有刚开始写Shader、或者写了半年还在靠复制粘贴混日子的朋友。1. 为什么Shader开发要先啃透矩阵变换1.1 矩阵变换在渲染管线里的角色GPU渲染一帧画面时场景里每个物体本质上都要经历一条固定的空间链路模型空间 - 世界空间 - 观察空间 - 裁剪空间 - 屏幕空间。每一步切换靠的都是一次矩阵乘法。换句话说矩阵变换就是整个渲染管线里“给坐标换坐标系”的搬运工。你可以这样理解你手里有一个立方体的顶点数据这些坐标一开始是相对于物体自己原点的比如(0.5, 0.5, 0.5)。可是这个物体可能被放在场景的某个角落旋转了30度又缩放成了1.2倍那么它顶点真正在场景中的位置就必须通过模型矩阵Model Matrix算出来。接下来摄像机站在某个位置朝某个方向看物体在世界空间里的坐标又要换成“相对摄像机”的坐标这一步靠视图矩阵View Matrix完成。最后摄像机视野像一个锥体要把这个锥体里的内容“压”成一个标准立方体方便GPU做裁剪和投影这就是投影矩阵Projection Matrix的工作。顶点着色器里最常见的一句代码o.vertex UnityObjectToClipPos(v.vertex);之所以能直接从模型空间跳到裁剪空间就是因为它内部直接做了一次组合矩阵乘法P * V * M。如果没有矩阵这个工具你就要手动对每个顶点做一堆三角函数计算、平移加减、投影公式推导顶点数量一多画面直接卡死。矩阵把“千万次顶点运算”压缩成了“几个矩阵相乘”这正是显卡并行架构最喜欢的计算形式。1.2 不懂矩阵变换会踩到哪些现实问题我先举几个实际项目里遇到的案例大家感受一下矩阵变换没搞懂是什么后果。第一个典型的坑某个物体挂在一个父节点下面父节点有旋转子物体Shader里手动算世界坐标时用了unity_ObjectToWorld这个矩阵结果位置怎么都对不上。原因就是unity_ObjectToWorld是物体自身相对世界空间的变换矩阵它已经把父节点的链式变换合并进去了可是很多人在心里把“世界坐标”理解成了“场景坐标原点直接位移”中间差了父节点的旋转缩放一下就乱套。第二个常见现象物体在Inspector里把Scale设成了非均匀缩放比如X方向2.0、Y方向1.0同样一份光照Shader在球体上还正常换成胶囊体后高光区域出现奇怪的条纹。这种问题十有八九是法线变换没做对法线直接拿模型矩阵乘了没有用逆转置矩阵。这类光照问题在美术调整缩放后突然出现非常折磨人。第三个让我印象深刻的坑想给一个UI物体做顶点动画参考别人的Shader写了mul(UNITY_MATRIX_MVP, v.vertex)结果物体渲染位置和实际点击区域偏移了。后来发现问题出在UI系统有自己一套特殊的Canvas变换矩阵不是普通场景里的MVP可以直接套用的。这些问题的共性是什么都不是语法层面的报错而是“坐标到底被哪个矩阵搬到哪去了”这个基础认知缺失。所以说矩阵变换不是大学线性代数考卷上的题目而是Shader世界里每天都在用的基本功。2. 四个核心矩阵逐个拆开讲从模型空间到屏幕2.1 模型矩阵把本地坐标摆进世界场景模型矩阵Model MatrixUnity里通常是unity_ObjectToWorld的作用是把物体顶点从模型空间也叫本地空间、对象空间变换到世界空间。这个矩阵通常由三部分组合而来缩放Scale、旋转Rotation、平移Translation。在Unity的Transform组件里你看到的Position、Rotation、Scale三个属性最终就会组合出这个矩阵。标准组合顺序是先缩放再旋转最后平移公式写成M T * R * S。为什么顺序必须是缩放-旋转-平移我举个直观例子。假设一个模型顶点在原点附近如果先旋转再缩放旋转后的顶点会带着旋转后的坐标系一起被拉伸非均匀缩放时会出现“斜切”的怪异效果而先缩放再旋转顶点只是在自身轴上被拉伸再整体转到目标方向最后平移到世界位置这符合我们设置Transform时的直觉。矩阵乘法不满足交换律这一点在Shader里同样成立。如果你手动拼模型矩阵记得顺序是mul(mul(T, R), S)反过来就错。实际写Shader时我们一般不手动拼模型矩阵直接用unity_ObjectToWorld就行。但有一个细节值得注意unity_ObjectToWorld里同时包含了旋转和平移如果你只想取旋转部分处理方向向量要么用(float3x3)unity_ObjectToWorld做强制截断要么用unity_WorldToObject配合转置——这在我们后面讲法线变换时会遇到。2.2 视图矩阵把世界坐标搬到摄像机眼前视图矩阵View Matrix解决的是一个问题摄像机在世界空间里也有一个位置和朝向那么所有物体顶点在世界空间里的坐标在摄像机这个“坐标系”看来是多少。把这个矩阵拆开看它本质上是摄像机Transform矩阵的逆矩阵。摄像机往前移动一段距离在摄像机坐标系里整个世界就往反方向移动了一段距离。所以视图矩阵里的值往往是摄像机位置的反向平移加上朝向的逆旋转。在Unity里你可以直接用UNITY_MATRIX_V拿到当前摄像机的视图矩阵。大多数情况下我们不需要自己算因为UnityObjectToClipPos内部已经把它和投影矩阵串成了一条链。但当你想写一些特殊效果比如屏幕空间反射、顶点沿视线方向偏移时就不得不和UNITY_MATRIX_V打交道了。比如我想让物体顶点沿着“摄像机看向它的方向”做膨胀就得先用视图矩阵把顶点变换到观察空间操作完再乘投影矩阵不然你单纯在世界空间里做偏移方向上很容易和最终屏幕效果对不上。有个细节Unity的观察空间坐标系约定和传统OpenGL并不完全一致左手系、右手系的差异导致Z轴的朝向和符号经常让人迷惑。我自己的经验是不要凭记忆去手写V矩阵尽量站在Unity给的矩阵变量之上做叠加运算符号问题让引擎去处理。2.3 投影矩阵把3D场景压成裁剪体投影矩阵Projection Matrix是把观察空间中的坐标变换到裁剪空间Clip Space的矩阵它决定了相机的视野范围、近远裁剪面以及透视还是正交。透视投影矩阵里和FOV视场角、宽高比、近裁剪面、远裁剪面相关的参数都参与计算。它做了一件非常重要的事把视锥体映射成一个标准的长方体NDC前的裁剪体。这个过程中靠近摄像机的物体在裁剪空间里Z值会占据更大的精度范围远离摄像机的物体则被压缩得很厉害。这就是为什么你在Shader里做深度相关的效果时经常发现“远处深度一抖一抖的”本质上是因为投影矩阵里深度是非线性分布的。正交投影和透视投影的区别也体现在投影矩阵上正交投影没有透视收缩效果矩阵最后一行不包含除Z外的透视除法系数透视投影则会在变换时把W分量变成一个跟随深度变化的数值为后续的透视除法做准备。在Unity中UNITY_MATRIX_P可以直接拿到当前相机的投影矩阵但实际开发里我很少单独拿它做乘法更多是依赖UnityObjectToClipPos一键搞定。不过理解投影矩阵的构成还是有必要的——当你想修改FOV、做“越肩视角”的偏移、或者写自定义的深度压缩算法时如果你完全不知道投影矩阵内部在做什么排错会非常痛苦。2.4 MVP合体与Unity里现成的矩阵变量把上面的三个矩阵按顺序乘在一起就得到MVP矩阵MVP P * V * M。在Shader代码里对一个顶点v做变换时正确写法是clipPos mul(P, mul(V, mul(M, v)))。这里有一个非常容易搞反的点矩阵乘法是从右往左生效的离向量最近的那个矩阵先作用。用人类语言说就是“先模型再视图最后投影”。Unity提供了一批内置矩阵变量我列一个常用的表格变量名含义常用场景UNITY_MATRIX_MVP模型-视图-投影组合矩阵旧式写法现在不推荐直接拼UNITY_MATRIX_M/unity_ObjectToWorld模型矩阵模型空间到世界空间顶点位置转世界坐标UNITY_MATRIX_V视图矩阵世界空间到观察空间视线方向、观察空间操作UNITY_MATRIX_P投影矩阵观察空间到裁剪空间自定义投影效果UNITY_MATRIX_VP视图-投影组合矩阵从世界空间直接到裁剪空间unity_WorldToObject模型矩阵的逆矩阵法线变换、反推本地坐标细心的人会发现UNITY_MATRIX_MVP这个变量在老版Shader里很常见但新版Unity官方更推荐用UnityObjectToClipPos(v.vertex)。原因不只是它写法更短更在于它内部会根据当前平台和API自动选择更合适的变换路径避免了某些平台上矩阵变量被特殊处理带来的额外问题。我自己所有新项目基本都只用UnityObjectToClipPos省心又安全。3. 最容易翻车的法线变换为什么要用逆转置矩阵3.1 法线为什么不能直接套用顶点变换很多教程会告诉你“法线变换不能用模型矩阵直接乘得用逆转置矩阵。”但为什么我用一个简单例子讲清楚。假设你有一条线段从点A到点BA在原点B在X轴正方向1单位处。这条线段对应的法线方向是Y轴正方向。现在做一个非均匀缩放X方向放大2倍Y方向保持1倍。缩放之后线段从原点指向(2, 0)这条线段确实还是水平的那么法线按理说应该还是Y轴方向。但如果你直接拿缩放矩阵去乘法线向量(0, 1)得到的结果是(0, 1)看起来没变好像也对换个角度原来是一条斜线比如从原点指向(1, 1)法线方向是(-1, 1)归一化后。现在X方向放大2倍斜线变成从原点指向(2, 1)倾斜角度变了。那么和它垂直的法线方向也必然要跟着变。可如果直接用同一个缩放矩阵去乘法线(-1, 1)得到(-2, 1)这个向量和变换后的线段是否还垂直算一下点积(2, 1) · (-2, 1) -4 1 -3不等于0已经不垂直了。法线一旦和表面不垂直光照计算里的漫反射、高光方向就全部错乱画面上就会出现五花八门的暗斑和条纹。所以结论是顶点坐标可以直接用模型矩阵变换但方向信息尤其是法线不能直接用同一个矩阵硬乘。3.2 逆转置矩阵的数学解释那怎么办数学上正确的做法是如果模型矩阵是M那么法线变换矩阵应该是(M⁻¹)ᵀ也就是M的逆矩阵再转置中文一般叫“逆转置矩阵”。这个结论推导起来也不难。一个法线向量n它和切线向量t之间满足点积为零n · t 0。经过变换之后切线t变换为M * t我们希望新的法线n依然垂直于新的切线也就是n · (M * t) 0。把点积写成矩阵乘法形式即(n)ᵀ * M * t 0。为了让这个等式成立需要(n)ᵀ * M nᵀ两边再转置一下就得到n (Mᵀ)⁻¹ * n也就是(M⁻¹)ᵀ * n。理解了这个推导过程你就明白为什么老手总说法线变换要用“逆转置”因为法线和切线之间的垂直关系必须在变换后继续保持只有逆转置矩阵能做到这一点。如果M本身是一个正交矩阵只有旋转、没有缩放或者均匀缩放那么M的逆等于M的转置(M⁻¹)ᵀ就等于M本身所以“直接用模型矩阵乘法线”在“只有旋转缩放均匀”的情况下碰巧正确。这就是为什么很多简单Demo里直接乘也没事一上美术的非均匀缩放就翻车。3.3 Unity Shader里的标准写法在Unity Shader里处理法线最常见的需求是把模型空间里的法线变换到世界空间代码如下float3 worldNormal normalize(mul(v.normal, (float3x3)unity_WorldToObject));注意这里的写法是mul(v.normal, 矩阵)把向量放在矩阵左边。前面说过这种行向量写法相当于在列向量形式下使用了矩阵的转置配合unity_WorldToObject也就是模型矩阵的逆矩阵整体算出来的效果正好就是逆转置矩阵变换。所以这里有两套看起来很像但结果完全不同的写法我单独列出来对比写法数学效果非均匀缩放时是否正确mul(v.normal, (float3x3)unity_WorldToObject)相当于(M⁻¹)ᵀ * n正确mul((float3x3)unity_ObjectToWorld, v.normal)相当于M * n错误mul((float3x3)unity_WorldToObject, v.normal)相当于M⁻¹ * n错误这一段是我自己当时学的时候最迷惑的地方因为网上很多代码片段三种写法混着出现而且在小缩放情况下肉眼看不出区别。为了保险我建议在把所有法线变换到世界空间的场合统一使用第一种写法不要再图省事直接乘unity_ObjectToWorld。4. 实操在Shader里验证矩阵变换的完整过程4.1 把你正在用的矩阵“看”出来想真正理解矩阵变换最好的方式不是背公式而是把矩阵内容输出到调试工具里亲眼看一遍。Unity的Frame Debugger就是一个很好的起点。操作路径很简单菜单栏Window - Analysis - Frame Debugger然后Enable选中一帧里的某个Draw Call右侧面板的“Shader Properties”或“Matrix”区域能看到当前物体的模型矩阵、视图矩阵、投影矩阵。另一个更强大的工具是RenderDoc它能抓取整个渲染帧在某个绘制事件的VS Input和VS Output里你可以看到顶点坐标在每个阶段的具体数值。我之前排查过一个模型在特定角度下闪烁的问题就是靠RenderDoc逐顶点对比世界坐标和裁剪坐标才发现是法线没归一化导致的。还有一个土办法直接在Shader里把矩阵数据编码成颜色输出出来。比如顶点着色器里用矩阵把一个点变换到某个空间然后把这个结果当作颜色传回片元再加上热力色的映射就能在画面上非常直观地看到数值的分布趋势。我用这个办法验证过视图矩阵方向有没有写反效果比盯着一堆数字效率高得多。4.2 一套可直接抄的顶点变换模板如果只是应付常规需求不搞深度定制我建议直接按下面这套模板来写顶点着色器它能把模型空间里的顶点和法线分别变换到世界空间再转到裁剪空间v2f vert(appdata v) { v2f o; // 模型空间顶点直接转到裁剪空间作为最终输出位置 o.vertex UnityObjectToClipPos(v.vertex); // 顶点在世界空间里的位置后面做光照、距离衰减都要用 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; // 法线从模型空间转到世界空间注意用的是行向量乘逆矩阵 float3 worldNormal normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 构造切线空间和世界空间的转换留给法线贴图用 // 这里省略后续展开 o.worldPos worldPos; o.worldNormal worldNormal; return o; }这里特别强调两点。第一顶点位置变换用UnityObjectToClipPos就好不需要自己拼接MVP。第二如果某个顶点动画需要你在世界空间里修改顶点的位置正确做法是先把顶点从模型空间变到世界空间修改完后再用mul(UNITY_MATRIX_VP, float4(worldPos, 1.0))把世界坐标转到裁剪空间。不要直接在世界空间坐标上做改动再塞回模型空间的运算那样矩阵链会乱掉。4.3 mul、列主序与矩阵乘法顺序的细节在Unity ShaderCg/HLSL里mul函数的用法背后藏着矩阵存储方式的大坑。Unity的矩阵是列主序存储的意思是一个四行四列的矩阵在内存里以列优先排列。你在Shader里写float4x4 m float4x4(...)时如果按照行主序的思维去填写很容易把矩阵填成转置后的效果。mul函数同时支持两种形态mul(matrix, vector)把向量当成列向量处理结果是M * vmul(vector, matrix)把向量当成行向量处理结果是v * M。数学上v * M等价于Mᵀ * v的转置。正是因为这一点我们在做逆转置矩阵变换时才能用“行向量乘逆矩阵”的写法偷懒。关于乘法顺序我再啰嗦一句。clipPos mul(P, mul(V, mul(M, v)))是从右往左读的。很多新手觉得“矩阵乘法是从右往左的”这句话很抽象我一般建议记成最右边的矩阵最先作用在向量上离向量越远的矩阵越晚作用。当你要把多个矩阵合并成一个时也要保持这个顺序比如combined mul(P, mul(V, M))不能随兴打乱。5. 常见问题与排查技巧实录5.1 物体被非均匀缩放后光照“花了”这是法线变换没过关的最典型症状。表现是同一个材质球体显示正常换成长方体或者胶囊体光照区域出现奇怪的硬边、色斑而且旋转物体时高光位置抖动。排查思路按三步走。第一步确认美术或代码里有没有非均匀缩放也就是Transform的Scale三个分量值不相等。第二步回到Shader里检查法线变换的写法看看是不是用了mul((float3x3)unity_ObjectToWorld, v.normal)。第三步改成normalize(mul(v.normal, (float3x3)unity_WorldToObject))重新查看光照是否恢复正常。我把这个规律总结成一句话只要遇到“非均匀缩放光照变花”的组合优先怀疑法线变换。5.2 自己拼MVP结果模型变成镜像或颠倒很多人试过不用UnityObjectToClipPos而是自己手动写clipPos mul(UNITY_MATRIX_MVP, v.vertex)然后发现模型有时候是镜像的有时候Z轴前后倒置尤其在不同平台设备上表现还不一样。原因在于Unity在不同图形API下的坐标系统约定有差异比如某些平台裁剪空间的Z范围是0到1另一些是-1到1有的观察空间是左手系有的则是右手系。自己手动拼接MVP相当于把这一堆平台差异全部揽到自己身上。解决办法其实很朴素顶点最终输出一律用UnityObjectToClipPos除非你非常清楚地知道自己在做什么。如果实在要自己拼至少用UnityWorldToClipPos(worldPos)这类引擎封装好的函数而不是凭教科书上的OpenGL公式硬套。5.3 w分量与深度精度问题顶点着色器输出的POSITION是一个float4其中w分量不是摆设。在透视投影下w的分量值会随着顶点深度变化而GPU最终做透视除法时是用x、y、z同时除以w把裁剪坐标变成屏幕坐标。如果你一不小心把w设成了1.0或者不小心清零画面会出现严重的畸变甚至整个三角形被裁剪掉。一个我在实际项目里遇到过的特例是做顶点动画时修改了坐标的x、y、z却没有同步修改w结果物体在某些视角下突然“消失”。排查后才发现我修改的是模型空间坐标却错误地在裁剪空间里直接改把w破坏了。正确思路是在模型空间或世界空间里做顶点位移最后交给裁剪变换生成新的w。另外投影矩阵里深度非线性分布带来的精度问题也是w这个环节衍生出来的经典旧坑。近裁剪面附近精度高远裁剪面附近精度低这让远距离物体深度闪烁。如果你在Shader里做手写深度记得趁早把深度转成线性深度或者想办法提高近裁剪面的数值范围别拖到后期再解决。5.4 排查矩阵变换的四把工具工具不在多关键要会用。第一把Frame Debugger。它能看到单个Draw Call使用的矩阵内容。如果你发现某个物体的矩阵数值和Transform面板对不上优先怀疑是不是脚本里手动覆盖了矩阵或者动画系统驱动了部分变换。第二把Shader调试用的可视化输出。比如把worldNormal映射成颜色return float4(worldNormal * 0.5 0.5, 1.0);。红色通道对应X方向绿色通道对应Y方向蓝色通道对应Z方向一眼就能看出法线变换后有没有发生偏移。第三把RenderDoc断点抓帧。专门对付那些“只在真机出现”或者“只在特定渲染路径出现”的问题可以逐顶点看数据流。第四把最土但最有效的二分法注释。把顶点着色器里跟矩阵有关的代码一行行注释掉每注释一步看一次画面变化。我曾经靠这个方法定位过一个“模型跟着相机晃动”的诡异Bug最后发现是视图矩阵被某个脚本重复赋值了两遍相当于对同一个顶点做了两次观察空间变换。我做Shader开发这几年下来最大的体会是矩阵变换在Unity里被封装得很深绝大多数时候你不需要自己写矩阵但你必须知道某个封装函数内部发生了什么。当你开始写自定义顶点动画、做视差映射、处理特殊空间转换时之前积累的这点矩阵直觉会直接决定你是半天搞定还是Debug三天还找不到北。最后再分享一个我自己的习惯每次开始一个新Shader项目我会先建一个最简单的Unlit Shader把顶点位置、法线、切线分别用颜色可视化出来确认整个矩阵链路是通的才开始做具体效果。这一步花不了十分钟却能在后面省下大把定位问题的时间。