unity矩阵与变换 - “空间转换的魔术”

第一部分:为什么需要矩阵?------ 统一处理变换

1. 变换的三种基本操作

在3D空间中,物体的变换可以分解为三种基本操作:

  • 平移(Translation):改变位置

  • 旋转(Rotation):改变朝向

  • 缩放(Scale):改变大小

问题:如果我们分别用三个不同的系统来处理这三种变换,那么组合变换会非常复杂。

解决方案:矩阵可以将这三种变换统一成一个数学形式,使得组合变换变得简单(只需矩阵乘法)。

2. 从向量到矩阵的升级

  • 向量:可以表示位置和方向,但无法表示变换。

  • 矩阵:可以表示变换,并且可以同时表示平移、旋转和缩放。

在Unity中,每个GameObject的Transform组件都关联一个4x4变换矩阵。


第二部分:矩阵基础 ------ 从线性代数到几何意义

1. 矩阵的数学定义

一个4x4矩阵有16个元素,通常表示为:

text

复制代码
[ m00, m01, m02, m03 ]
[ m10, m11, m12, m13 ]
[ m20, m21, m22, m23 ]
[ m30, m31, m32, m33 ]

在Unity中,矩阵是行主序的(Row-major),但我们在代码中通常按列来访问。

2. 单位矩阵 ------ 什么都不做的变换

单位矩阵是对角线为1,其他为0的矩阵:

text

复制代码
[ 1, 0, 0, 0 ]
[ 0, 1, 0, 0 ]
[ 0, 0, 1, 0 ]
[ 0, 0, 0, 1 ]

任何向量乘以单位矩阵都等于它自己。

3. 矩阵乘法 ------ 变换的组合

矩阵乘法的关键特性:不满足交换律 ,即 A * B != B * A

在变换中,顺序非常重要:

  • 先平移后旋转 ≠ 先旋转后平移

csharp

复制代码
// Unity中的矩阵乘法
Matrix4x4 m1 = Matrix4x4.TRS(new Vector3(1,0,0), Quaternion.identity, Vector3.one);
Matrix4x4 m2 = Matrix4x4.TRS(Vector3.zero, Quaternion.Euler(0,90,0), Vector3.one);

Matrix4x4 result1 = m2 * m1; // 先平移,后旋转
Matrix4x4 result2 = m1 * m2; // 先旋转,后平移

// 注意:Unity中矩阵乘法是左乘,即result1表示先应用m1,再应用m2

第三部分:变换矩阵的构造 ------ 平移、旋转、缩放

1. 平移矩阵

将点 (x, y, z) 平移 (tx, ty, tz):

text

复制代码
[ 1, 0, 0, tx ]
[ 0, 1, 0, ty ]
[ 0, 0, 1, tz ]
[ 0, 0, 0, 1  ]

在Unity中创建平移矩阵:

csharp

复制代码
Matrix4x4 translationMatrix = Matrix4x4.Translate(new Vector3(1, 2, 3));

2. 缩放矩阵

将点 (x, y, z) 缩放 (sx, sy, sz) 倍:

text

复制代码
[ sx, 0,  0,  0 ]
[ 0,  sy, 0,  0 ]
[ 0,  0,  sz, 0 ]
[ 0,  0,  0,  1 ]

在Unity中创建缩放矩阵:

csharp

复制代码
Matrix4x4 scaleMatrix = Matrix4x4.Scale(new Vector3(2, 1, 0.5f));

3. 旋转矩阵

绕X轴旋转θ角度:

text

复制代码
[ 1, 0,     0,    0 ]
[ 0, cosθ, -sinθ, 0 ]
[ 0, sinθ,  cosθ, 0 ]
[ 0, 0,     0,    1 ]

绕Y轴旋转θ角度:

text

复制代码
[ cosθ,  0, sinθ, 0 ]
[ 0,     1, 0,    0 ]
[ -sinθ, 0, cosθ, 0 ]
[ 0,     0, 0,    1 ]

