PA0 Matrices

Shader 与渲染算法 - 基础 - 数学基础

对 mesh 进行 transform 变化(放缩、移动、旋转)是通过对它进行顶点操作来实现的。

接下里的操作是针对 space(空隔)的 transform 变换,创建的以 prefab 作为顶点的 3D 网格是为了观察空格的变换。它们不是变换针对的对象本身。

1 Visualizing Space

TransformationGrid.cs
// 生成 grid 的 三层 for 循环
public class TransformationGrid : MonoBehaviour {
    public Transform prefab; // 用属性作为对象?
    public int gridResolution = 10; // 解析度。作为 grid 的观察点,实际看起来就是 points(prefabs)的数量
    private Transform[] _grid;

    private void Awake() {
        _grid = new Transform[gridResolution * gridResolution * gridResolution];
        for (int i = 0, z = 0; z != gridResolution; z++) {
            for (int y = 0; y != gridResolution; y++) {
                for (int x = 0; x != gridResolution; x++) {
                    _grid[i] = CreateGridPoint(x, y, z);
                    i++;
                }
            }
        }
    }
}

Awake 函数在 Start 函数调用之前被调用,通常用于初始化资源和设置依赖;Start 函数则用于设置资源的初始状态。

可以想象为 Awake 函数相当于执行 int x; 而 Start 函数则是 x = 0;

TransformationGrid.cs
// 生成 point 的具体实现
Transform CreateGridPoint(int x, int y, int z) {
    Transform point = Instantiate<Transform>(prefab); // 生成克隆
    point.localPosition = GetCoordinates(x, y, z);
    point.GetComponent<MeshRenderer>().material.color = new Color((float)x / gridResolution,
        (float)y / gridResolution, (float)z / gridResolution);
    return point;
}

Instantiate 方法是生成克隆。克隆体类型为 Transform,克隆原型为 prefab;prefab 在前述代码里已声明。将该代码绑定在对象上时,可以看到需要绑定一个 prefab,此时可以在界面中设置(场景中创建 cube,设置其大小为 0.5,然后拖拽到文件里变成预制体,再绑定到代码 prefab 里。删除场景中的 cube)。

将该克隆赋给变量 point,其后设置 point 的位置和颜色,函数返回 point。

设置位置

TransformationGrid.cs
// 设置位置
Vector3 GetCoordinates(int x, int y, int z) {
    return new Vector3(x - (gridResolution - 1) * 0.5f, y - (gridResolution - 1) * 0.5f,
        z - (gridResolution - 1) * 0.5f);
}

已知,在大循环中,x、y、z 依次为 0~9(循环条件设置为 < gridResolution(10) 时),且预制体大小为 0.5 * 0.5 * 0.5

可知预制体应当是从左至右、从下至上、从远及近创建。如果想要整体 grid 中心在原点,则需要设置 -4.5 的偏移量,此处为 "- (gridResolution - 1) * 0.5f"

(想象如果没有偏移量,原点就会为左、下、远的顶点)

设置颜色

由 x、y、z 分别向 gridResolution 递增,除以 gridResolution,得到颜色空间 0-1(接近)

perfect

2 Transformations

实现最基本的变换(移动、旋转、缩放)也各自有许多细节。因此我们先创建一个变换的基础组件(抽象类),而后由不同变换各自继承。

关于抽象类的描述详见 “C#_类”

Transformation.cs
// 变换基础组件,抽象类 Transformation
using UnityEngine;

public abstract class Transformation : MonoBehaviour {
    public abstract Vector3 Apply(Vector3 point);
}

Transformation 作为变换的基础抽象类,其内部有虚函数 Apply 作为待实现的变换方法。

变换的框架在 TransformationGrid.cs 里加入实现。

TransformationGrid.cs
// _transformations 的声明
using UnityEngine;
using System.Collections.Generic;

public class TransformationGrid : MonoBehaviour {

	List<Transformation> _transformations;
	
	void Awake () {

		transformations = new List<Transformation>();
	}
}

声明 _transformations 为内部类型是 Transformation 的列表(Transformation 即上述抽象类)。

TransformationGrid.cs
// Update 函数:取得每一个 point,交由 TransformPoint 函数作位置更新
private void Update() {
    GetComponents<Transformation>(_transformations);  // GetComponents 用于获取对象上特定类型的组件;
    // GetComponents<T>(List<T>): 获取所有 T,然后添加到 List<T> 中
    for (int i = 0, z = 0; z != gridResolution; z++) {
        for (int y = 0; y != gridResolution; y++) {
            for (int x = 0; x != gridResolution; x++) {
                _grid[i].localPosition = TransformPoint(x, y, z);
                i++;
            }
        }
        
    }
}

