第一部分:为什么需要矩阵?------ 统一处理变换
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. 相机变换的三个矩阵
-
模型矩阵(Model Matrix):物体本地到世界(transform.localToWorldMatrix)
-
视图矩阵(View Matrix):世界到相机本地(Camera.worldToCameraMatrix)
-
投影矩阵(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);
}
总结:矩阵思维模式
-
矩阵是变换的封装:它包含了平移、旋转、缩放的所有信息。
-
理解乘法顺序:矩阵乘法顺序就是变换的应用顺序。
-
区分点和向量:点有位置(w=1),向量只有方向(w=0)。
-
掌握空间转换:本地、世界、相机、屏幕空间之间的转换是游戏开发的核心。
-
善用Unity内置方法:大多数情况下,使用Transform组件的方法比直接操作矩阵更方便。
最终练习 :
创建一个简单的太阳系模拟:
-
地球绕太阳公转(世界空间旋转)
-
月球绕地球公转(相对地球的本地空间旋转)
-
每个行星自转(本地空间旋转)
-
使用矩阵手动计算每个物体的位置,而不是嵌套GameObject
通过这个练习,你将深刻理解本地空间与世界空间的转换关系。
记住:矩阵是连接数学与视觉的桥梁。开始时可能觉得抽象,但随着实践,你会逐渐建立直觉。从简单的2D变换开始,逐步扩展到3D,很快你就能掌握这种"空间转换的魔术"了!