绕Z轴旋转θ角度:

text

复制代码
[ cosθ, -sinθ, 0, 0 ]
[ sinθ,  cosθ, 0, 0 ]
[ 0,     0,    1, 0 ]
[ 0,     0,    0, 1 ]

在Unity中,旋转矩阵通常由四元数转换而来:

csharp

复制代码
Quaternion rotation = Quaternion.Euler(0, 45, 0);
Matrix4x4 rotationMatrix = Matrix4x4.Rotate(rotation);

4. 组合变换矩阵

通常,我们希望同时进行平移、旋转和缩放。在Unity中,变换的顺序是:先缩放,再旋转,最后平移

组合变换矩阵:M = T * R * S (注意:Unity中矩阵乘法是左乘,所以实际应用时是先缩放,再旋转,最后平移)

csharp

复制代码
// 创建一个组合变换矩阵
Vector3 position = new Vector3(1, 2, 3);
Quaternion rotation = Quaternion.Euler(0, 45, 0);
Vector3 scale = new Vector3(2, 1, 1);

Matrix4x4 matrix = Matrix4x4.TRS(position, rotation, scale);

第四部分:矩阵的应用 ------ 变换点与向量

1. 变换点(带平移)

点是有位置的,所以平移会影响点。使用齐次坐标,点的w分量为1。

csharp

复制代码
Vector3 point = new Vector3(1, 0, 0);
Matrix4x4 matrix = Matrix4x4.TRS(new Vector3(1, 0, 0), Quaternion.identity, Vector3.one);

// 方法1:使用矩阵乘法
Vector3 transformedPoint = matrix.MultiplyPoint(point);

// 方法2:使用乘法操作符(注意:这个操作符实际上是MultiplyPoint3x4,需要点w=1)
Vector3 transformedPoint2 = matrix * new Vector4(point.x, point.y, point.z, 1);
// 然后取前三个分量

// 注意:MultiplyPoint会考虑平移,因为它假设输入是一个点(w=1)

2. 变换向量(不考虑平移)

向量只有方向,没有位置,所以平移不应该影响向量。向量的w分量为0。

csharp

复制代码
Vector3 vector = new Vector3(1, 0, 0);
Matrix4x4 matrix = Matrix4x4.TRS(new Vector3(1, 0, 0), Quaternion.Euler(0, 90, 0), Vector3.one);

// 方法1:使用MultiplyVector(只考虑旋转和缩放,不考虑平移)
Vector3 transformedVector = matrix.MultiplyVector(vector);

// 方法2:使用矩阵乘法,但w=0
Vector4 vector4 = new Vector4(vector.x, vector.y, vector.z, 0);
Vector4 result = matrix * vector4;
Vector3 transformedVector2 = new Vector3(result.x, result.y, result.z);

3. 变换法线向量

法线向量需要特殊处理:如果存在非均匀缩放,直接使用变换矩阵可能会破坏法线与表面的垂直关系。

csharp

复制代码
// 正确变换法线的方法:使用逆转置矩阵
Matrix4x4 normalMatrix = matrix.inverse.transpose;
Vector3 normal = new Vector3(0, 1, 0);
Vector3 transformedNormal = normalMatrix.MultiplyVector(normal).normalized;

// Unity提供了便捷方法:transform.TransformDirection用于变换方向
// 但注意:TransformDirection不会考虑缩放

第五部分:空间变换 ------ 本地与世界的转换

1. 本地空间与世界空间

  • 本地空间(Local Space):相对于父物体的坐标系

  • 世界空间(World Space):全局统一的坐标系

每个物体的变换矩阵实际上定义了从本地空间到世界空间的变换。

2. 本地到世界的变换

csharp

复制代码
// 方法1:使用Transform组件的方法
Vector3 worldPosition = transform.TransformPoint(localPosition);
Vector3 worldDirection = transform.TransformDirection(localDirection);
Vector3 worldVector = transform.TransformVector(localVector); // 考虑缩放