其中 GetComponents<T>(List<T>) 用于获取对象(网格)的所有 Transformation 属性,然后添加到 _transformations 列表里。

核心理解:

  1. 整个变换发生的过程是自上而下(而不是我最开始以为的自下而上的),即首先进行整体网格的变换,然后在 Update 的三层 for 循环中,将该变换应用于网格中每一个节点(Cube 预制体)上。

  2. _transformations 列表获取的 Transformation 属性不是内部的 Cube 节点,而是 Transformation 类型的东西。由于我们在这里定义了 Transformation 作为基类,后续会派生出子类做 Apply 方法的具体实现。因此 _transformations 里存放的是其子类的实例。

TransformationGrid.cs
// TransformPoint 函数:
Vector3 TransformPoint(int x, int y, int z) {
    Vector3 coordinates = GetCoordinates(x, y, z); // 取得 Point(Cube预制体)的原始坐标
    // 将变换依次赋予该 Point
    for (int i = 0; i != _transformations.Count; i++) {
        coordinates = _transformations[i].Apply(coordinates);
    }
    return coordinates;
}

注意刚开始的 coordinates 是通过对先前定义函数 GetCoordinates 的调用,获取它们的原始坐标。如果获取的是该 Point(Cube预制体)的实际坐标,就会成为变换的叠加(比如刚开始设置左移 3,再设置 左移 4 后,变成了左移 7, 而不是预想的左移 4)

2.1 Translation & Scaling

首先考虑比较简单的位移和缩放变换。

新建两个文本,继承自 Transformation,各自对来自基类的 Apply 函数进行具体实现。

PositionTransformation.cs
// translation:
public class PositionTransformation : Transformation {
    public Vector3 position;

    public override Vector3 Apply(Vector3 point) {
        return point + position;
    }
}
ScaleTransformation.cs
// scaling:
public class ScaleTransformation : Transformation {
    public Vector3 scale = Vector3.one; // 初始化为(1, 1, 1), Unity 预定义的常量
    public override Vector3 Apply(Vector3 point) {
        point.x *= scale.x;
        point.y *= scale.y;
        point.z *= scale.z;
        return point;
    }
}

上述两个类派生自 Transformation,由此可以整体的理解 _transformations 列表的遍历。

在 Update 函数中添加调试代码:

TransformationGrid.cs
// GetComponents<Transformation>(_transformations)执行后
// 打印 _transformations 内保存的组件名称
foreach (var transformation in _transformations) {
    Debug.Log(transformation.GetType().Name);
}

控制台打印结果:

PositionTransformation

ScaleTransformation

_transformation: 获取 <Transformation> 的组件,结果为其两个子类

由此可以理解上述 TransformPoint 函数内赋值语句

coordinates = _transformations[i].Apply(coordinates)

_transformation[i],即其中保存的子类的实例,而该实例中完成了其中一种 Transformation 的具体实现

perfect

2.2 Rotation:围绕 Z 轴

我们首先限定实现围绕 Z 轴的 Rotation 变换。即网格将在屏幕上像轮子一般滚动。

方向:左手坐标系

因为 Unity 使用的是左手坐标系,因此当变换的参数渐增,这个 “轮子” 将逆时针旋转。

将 x 轴、y 轴方向上的单位向量用作对于 x-y 平面的表示,则当 x-y 平面进行递增的 Rotation 变换,可以视为屏幕上单位圆的逆时针旋转,如下图。

围绕 Z 轴的逆时针旋转。由红绿色箭头表示 x-y 平面旋转形成的单位圆

首先,如图进行坐标轴正方向的判定

然后判定正旋转方向。

用左手握住该轴,大拇指朝向该轴正方向,其余手指弯曲的方向即为正旋转方向。

在此例中,左手的手势类似按指纹。

规律:Sine and Cosine

单位圆旋转,得到单位向量在 X-Y 平面上的坐标

易知对于 X 轴(红色单位向量),在单位圆以 90° 为 step 旋转一周后,经历落点 (1, 0), (0,1), (-1, 0), (0, -1), (1, 0);而对于 Y 轴(绿色红色单位向量),同理也容易得出其轨迹。

跟踪 X、Y 轴做 Rotation 变换的轨迹画在坐标系上,有:

X 轴(红色单位向量)变换轨迹 (cosZ,sinZ)(\cos Z, \sin Z); Y 轴(绿色单位向量)变换轨迹 (sinZ,cosZ)(-\sin Z, \cos Z);

