做Unity Shader开发,矩阵变换是个绕不过去的核心基础。很多时候大家写Shader,顶点的处理就是套用几个引擎内置矩阵,比如UNITY_MATRIX_MVP、unity_ObjectToWorld,代码抄过来能跑,但一旦遇到模型变形、法线方向异常、坐标空间切换出错,就完全不知道从哪里排查了。
这篇文章就把Unity Shader里Transform Matrix(矩阵变换)这件事彻底讲透:矩阵到底在做什么、三张核心矩阵的关系、Shader里怎么用、实际项目里会踩哪些坑。适合刚入门的Shader学习者,也适合写了一段时间但总觉得“知其然不知其所以然”的开发者,把它当一篇查缺补漏的参考。
1. 为什么Shader必须理解矩阵:矩阵到底在帮我们做什么
1.1 矩阵不是数学考试,是三维世界的坐标翻译官
很多朋友一听“矩阵”就头疼,觉得这是纯数学内容,跟写Shader关系不大。其实在Unity Shader里,矩阵扮演的角色非常朴素:它把三维空间中的一个点,从一个坐标系“搬运”到另一个坐标系。
这就像寄快递。你在中国想给日本的朋友寄东西,不能直接把包裹丢出去,你写的是中文地址,快递系统要把它转译成国际通用的单号,再转成日本本地派送地址。三维坐标也一样:建模师在模型软件里用“模型本地坐标”描述一个顶点,顶点在模型文件里可能就是(0.5, 1.2, 0.3),但这个坐标只对这个模型本身有意义。把它放到整个游戏世界里,就要先让矩阵把本地坐标换算成世界坐标;再从世界坐标换成以相机为原点的观察坐标;最后还要通过投影矩阵,把三维坐标压成屏幕上能显示的二维坐标。
这一整个过程,每个环节都是一次矩阵乘法。顶点从进Shader到最终出现在屏幕上,本质就是一连串矩阵变换的结果。所以如果不懂矩阵,你看到的Shader顶点代码就是一团魔法数字;懂了矩阵,Shader的顶点阶段其实非常清晰,就是一条流水线:本地坐标 → 世界坐标 → 观察坐标 → 裁剪坐标 → 屏幕坐标。
1.2 顶点变换流水线:Shader里的一趟“货运专线”
在Unity Shader中,顶点着色器里常见的是这样的代码:
v2f vert(appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); return o; }这个UnityObjectToClipPos是一个封装好的函数,它做的不是一步变换,而是好几步矩阵乘法的合成结果:模型矩阵(Model Matrix)乘以视图矩阵(View Matrix)再乘以投影矩阵(Projection Matrix),最后合成的矩阵一般叫MVP矩阵(Model-View-Projection)。在旧版Unity Shader里,你常见到UNITY_MATRIX_MVP这个内置矩阵,它就是这个合成后的结果。
你可以把它拆开理解:
unity_ObjectToWorld:模型矩阵,把顶点从模型本地空间搬到世界空间。UNITY_MATRIX_V:视图矩阵,把顶点从世界空间搬到相机观察空间。UNITY_MATRIX_P:投影矩阵,把顶点从观察空间搬到裁剪空间。
UNITY_MATRIX_MVP = UNITY_MATRIX_P * UNITY_MATRIX_V * unity_ObjectToWorld。注意这个顺序,Unity里用的是列向量,顶点坐标是一个4x1的列向量,矩阵从左往右依次作用。也就是说,顶点先被unity_ObjectToWorld作用,再被UNITY_MATRIX_V作用,最后被UNITY_MATRIX_P作用。
这个顺序就是Shader世界的“发货路径”。任何时候你想在Shader里手动改一个顶点位置,脑子里都要先过一遍这个路径,明确当前坐标在哪个空间,下一步要往哪个空间换。这是排查很多渲染异常的第一步。
2. 核心矩阵逐个拆解:模型矩阵、视图矩阵、投影矩阵
2.1 模型矩阵(Model Matrix):从本地坐标到世界坐标
模型矩阵在Unity里对应的就是unity_ObjectToWorld,它也有一个对应的逆矩阵unity_WorldToObject。这个矩阵干的事情非常直观:把模型上每一个顶点从“以模型自身原点为基准的坐标”转换成“以世界原点为基准的坐标”。
比如一个Cube,在建模软件里,它的八个顶点的本地坐标是(-0.5, -0.5, -0.5)到(0.5, 0.5, 0.5)。你把它放到世界坐标(10, 0, 5)处,并且旋转了45度、缩放了2倍,如果不经过模型矩阵,这个Cube的顶点还是围在(0,0,0)附近,完全不可能是你看到的位置。模型矩阵把这个Cube的平移、旋转、缩放全部打包进一个4x4矩阵,顶点乘进去,就得到了正确的世界位置。
这个矩阵在Shader里可以直接用:
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;很多朋友会把模型矩阵理解为Transform组件的另一种形态。其实Unity的Transform组件内部就是维护了这样几个矩阵:localToWorldMatrix和worldToLocalMatrix,unity_ObjectToWorld就是C++侧在绘制物体之前把它传给了Shader。
要注意的是,模型矩阵包含的是非等比缩放信息。什么意思?比如你对一个模型在X轴缩放了2倍,Y轴缩放了1倍,Z轴缩放了0.5倍,那么模型矩阵的左上角3x3部分就不是一个正交基了。这直接影响后面法线变换的处理,后面会专门讲这个坑。
2.2 视图矩阵(View Matrix):把世界摆到相机面前
视图矩阵的作用是:把世界空间里的顶点,转换到以相机位置为原点、以相机朝向为Z轴方向的观察空间。这个矩阵在Unity里通常用UNITY_MATRIX_V访问。
换句话说,不管相机在世界里怎么动,视图矩阵都在做一件事:想象把相机固定到一个标准位置——一般是指向-Z方向、原点在世界原点——然后把整个世界所有物体都相对地挪动到对应的位置。这就是“视图”的本质,不是相机在动,而是整个宇宙在绕着相机反向运动。
这种思路对Shader调试很有借鉴意义:如果你想做一个始终面向相机的特效片,比如公告板(Billboard),你把世界坐标通过UNITY_MATRIX_V转到观察空间,然后在观察空间里做旋转,再转回世界空间,这个片就会永远面向相机。你其实是在跟视图矩阵配合,站在相机立场上做“作弊”。
在Shader里,视图矩阵通常不单独使用。大多数情况下你会用到的是UNITY_MATRIX_VP,也就是视图投影合成矩阵,一次把世界坐标转到裁剪空间。Unity封装好的UnityWorldToClipPos就是这个逻辑。
2.3 投影矩阵(Projection Matrix):透视与正交的秘密
投影矩阵做的事情,是把观察空间里的三维坐标,压成一个裁剪空间坐标。这个坐标的w分量不再是1,而是携带了“深度”的信息。在透视投影下,w分量等于观察空间里的-z值。也就是说,离相机越远的点,w越大,顶点坐标会被整体除一个越来越大的w,产生“近大远小”的效果。
很多新手会疑惑:投影矩阵到底投影了什么?它其实没有真正把坐标变成2D,而是把所有可见物体压进一个边长为2的立方体范围内(标准化设备坐标,NDC)。x、y、z都会被映射到[-1, 1]或者[0, 1]范围内,超出这个范围的顶点会被裁剪掉,这就是视锥体裁剪的原理。
投影矩阵在Shader里通过UNITY_MATRIX_P访问。你不常直接碰它,但理解它的一个参数对你调试很有帮助:Camera的Field of View(视场角)。FOV越大,投影矩阵的透视形变更强,你能看到的世界范围越宽,但边缘变形越明显。这个在Shader里做屏幕后处理效果、做视锥体可视化Debug时会用到。
如果你手动修改了裁剪空间的顶点坐标,比如为了做顶点膨胀效果,你需要记得同时调整w分量,否则你改的坐标会被当成“没考虑深度”的状态,显示出来会错得莫名其妙。
投影矩阵还有一个非常重要的衍生知识:近远裁剪面的设置会直接影响深度缓冲的精度。深度缓冲不是线性的,在近裁剪面附近精度极高,在远裁剪面附近精度极低。所以如果项目里出现画面闪烁、模型互相“打架”的现象,第一反应不是去调Shader,而是去看相机近远裁剪面:近裁剪面切记不要设太小,远裁剪面也不要设太大,否则深度精度不够,Z-fighting就会找上门。
3. 实战:在Shader里正确使用矩阵变换
3.1 UNITY_MATRIX_MVP与UNITY_MATRIX_IT_MV的来历与用法
在Unity里,矩阵并不是每次都要自己手算。引擎提供了一整套内置矩阵常量,它们都在Shader里可以直接访问:
unity_ObjectToWorld // 模型矩阵 unity_WorldToObject // 模型矩阵的逆 UNITY_MATRIX_V // 视图矩阵 UNITY_MATRIX_P // 投影矩阵 UNITY_MATRIX_VP // 视图投影矩阵 UNITY_MATRIX_MV // 模型视图矩阵 UNITY_MATRIX_MVP // 模型视图投影矩阵(旧教程常见) UNITY_MATRIX_IT_MV // 模型视图矩阵的逆转置矩阵日常开发中,新版Unity推荐直接使用封装函数:
UnityObjectToClipPos(v.vertex); // 本地坐标 → 裁剪坐标 UnityObjectToWorldNormal(v.normal); // 本地法线 → 世界法线 UnityWorldToClipPos(worldPos); // 世界坐标 → 裁剪坐标 UnityObjectToWorld(v.vertex); // 本地坐标 → 世界坐标这些包装函数内部其实就是封装好的矩阵乘法。它们不是神秘的黑魔法,只是把矩阵乘法封装成更可读的函数名称。当你看到旧Shader代码里的mul(UNITY_MATRIX_MVP, v.vertex),要知道它和UnityObjectToClipPos(v.vertex)基本等价,只是后者在Unity 5.6以后为优化做了一些平台差异处理,更推荐使用新写法。
还有一个常用矩阵是UNITY_MATRIX_IT_MV,它的全称是“模型视图矩阵的逆转置矩阵”。这个矩阵最主要的用途是法线变换。如果你的Shader需要把法线从模型空间转到观察空间,你会看到这样的旧代码:
float3 normalView = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);这跟我们平时用的模型矩阵有什么差别?为什么不能直接用UNITY_MATRIX_MV的旋转部分?因为法线是方向向量,不是位置向量——它不能套用跟顶点一模一样的变换规则。这个问题非常关键,下面单独讲。
3.2 法线变换为啥不能用MVP:法线矩阵的坑
这是几乎每个Shader开发者都会踩的坑。我当年第一次做“顶点膨胀”效果,把法线也当成顶点一起去乘模型矩阵,结果光照效果全面崩坏:模型表面亮暗不均,旋转到某个角度时甚至会出现完全反黑的区域。
问题出在哪?模型矩阵不仅包含旋转和平移,还可能包含非等比缩放。如果一个模型在X轴被拉伸了2倍,你直接用法线坐标乘以模型矩阵,法线方向会被歪曲,它不再垂直模型表面。但顶点位置被缩放了是符合预期的,法线方向却不能被这样盲目缩放。
正确的做法是:法线要用模型矩阵的“逆转置矩阵”(Inverse Transpose Matrix)来变换。这是一个典型的图形学结论,原因可以简化理解:法线本质上是一个与表面垂直的向量,表面切线的变换可以套用模型矩阵,但法线需要用逆矩阵转置的方式来保持“垂直于表面”的性质。对等比缩放来说,逆转置矩阵和原矩阵就差一个系数,无关紧要;但一旦存在非等比缩放,这个区别就是天壤之别。
在Shader里,如果你需要把法线从模型空间转到世界空间,千万不要写:
// 错误做法 float3 worldNormal = mul((float3x3)unity_ObjectToWorld, v.normal);而是用内置封装:
// 正确做法 float3 worldNormal = UnityObjectToWorldNormal(v.normal);或者手动用逆转置矩阵:
float3 worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);注意上面这两种写法差异很大:后者是法线这个行向量左乘unity_WorldToObject的3x3部分,这在数学上等价于使用模型矩阵的逆转置矩阵。UnityObjectToWorldNormal正是封装了这一步。
这个小知识点看似细节,实际上决定了你的光照、法线贴图、边缘光、描边效果是否正常。不要因为“看起来差不多”就跳过,实际项目里很多诡异的渲染Bug最后都能追到这一行。
3.3 坐标空间转换的具体代码实现
在Shader里,坐标空间转换几乎贯穿所有效果。这里列几个我实际项目中用过很多次的例子,你可以直接抄。
例一:在顶点着色器里做顶点膨胀(Rim / 边缘膨胀)
// 把顶点沿法线方向在模型空间内偏移 float3 expandedPos = v.vertex.xyz + v.normal * _ExpandStrength; float4 clipPos = UnityObjectToClipPos(float4(expandedPos, 1.0)); o.vertex = clipPos;这里要注意:法线和顶点都在模型空间,所以直接相加,不需要切换坐标空间。这个操作很简单,但初学者容易犯的错是先去把顶点转到世界空间,再把法线也转到世界空间再相加,白白增加计算量而且容易出现精度问题。在不同空间里操作,只要保证两个向量处于同一空间,结果就是一致的。
例二:判断顶点是否在相机前方
float3 viewPos = UnityObjectToViewPos(v.vertex.xyz); // 观察空间里相机朝-z方向,所以z > 0就是已经在相机背后(看不见) if (viewPos.z > 0) { /* 处理相机背后的逻辑 */ }当你想做“相机穿过模型时隐藏模型”的效果时,这个写法非常有用。关键是你要记得观察空间的方向约定,避免搞反。
例三:把世界坐标转为模型坐标(用于程序化噪声定位)
float3 localPos = mul(unity_WorldToObject, float4(worldPos, 1.0)).xyz;有时候你在做体积效果时,手上拿到的是世界坐标,但你需要用模型坐标来采样噪声纹理,让噪声“粘”在模型上跟着旋转。这个时候直接乘unity_WorldToObject就行。
例四:在片元着色器里拿深度做后处理
float depth = tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)).r; float linearDepth = LinearEyeDepth(depth);这一步其实也是矩阵知识的延伸:LinearEyeDepth内部还原了投影矩阵的逆向过程,把你从深度缓冲里读到的非线性深度值还原成线性深度。理解投影矩阵之后,你会很好理解为什么需要这么一个还原步骤:深度缓冲里存的不是真实的距离,而是经过投影矩阵映射后的值。
这些代码例子看起来零散,但它们有一个共性:你必须知道当前坐标属于哪个空间,才能正确选择矩阵。Shader里没有“万能坐标”,脱离空间谈坐标全是耍流氓。
4. 常见问题与排查技巧实录
4.1 法线方向错了,光照总是怪怪的
症状:模型有部分区域在正常光照下显示为黑色,旋转视角后发现黑斑在移动;描边效果一边粗一边细;法线贴图看起来“反了”。
排查思路:先检查是不是法线变换写错了。打开Frame Debugger,看顶点着色器的输出。如果你在Shader里自己手写了法线变换,立刻换成Unity封装函数UnityObjectToWorldNormal再测试。如果恢复正常,说明你的矩阵用错了方向。
另一个容易忽略的坑是统一缩放和非统一缩放的问题。即使你的法线变换写对了,如果模型Transform上用了负数缩放(比如把负值填进Scale的某一轴),法线方向依然可能翻转,导致模型整体光照反向。这个问题在美术制作模型时很少遇到,但在程序化生成场景时偶尔出现,排查时要留意Transform的Scale是否有负值。
4.2 模型闪烁、抖动:先查投影矩阵再查Shader
症状:两个面重叠的地方出现密集的闪烁噪点,距离拉远时更明显;或者模型在移动时有微弱的抖动感。
排查思路:第一时间要看Camera的近远裁剪面设置。近裁剪面设成0.01、远裁剪面设成5000这样的组合,在大部分场景里都会引起深度精度不足,导致深度冲突,也就是Z-fighting。这不是Shader矩阵写错,但跟矩阵直接相关——投影矩阵决定了深度缓冲的分布。
如果你设了合理的近远裁剪面,闪烁依然存在,那再检查Shader里是否手动修改了裁剪空间的坐标。如果你为了做模型描边把顶点沿法线偏移了,但描边物体和被描边物体的深度关系在偏移后发生了变化,也会出现闪烁。这类问题解决思路一般是给偏移一个导数补偿,或者把描边物体放到单独一个渲染Layer,用渲染顺序解决。
4.3 矩阵踩坑速查表
| 症状 | 可能原因 | 快速解法 |
|---|---|---|
| 模型位置错乱,旋转完全不对 | 矩阵乘法顺序反了 | 确认列向量约定,mul(matrix, vector)才是Unity正确顺序 |
| 光照异常、黑斑移动 | 法线直接套用了模型矩阵,没有逆转置变换 | 使用UnityObjectToWorldNormal |
| 描边粗细受视角影响 | 在模型空间偏移但目标空间不一致 | 统一在相同空间计算偏移 |
| 顶点膨胀后模型爆炸 | 忘了把位移向量w设为0 | 方向向量w置0,位置向量w置1 |
| 深度闪烁 | 近远裁剪面范围过大 | 调近裁剪面,不要把近裁剪面设太小 |
| 后处理采样错位 | 屏幕坐标没有正确做平台适配 | 使用UNITY_PROJ_COORD或ComputeScreenPos |
还有一个大家容易忽略的矩阵细节:向量的w分量。位置向量(比如顶点坐标)w是1,方向向量(比如法线、切线)w是0。这个区别直接影响平移是否有效。w=0时,矩阵的平移部分被消掉了,所以方向向量不会被平移;w=1时,位置向量会被正确平移。如果代码里把一个向量误写成位置向量,或者反过来,你会发现平移操作要么没效果,要么把坐标整体带歪。这是最隐蔽也最常见的矩阵“低级错误”。
4.4 坐标值“凭空消失”:矩阵与平台差异
在移动平台上,Shader里的浮点精度没有PC那么高。如果你把坐标从对象空间一路乘到世界空间、观察空间、裁剪空间,中间每一步都有精度损失,尤其在远距离大世界场景里,顶点抖动会非常明显。
这个问题的根源还是矩阵变换链太长。解决办法是在Shader里尽量用对象空间本地坐标做计算,或者把精度损失大的计算挪到CPU侧提前处理。Unity的URP管线里引擎会自动处理一部分,但如果你写了自定义Shader,这些平台差异就要自己放在心上。
我自己的习惯是:凡是能用模型空间做计算的效果,绝不在世界空间里做完再转回去。不是世界空间不好,而是每一次矩阵乘法都是一次浮点运算,链条越长,误差越大。做贴地型效果、近距离特效,模型空间计算足够用;只有那些需要在多个模型之间做交互的效果,才值得全部转到世界空间。
5. 调试矩阵的实用工具与个人经验
5.1 三个非常实用的调试方法
第一,Frame Debugger。Unity的Frame Debugger能看到相机Draw Call的完整渲染状态,包括矩阵列表。你选中任意一个Draw Call,右侧会列出各个阶段用到的矩阵,从对象到世界、从世界到相机的每一步都有。这个工具是排查矩阵问题的第一步,比在代码里加Log直观得多。
手动在Shader里输出矩阵值也可以,通过Debug.Log不太方便,但可以在OnDrawGizmos里读Camera的投影矩阵,或者读transform.localToWorldMatrix,把关键矩阵打印出来,看看Unity在C++侧到底传了什么。记住,Shader里拿到的矩阵和C#侧Transform组件里的数值是一致的,只是侧重点不同。
第二,OnDrawGizmos可视化。想做视锥体裁剪精确调试时,可以写一个Gizmos脚本,把Camera的投影矩阵和视图矩阵组合,画出视锥体的六个面。这样你能直观看到视锥体在场景里的形状,理解为什么投影矩阵会裁掉某些物体。
第三,数学对照法。很多新手排查矩阵问题靠猜,其实有一个很稳的办法:写一个C#脚本,用Transform类的localToWorldMatrix.MultiplyPoint,在CPU侧把一个顶点坐标从本地转到世界空间,把它和Shader里的GPU结果对比。如果两者不一致,那一定是你CPU侧和GPU侧用了不同的矩阵约定,而不是“显示错了”。
这样查错的好处是:C#侧的计算过程可以打断点、看数值,什么都能看清楚。GPU那边接好RenderDoc或者使用后处理Debug输出,两厢对照,问题一秒现形。
5.2 新手如何高效拿下列式变换
我自己带过不少实习生,发现大家学矩阵有一个共同问题:把矩阵当成一个整体去背,而不是拆开看。其实4x4矩阵没那么可怕,你只需要知道:
- 左上角3x3:旋转和缩放信息。
- 最后一列前三个分量:平移信息。
- 最后一行一般是
0 0 0 1(投影矩阵除外)。
任何矩阵变换,第一步都是用4维向量把位置和方向分开,第二步看是哪三种变换的叠加(平移、旋转、缩放),第三步看变换顺序。Unity是列向量左乘矩阵,mul(matrix, vector)顺序不能换。把这三点刻到脑子里,矩阵就通了。
学习顺序上,我建议先手算一个简单2D旋转矩阵,把它扩展到3D,再自己写一个模型矩阵(用角度和缩放),在C#里MultiplyPoint测试和Unity引擎结果是否一致。等你理解了一层矩阵,再看MVP合成、法线矩阵,就会觉得非常自然,不再是一堆名词堆砌。
最后再分享一个小技巧:如果你在Shader里总是记不住哪个矩阵对应哪个空间,可以把内置矩阵统一写在一个自定义cginc文件里,用宏命名成通俗易懂的名字,比如WORLD_TO_VIEW、LOCAL_TO_WORLD,然后让别人和你自己一看就懂。这不算什么高端技巧,但对团队成员协作、对几个月后回看自己的项目,都能省下不少脑细胞。
矩阵变换这个主题,表面上是数学,内核其实是对坐标空间的掌控力。你在Shader里写下的每一行顶点代码,背后都是在跟这些矩阵打交道。把这部分吃透了,顶点着色器对你来说就不再是一堆魔数的堆砌,而是可以随意指挥的流水线。这部分基础打得越扎实,后面做特效、做渲染管线的深度优化都会轻松很多。