// 方法2:使用变换矩阵
Matrix4x4 localToWorldMatrix = transform.localToWorldMatrix;
Vector3 worldPosition2 = localToWorldMatrix.MultiplyPoint(localPosition);
Vector3 worldDirection2 = localToWorldMatrix.MultiplyVector(localDirection);

3. 世界到本地的变换

csharp

复制代码
// 方法1:使用Transform组件的方法
Vector3 localPosition = transform.InverseTransformPoint(worldPosition);
Vector3 localDirection = transform.InverseTransformDirection(worldDirection);
Vector3 localVector = transform.InverseTransformVector(worldVector);

// 方法2:使用变换矩阵
Matrix4x4 worldToLocalMatrix = transform.worldToLocalMatrix;
Vector3 localPosition2 = worldToLocalMatrix.MultiplyPoint(worldPosition);
Vector3 localDirection2 = worldToLocalMatrix.MultiplyVector(worldDirection);

4. 实际应用:在敌人头顶显示血条

csharp

复制代码
public class HealthBar : MonoBehaviour {
    public Transform target; // 敌人
    public Vector3 offset = new Vector3(0, 2, 0); // 头顶偏移
    
    void Update() {
        // 将本地偏移转换为世界位置
        Vector3 worldPosition = target.position + target.TransformDirection(offset);
        
        // 或者使用TransformPoint
        Vector3 worldPosition2 = target.TransformPoint(offset);
        
        // 将血条移动到该位置
        transform.position = worldPosition;
        
        // 让血条始终面向相机(Billboard效果)
        transform.LookAt(transform.position + Camera.main.transform.rotation * Vector3.forward,
                        Camera.main.transform.rotation * Vector3.up);
    }
}

第六部分:矩阵分解 ------ 从矩阵中提取变换信息

1. 为什么需要分解?

有时我们只有一个变换矩阵(例如从外部文件加载),但需要获取位置、旋转和缩放信息。

2. 从矩阵中提取位置

平移信息存储在矩阵的最后一列(m03, m13, m23):

csharp

复制代码
Matrix4x4 matrix = transform.localToWorldMatrix;
Vector3 position = matrix.GetColumn(3); // 获取第四列
// 或者
Vector3 position2 = new Vector3(matrix.m03, matrix.m13, matrix.m23);

3. 从矩阵中提取旋转

旋转信息存储在矩阵的前3x3部分,但提取过程复杂,因为还包含了缩放。

csharp

复制代码
// 方法1:使用Quaternion.LookRotation(需要知道前方向和上方向)
Vector3 forward = matrix.GetColumn(2); // 前方向
Vector3 up = matrix.GetColumn(1);      // 上方向
Quaternion rotation = Quaternion.LookRotation(forward, up);

// 方法2:使用矩阵分解(Unity内置方法)
Vector3 position;
Quaternion rotation;
Vector3 scale;
matrix.Decompose(out position, out rotation, out scale);

4. 从矩阵中提取缩放

缩放信息也存储在前3x3部分,但需要排除旋转的影响。

csharp

复制代码
// 方法:每列的向量长度就是缩放因子
Vector3 scale;
scale.x = matrix.GetColumn(0).magnitude;
scale.y = matrix.GetColumn(1).magnitude;
scale.z = matrix.GetColumn(2).magnitude;

// 注意:如果旋转矩阵不是正交的(包含剪切),这种方法不准确

第七部分:视图矩阵和投影矩阵 ------ 相机的工作原理

1. 相机变换的三个矩阵

  1. 模型矩阵(Model Matrix):物体本地到世界(transform.localToWorldMatrix)

  2. 视图矩阵(View Matrix):世界到相机本地(Camera.worldToCameraMatrix)

  3. 投影矩阵(Projection Matrix):3D到2D投影(Camera.projectionMatrix)

2. 视图矩阵(View Matrix)

视图矩阵将世界坐标转换到相机坐标系(以相机为原点)。