跟踪变换轨迹,得出 x-y 平面 Rotation 的规律

Apply 函数:具体实现

上述分析与规律是针对 X-Y 方向单位向量而言(红绿箭头),而这两个单位向量张成了 X-Y 平面。对于平面上任意坐标,均可以分解为 xX+yYxX+yY 的形式。

根据上述分解,将 X-Y 方向向量写作矩阵形式(两个列向量)与 “任意坐标的原坐标” 相乘,可得:

[cosZsinZsinZcosZ][xy]=[xcosZysinZxsinZ+ycosZ]\begin{bmatrix} \cos Z & -\sin Z \\ \sin Z & \cos Z \end{bmatrix} \begin{bmatrix} x \\ y\end{bmatrix} = \begin{bmatrix} x\cos Z -y \sin Z\\ x\sin Z + y \cos Z\end{bmatrix}

由此,平面上任意一点的旋转坐标: (xcosZysinZ,xsinZ+ycosZ)(x \cos Z - y \sin Z, x \sin Z + y \cos Z)

RotationTransformation.cs
// 围绕 z 轴的 rotation:
public class RotationTransformation : Transformation {
    public Vector3 rotation;

    public override Vector3 Apply(Vector3 point) {
        float radZ = rotation.z * Mathf.Deg2Rad; // 得出 z 轴旋转角度的弧度制表示
        // 分别计算 z 轴旋转角度的 sin 和 cos 值(需使用弧度制)
        float sinZ = Mathf.Sin(radZ);
        float cosZ = Mathf.Cos(radZ);
        
        return new Vector3(point.x * cosZ - point.y * sinZ, point.x * sinZ + point.y * cosZ, point.z);
    }
}

注意,因为围绕 Z 轴进行旋转,需要计算围绕 Z 轴旋转角度的 sin、cos 值,而 Unity 中 sin 和 cos 的计算需要使用弧度制,因此在此之前进行旋转角度的(角度制 - 弧度制)转换。

实现围绕 z 轴的旋转

Unity 中的 Transform 变换按照 “缩放-旋转-平移” 来进行。这个 idea 是按照 “对坐标系特质的影响程度” 来进行排序的,遵循从小到大的改变顺序。

首先进行缩放设置,其不改变任何坐标系;然后是旋转操作 ,改变了物体的本地坐标系;最后是平移,修改了物体在世界坐标系中的相对位置。

因此为了确保 Transform 操作均在正确的基础上执行,需要将三个 Transform 组件拖成正确的执行顺序。

闫老师方法

闫老师方法也是如此。只是他直接告知了为逆时针旋转,省去了左手坐标系步骤,以及没有通过作 sin / cos 图,而是直接三角作图来表示:

Rotation Matrix
闫老师手推公式

3 Full Rotations

接下来的坐标都将写作列向量的形式。

矩阵扩展:三维旋转的准备工作

上述矩阵实现了 2D 情景下的旋转,而想要实现三维空间中的旋转,应需要确保三个轴同时被考虑进去,即所乘向量从 [xy]\begin{bmatrix}x \\ y \end{bmatrix} 转为 [xyz]\begin{bmatrix}x \\ y \\ z\end{bmatrix}。此时就出现了需求:

  1. [cosZsinZsinZcosZ][xyz]\begin{bmatrix} \cos Z & -\sin Z \\ \sin Z & \cos Z \end{bmatrix} \begin{bmatrix} x \\ y \\ z\end{bmatrix}并不合法,矩阵乘法需满足 A 列 = B 行,即至少是 [ABCDEF][xyz]\begin{bmatrix} A & B & C \\ D & E & F \end{bmatrix} \begin{bmatrix} x \\ y \\ z\end{bmatrix}形式.

  2. 上述规则所乘结果行只有两行( [Ax+By+CzDx+Ey+Fz]\begin{bmatrix} Ax+By+Cz \\ Dx+Ey+Fz \end{bmatrix}),而实现三维情景下的旋转需要三个方向都有变动,即要求结果行至少三行。

为了保证满足矩阵乘法要求和三维空间的操作要求,将 3D 旋转矩阵拓展为 [cosZsinZ0sinZcosZ0001]\begin{bmatrix} \cos Z & -\sin Z & 0 \\ \sin Z & \cos Z & 0 \\ 0 & 0 & 1 \end{bmatrix}. 其余位置补 0 很好理解,右下角补为 1,是为了确保对于三维坐标 [xyz]\begin{bmatrix}x \\ y \\ z\end{bmatrix} ,矩阵变换前后 z 不变。

