尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity开发必备:矩阵变换原理与Shader实战应用详解

Unity开发必备:矩阵变换原理与Shader实战应用详解 1. 项目概述矩阵在Unity中的核心地位如果你在Unity里做过一段时间的开发尤其是涉及到自定义Shader或者复杂的物体运动那么“矩阵”这个词对你来说一定不陌生。它就像游戏世界里的“魔法咒语”虽然看起来是一堆数字的排列组合但正是这些数字决定了游戏里每一个顶点最终出现在屏幕上的位置、物体的旋转缩放甚至是光影如何投射到模型表面。很多开发者特别是从脚本逻辑入门的一看到矩阵、齐次坐标、MVP变换这些词就头疼宁愿用现成的API也不愿深究背后的原理。但说实话一旦你掌握了这套“矩阵魔法”很多之前觉得棘手的效果实现起来会变得异常清晰性能优化也有了方向。这个内容就是为你准备的无论你是想理解为什么Transform.position和localPosition不一样还是想自己写Shader实现一些酷炫的顶点动画或屏幕后处理效果矩阵都是你绕不开的基石。我会从最直观的Transform组件讲起一步步拆解它背后的矩阵运算然后深入到顶点着色器Vertex Shader中看看Unity是如何把模型顶点一步步变换到屏幕上的。整个过程我会尽量避免枯燥的数学推导多用Unity编辑器的实际操练和Shader代码示例让你能直观地看到矩阵是如何起作用的。我们的目标很明确让你不仅能看懂更能亲手用矩阵解决实际问题。2. 从Transform组件理解局部与世界矩阵2.1 Transform的本地坐标系与矩阵表示在Unity场景中每个GameObject的Transform组件都定义了一个局部坐标系。这个坐标系的原点0,0,0就是该物体的Pivot点三个轴的方向由其旋转Rotation决定。当我们设置Transform的position,rotation,scale时本质上是在定义这个局部坐标系相对于其父级坐标系如果是根节点则是世界坐标系的变换关系。这个变换关系在数学上就是一个4x4的变换矩阵。一个标准的变换矩阵TRS矩阵即先缩放Scale再旋转Rotate最后平移Translate可以分解为四个部分| Xx Xy Xz Tx | | Yx Yy Yz Ty | | Zx Zy Zz Tz | | 0 0 0 1 |左上角3x3子矩阵包含了旋转和缩放信息。前三列可以看作是物体局部坐标系的X轴、Y轴、Z轴在世界坐标系或其父坐标系下的方向向量同时它们的长度也包含了缩放因子。最右边一列的前三个元素Tx, Ty, Tz这就是物体的位置平移信息。最后一行0, 0, 0, 1这是齐次坐标的表示为了统一平移、旋转等变换而引入的保证矩阵乘法的兼容性。在Unity脚本中我们可以通过transform.localToWorldMatrix属性直接获取这个矩阵。这个矩阵的作用是将一个点从该物体的局部坐标系Local Space变换到世界坐标系World Space。注意Unity中的变换矩阵是列主序Column-major的。这意味着当你把矩阵看作一个二维数组时数据在内存中是按列存储的。在Shader中传递和使用矩阵时要特别注意这一点否则变换结果会完全错误。不过在C#脚本层面我们通常不需要关心其内部存储直接使用即可。2.2 手动构建与验证变换矩阵光说不练假把式我们写段代码来验证一下。创建一个脚本MatrixDebug.cs挂到场景中的物体上using UnityEngine; public class MatrixDebug : MonoBehaviour { void Update() { // 1. 获取Unity计算出的本地到世界矩阵 Matrix4x4 unityMatrix transform.localToWorldMatrix; // 2. 根据Transform的position, rotation, scale手动构建一个矩阵 Vector3 pos transform.position; Quaternion rot transform.rotation; Vector3 scale transform.lossyScale; // 注意使用lossyScale获取全局缩放 // 手动构建一个TRS矩阵 (顺序缩放 - 旋转 - 平移) Matrix4x4 manualMatrix Matrix4x4.TRS(pos, rot, scale); // 3. 打印并比较 Debug.Log( Unity Matrix (localToWorldMatrix) ); LogMatrix(unityMatrix); Debug.Log( Manual TRS Matrix ); LogMatrix(manualMatrix); // 4. 用一个测试点比如局部坐标(1,0,0)验证 Vector3 localPoint new Vector3(1, 0, 0); Vector3 worldPointByUnity unityMatrix.MultiplyPoint(localPoint); Vector3 worldPointByManual manualMatrix.MultiplyPoint(localPoint); Debug.Log($Local Point: {localPoint}); Debug.Log($World Point (via Unity Matrix): {worldPointByUnity}); Debug.Log($World Point (via Manual Matrix): {worldPointByManual}); Debug.Log($Are they equal? {Vector3.Distance(worldPointByUnity, worldPointByManual) 0.0001f}); } void LogMatrix(Matrix4x4 m) { for (int i 0; i 4; i) { Debug.Log($Row {i}: [{m.GetRow(i).x:F4}, {m.GetRow(i).y:F4}, {m.GetRow(i).z:F4}, {m.GetRow(i).w:F4}]); } } }运行后在场景中移动、旋转、缩放这个物体观察控制台输出。你会发现unityMatrix和manualMatrix在绝大多数情况下是相等的。这验证了Transform组件的数据本质上就是存储在一个矩阵里。MultiplyPoint方法就是执行了矩阵与向量的乘法将局部坐标点变换到了世界空间。实操心得使用lossyScale而不是localScale来构建世界矩阵因为localScale是相对于父物体的而世界变换需要的是全局缩放。如果物体没有父物体或者父物体没有缩放两者才相等。当物体的旋转是欧拉角时Transform.rotation返回的是一个四元数Quaternion它避免了万向节锁并且能更高效地与矩阵相互转换。Matrix4x4.TRS方法内部就处理了这些转换。矩阵乘法不满足交换律Matrix4x4.TRS(pos, rot, scale)内部实现的顺序是先缩放(S)再旋转(R)最后平移(T)。如果你手动按不同顺序组合矩阵会得到完全不同的结果。例如先平移再旋转物体会绕着世界原点旋转而先旋转再平移物体是绕自身中心旋转后再移动。2.3 逆矩阵与世界到局部的变换有localToWorldMatrix自然就有它的逆矩阵——worldToLocalMatrix。这个矩阵的作用正好相反将一个点从世界坐标系变换回该物体的局部坐标系。这个操作非常有用。比如你想知道世界空间中的某个点比如鼠标点击的射线碰撞点相对于当前物体局部空间的位置就可以用这个矩阵Vector3 worldHitPoint ...; // 假设这是世界空间中的一个碰撞点 Vector3 localHitPoint transform.worldToLocalMatrix.MultiplyPoint(worldHitPoint); // 现在 localHitPoint 就是这个碰撞点在该物体局部坐标系下的坐标在Shader中我们同样会频繁地进行世界空间与局部空间、世界空间与观察空间之间的相互转换其核心就是乘以对应的变换矩阵或其逆矩阵。3. 渲染管线中的矩阵流水线从模型到屏幕理解了单个物体的变换后我们来看整个渲染流程。一个模型的顶点从它本地的模型文件模型空间Model Space最终画到你的屏幕上屏幕空间Screen Space需要经历一系列矩阵变换。这个过程就是经典的MVP变换。3.1 MVP矩阵链详解MVP是三个矩阵的缩写M (Model Matrix) 模型矩阵作用是将顶点从模型空间 (Object Space)变换到世界空间 (World Space)。对于单个模型来说这就是我们上一节讨论的localToWorldMatrix。在Shader中Unity通过unity_ObjectToWorld(或旧版的_Object2World) 提供这个矩阵。V (View Matrix) 观察矩阵作用是将顶点从世界空间变换到观察空间 (View Space / Eye Space)。观察空间是以摄像机为原点的坐标系摄像机看向-Z轴方向。这个矩阵本质上就是摄像机Transform组件的worldToLocalMatrix。在Shader中对应UNITY_MATRIX_V。P (Projection Matrix) 投影矩阵作用是将顶点从观察空间变换到齐次裁剪空间 (Homogeneous Clip Space)。这个矩阵负责透视投影产生近大远小效果或正交投影并定义视锥体Frustum在此空间外的顶点将被裁剪掉。在Shader中对应UNITY_MATRIX_P。这三个矩阵连续相乘就得到了MVP矩阵MVP P * V * M。在Unity的旧版内置Shader中你常会看到UNITY_MATRIX_MVP它就是预计算好的这个乘积矩阵。顶点着色器最基本的任务就是将模型空间顶点v.vertex乘以这个矩阵// 在顶点着色器中 o.pos mul(UNITY_MATRIX_MVP, v.vertex); // 旧版写法 // 或者更现代、更清晰的写法 o.pos mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0)));重要变化在较新的Unity版本和SRP可编程渲染管线如URP/HDRP中更推荐使用分开的矩阵乘法因为这样更灵活也便于在顶点着色器中访问世界空间位置等其他信息。3.2 齐次坐标与透视除法经过MVP矩阵变换后顶点坐标是一个四维向量(x, y, z, w)其中w分量非常关键。在透视投影中w值通常不等于1它包含了深度信息。GPU接下来会进行透视除法Perspective Divide将x, y, z三个分量都除以w分量。这一步是产生透视效果近大远小的数学核心。除法之后坐标就进入了**归一化设备坐标NDC**空间其x, y, z分量范围都在[-1, 1]之间DirectX风格OpenGL的y轴范围略有不同。最后NDC坐标会通过视口变换映射到最终的屏幕空间像素坐标。我们可以写一个简单的Shader来可视化这个变换过程Shader Custom/MatrixVisualization { SubShader { Tags { RenderTypeOpaque } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 clipPos : SV_POSITION; float3 worldPos : TEXCOORD0; float3 viewPos : TEXCOORD1; float4 ndcPos : TEXCOORD2; }; v2f vert (appdata v) { v2f o; // 1. 模型 - 世界 o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; // 2. 世界 - 观察 o.viewPos mul(UNITY_MATRIX_V, float4(o.worldPos, 1.0)).xyz; // 3. 观察 - 齐次裁剪空间 o.clipPos mul(UNITY_MATRIX_VP, float4(o.worldPos, 1.0)); // 4. 手动进行透视除法得到NDC坐标 (仅用于演示实际由GPU完成) o.ndcPos o.clipPos / o.clipPos.w; return o; } fixed4 frag (v2f i) : SV_Target { // 用颜色显示不同的空间坐标信息 // R通道显示世界空间X坐标归一化到0-1 // G通道显示观察空间Z坐标的绝对值代表深度 // B通道显示NDC空间的X坐标从-1到1映射到0到1 float r (i.worldPos.x 5) / 10.0; // 假设世界坐标在-5到5之间 float g saturate(abs(i.viewPos.z) / 20.0); // 深度可视化 float b (i.ndcPos.x 1.0) * 0.5; // NDC的x从[-1,1]映射到[0,1] return fixed4(r, g, b, 1.0); } ENDCG } } }将这个Shader赋给一个材质并应用到场景中的物体上移动物体或摄像机你会看到颜色随着物体在世界中的位置、距离摄像机的深度以及其在屏幕上的横向位置而变化直观地展示了不同空间坐标的含义。4. Shader中的矩阵实战应用掌握了矩阵变换的流程我们就可以在Shader中玩出更多花样了。很多高级效果的本质就是对顶点或像素进行“非标准”的矩阵变换。4.1 顶点动画波浪与旋转顶点动画的核心是在顶点着色器中在模型变换到世界空间之前或之后对顶点位置进行修改。矩阵为我们提供了强大的数学工具。示例1基于世界坐标的波浪效果假设我们想让一个平面像水面一样波动。波动方程通常依赖于位置和时间。我们可以在世界空间中进行计算v2f vert (appdata v) { v2f o; // 先将顶点变换到世界空间 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; // 基于世界XZ坐标和时间计算Y轴偏移 float waveFrequency 1.0; float waveSpeed 1.0; float waveHeight 0.2; float wave sin(worldPos.x * waveFrequency _Time.y * waveSpeed) * cos(worldPos.z * waveFrequency * 0.8 _Time.y * waveSpeed) * waveHeight; // 修改世界空间Y坐标 worldPos.y wave; // 将修改后的世界坐标变换回裁剪空间 o.vertex mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; }示例2模型局部旋转如果想实现一个绕着自身Y轴旋转的风车叶片我们需要在模型空间或局部空间进行旋转。这时需要构造一个旋转矩阵// 在顶点着色器中 float3 localPos v.vertex.xyz; // 模型空间坐标 float angle _Time.y * _RotationSpeed; // 随时间旋转的角度 // 构建绕Y轴旋转的矩阵 (3x3部分) float sinA, cosA; sincos(angle, sinA, cosA); float3x3 rotMatrix float3x3( cosA, 0, sinA, 0, 1, 0, -sinA,0, cosA ); // 应用旋转 localPos mul(rotMatrix, localPos); // 然后进行正常的MVP变换 float4 worldPos mul(unity_ObjectToWorld, float4(localPos, 1.0)); o.vertex mul(UNITY_MATRIX_VP, worldPos);4.2 法线变换容易被忽略的关键点在Shader中处理光照时法线Normal向量至关重要。但是法线的变换矩阵和顶点的变换矩阵不是同一个顶点变换矩阵M模型矩阵可能包含非均匀缩放即X, Y, Z方向的缩放比例不同。如果直接用这个矩阵去变换法线变换后的法线将不再垂直于变换后的表面导致光照计算错误。正确的法线变换矩阵是模型矩阵的逆转置矩阵Inverse Transpose Matrix。简单推导设表面切线向量为T法线为N满足T·N 0。变换后T M * T我们希望N满足T·N 0。代入可得(M*T)·N 0T^T * M^T * N 0。由于原来T^T * N 0因此需要M^T * N N即N (M^T)^-1 * N (M^-1)^T * N。这就是逆转置矩阵。幸运的是Unity为我们提供了这个矩阵。在顶点着色器中我们应该这样变换法线// 将法线从模型空间变换到世界空间 float3 worldNormal normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 或者使用Unity内置宏它内部处理了逆转置 float3 worldNormal UnityObjectToWorldNormal(v.normal);unity_WorldToObject是unity_ObjectToWorld的逆矩阵。注意这里用的是mul(vector, matrix)即行向量左乘矩阵这等价于用矩阵的逆转置去乘列向量。UnityObjectToWorldNormal这个宏封装了正确的计算。常见问题如果你的模型光照看起来很奇怪在非均匀缩放下尤其明显第一个要检查的就是法线变换是否正确。4.3 屏幕后处理与纹理采样屏幕后处理效果如模糊、边缘检测、扭曲通常在全屏四边形上运行一个Shader。这时我们需要将屏幕像素坐标或UV转换到其他空间进行计算。一个常见的需求是重建世界空间位置。我们有了当前像素的深度值来自深度纹理_CameraDepthTexture和屏幕UV如何得到它的世界坐标呢核心思路是逆推MVP变换将屏幕UV和深度重建为裁剪空间位置NDC空间范围[-1,1]。用投影矩阵的逆矩阵unity_CameraInvProjection将裁剪空间位置变换到观察空间。用观察矩阵的逆矩阵即摄像机的localToWorldMatrix将观察空间位置变换到世界空间。Unity提供了内置变量来简化这些计算。以下是一个在片段着色器中重建世界位置的示例sampler2D _CameraDepthTexture; float4x4 _InverseVPMatrix; // 通常需要自己从脚本传递 VP矩阵的逆 float4 frag (v2f i) : SV_Target { // 采样深度纹理得到线性深度值 (0-1 1为远平面) float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); // 将深度值转换为线性眼空间深度可选取决于深度纹理格式 depth Linear01Depth(depth, _ZBufferParams); // 方法1使用内置矩阵和计算更通用 // 将屏幕UV和深度转换为裁剪空间坐标 float4 clipPos float4(i.uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); // D3D风格y可能需要翻转 #if UNITY_UV_STARTS_AT_TOP clipPos.y -clipPos.y; #endif // 用逆VP矩阵变换到世界空间 float4 worldPos mul(_InverseVPMatrix, clipPos); worldPos.xyz / worldPos.w; // 齐次除法 // 方法2使用Unity的ComputeWorldSpacePosition函数如果可用 // float3 worldPos ComputeWorldSpacePosition(i.uv, depth, unity_CameraInvProjection); // 现在 worldPos.xyz 就是该像素对应的世界空间位置 // ... 基于世界位置进行计算 ... }提示_InverseVPMatrix通常不在内置变量中需要在C#脚本中计算并传递给Shader。Matrix4x4 inverseVP (cam.projectionMatrix * cam.worldToCameraMatrix).inverse;5. 性能优化与常见问题排查矩阵运算在Shader中是密集操作理解其性能影响和常见陷阱至关重要。5.1 矩阵运算的性能考量精度选择在Shader中矩阵有float4x4高精度、half4x4中精度和fixed4x4低精度已逐渐弃用之分。对于位置变换如MVP必须使用float精度以保证准确性。但对于一些颜色变换或简单的2D操作可以考虑使用half精度以提升性能尤其是在移动平台上。避免重复计算如果一个矩阵在顶点着色器中被所有顶点共享使用确保它在着色器外部CPU侧计算好并通过uniform常量传入。不要在着色器内部如片段着色器为每个像素都计算复杂的矩阵逆运算。利用矩阵特性正交矩阵旋转矩阵是正交矩阵其逆矩阵等于其转置矩阵。即R^-1 R^T。计算转置的成本远低于求逆。在Shader中如果知道一个矩阵只包含旋转可以用transpose(matrix)来快速获取其逆变换用于法线等计算。平移矩阵的逆平移矩阵的逆就是反向平移。inverse(translation) translation(-vector)。空间变换的取舍并非所有计算都需要在世界空间进行。有时在观察空间或切线空间进行计算更高效。例如一些基于视角的效果在观察空间计算更简单法线贴图通常在切线空间存储和计算。5.2 常见问题排查技巧实录问题1物体渲染位置完全错误或者被拉伸变形。排查首先检查传递给Shader的矩阵是否正确。在C#脚本中使用Debug.Log打印矩阵的关键元素如位置行、缩放对角线。在Shader中可以尝试将矩阵直接输出为颜色来可视化检查例如将矩阵的第一行作为RGB输出。可能原因矩阵顺序错误在C#中构建矩阵时乘法的顺序错误例如将世界矩阵当成了观察矩阵。行列序混淆从其他库或资源导入的矩阵可能是行主序而Unity是列主序直接使用会导致错误。需要进行转置。缩放值异常检查lossyScale是否包含零或负值这会导致矩阵不可逆或产生镜像。问题2光照在物体缩放后变得奇怪。排查几乎可以肯定是法线变换问题。在片段着色器中将变换后的世界空间法线worldNormal可视化return float4(worldNormal * 0.5 0.5, 1.0)。如果法线看起来不均匀或不连续说明变换有误。解决确保使用UnityObjectToWorldNormal(v.normal)或正确的逆转置矩阵mul(v.normal, (float3x3)unity_WorldToObject)来变换法线。对于非均匀缩放模型这是必须的。问题3自定义投影或扭曲效果时画面出现撕裂或不对齐。排查检查你的自定义投影矩阵是否满足透视投影矩阵的特性。一个有效的透视投影矩阵其最后一列应该是(0, 0, A, B)的形式并且经过透视除法后Z值能正确映射到深度缓冲区范围通常是0到1或-1到1。工具使用Unity场景视图的调试绘制功能。在脚本中调用Debug.DrawLine或Gizmos.DrawFrustum来可视化你计算出的视锥体或关键射线与场景实际渲染结果进行对比。问题4屏幕后处理效果中重建的世界位置有偏差。排查深度纹理格式确认你获取的深度值是线性的还是非线性的。Linear01Depth和LinearEyeDepth函数可以帮你正确转换。UV坐标系确认屏幕UV的起点(0,0)是在左下角还是左上角。Unity中不同平台和抗锯齿设置下可能有差异使用UnityStereoTransformScreenSpaceTex宏可以处理大部分情况。矩阵的传递时机确保你在每一帧、在摄像机渲染前将正确的、当前帧的逆VP矩阵传递给Shader。如果使用了命令缓冲区Command Buffer要注意矩阵的获取时机。一个实用的调试技巧在Shader中输出中间值。当你无法确定是哪个矩阵出错时可以在片段着色器中将不同变换阶段的结果如世界坐标、观察坐标、裁剪坐标的某个分量映射到颜色上输出。例如将世界坐标的Y值映射为绿色通道的亮度。通过观察颜色的变化是否符合预期可以快速定位问题发生在哪个变换阶段。矩阵是连接逻辑与渲染、CPU与GPU的数学桥梁。刚开始接触时觉得抽象是正常的最好的学习方法就是动手写代码去验证。尝试修改上面提供的示例代码中的矩阵观察物体在场景中的变化尝试在Shader中自己构造旋转、缩放矩阵来实现简单的动画。当你能够预测并控制这些数字如何影响屏幕上每一个像素时你就真正掌握了这门“矩阵魔法”。
返回列表