csharp

复制代码
// 获取相机的视图矩阵
Camera camera = Camera.main;
Matrix4x4 viewMatrix = camera.worldToCameraMatrix;

// 视图矩阵实际上是相机变换的逆矩阵
// 因为将世界中的点转换到相机空间 = 相机变换的逆变换
Matrix4x4 cameraTransform = camera.transform.localToWorldMatrix;
Matrix4x4 viewMatrix2 = cameraTransform.inverse;

3. 投影矩阵(Projection Matrix)

投影矩阵将相机空间的3D坐标投影到2D裁剪空间。

csharp

复制代码
// 获取相机的投影矩阵
Matrix4x4 projectionMatrix = camera.projectionMatrix;

// 投影矩阵有两种类型:
// 1. 透视投影(Perspective):近大远小
// 2. 正交投影(Orthographic):平行投影,无透视效果

4. 模型-视图-投影矩阵(MVP)

将点从模型本地空间转换到屏幕空间需要三个矩阵的连续乘法:

csharp

复制代码
// 计算MVP矩阵
Matrix4x4 modelMatrix = transform.localToWorldMatrix;
Matrix4x4 viewMatrix = Camera.main.worldToCameraMatrix;
Matrix4x4 projectionMatrix = Camera.main.projectionMatrix;

Matrix4x4 mvpMatrix = projectionMatrix * viewMatrix * modelMatrix;

// 将本地坐标转换到裁剪空间
Vector4 clipPosition = mvpMatrix * new Vector4(localPosition.x, localPosition.y, localPosition.z, 1);

第八部分:实战应用案例

案例1:自定义顶点着色器中的矩阵变换

csharp