围绕 X 轴和 Y 轴的旋转

通过左手定则(观察旋转方向,得出相应位置变化函数)应用上述 Rotation 推导过程,容易得到围绕 X 轴和 Y 轴的旋转矩阵:

Rotation:围绕 Y 轴 [cosY0sinY010sinY0cosY]\begin{bmatrix} \cos Y & 0 & \sin Y \\ 0 & 1 & 0 \\ -\sin Y & 0 & \cos Y \end{bmatrix};

Rotation:围绕 X 轴 [1000cosXsinX0sinXcosX ]\begin{bmatrix} 1 & 0 & 0 \\ 0 &\cos X & - \sin X \\ 0 & \sin X & \cos X \ \end{bmatrix}.

统一旋转矩阵

通过对三个方向矩阵的乘积,可以得出最终的旋转矩阵:

[cosYcosZcosYsinZsinYcosXsinZ+sinXsinYcosZcosXcosZsinXsinYsinZsinXcosYsinXsinZcosXsinYcosZsinXcosZ+cosXsinYsinZcosXcosY]\begin{bmatrix}\cos Y \cos Z &-\cos Y \sin Z & \sin Y \\ \cos X \sin Z + \sin X \sin Y \cos Z & \cos X \cos Z - \sin X \sin Y \sin Z & -\sin X \cos Y \\ \sin X \sin Z - \cos X \sin Y \cos Z & \sin X \cos Z + \cos X \sin Y \sin Z & \cos X \cos Y\end{bmatrix}

注:上述矩阵乘积的方式为 X × (Y×Z),而 Unity 实际使用的矩阵乘法顺序为 ZXY。

Unity 中,ZXY 乘法顺序的选择:

  1. 首先进行 Z 轴旋转,以调整物体朝向(平面上的旋转);

  2. 然后进行 X 轴旋转,以倾斜物体(向上或向下);

  3. 最后进行 Y 轴旋转,以侧倾物体(平面上的进一步调整)。

ZXY 顺序一来符合大多数人对于旋转操作的直觉,二来符合行业标准,最后是在很多情况下,避免了万向锁的问题。

旋转矩阵:具体实现

RotationTransformation.cs
// Apply 函数:Full Rotations
public override Vector3 Apply(Vector3 point) {
    // 得出三个方向坐标轴旋转角度的弧度制表示
    float radX = rotation.x * Mathf.Deg2Rad;
    float radY = rotation.y * Mathf.Deg2Rad;
    float radZ = rotation.z * Mathf.Deg2Rad;
    
    // 分别计算三个坐标轴旋转角度的 sin 和 cos 值(需使用弧度制)
    float sinX = Mathf.Sin(radX);
    float cosX = Mathf.Cos(radX);
    float sinY = Mathf.Sin(radY);
    float cosY = Mathf.Cos(radY);
    float sinZ = Mathf.Sin(radZ);
    float cosZ = Mathf.Cos(radZ);

    // 分别对应上述结果矩阵的 1 - 3 列
    Vector3 xAxis = new Vector3(cosY * cosZ, cosX * sinZ + sinX * sinY * cosZ, sinX * sinZ - cosX * sinY * cosZ);
    Vector3 yAxis = new Vector3(-cosY * sinZ, cosX * cosZ - sinX * sinY * sinZ, sinX * cosZ + cosX * sinY * sinZ);
    Vector3 zAxis = new Vector3(sinY, -sinX * cosY, cosX * cosY);

    return xAxis * point.x + yAxis * point.y + zAxis * point.z;
}

4 Combining:大一统实现

正如闫令琪老师数学课程中所述,大一统的障碍在于位移变换。

能够呈现位移的矩阵形如 [100201030014][xyz]=[x+2y+3z+4]\begin{bmatrix} 1&0&0&2\\0&1&0&3\\0&0&1&4 \end{bmatrix} \begin{bmatrix}x\\y\\z \end{bmatrix} = \begin{bmatrix} x+2 \\ y+3\\ z+4 \end{bmatrix}能够实现功能,但是在数学上不合法。因此需要再度扩展。最终能够实现大一统的矩阵为 4×4 形式:

