Unity 渲染系列__00__矩阵
该文章为本人学习后,并结合自身的理解以偏向中文的方式书写。原文链接:Rendering 1 (catlikecoding.com)
1 Visualizing Space
当你知道 Unity 中的网格(Mesh)是什么以及它在场景空间中是如何定位的。但网格定位实际上是如何运行的呢?它又是如何进行着色的呢?虽然我们可以通过 Unity 自身的 Transform 组件和着色器(Shader)来实现。但如果你想要完全控制它们,理解它们在运行中发生了什么是至关重要的。为了充分理解这个过程,我们最好是创建自己的程序来实现它们。
通过操纵网格的顶点位置,我们可以实现移动、旋转、缩放。这是一个空间的变换。而我们要看到它实际上是如何运行的,那么就需要将它们可视化。我们可以创建一个基于 Unity Cube 物体模拟网格的顶点。你也可以使用其他物体。
using UnityEngine;
public class TransformationGrid : MonoBehaviour
{
public Transform prefab;
public int gridResolution = 10;
Transform[] grid;
void Awake()
{
grid = new Transform;
for (int i = 0, z = 0; z < gridResolution; z++)
{
for (int y = 0; y < gridResolution; y++)
{
for (int x = 0; x < gridResolution; x++, i++)
{
grid = CreateGridPoint(x, y, z);
}
}
}
}
}
实例化预制体作为顶点,确定它的坐标并设置不同的颜色。
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;
}
最明显的网格形状是一个立方体,让我们采用这个形状。我们的变换将以原点为中心进行,但旋转和缩放是相对于网格立方体的中心点进行的。
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
);
}
设置我们的预制件 Cube,缩放到一半大小,以便它们之间有空间。
Cube 预制体
创建一个对象,添加我们的组件,赋值预制件。当点击运行,网格立方体将以对象局部原点为中心生成。
Transformation grid.
2.2 Transformations
理想中,我们应该能够对网格应用任意类型的变换。但我们只考虑定位,旋转和缩放。
如果我们为三种变换各自创建一个组件,我们可以,以任何顺序和数量添加到我们的网格对象中。虽然每个变换的细节不同,但它们都需要一种方法来应用于空间中的一个点。
让我们为所有变换创建一个可以继承的基础组件。这将是一个抽象类,给它一个抽象的方法 Apply,方法将由具体的变换组件来实现。
using UnityEngine;
public abstract class Transformation : MonoBehaviour
{
public abstract Vector3 Apply(Vector3 point);
}
一旦我们将这样的组件添加到我们的网格对象中,我们必须以某种方式检索它们,以便我们将它们应用于所有网格顶点。我们将使用一个泛型列表来存储对这些组件的引用。
using UnityEngine;
using System.Collections.Generic;
public class TransformationGrid : MonoBehaviour
{
...
List<Transformation> transformations;
void Awake()
{
...
transformations = new List<Transformation>();
}
}
现在我们可以添加一个 Update 方法来检索变换,然后循环遍历整个网格并变换我们所有的点。
void Update()
{
GetComponents<Transformation>(transformations);
for (int i = 0, z = 0; z < gridResolution; z++)
{
for (int y = 0; y < gridResolution; y++)
{
for (int x = 0; x < gridResolution; x++, i++)
{
grid.localPosition = TransformPoint(x, y, z);
}
}
}
}
为什么每次更新都要获取组件?
这可以让我们在保持播放模式的同时对转换组件进行修改,并立即看到效果。为什么使用列表而不是数组?
GetComponents 方法有两种重载。
第一种是返回一个包含所请求类型组件的数组。在本例中我们使用 Update 方法每帧更新,这意味着每次调用都会创建一个新数组。
第二种是传入列表参数。这种方法的优点是将组件放入列表中,而不是创建一个新数组。
在我们的示例中,这并不是一个至关重要的优化,但每当您经常 Get 组件时,使用 List 是一个好习惯。变换每个点的方法是先获取原始坐标,然后应用每个变换。我们不能依赖于每个点的实际位置,因为它们已经被变换了,我们的变换应该始终基于原始的位置,不应该基于变换后位置。
Vector3 TransformPoint(int x, int y, int z)
{
Vector3 coordinates = GetCoordinates(x, y, z);
for (int i = 0; i < transformations.Count; i++)
{
coordinates = transformations.Apply(coordinates);
}
return coordinates;
}
2.1 Translation
我们第一个组件将会是平移,这看起来是最简单的。我们创建一个继承 Transformation 的新组件,并使用变量 position 作为局部偏移量。
using UnityEngine;
public class PositionTransformation : Transformation
{
public Vector3 position;
}
此时,编译器会警告我们没有实现继承的抽象方法 Apply,所以让我们处理它。目前我们只是简单地将期望位置添加到它的原始位置上。
public override Vector3 Apply(Vector3 point)
{
return point + position;
}
现在可以给我们的网格对象添加位置变换组件了。这允许我们在不移动实际网格对象的情况下移动点。
Transforming the position.
2.2 Scaling
接下来是缩放变换。它几乎和移动一样,只是缩放分量是乘以原始点,而不是添加。
using UnityEngine;
public class ScaleTransformation : Transformation
{
public Vector3 scale;
public override Vector3 Apply(Vector3 point)
{
point.x *= scale.x;
point.y *= scale.y;
point.z *= scale.z;
return point;
}
}
同样的将该组件添加到网格对象中。现在我们也可以缩放网格了。但注意,我们只是调整网格点的位置,因此缩放不会改变它们的可视化大小。
Adjusting scale.
当你同时进行移动和缩放。你会发现缩放也会影响位置。这是因为我们首先进行了移动,然后缩放它。Unity 的变换组件正好相反,这更有用。我们也应该这样做,这可以通过点击组件右上角齿轮弹出的菜单进行顺序调整。
为什么是先缩放后移动?
我们默认缩放是不允许影响自身位置。
我们空间中的点,它并不是真正的物体,他之所以呈现出来,是因为我们需要将它可视化。在最开始说过我们是基于它模拟网格中的顶点是如何进行变换的。那么在计算机中,物体想要缩放自身,那么必然是顶点的位置进行了比例偏移,从而绘制出的面进行了比例放大或缩小。因此缩放的本质还是偏移了顶点的位置。所以如果先移动,那么缩放便会在移动后的顶点基础上在做比例偏移。
点坐标 (0,0)先移动(1,1)的量在缩放 1.5 倍
点坐标 (0,0)先缩放 1.5 倍在移动(1,1)的量,很明显结果是不一样的。
Changing the order of transformations.
2.3 Rotation
第三种变换类型是旋转。它比前两个更难一点。从一个新的组件开始,目前不做任何处理直接返回point。
using UnityEngine;
public class ScaleTransformation : Transformation
{
public Vector3 rotation;
public override Vector3 Apply(Vector3 point)
{
return point;
}
}
那么旋转是如何工作的呢?现在我们只围绕一个轴旋转,即 Z 轴。围绕这个轴的点旋转就像旋转一个轮子。因为Unity 使用的是左手坐标系,正向旋转将使轮子逆时针旋转,当在 Z 轴正方向看它时。
2D rotation around the Z axis.
在它旋转时,它的坐标会发生什么?最简单的方法是考虑圆上的点,设圆的半径单位为 1,即单位圆。最直接的点对应于 X 轴和 Y 轴。如果以 90° 为步长旋转这些点 ,我们最终得到的坐标只能是 0 或 1 或 -1。
Rotating (1,0) and (0,1) by 90 and 180 degrees.
设点(1,0)为 A,它在旋转第一次后的坐标为(0,1),然后是(-1,0),最后是(1,0)。
设点(0,1)为 B,从它开始,则只比 A 提前的一步。从(0,1)→(-1,0)→(0,-1)→(1,0)。
A、B 两点的坐标经过了 1、0、-1 的再回到原来的位置形成一个循环。它们只是起点不同。
如果我们以 45° 为步长旋转一次呢?这将产生位于 XY 平面对角线上的点。由于到原点的距离不变,我们最终得到的坐标形式为 (\pm\sqrt[]{\frac{1}{2}},\pm\sqrt[]{\frac{1}{2}})。
这将我们的循环周期扩展为 0、\sqrt{\frac{1}{2}}、1、\sqrt{\frac{1}{2}}、0、-\sqrt{\frac{1}{2}}、-1、-\sqrt{\frac{1}{2}} 。如果我们不断减小步长,最终会得到一个正弦波。
Sine and cosine.
在这个例子中, 从 A(1,0)开始,在旋转 90° 时得到是(0,1),与 X 轴 0 的量匹配的是 cos(90),与 Y 轴 1 的量匹配的是 sin(90)。这意味着我们可以将 A(1,0)定义为 (cos\ z,sin\ z)。同样在 B(0,1)开始,在旋转 90° 时得到是(-1,0),对应匹配的是 ( -sin\ z,cos\ z)。 z 为旋转角度。
我们从计算绕 Z 轴旋转的正弦和余弦开始。我们提供了以度数表示的角度,但Mathf.Sin和Mathf.Cos规定的参数的是弧度,因此必须转换它。
public override Vector3 Apply(Vector3 point)
{
float radZ = rotation.z * Mathf.Deg2Rad;
float sinZ = Mathf.Sin(radZ);
float cosZ = Mathf.Cos(radZ);
return point;
}
我们找到了一种旋转 A(1,0)和 B(0,1)点的方法,这很好,但是旋转任意点呢?AB 两个点定义了 X 轴和 Y 轴。我们可以分解任意 2D 点 (x,y) 到 xX + yY。在没有任何旋转等同于 x(1,0)+y(0,1) 这结果确实只是 (x,y)。但是在旋转时,我们现在可以使用 x(cos\ Z,sin\ Z) + y(sin\ Z, cos\ Z) 并最终得到一个正确旋转的点。你可以把它想象成缩放一个点,让它落在单位圆上,旋转,然后缩放回去。压缩成一对坐标,变成为 (x\ cos\ Zy\ sin\ Z,\ x\ sin\ Z + y\ cos\ Z)
return new <span class="n">Vector3(
point.x * cosZ - point.y * sinZ,
point.x * sinZ + point.y * cosZ,
point.z
);
添加旋转组件搭到网格对象,并使其成为中间的变换。这意味着我们首先缩放,然后旋转,最后改变移动,这正是 Unity 的 Transform 组件所做的。当然,在这一点上我们只支持绕 Z 旋转。我们稍后再讨论另外两个坐标轴。
All three transformations.
3 Full Rotations
现在我们只可以围绕 Z 轴旋转,为了能支持与 Unity Transform 组件功能相同,我们还需要实现围绕 X 与 Y 轴的旋转。虽然围绕这些轴单独旋转类似于围绕 Z 旋转,但当同时围绕多个轴旋转时,它变得更加复杂。为了解决这个问题,我们可以使用一种更好的方法来编写旋转公式。
3.1 Matrices
从现在开始,我们将使用垂直坐标 \left[ \matrix { x\\ y } \right] 代替水平坐标 (x,y) 。
同样, ( xcosZysinZ , xsinZ + ycosZ ) 变成 \left[ \matrix { xcosZ - ysinZ\\ xsinZ + ycosZ} \right],这样更容易阅读。
注意,x 和 y 元素最终是垂直排列的。像是我们乘以了\left[ \matrix { x\\ y } \right]。这意味着一个 2D 乘法。实际上,我们执行的乘法是 \left[ \matrix { cosZ & -sinZ\\sinZ & cosZ} \right] \left[ \matrix { x\\ y } \right]。 这是一个矩阵乘法。这个 2 × 2 矩阵的第一列表示 X 轴,第二列表示 Y 轴。
Defining the X and Y axes with a 2D matrix.
一般来说,俩个矩阵相乘,你可以通过第一个矩阵的第一行的每个列元素乘以第二个矩阵的每一列的每个行元素。结果矩阵中的每一项都是某一行的元素与某一列相应元素的乘积之和。这意味着第一个矩阵的行和第二个矩阵的列必须具有相同的元素数量。
结论:矩阵的相乘必须满足第一个矩阵的行数与第二个矩阵的列数一致。
Multiplying two 2 by 2 matrices.
结果矩阵中的第一行的第一列等于被乘矩阵的第一行和乘矩阵的第一列乘积之和,第二行中的第一列等于被乘矩阵的第二行和乘矩阵的第一列乘积之和。由此可见:结果矩阵中的行数必定与被乘矩阵的行数一致。
结果矩阵中的第一行的第二列等于被乘矩阵的第一行和乘矩阵的第二列乘积之和,第二行中的第二列等于被乘矩阵的第二行和乘矩阵的第二列乘积之和。由此可见:结果矩阵中的列数必定与乘矩阵的列数一致。
3.2 3D Rotation Matrices
到目前为止,我们有一个2 × 2的矩阵,可以用来围绕Z轴旋转一个2D点。但实际上我们用的是三维点。我们试着做 \left[ \matrix { cosZ & -sinZ\\sinZ & cosZ} \right] \left[ \matrix { x\\ y\\ z } \right]的乘法, 这是无效的,因为矩阵的行和列长度不匹配。因此,我们必须将旋转矩阵增加到 3 × 3,包括第三维空间。如果我们用 0 填充它会怎么样?
\left[ \matrix { cosZ & -sinZ & 0\\sinZ & cosZ &0\\ 0&0&0} \right] \left[ \matrix { x\\ y\\ z } \right] = \left[\matrix {xcosZ - ysin Z + 0z \\ xsinZ + ycosZ + 0z \\ 0x+0y+0z} \right]=\left[\matrix {x cosZ - ysinZ \\ xsinZ + ycosZ \\0} \right]
结果的 X 和 Y 分量都是合理的,但 Z 分量总是变成零。这是不正确的。为了保持Z不变,我们必须在旋转矩阵的右下角插入一个 1。这是可行的,因为第三列代表 Z 轴 \left[ \matrix { 0\\ 0\\ 1 } \right]。
\left[ \matrix { cosZ & -sinZ & 0\\sinZ & cosZ &0\\ 0&0&1} \right] \left[ \matrix { x\\ y\\ z } \right]=\left[\matrix {xcosZ - ysinZ \\ xsinZ + ycosZ \\z} \right]
如果我们同时对三个维度使用这个技巧,最终得到的矩阵对角线上都是 1,其他地方都是 0。这被称为单位矩阵,因为无论与什么相乘,它都不会改变。它就像一个过滤器,让一切保持不变。
\left[ \matrix { 1 & 0 & 0\\ 0 & 1 &0\\ 0&0&1} \right] \left[ \matrix { x\\ y\\ z } \right]=\left[\matrix {x\\\ y \\z} \right]
3.3 Rotation Matrices for X and Y
我们可以利用像找到绕 Z 轴旋转的方法的推理,提出一个绕 Y 旋转的矩阵。首先,从X 轴开始 \left[ \matrix { 1\\ 0\\ 0 } \right] 逆时针旋转 90° 之后变成 \left[ \matrix { 0\\ 0\\ -1 } \right]。这意味着旋转 X 轴可以表示为 \left[ \matrix { cosY\\ 0\\ -sinY } \right]。Z 轴落后于它 90° 所以 Z 轴是 \left[ \matrix { sinY\\ 0\\ cosY } \right]。Y 轴保持不变,完成一个旋转矩阵。
\left[\matrix {cosY&0&sinY \\ 0 &1&0\\-sinY&0&cosY} \right]
第三个旋转矩阵保持 X 不变,并以类似的方式调整 Y 和 Z。
\left[\matrix {1 &0&0\\ 0&cosX &-sinX \\ 0&sinX&cosX} \right]
3.4 Unified Rotation Matrix
我们的三个旋转矩阵每次围绕一个轴旋转。为了组合它们,我们必须一个接一个地应用。让我们首先绕 Z 旋转,然后绕 Y 旋转,最后绕 X 旋转。我们可以首先通过对我们的点应用 Z 旋转,然后对结果应用 Y 旋转,再对结果应用 X 旋转。
但是我们也可以将旋转矩阵彼此相乘。这将产生一个新的旋转矩阵,它将同时应用所有三个旋转。我们先来计算 Y 乘 Z。
结果矩阵中的第一项是 ( cosYcosZ0sinZ-0sinY - cosYcosZ )。整个矩阵需要大量的乘法运算,但很多部分最终都是 0,可以丢弃。
\left[\matrix {cosYcosZ & -cosYsinZ &sinY\\sinZ&cosZ&0\\-sinYcosZ&sinYsinZ&cosY} \right]
现在执行 X × (Y × Z) 来得到我们最终的矩阵。
\left[ \matrix { cosYcosZ & -cosYsinZ & sinY\\ cosXsinZ+sinXsinYcosZ & cosXcosZ-sinXsinYsinZ & -sinXcosY\\ sinXsinZ-cosXsinYcosZ & sinXcosZ+cosXsinYsinZ & cosXcosY } \right]
矩阵乘法顺序重要嘛?
计算乘法的顺序无关紧要,X × ( Y × Z ) = ( X × Y ) × Z。你会得到不同的中间步骤,但最终的结果是一样的。
然而,在这个等式中重新排序矩阵改变旋转顺序,这将产生不同的结果。 X × Y × Z ≠ Z × Y × X。 在这一点上,矩阵乘法不同于单个数字的乘法。Unity 的实际旋转顺序是 ZXY。
现在我们有了这个矩阵,我们可以看到旋转 X、Y 和 Z 轴的结果是如何构建的。
public override Vector3 Apply (Vector3 point)
{
float radX = rotation.x * Mathf.Deg2Rad;
float radY = rotation.y * Mathf.Deg2Rad;
float radZ = rotation.z * Mathf.Deg2Rad;
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);
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;
}
Rotating around three axes.
4 Matrix Transformations
未完,更新中……
页:
[1]