复制代码
// 在Shader中,我们通常这样变换顶点:
Shader "Custom/Example" {
    Properties {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader {
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "UnityCG.cginc"
            
            struct appdata {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };
            
            struct v2f {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };
            
            sampler2D _MainTex;
            
            v2f vert (appdata v) {
                v2f o;
                // 模型空间 -> 世界空间 -> 视图空间 -> 裁剪空间
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target {
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
    }
}

案例2:在脚本中手动计算屏幕坐标

csharp

复制代码
public class WorldToScreenExample : MonoBehaviour {
    public Transform target;
    
    void Update() {
        if (target != null) {
            // 方法1:使用Camera.WorldToScreenPoint
            Vector3 screenPos = Camera.main.WorldToScreenPoint(target.position);
            
            // 方法2:手动计算(理解原理)
            Matrix4x4 mvp = Camera.main.projectionMatrix * Camera.main.worldToCameraMatrix * target.localToWorldMatrix;
            Vector4 clipPos = mvp * new Vector4(0, 0, 0, 1); // 假设目标在本地原点
            
            // 透视除法:将裁剪空间坐标转换为NDC(归一化设备坐标)
            Vector3 ndc = new Vector3(clipPos.x / clipPos.w, clipPos.y / clipPos.w, clipPos.z / clipPos.w);
            
            // 将NDC转换为屏幕坐标
            float pixelX = (ndc.x * 0.5f + 0.5f) * Screen.width;
            float pixelY = (ndc.y * 0.5f + 0.5f) * Screen.height;
            
            Debug.Log($"Screen position: ({pixelX}, {pixelY})");
        }
    }
}

案例3:制作一个跟随鼠标的3D物体

csharp

复制代码
public class FollowMouse3D : MonoBehaviour {
    public float distanceFromCamera = 10f;
    
    void Update() {
        // 获取鼠标位置(屏幕坐标)
        Vector3 mousePos = Input.mousePosition;
        mousePos.z = distanceFromCamera;
        
        // 将屏幕坐标转换为世界坐标
        Vector3 worldPos = Camera.main.ScreenToWorldPoint(mousePos);
        
        // 设置物体位置
        transform.position = worldPos;
        
        // 或者使用射线投射,让物体跟随鼠标在3D空间中的位置
        if (Input.GetMouseButton(0)) {
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;
            if (Physics.Raycast(ray, out hit)) {
                transform.position = hit.point;
            }
        }
    }
}

案例4:创建自定义的变换矩阵(扭曲效果)

csharp

复制代码
public class MatrixDeformation : MonoBehaviour {
    public float twistAmount = 30f; // 扭曲角度
    
    void Update() {
        // 创建一个自定义的变换矩阵
        Matrix4x4 twistMatrix = Matrix4x4.identity;
        
        // 根据高度添加旋转(扭曲效果)
        float angle = transform.position.y * twistAmount;
        Quaternion twist = Quaternion.Euler(0, angle, 0);
        
        // 创建旋转矩阵
        Matrix4x4 rotationMatrix = Matrix4x4.Rotate(twist);
        
        // 应用额外的缩放
        Matrix4x4 scaleMatrix = Matrix4x4.Scale(new Vector3(1, 2, 1));
        
        // 组合矩阵:先缩放,后旋转,再平移
        Matrix4x4 customMatrix = transform.localToWorldMatrix * rotationMatrix * scaleMatrix;
        
        // 应用自定义矩阵到渲染器
        MeshFilter meshFilter = GetComponent<MeshFilter>();
        if (meshFilter != null) {
            // 注意:这会修改mesh的顶点,通常应该在Shader中做
            // 这里只是演示矩阵的应用
            Vector3[] vertices = meshFilter.mesh.vertices;
            for (int i = 0; i < vertices.Length; i++) {
                vertices[i] = customMatrix.MultiplyPoint3x4(vertices[i]);
            }
            meshFilter.mesh.vertices = vertices;
            meshFilter.mesh.RecalculateNormals();
        }
    }
}

第九部分:性能优化与最佳实践

1. 矩阵运算的开销

矩阵乘法是昂贵的操作,尤其是对于4x4矩阵。优化策略:

  • 缓存矩阵:如果矩阵没有变化,不要每帧重新计算

  • 使用简单变换:对于2D游戏或简单3D,考虑使用2x2或3x3矩阵

  • 使用局部变量:避免重复访问transform属性

2. 矩阵乘法的优化

csharp

复制代码
// 不好的做法:每帧多次访问
void Update() {
    Matrix4x4 m1 = GetMatrix1();
    Matrix4x4 m2 = GetMatrix2();
    Matrix4x4 result = m1 * m2; // 每帧都计算
    UseMatrix(result);
}

// 好的做法:缓存结果
private Matrix4x4 cachedMatrix;
private bool isDirty = true;

void Update() {
    if (isDirty) {
        Matrix4x4 m1 = GetMatrix1();
        Matrix4x4 m2 = GetMatrix2();
        cachedMatrix = m1 * m2;
        isDirty = false;
    }
    UseMatrix(cachedMatrix);
}

3. 使用TRS而不是单独操作

csharp

复制代码
// 不好的做法:多次单独变换
transform.position = position;
transform.rotation = rotation;
transform.localScale = scale;

// 好的做法:如果可能,使用矩阵一次性设置
// 在某些情况下(如实例化渲染),直接设置矩阵更高效

第十部分:常见错误与调试技巧

1. 矩阵乘法的顺序错误

csharp

复制代码
// 错误:顺序颠倒
Matrix4x4 wrongMatrix = translation * rotation * scale; // 错误顺序

// 正确:Unity的顺序是先缩放,再旋转,最后平移
Matrix4x4 correctMatrix = translation * rotation * scale;
// 但实际上,因为矩阵乘法是左乘,所以应用时先应用scale,再rotation,最后translation

2. 忘记齐次坐标的w分量

csharp

复制代码
// 错误:变换向量时使用了w=1
Vector4 vector = new Vector4(1, 0, 0, 1); // 这是一个点,不是向量
Matrix4x4 matrix = Matrix4x4.Translate(new Vector3(5, 0, 0));
Vector4 result = matrix * vector; // 结果被平移了,这不是我们想要的向量变换

// 正确:变换向量时使用w=0
Vector4 direction = new Vector4(1, 0, 0, 0); // 这是一个方向向量
Vector4 result2 = matrix * direction; // 结果不会被平移

3. 使用错误的矩阵变换法线

csharp

复制代码
// 错误:直接使用模型矩阵变换法线
Vector3 transformedNormal = modelMatrix.MultiplyVector(normal);

// 正确:使用逆转置矩阵变换法线(当存在非均匀缩放时)
Matrix4x4 normalMatrix = modelMatrix.inverse.transpose;
Vector3 correctNormal = normalMatrix.MultiplyVector(normal).normalized;

4. 调试技巧:可视化矩阵

csharp

复制代码
void DebugMatrix(Matrix4x4 m) {
    Debug.Log($"Matrix:");
    Debug.Log($"{m.m00:F2}, {m.m01:F2}, {m.m02:F2}, {m.m03:F2}");
    Debug.Log($"{m.m10:F2}, {m.m11:F2}, {m.m12:F2}, {m.m13:F2}");
    Debug.Log($"{m.m20:F2}, {m.m21:F2}, {m.m22:F2}, {m.m23:F2}");
    Debug.Log($"{m.m30:F2}, {m.m31:F2}, {m.m32:F2}, {m.m33:F2}");
}

// 可视化变换轴
void OnDrawGizmos() {
    // 绘制本地坐标轴
    Gizmos.color = Color.red;
    Gizmos.DrawRay(transform.position, transform.right);
    Gizmos.color = Color.green;
    Gizmos.DrawRay(transform.position, transform.up);
    Gizmos.color = Color.blue;
    Gizmos.DrawRay(transform.position, transform.forward);
}

总结:矩阵思维模式

  1. 矩阵是变换的封装:它包含了平移、旋转、缩放的所有信息。

  2. 理解乘法顺序:矩阵乘法顺序就是变换的应用顺序。

  3. 区分点和向量:点有位置(w=1),向量只有方向(w=0)。

  4. 掌握空间转换:本地、世界、相机、屏幕空间之间的转换是游戏开发的核心。

  5. 善用Unity内置方法:大多数情况下,使用Transform组件的方法比直接操作矩阵更方便。

最终练习

创建一个简单的太阳系模拟:

  1. 地球绕太阳公转(世界空间旋转)

  2. 月球绕地球公转(相对地球的本地空间旋转)

  3. 每个行星自转(本地空间旋转)

  4. 使用矩阵手动计算每个物体的位置,而不是嵌套GameObject

通过这个练习,你将深刻理解本地空间与世界空间的转换关系。

记住:矩阵是连接数学与视觉的桥梁。开始时可能觉得抽象,但随着实践,你会逐渐建立直觉。从简单的2D变换开始,逐步扩展到3D,很快你就能掌握这种"空间转换的魔术"了!

相关推荐
fcm192 小时前
pico之调试unity项目
unity·vr·pico
WarPigs2 小时前
Unity生命周期函数笔记
unity·游戏引擎
阿蒙Amon2 小时前
C#每日面试题-值类型与引用类型区别
java·面试·c#
Leoysq2 小时前
3Dmax 导入Unity 的设置
unity
nnsix2 小时前
Unity SenseGlove力反馈手套 基础配置
java·unity·游戏引擎
foundbug9992 小时前
C#实现的自动升级系统
服务器·网络·c#
王柏龙2 小时前
c# aggregate使用
开发语言·c#
先生沉默先3 小时前
c#Socket学习,使用Socket创建一个在线聊天,需求分析与创建项目,数据结构创建(1)
数据结构·学习·c#
CodeCraft Studio3 小时前
Excel处理控件Aspose.Cells教程:使用C#在Excel中创建气泡图
信息可视化·c#·excel·aspose·excel api库·excel气泡图·excel组件库