[1002010300140001][xyz1]=[1x+0y+0z+20x+1y+0z+30x+0y+1z+40x+0y+0z+1]=[x+2y+3z+41]\begin{bmatrix} 1&0&0&2\\0&1&0&3\\0&0&1&4 \\ 0&0&0&1\end{bmatrix} \begin{bmatrix}x\\y\\z\\1 \end{bmatrix} =\begin{bmatrix}1x+0y+0z+2\\0x+1y+0z+3\\0x+0y+1z+4\\0x+0y+0z+1 \end{bmatrix}= \begin{bmatrix} x+2 \\ y+3\\ z+4\\1 \end{bmatrix}

4.1 Homogeneous Coordinates 齐次坐标系

make sense of 第四列坐标:

如果最后一个数值为 1,就能够实现点(point)的位移(offset);而当其值为 0 时,该矩阵仅能进行放缩和旋转的操作,不能实现位移。即向量(vector)。

因此, [xyz1]\begin{bmatrix}x\\y\\z\\1 \end{bmatrix}呈现了一个点,而 [xyz0]\begin{bmatrix}x\\y\\z\\0 \end{bmatrix}呈现了一个向量。

齐次坐标与笛卡尔坐标的转换:w 分量

当需要将数学上方便操作的齐次坐标向呈现现实世界的笛卡尔坐标进行转换时,需要按照上述法则进行归一化,即引入 w 分量,以确保该矩阵表示的是点(最后一个值为 1)或向量(最后一个值为 0):

[xyzw]=1w[xyzw]=[xwywzw1][xwywzw]\begin{bmatrix}x\\ y\\z\\w \end{bmatrix} = \frac{1}{w} \begin{bmatrix}x\\y\\z\\w \end{bmatrix} = \begin{bmatrix} \frac{x}{w} \\ \frac{y}{w} \\ \frac{z}{w} \\ 1 \end{bmatrix} \longrightarrow \begin{bmatrix} \frac{x}{w} \\ \frac{y}{w} \\ \frac{z}{w} \end{bmatrix}

4.2 Unified Matrix

本次代码更新旨在使用统一的矩阵以进行一次性的矩阵变换。体现如下:

  1. 具体变换内部不再分别进行变换,而只是提供该种变换的矩阵。

  2. 在 TransformationGrid 文件中,查询所有的变换矩阵,对返回结果乘积得到一个结果矩阵,将其应用于 Mesh 的每个 point 上。

实现上述操作,核心在于 Transformation 文件使用了抽象属性 Matrix(其中包含属性访问器 get)来代替原先的抽象方法 Apply。

Transformation.cs
// Transformation:old approach & new apporoach
public abstract class Transformation : MonoBehaviour {
    // public Vector3 Apply(Vector3 point) {
    //     return Matrix.MultiplyPoint(point);
    // }
    
    public abstract Matrix4x4 Matrix { get; } // 抽象属性
    // get 是属性访问器,允许从属性中获取值。且 “get” 表明是只读的,如果可写的话,会有 “set”
}

以 Matrix 抽象属性代替 Apply 抽象方法,实现之后具体变换文件的职能变更。

变换具体实现文件:以 Scaling 为例

ScaleTransformation.cs
// 以 Scaling 为例,比较 old approach 和 new approach
public class ScaleTransformation : Transformation {
    public Vector3 scale = Vector3.one; // 初始化为(1, 1, 1), Unity 预定义的常量

    // old approach
    // public override Vector3 Apply(Vector3 point) {
    //     point.x *= scale.x;
    //     point.y *= scale.y;
    //     point.z *= scale.z;
    //     return point;
    // }

    public override Matrix4x4 Matrix {
        get {
            Matrix4x4 matrix = new Matrix4x4();
            matrix.SetRow(0, new Vector4(scale.x, 0f, 0f, 0f));
            matrix.SetRow(1, new Vector4(0f, scale.y, 0f, 0f));
            matrix.SetRow(2, new Vector4(0f, 0f, scale.z, 0f));
            matrix.SetRow(3, new Vector4(0f, 0f, 0f, 1f));
            return matrix;
        }
    }
}

以 Matrix 抽象属性代替 Apply 抽象方法。注意其中 matrix 的设置方法(直接硬编码进去)。

另外由于旧方法的 Rotation 变换就是以列直接硬编码的,所以在 get 中使用 matrix.SetColumn。

TransformationGrid:一次性实现变换

TransformationGrid 文件结构变更主要体现在:

  1. 新增 UpdateTransformation 函数,使用索引遍历 _transformations 列表,将获取的变换矩阵进行乘积。

  2. 修改返回每个 point 变换后位置的 TransformPoint 函数,不再在该函数内遍历 _transformations 列表,而只返回 point 原位置与变换矩阵的乘积。


APPENDIX

Anki

Reference

Last updated