1. 从模型顶点到屏幕像素:矩阵变换到底在做什么
很多人第一次接触Unity渲染管线里的矩阵变换,脑子里冒出来的是一堆线性代数公式,什么平移矩阵、旋转矩阵、缩放矩阵,背得滚瓜烂熟,但一到实际项目里就懵了——为什么我的模型明明放在场景里,摄像机一拉远就跑到屏幕外面去了?为什么UI元素跟着摄像机旋转会飘?为什么法线方向在非均匀缩放下会出错?
这些问题的根源,都在于没有把矩阵变换当成一条完整的“流水线”来理解,而是把它拆成了孤立的数学知识点。我刚开始做渲染相关的工作时也是这样,直到有一次做一个模型描边效果,发现描边宽度在模型缩放后完全失控,才逼着自己把整条变换链路从头到尾捋了一遍。
这篇文章就是那次梳理的完整产物。我会从模型空间的顶点坐标出发,一路讲到它最终变成屏幕上的像素,中间经过的每一个矩阵、每一次坐标空间切换,都会拆开揉碎讲清楚。同时,我会重点讲那些文档里不会写的坑——比如法线变换为什么要用逆转置矩阵、深度缓冲的非线性到底怎么影响你的效果、不同渲染路径下矩阵的差异等等。
不管你是刚接触Shader的新手,还是已经能写简单顶点片元着色器但总觉得矩阵这块不够通透的开发者,这篇内容都能帮你把这条链路彻底打通。我还会给出可以直接复制到项目里验证的代码片段,让你能亲眼看到每个矩阵对最终结果的影响。
2. 模型空间到世界空间:物体变换矩阵的完整拆解
2.1 物体变换矩阵到底包含了什么
在Unity里,每个GameObject的Transform组件上都有Position、Rotation、Scale三个属性。这三个属性组合起来,就构成了这个物体的模型矩阵(Model Matrix),也叫物体变换矩阵(Object-to-World Matrix)。它的作用是把顶点从模型空间(Model Space)变换到世界空间(World Space)。
模型空间是啥?你可以把它理解成“模型自己的坐标系”。比如你从建模软件里导出一个角色模型,它的原点可能在脚底,也可能在腰部,这个原点位置就是模型空间的原点。模型空间里的坐标是固定的,不会因为你把物体拖到场景哪个位置而改变。
世界空间则是整个场景的统一坐标系。当你把模型拖到场景里,设置它的Position为(3, 0, 5),实际上就是在说:把这个模型的模型空间原点,放到世界空间的(3, 0, 5)位置。
模型矩阵的构建顺序是先缩放,再旋转,最后平移。这个顺序不能乱,因为矩阵乘法不满足交换律。你可以这样理解:缩放和旋转都是相对于物体自身原点进行的,而平移是把整个已经缩放旋转好的物体搬到目标位置。如果先平移再旋转,物体会绕着世界原点转,而不是绕自己的原点转,结果就完全不对了。
在Shader里,Unity已经帮你算好了这个矩阵,直接通过unity_ObjectToWorld就能拿到。如果你想知道它的具体数值,可以在Shader里输出这个矩阵的某一行来看看。
2.2 手动构建模型矩阵:为什么顺序如此重要
假设一个物体在模型空间里有一个顶点P,它的世界坐标P_world可以表示为:
P_world = T * R * S * P_local其中T是平移矩阵,R是旋转矩阵,S是缩放矩阵。注意这里的乘法顺序是从右往左应用的:先对P_local应用S,再应用R,最后应用T。
我用一个具体的例子来说明为什么顺序不能反。假设有一个单位立方体,一个顶点在(1, 1, 1)。现在我要把它缩放2倍,然后绕Y轴旋转90度,最后平移到(5, 0, 0)。
如果按正确顺序(先缩放,再旋转,最后平移):
- 缩放后:(2, 2, 2)
- 旋转90度后(绕Y轴):(2, 2, -2)(假设旋转方向使得x变成-z)
- 平移后:(7, 2, -2)
如果顺序反了(先平移,再旋转,最后缩放):
- 平移后:(6, 1, 1)
- 旋转90度后:(1, 1, -6)
- 缩放后:(2, 2, -12)
结果完全不同。所以在手动构建矩阵时,一定要记住这个顺序。Unity的Transform组件内部也是按这个顺序来构建矩阵的。
在C#里,你可以用Matrix4x4.TRS(position, rotation, scale)来一次性构建这个矩阵,它内部就是按TRS的顺序组合的。如果你需要自己写矩阵乘法,一定要注意列主序和行主序的区别——Unity的Matrix4x4是列主序的,也就是说矩阵的每一列对应一个基向量。
2.3 模型空间的法线为什么要用逆转置矩阵变换
这是一个非常经典的坑。顶点的变换直接用模型矩阵就行,但法线的变换不能用同一个矩阵,尤其是当模型存在非均匀缩放的时候。
原因在于:法线是垂直于表面的方向向量,它描述的是“朝向”,而不是“位置”。当你对一个物体进行非均匀缩放时,表面的倾斜角度会发生变化,法线也必须相应地调整。如果直接用模型矩阵变换法线,得到的方向可能不再垂直于变换后的表面。
数学上的推导是这样的:假设切线向量为T,法线向量为N,它们满足T·N = 0。经过模型矩阵M变换后,新的切线为MT,新的法线为MN。要保证(MT)·(MN) = 0,法线实际上应该用M的逆转置矩阵(M^-1)^T来变换。
在Unity的Shader里,Unity提供了一个现成的矩阵unity_WorldToObject,它是unity_ObjectToWorld的逆矩阵。所以变换法线的正确做法是:
float3 normalWorld = normalize(mul(float4(normalObject, 0.0), unity_WorldToObject).xyz);注意这里用的是unity_WorldToObject而不是它的转置,因为Unity在Shader里对矩阵乘法的顺序做了处理,实际上等价于用逆转置矩阵变换。如果你自己手动构建矩阵,一定要记得先求逆再转置。
提示:如果模型只有均匀缩放(三个轴的缩放系数相同),那么法线直接用模型矩阵变换也不会出错,因为均匀缩放下逆转置矩阵和原矩阵只差一个缩放因子,归一化后就抵消了。但一旦有非均匀缩放,就必须用逆转置。
3. 世界空间到观察空间:摄像机矩阵的隐藏细节
3.1 观察矩阵的构建逻辑
从世界空间到观察空间(也叫摄像机空间、视图空间),靠的是观察矩阵(View Matrix)。这个矩阵的作用是把世界坐标系下的点,转换到以摄像机为原点的坐标系下。
观察矩阵的构建思路是:先平移,再旋转。具体来说,就是把世界原点平移到摄像机位置的反方向,然后旋转使得摄像机的朝向对齐到-Z轴方向。
在Unity里,摄像机的观察矩阵可以通过Camera.worldToCameraMatrix获取。如果你在Shader里写渲染,Unity会自动把相关的矩阵传进来,比如UNITY_MATRIX_V就是观察矩阵。
观察矩阵有一个很重要的特性:它把摄像机放在原点,摄像机看向-Z方向(在Unity的左手坐标系下)。这意味着在观察空间里,所有可见物体的z坐标都是负的(因为它们在摄像机前方)。这个特性在写深度相关效果时非常关键。
3.2 摄像机矩阵在Shader中的获取方式与差异
在不同的渲染路径下,获取摄像机矩阵的方式略有不同。如果你写的是内置渲染管线的Shader,可以直接用UNITY_MATRIX_V。如果是最新的URP或HDRP,矩阵的获取方式会有变化,比如URP里需要用GetWorldToViewMatrix()或者直接从CBuffer里读。
这里有一个很容易踩的坑:在顶点着色器里和片元着色器里,某些矩阵的值可能不一样。比如在片元着色器里,UNITY_MATRIX_V可能已经被优化掉了,或者在某些平台上精度不同。如果你需要在片元着色器里做世界空间的计算,最好在顶点着色器里把世界坐标算好,然后插值传到片元着色器。
另一个坑是摄像机的正交投影和透视投影对观察矩阵没有影响。观察矩阵只负责把世界空间转到摄像机空间,投影矩阵才负责把摄像机空间转到裁剪空间。所以不管你是正交相机还是透视相机,观察矩阵的构建方式是一样的。
3.3 观察空间里的深度值为什么是非线性的
这是一个让很多人困惑的问题:为什么在观察空间里,z值看起来是线性的,但到了裁剪空间和屏幕空间,深度就变成非线性的了?
原因在于透视投影矩阵。透视投影矩阵会把观察空间的z值映射到裁剪空间的z值,这个映射不是线性的。具体来说,透视投影矩阵的第三行和第四行会导致w分量等于-z(观察空间的z),然后裁剪空间的z除以w之后,就变成了一个非线性的深度值。
这个非线性带来的实际影响是:靠近摄像机的深度精度高,远离摄像机的深度精度低。这就是为什么在做深度相关的效果(比如软粒子、深度雾、SSAO)时,远处的物体容易出现深度冲突(Z-Fighting)。
如果你需要在Shader里把深度值转回线性的观察空间深度,Unity提供了一个辅助函数LinearEyeDepth,它可以把屏幕空间的深度值转成观察空间的线性深度。反过来,Linear01Depth可以转成0到1之间的线性深度。
注意:
LinearEyeDepth返回的是观察空间下的深度,单位是世界单位。Linear01Depth返回的是归一化后的深度,适合用来做渐变效果。
4. 观察空间到裁剪空间:投影矩阵与齐次裁剪
4.1 透视投影矩阵的参数计算过程
透视投影矩阵是整条链路里最复杂的一个矩阵。它的作用是把观察空间下的视锥体(一个截锥体)映射到一个标准的立方体(裁剪空间),这个立方体的x、y、z范围都是[-w, w],其中w是齐次坐标的第四个分量。
透视投影矩阵的关键参数有四个:FOV(视场角)、Aspect(宽高比)、Near(近裁剪面)、Far(远裁剪面)。这四个参数决定了投影矩阵的具体数值。
FOV决定了视锥体在垂直方向上的开口角度。Aspect决定了水平方向和垂直方向的比例关系。Near和Far决定了视锥体在深度方向上的范围。
投影矩阵的构建公式大致是这样的(以Unity的透视投影为例):
f = 1 / tan(FOV/2) m00 = f / Aspect m11 = f m22 = (Far + Near) / (Near - Far) m23 = (2 * Far * Near) / (Near - Far) m32 = -1其他元素为0。注意m32 = -1这一项,它就是把观察空间的z值取反后赋给w分量,从而实现透视除法。
这个矩阵的推导过程涉及相似三角形和齐次坐标的变换,这里不展开讲,但你需要知道的是:FOV越大,视野越广,但物体看起来越小;Near和Far的比值越大,深度精度越差。
4.2 正交投影矩阵与透视投影的本质区别
正交投影矩阵和透视投影矩阵最大的区别在于:正交投影没有透视除法。也就是说,正交投影矩阵的w分量始终是1,不会因为距离远近而改变。
正交投影矩阵的参数是Size(垂直方向的大小)、Aspect、Near、Far。它的构建公式比透视投影简单得多:
m00 = 1 / (Size * Aspect) m11 = 1 / Size m22 = 2 / (Near - Far) m23 = (Far + Near) / (Near - Far) m33 = 1正交投影下,物体的大小不会因为距离而改变,所以常用于2D游戏、UI渲染、以及一些需要精确尺寸控制的场景。
在实际项目中,选择哪种投影方式取决于你的需求。如果你做的是3D游戏,通常用透视投影;如果你做的是2D游戏或者需要等距视角的效果,正交投影更合适。
4.3 裁剪空间的齐次坐标与透视除法
裁剪空间是一个四维的齐次坐标空间。一个顶点在裁剪空间下的坐标是(x, y, z, w)。只有当x、y、z都在[-w, w]范围内时,这个顶点才会被保留;否则就会被裁剪掉。
透视除法就是把裁剪空间的坐标除以w分量,得到归一化设备坐标(NDC)。NDC的x、y、z范围都是[-1, 1](在Unity里,z的范围是[0, 1]或者[-1, 1]取决于平台,但Unity会统一处理)。
透视除法的意义在于:它把远处的物体“压小”,近处的物体“放大”,从而实现近大远小的透视效果。这也是为什么透视投影矩阵的m32 = -1——它把观察空间的z值取反后作为w,这样除以w之后,z值越负(越远),坐标就越小。
在Shader里,你通常不需要手动做透视除法,因为GPU会在光栅化阶段自动完成。但如果你在做一些屏幕空间的计算,比如把世界坐标投影到屏幕上,就需要自己处理这一步。
5. 裁剪空间到屏幕空间:视口变换与像素映射
5.1 视口变换矩阵的构建与作用
从裁剪空间(经过透视除法后的NDC)到屏幕空间,需要经过视口变换。视口变换的作用是把NDC坐标映射到屏幕的像素坐标。
视口变换的公式是:
screenX = (ndcX * 0.5 + 0.5) * screenWidth + viewportX screenY = (ndcY * 0.5 + 0.5) * screenHeight + viewportY其中screenWidth和screenHeight是屏幕的像素尺寸,viewportX和viewportY是视口在屏幕上的偏移。
在Unity里,这个变换通常由GPU自动完成,你不需要手动计算。但如果你在做后处理效果,需要把屏幕像素坐标转回NDC或者世界坐标,就需要用到这个公式的逆运算。
5.2 屏幕空间坐标在Shader中的实际应用
屏幕空间坐标在Shader里有很多应用场景,比如:
- 屏幕空间反射(SSR):需要把世界坐标投影到屏幕空间,然后采样屏幕纹理。
- 屏幕空间环境光遮蔽(SSAO):需要在屏幕空间下计算深度差异。
- 边缘检测:需要在屏幕空间下采样相邻像素的颜色。
- UI渲染:UI元素的位置通常直接以屏幕空间坐标给出。
在Unity的Shader里,你可以通过ComputeScreenPos函数来计算屏幕空间坐标。这个函数返回的是齐次坐标下的屏幕位置,需要除以w分量才能得到真正的屏幕坐标。
float4 screenPos = ComputeScreenPos(UnityObjectToClipPos(v.vertex)); float2 screenUV = screenPos.xy / screenPos.w;注意ComputeScreenPos返回的坐标范围是[0, 1](在除以w之后),而不是像素坐标。如果你需要像素坐标,还需要乘以屏幕尺寸。
5.3 深度缓冲的精度问题与解决方案
深度缓冲是屏幕空间下存储每个像素深度值的缓冲区。它的精度直接影响到深度测试的准确性。
深度缓冲的精度问题主要来自两个方面:一是深度值的非线性分布,二是深度缓冲的位深。
前面说过,透视投影下深度值是非线性的,靠近摄像机的精度高,远离摄像机的精度低。如果Near和Far的比值太大(比如Near=0.01,Far=1000),远处的深度精度会非常差,导致Z-Fighting。
解决方案有几种:
- 增大Near值:把Near从0.01改成0.1,深度精度会提升很多。这是最简单有效的方法。
- 减小Far值:如果场景不需要看到很远的地方,把Far调小也能提升精度。
- 使用反向深度缓冲:把深度值反转,让近处的精度低、远处的精度高。这在一些引擎里是默认开启的。
- 使用对数深度缓冲:在Shader里手动计算对数深度,可以大幅提升远处精度,但需要修改Shader。
提示:在Unity里,你可以通过
Camera.main.depthTextureMode来开启深度纹理的生成。如果你需要读取深度缓冲,记得在Shader里声明_CameraDepthTexture。
6. 法线变换与切线空间:容易被忽略的矩阵细节
6.1 法线变换为什么不能用模型矩阵
前面已经提到过,法线变换需要用模型矩阵的逆转置矩阵。这里再深入讲一下为什么。
法线的本质是描述表面的朝向,它是一个垂直于表面的向量。当你对物体进行非均匀缩放时,表面的倾斜角度会改变,法线也必须相应地改变。如果直接用模型矩阵变换法线,得到的方向可能不再垂直于变换后的表面。
举个例子:假设有一个表面,法线朝上(0, 1, 0),切线朝右(1, 0, 0)。现在把物体在x方向上缩放2倍,在y方向上缩放1倍。变换后的切线变成(2, 0, 0),但法线如果直接用模型矩阵变换,还是(0, 1, 0)。这两个向量不再垂直了,因为(2, 0, 0)·(0, 1, 0) = 0,看起来还是垂直的?等等,这个例子不对,因为法线在y方向没有缩放,所以还是垂直的。
换一个例子:法线朝右上(1, 1, 0)归一化后是(0.707, 0.707, 0),切线朝左上(-1, 1, 0)归一化后是(-0.707, 0.707, 0)。现在在x方向缩放2倍,y方向缩放1倍。变换后的切线是(-1.414, 0.707, 0),法线如果用模型矩阵变换是(1.414, 0.707, 0)。这两个向量的点积是(-1.4141.414 + 0.7070.707) = (-2 + 0.5) = -1.5,不等于0,说明它们不再垂直。
而如果用逆转置矩阵变换法线:模型矩阵M的逆是x方向缩放0.5,y方向缩放1,转置后还是x方向缩放0.5,y方向缩放1。法线(1, 1, 0)变换后是(0.5, 1, 0),归一化后是(0.447, 0.894, 0)。切线和这个法线的点积是(-1.4140.447 + 0.7070.894) = (-0.632 + 0.632) = 0,垂直了。
所以逆转置矩阵是正确的。
6.2 切线空间的构建与TBN矩阵
切线空间是法线贴图使用的坐标系。在切线空间里,法线贴图的RGB值对应法线的xyz方向。要把切线空间的法线转到世界空间,需要用到TBN矩阵。
TBN矩阵由三个基向量组成:切线(Tangent)、副切线(Bitangent)、法线(Normal)。这三个向量构成了一个正交基,把切线空间的向量转到世界空间。
在Unity里,你可以通过UnityObjectToWorldNormal来变换法线,通过UnityObjectToWorldDir来变换切线。副切线可以通过法线和切线的叉积得到,但要注意叉积的顺序和切线的w分量(w分量表示副切线的方向,通常是-1或1)。
float3 normalWorld = UnityObjectToWorldNormal(v.normal); float3 tangentWorld = UnityObjectToWorldDir(v.tangent.xyz); float3 bitangentWorld = cross(normalWorld, tangentWorld) * v.tangent.w; float3x3 TBN = float3x3(tangentWorld, bitangentWorld, normalWorld); float3 normalTangent = UnpackNormal(tex2D(_NormalMap, uv)); float3 normalWorldFinal = normalize(mul(normalTangent, TBN));注意TBN矩阵的构建顺序:切线、副切线、法线分别对应矩阵的三列。在乘法时,切线空间的向量左乘TBN矩阵,得到世界空间的向量。
6.3 非均匀缩放下切线空间的修正
当模型存在非均匀缩放时,切线空间的基向量也需要修正。切线和法线一样,不能直接用模型矩阵变换,而是需要用模型矩阵变换后再做正交化处理。
具体做法是:先用模型矩阵变换切线,然后用变换后的法线对切线做正交化(减去切线在法线方向上的投影),最后归一化。副切线则通过法线和切线的叉积重新计算。
float3 normalWorld = UnityObjectToWorldNormal(v.normal); float3 tangentWorld = mul((float3x3)unity_ObjectToWorld, v.tangent.xyz); tangentWorld = normalize(tangentWorld - normalWorld * dot(tangentWorld, normalWorld)); float3 bitangentWorld = cross(normalWorld, tangentWorld) * v.tangent.w;这样做可以保证在非均匀缩放下,切线空间仍然是正交的,法线贴图的效果不会出错。
7. 不同渲染路径下的矩阵差异与实战验证
7.1 内置管线与URP/HDRP的矩阵获取差异
Unity的内置渲染管线和URP/HDRP在矩阵的获取方式上有一些差异,这些差异如果不注意,很容易导致Shader在不同管线下表现不一致。
在内置管线里,常用的矩阵有:
UNITY_MATRIX_M:模型矩阵UNITY_MATRIX_V:观察矩阵UNITY_MATRIX_P:投影矩阵UNITY_MATRIX_MVP:模型-观察-投影矩阵UNITY_MATRIX_MV:模型-观察矩阵UNITY_MATRIX_VP:观察-投影矩阵
在URP里,这些矩阵大部分仍然可用,但有些被替换成了新的API。比如URP推荐使用TransformObjectToWorld、TransformWorldToView等函数来替代直接使用矩阵。HDRP的差异更大,很多矩阵需要通过CBuffer来获取。
如果你写的Shader需要跨管线使用,最好用Unity提供的宏和函数,而不是直接引用矩阵。比如用UnityObjectToClipPos来替代mul(UNITY_MATRIX_MVP, v.vertex)。
7.2 用可视化方式验证矩阵变换的每一步
光看公式容易晕,最好的学习方式是把每一步变换的结果可视化出来。我常用的做法是写一个简单的Shader,把不同空间的坐标映射到颜色上,然后观察模型在不同变换下的颜色变化。
比如,把模型空间的坐标映射到RGB:
float3 modelColor = v.vertex.xyz * 0.5 + 0.5; return float4(modelColor, 1.0);把世界空间的坐标映射到RGB:
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float3 worldColor = worldPos * 0.1; return float4(worldColor, 1.0);把观察空间的坐标映射到RGB:
float3 viewPos = mul(UNITY_MATRIX_V, mul(unity_ObjectToWorld, v.vertex)).xyz; float3 viewColor = viewPos * 0.1; return float4(viewColor, 1.0);通过对比这些颜色,你可以直观地看到每个矩阵对顶点位置的影响。比如观察空间的z值是负的,所以蓝色通道会偏暗;世界空间的坐标范围更大,所以颜色变化更剧烈。
7.3 常见矩阵变换错误的排查清单
在实际项目中,矩阵变换相关的错误往往表现为:模型位置不对、法线方向错误、深度测试异常、屏幕空间效果偏移等。下面是我总结的一份排查清单,遇到问题时可以逐项检查:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 模型位置偏移 | 模型矩阵构建顺序错误 | 检查TRS顺序,确认是先缩放再旋转再平移 |
| 法线方向错误 | 未使用逆转置矩阵 | 检查法线变换是否用了unity_WorldToObject |
| 深度冲突 | Near/Far比值过大 | 增大Near或减小Far,检查深度缓冲精度 |
| 屏幕空间效果偏移 | 未除以w分量 | 检查ComputeScreenPos后是否除了w |
| 切线空间法线错误 | TBN矩阵构建顺序错误 | 检查切线、副切线、法线的排列顺序 |
| 跨管线表现不一致 | 直接引用了管线特定的矩阵 | 改用Unity提供的通用函数和宏 |
这份清单是我在实际项目中踩坑后总结的,基本上覆盖了90%以上的矩阵变换问题。遇到问题时,先对照这张表排查,往往能快速定位原因。
8. 个人实操心得与几个容易翻车的细节
8.1 矩阵乘法顺序的记忆技巧
矩阵乘法的顺序是很多人容易搞混的地方。我的记忆方法是:从右往左读,先应用的写在右边。比如MVP * vertex,读作“先应用M,再应用V,再应用P”。因为矩阵乘法是从右往左结合的,所以最右边的矩阵最先作用于顶点。
在Unity的Shader里,mul(M, v)表示用矩阵M变换向量v。如果是多个矩阵相乘,比如mul(P, mul(V, mul(M, v))),等价于mul(P * V * M, v)。但要注意Unity的矩阵是列主序的,所以P * V * M的顺序和数学上的写法可能相反。实际写代码时,直接用UnityObjectToClipPos最省事,不用纠结顺序。
8.2 精度问题在移动端的特殊表现
移动端GPU对浮点数的精度支持通常比桌面端低,尤其是half精度(16位浮点)。在矩阵变换中,如果用了half精度来计算世界坐标或观察坐标,可能会出现明显的精度问题,比如模型抖动、深度冲突加剧、法线贴图出现块状伪影。
我的建议是:在顶点着色器里,世界坐标和观察坐标的计算尽量用float精度。虽然half精度更快,但精度损失在移动端会被放大。如果性能允许,关键矩阵运算都用float。如果必须用half,至少保证在计算深度和法线时用float。
8.3 一个容易被忽略的细节:摄像机的投影矩阵翻转
在某些平台上(比如某些图形API),摄像机的投影矩阵可能需要翻转Y轴。Unity会自动处理这个差异,但如果你在Shader里手动构建投影矩阵,就需要注意这个平台差异。
具体来说,OpenGL和DirectX的NDC空间在Y轴方向上是相反的。Unity在内部会做转换,但如果你直接读取UNITY_MATRIX_P并手动做投影,可能会发现渲染结果上下颠倒。解决方法是检查UNITY_UV_STARTS_AT_TOP宏,根据平台决定是否翻转Y。
这个坑我在做后处理效果时踩过,当时画面上下颠倒,排查了很久才发现是投影矩阵的平台差异。后来养成了习惯:凡是涉及手动投影的代码,都先检查这个宏。
8.4 矩阵变换在Shader调试中的实用技巧
调试矩阵变换问题时,最有效的方法是把中间结果可视化出来。除了前面说的用颜色映射坐标,还可以用以下技巧:
- 用Gizmos画线:在C#里用
Gizmos.DrawLine画出变换后的坐标轴,直观看到矩阵的效果。 - 用Debug.Log输出矩阵:在C#里把
Matrix4x4的数值打印出来,和预期值对比。 - 用RenderDoc抓帧:如果条件允许,用RenderDoc抓一帧,查看每个DrawCall的矩阵数值和顶点数据,这是最准确的调试方式。
- 写一个简单的测试Shader:把顶点直接输出到裁剪空间的不同位置,观察屏幕上的变化。
这些技巧我在实际项目中都用过,尤其是RenderDoc,几乎是排查渲染问题的必备工具。虽然学习曲线有点陡,但一旦上手,效率提升非常明显。
矩阵变换这条链路,从模型空间到屏幕空间,中间经过的每一步都有它的道理和坑。把这条链路彻底搞明白,不仅能让你的Shader写得更稳,还能在遇到渲染问题时快速定位原因。希望这篇内容能帮你少走一些弯路。