创建一个Cube数组
支持缩放、位移、旋转
处理变换矩阵
简单的摄像机投影
使用Unity5.6.6f2
1 可视空间
Unity Shader是怎么知道一个像素该画在哪个位置?下面是先展示一组Cube
1-1. 操控一组3维坐标
创建一10*10*10的3维Cube数组,并作为UnityMatrices对象的成员变量,接下来显示这些Cube在空间中的位置
void InitCubeArray() { for (int i =0 , z = 0; z < generalCount; z++) { for (int y = 0; y < generalCount; y++) { for (int x = 0; x < generalCount; x++) { cubes[i++] = CreateCubesPoint(x, y, z); } } } }
Transform CreateCubesPoint(int x, int y, int z) { GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube); cube.transform.localScale = new Vector3(0.5f, 0.5f, 0.5f); cube.transform.localPosition = CreateCoordinate(x, y, z); cube.GetComponent().material.color = CreateColor(x, y, z); return cube.transform; }
设置每个Cube的位置,都以(0,0,0)为原点,(10-1)*0.5为Center左右两边对称
Vector3 CreateCoordinate(int x, int y, int z) { return new Vector3( x - center, y - center, z - center ); }
然后再用自身坐标xyz分量与Center的比率初始化颜色rgb。效果如上图1-1
Color CreateColor(int x, int y, int z) { return new Color( (float)x / generalCount, (float)y / generalCount, (float)z / generalCount ); }
2 空间变换
positionning,rotating,and scaling
Cube数组中每个元素在空间中的变换有可能会有差异,虽然每个Cube变换的细节不同,但它们都需要经过一个方法来变换到空间中的某个坐标点。为此我们可以为所有变换创建一个abstract 基类,包含一个抽象的Applay()成员方法,由具体的变换组件去实现这个方法。
public abstract class Transformation : MonoBehaviour { public abstract Vector3 Apply(Vector3 point); }
我们给这个UnityMatrices对象添加这样的组件,同时检索Cube数组每个对象,将其坐标传入这个组件的Apply()方法进行计算得到新坐标并应用,这里始终以(0,0,0)作为每个Cube对象的原点坐标,而不能依赖其实际坐标,因为会每帧实时计算并改变。最后我们用泛型列表存储这种一系列变换组件方便统一计算。
private void Update() { GetComponents(transformations); //for (int i = 0; i < cubes.Length; i++) //{ // cubes[i].localPosition = TransformPoint(cubes[i].localPosition); //} for (int i =0 , z = 0; z < generalCount; z++) { for (int y = 0; y < generalCount; y++) { for (int x = 0; x < generalCount; x++) { cubes[i++].localPosition = TransformPoint(x, y, z); } } } }
Vector3 TransformPoint(int x, int y, int z) { Vector3 coordinates = CreateCoordinate(x, y, z); for (int i = 0; i < transformations.Count; i++) { coordinates = transformations[i].Apply(coordinates); } return coordinates; }
2.1 位移
现在来做第一种变换:translation位移,这很简单。首先创建一个继承自Transformation组件子类,并定义一个表示自身位置属性的变量,并实现基类的抽象方法。然后添加给Cube数组对象
public class PositionTransformation : Transformation
{
public Vector3 position;
public override Vector3 Apply(Vector3 point)
{
return point + position;
}
}
现在可以向UnityMatrices对象添加位置变换组件。这允许我们在不移动UnityMatrices对象的情况下移动数组中每个对象的坐标,所有的变换都发生在其局部空间。
2-1. 位移
2.2 缩放
接下来做第二种变换:Scaling缩放,这更简单。
public class ScaleTransformation : Transformation
{
public Vector3 scale = new Vector3(1, 1, 1);
public override Vector3 Apply(Vector3 point)
{
point.x *= scale.x;
point.y *= scale.y;
point.z *= scale.z;
return point;
}
}
2-2. 缩放
这里有一个问题:当进行缩放时,缩放会改变每个Cube对象的position。这是因为我们首先重新计算了空间坐标,然后才缩放它。而Unity中Transform组件是先缩放后位移。所有需要交换计算顺序:先缩放后位移。
2.3 旋转(二维)
先把组件写上,这个不是很难。
public class RotationTransform : Transformation { public Vector3 rotation; public override Vector3 Apply(Vector3 point) { return point;//先占位 } }
旋转是如何工作的?现在先假定在2维空间下一点P,绕Z轴旋转。Unity使用了左手坐标系,正向旋转是逆时针方向,如下图:
2-3. 2维空间下绕Z轴旋转
旋转一个点坐标后会发什么吗?先简单的考虑一个以原点为中心的单位圆上的一点P,设p初始位置为(1,0),然后再以每90°增量进行一次旋转,如下图:
2-4. 0°旋转到90°和180°时值得变化
由上图可知,点p(1,0)旋转一次(90°)变为了(0,1),再旋转一次(180°)变为了(-1,0),再往下旋转会变为(0,-1),最后回到原位置(1,0). 那如果用点(0,1)作为初始位置,其变换顺序(0,1)->(-1,0)->(0,-1)->(1,0)->(0,1). 因此这个点坐标始终围绕0,1,0,-1进行循环,唯一得区别是起始点位置不同。
那如果以45°增量进行旋转呢?它会在XY平面对角线上产生一点,其坐标为(±√½, ±√½),这些点到原点的距离始终是一致的。而这个循环顺序也类似上面,是0, √½, 1, √½, 0, −√½, −1, −√½。如果继续减小增量值,我们就可以得到一个Sine曲线。
2-5 Sine 和 Cosine
结合图2-4、图2-5,sine曲线代表了Y分量,cosine曲线代表了X分量,坐标用曲线表示就是(cos z, sin z),若起始点为(1,0) 则= (cosz,sinz),逆时针旋转90°后则= (−sinz,cosz)。因此我们可以用绕Z轴计算sine和cosine曲线,由于提供的是角度,但实际上sin及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; }
上述方法对于旋转(1,0)或(0,1)或许很好,那有米有旋转任意点的方式呢? 这些点都是由X和Y定义的,我们可以把2维点(x,y)拆分为一个公式xX+yY。没有旋转时,这个等式:x(1,0)+y(0,1)=(x, y),是成立的。而当有旋转时,可以用x(cos z, sin z)+y(-sin z, cos z)来得到经过正确旋转后的点。组合为坐标对就变成了(xcosZ−ysinZ,xsinZ+ycosZ).
public override Vector3 Apply(Vector3 point) { float radz = rotation.z * Mathf.Deg2Rad; float sinz = Mathf.Sin(radz); float cosz = Mathf.Cos(radz); //return point; return new Vector3( point.x * cosz - point.y * sinz, point.x * sinz + point.y * cosz, point.z ); }
最后我们按先缩放、再旋转,最后位移顺序,Unity的Transform组件也是这个顺序。
void Start () { //... gameObject.AddComponent(); gameObject.AddComponent (); gameObject.AddComponent (); }
2-6. 3种变换,其中只有绕Z轴旋转
3 旋转完全体
现在我们只能绕Z轴旋转,但是为了能够复刻支持Unity的Transform组件那样复制的旋转,现在就得要支持绕X轴和绕Y轴旋转。虽然分别绕这些轴旋转与绕Z轴旋转的方法相似,但是当一次同时绕多个轴旋转时这就很复杂了。为了达到这个目标:一次同时绕多个轴旋转,迎南而上吧。。
3.1 2D矩阵Matrices
现在开始,我们要把坐标书写格式由水平式替代为垂直式。把(x,y)被改写为。把(xcosZ−ysinZ,xsinZ+ycosZ)也同样被拆分为,再把这个表达式进一步拆分:,这就是矩阵乘法。2x2矩阵的第一列代表X轴,第二列代表了Y轴:
3-1. 2D矩阵的X轴和Y轴的定义
总结一下:
由于Unity是采用左手法则,以上文2.3中单位园上一点绕Z轴旋转的增量度不同,cos代表X轴,sin代表Y轴,在结合本文的矩阵可得。
数学上定义,当两个矩阵相乘时,只有在第一个矩阵的列数(column)和第二个矩阵的行数(row)相同时才有意义。结果矩阵的每一项元素等于第一个矩阵逐行元素与第二个矩阵逐列元素的乘积之和。
3-2. 两2x2矩阵相乘
总结一下:
A矩阵 * B矩阵 = (A矩阵的行) * (B矩阵的列)
只有当: A矩阵行中的元素个数(列数) = B矩阵列的元素个数(行数)时。如上图3-2.
3.2 3D矩阵
到目前为止,我们有了一个2x2阶矩阵,可以用这个矩阵来绕Z轴旋转一个2D点。但我们实际上使用的是3D坐标。若试图用这个矩阵乘法就是错误的,因为这两个矩阵的行与列的个数不匹配。为确保满足矩阵相乘,我们就需要填充这个第三维Z轴,先试着用0填充:
得到的结果中X轴和Y轴是正确的,但是Z轴总是为0。为了确保绕Z旋转而不改变Z轴的值,我们先插入一个数字1在旋转矩阵的右下角位置。简化理解,这个第三列值就是代表了Z轴。
由数学上定义,任何矩阵与单位矩阵相乘都等于本身,单位矩阵如同乘法中的1.那么就此可以得出三个矩阵:
绕X轴旋转矩阵 绕Y轴旋转矩阵 绕Z轴旋转矩阵
3.3 绕X轴和Y轴的旋转矩阵推导
根据绕Z轴旋转的方式推理可以得出绕X轴和Y轴的旋转矩阵。
以绕Y轴为例,首先,X轴是以开始,经过逆时针旋转90°后以结束。那么经过旋转后的X轴可以表示为。而Z轴与X轴垂直,所以Z轴就是。而Y轴始终保持不变,最后绕Y轴的旋转矩阵:
同理绕X轴的旋转矩阵, X轴不变:
3.4 统一的旋转矩阵
通过上文我们分别得到了绕某个单独轴的旋转矩阵,现在开始我们要组合起来使用。这里的同时旋转本质上也是分步进行的,先绕Z轴旋转,然后绕Y轴,最后是绕X轴。
这里有两种算法:
第一种:先计算点坐标绕Z旋转,结果再计算绕Y轴旋转,结果最后计算绕X轴旋转,得到最终的旋转坐标。
第二种:把每个旋转矩阵相乘得到一个最终的新的旋转矩阵,这将同时作用与三个轴旋转。首先计算Y x Z.
经过计算,这个结果矩阵的第一项的值是cosYcosZ−0sinZ−0sinY=cosYcosZ。
最后计算X × (Y × Z)给出最终矩阵:
public Vector3 rotation;//每个分量表示角度 public int rotDelta; private void Update() { rotation = new Vector3(rotDelta, rotDelta, rotDelta); } 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 xRot = new Vector3( cosy * cosz, cosx * sinz + sinx * siny * cosz, sinx * sinz - cosx * siny * cosz ); Vector3 yRot = new Vector3( -cosy * sinz, cosx * cosz - sinx * siny * sinz, sinx * cosz + cosx * siny * sinz ); Vector3 zRot = new Vector3( siny, -sinx * cosy, cosx * cosy ); return xRot * point.x + yRot * point.y + zRot * point.z; }
4 矩阵变换
实现一个矩阵完成缩放、旋转、位移的计算
为了实现这个目标,所以借鉴3.4旋转矩阵组合的方式,先对缩放和位移组合,即位移 x 缩放。
缩放,根据单位矩阵的性质,任何矩阵与单位矩阵相乘的结果都是本身。那么对单位矩阵进行缩放即可:
位移,不是对三个分量完全重新计算,而是在现有的坐标之上进行偏移。因此现在不能简单的重新表示为3x3阶矩阵,而是需要额外增加一列表示偏移。
但是,又由于矩阵乘法规定,第一个矩阵的列数等于第二个矩阵的行数才有意义。上图就是错误的。所以我们需要给点坐标增加第四个元素,偏移矩阵增加一行。当它们增加的这个分量进行矩阵相乘时,其结果为1(我们先保留下这个数字1,以备后续使用).那就变成了4x4阶矩阵样式和一个4D点。
根据位移矩阵,所以我们要统一用4×4的变换矩阵。缩放和旋转矩阵会额外增加一行一列,其右下角是1。所有的点都带有一个第四维坐标分量,它总是1——其次坐标。
4.1 其次坐标(Homogeneous Coordinates)
不知道就问:
- 这个坐标的第四分量坐标是个啥?
- 它有啥用啊?
- 我们只知道上文提到位移时有用,那么缩放、旋转有用吗?
- 当它的值为0,1,-1时会发生什么呢?
有这样一个东西不叫坐标而叫向量,它可以被缩放和旋转,但不能被移动。向量描述了相对于某个点的偏移,具有方向和长度属性,没有位置属性。
它表示为一个点,而它表示为一个向量。这样区分非常有用,因为我们可以使用相同的矩阵来变换一个点的位置、法线和切线。
当第四个坐标值是0或1或其他数值时会发生什么?答案是什么也不会,准确的说是没有差异。这个坐标的术语叫做其次坐标,它的意思是空间中每个点都可以用一个无穷数量坐标集和来表示。而现在普遍做法的形式是使用1作为第四个坐标值,而所有其他的数字都能通过使用整个集合乘以任意数来找到。
当我们知道了一个其次坐标时,需要转为3D坐标,只需要把第四个坐标化为1,怎么做呢?没错,就是把每个坐标除以第四个坐标,然后再舍弃第四个坐标。
所以当第四个坐标为0时是不能做上面的除法的,因此当第四个坐标值为0时,表示为向量,这就是为什么它们像方向一样。
4.2 使用矩阵
我们能用Unity的Matrix4x4结构体来完成矩阵乘法。从现在开始,我们将用它来代替上面的3D旋转方法。
在Transformation增加一个抽象只读属性以检索变换矩阵。
public abstract Matrix4x4 Matrix { get; }
Transformation组件的Apply方法不再需要设为抽象,它将获取到矩阵并执行乘法运算。
public Vector3 Apply (Vector3 point) { return Matrix.MultiplyPoint(point); }
注意这个Matrix4x4.MultiplyPoint
需要一个3D坐标参数,坐标参数假定了第四个值为1.该方法会负责把得到的其次坐标转为3D坐标,若只想计算方向向量可以使用Matrix4x4.MultiplyVector
.该方法会忽略第四个坐标。
public Vector3 MultiplyPoint(Vector3 v) { Vector3 vector; vector.x = (((this.m00 * v.x) + (this.m01 * v.y)) + (this.m02 * v.z)) + this.m03; vector.y = (((this.m10 * v.x) + (this.m11 * v.y)) + (this.m12 * v.z)) + this.m13; vector.z = (((this.m20 * v.x) + (this.m21 * v.y)) + (this.m22 * v.z)) + this.m23; float num = (((this.m30 * v.x) + (this.m31 * v.y)) + (this.m32 * v.z)) + this.m33;//其次坐标 num = 1f / num; vector.x *= num;//转换计算 vector.y *= num;//转换计算 vector.z *= num;//转换计算 return vector; } public Vector3 MultiplyVector(Vector3 v) { Vector3 vector; vector.x = ((this.m00 * v.x) + (this.m01 * v.y)) + (this.m02 * v.z); vector.y = ((this.m10 * v.x) + (this.m11 * v.y)) + (this.m12 * v.z); vector.z = ((this.m20 * v.x) + (this.m21 * v.y)) + (this.m22 * v.z); return vector; }
具体的Transformation类现在必须将其Apply()方法更改为Matrix属性。
首先是PositionTransformation组件,Matrix4x4.SetRow接口能很简易地填充这个矩阵。
public override Matrix4x4 Matrix { get { Matrix4x4 matrix = new Matrix4x4(); matrix.SetRow(0, new Vector4(1f, 0f, 0f, position.x)); matrix.SetRow(1, new Vector4(0f, 1f, 0f, position.y)); matrix.SetRow(2, new Vector4(0f, 0f, 1f, position.z)); matrix.SetRow(3, new Vector4(0f, 0f, 0f, 1f)); return matrix; } }
其次是ScaleTransformation
.
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; } }
最后是RotationTransformation
, 它设置行与列就更简单了,把之前的方法改改就能用。
public override Matrix4x4 Matrix { get { 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); Matrix4x4 matrix = new Matrix4x4(); matrix.SetColumn(0, new Vector4( cosy * cosz, cosx * sinz + sinx * siny * cosz, sinx * sinz - cosx * siny * cosz, 0f )); matrix.SetColumn(1, new Vector4( -cosy * sinz, cosx * cosz - sinx * siny * sinz, sinx * cosz + cosx * siny * sinz, 0f )); matrix.SetColumn(2, new Vector4( siny, -sinx * cosy, cosx * cosy, 0 )); matrix.SetColumn(3, new Vector4(0f,0f,0f,1f)); return matrix; } }
4.3 合并矩阵
现在我们把上述所有变换矩阵合并为一个矩阵。 为此先在UnityMatrices类增加一个矩阵类型字段transformation。我们将在Update函数每帧更新该变量值,该步骤为先获取到第一个Transformation组件的矩阵,并依次与其他矩阵相乘,需要确保这块正确的相乘顺序。
最后不再执行Apply方法,而改用矩阵乘法代替:
Vector3 TransformPoint(int x, int y, int z)
{
Vector3 coordinates = CreateCoordinate(x, y, z);
//for (int i = 0; i < transformations.Count; i++)
//{
// coordinates = transformations[i].Apply(coordinates);
//}
return transformation.MultiplyPoint(coordinates);;
}
这个新方法是非常有效的,因为我们之前使用的方法是分别给每个点船家女一个变换矩阵并作用与它。而现在我们只需要一次创建一个统一的变换矩阵作用与所有点。Unity使用类似的方案将每个对象的变换层次结构简化为单个变换矩阵。
在这个例子中,我们可以使它更有效。所有的变换矩阵都有一个相同的行——[0 0 0 1]。知道了这一点,我们可以忽略这一行,跳过所有0的计算和最后的除法转换。Matrix4x4.MultiplayPoint3x4方法就是这样做的。
public Vector3 MultiplyPoint3x4(Vector3 v) { Vector3 vector; vector.x = (((this.m00 * v.x) + (this.m01 * v.y)) + (this.m02 * v.z)) + this.m03; vector.y = (((this.m10 * v.x) + (this.m11 * v.y)) + (this.m12 * v.z)) + this.m13; vector.z = (((this.m20 * v.x) + (this.m21 * v.y)) + (this.m22 * v.z)) + this.m23; return vector; }
这个方法有时候有用,有时候不能用。因为有时我们需要的一些变换矩阵会改变这最后一行。
到目前为止只有位移变换需要第四行。所以缩放、旋转使用Matrix4x4.MultiplayPoint3x4计算速度会更快。现在就把Apply()方法改为虚方法,再由旋转、缩放组件重写,代码就不贴了。
5 3D to 2D space 投影矩阵
到目前为止,我们能够把一个点的坐标从一个3D空间变换到另一个3D空间。但是这些点又如何展示到2D空间呢?这肯定需要一个从3D到2D的变换矩阵。那么我们开始寻找这个矩阵吧!
先构造一个新的继承自Transformation的实体变换组件作用于摄像机的投影,默认值为单位矩阵。
public class CameraTransformation : Transformation { public override Matrix4x4 Matrix { get { Matrix4x4 matrix = new Matrix4x4(); matrix.SetRow(0, new Vector4(1f, 0f, 0f, 0f)); matrix.SetRow(1, new Vector4(0f, 1f, 0f, 0f)); matrix.SetRow(2, new Vector4(0f, 0f, 1f, 0f)); matrix.SetRow(3, new Vector4(0f, 0f, 0f, 1f)); return matrix; } } }
5.1 Orthographic Camera
从3D变换到2D空间最直接粗暴的方式是丢弃一个维度数据。就好像把3维空间压缩到2维平面,这个平面就像一个画布,用来渲染屏幕。现在我们把Z轴丢弃,试试看会发生什么。
5-1. 好似塌缩形成了一个平面
实际上,这种粗暴的方法还蛮像那么回事,确实变成了2D了。其他的X、Y轴同理,就不演示了。这就是正交投影。不管相机如何缩放、旋转、位移,始终呈现的2D效果。移动相机的视觉效果和移动世界的相反方向是一致的,也就是3个变换组件的变量与摄像机的缩放、旋转、位移变量是互为正负关系。
5.2 Perspective Camera
正交相机很好,但不能模拟3D世界就很尴尬了。所以我们需要一个透视相机,由于视角的原因,呈现一个原小近大的视觉。那么基于此,我们可以根据点到摄像机的距离重建这个视觉效果。
先以Z轴为例,把单位矩阵代表Z轴的列元素全部置为0,再把单位矩阵最后一行值改为[0,0,1,0],这一步改变将确保结果坐标的第四个值等于Z坐标,最后所有坐标都除以Z。
与正交投影最大的不同是这些点不会直接移向到平面,而是他们会移向摄像机的位置,当然这只对位于摄像机前面的点有效,而在摄像机后面的点就不会正确的投影。先确保所有点都能位于摄像机的前方,把摄像机的Unity.Transform组件Position.Z值调好,保证所有点都先可见。
5-2. 透视投影
设置一个点到平面的投影距离,它也会影响投影视觉效果。它就像相机的焦距,值越大视野就越小。现在我们先定义一个变量focalLength 值默认为1,这能产生90°的视野。public float focalLength = 1f;
5.3 focal length
当这个值越大就像相机在进行聚焦,这有效地增加了所有点的比例(想象一下单反火箭筒)。当我们压缩Z轴时,是不必进行缩放的。
现在有了一个简单的透视相机,如果要完全模拟Unity的透视相机,我们还必须处理近平面和远平面。这将需要处理投影到一个立方体而不是一个平面,因此需要保留深度信息。然后还有视野裁切方面的问题。此外,Unity的摄像头是在负Z方向上拍摄的,这需要对一些数字进行求负。
矩阵不可怕。
翻译原文
这篇文章很好,有意就赞助它吧!