前言
笔者是一个游戏行业的程序员,读书的时候做的基本都是native graphic library的项目,工作了反而对渲染管线细节接触少很多,说到底还是现在的商业引擎都太好用了啊喂。目前绝大多数的渲染算法在github,shader toy上都能找到非常完整的实现,大量的内置函数和宏隐藏了复杂的渲染细节。假如你需要修改unity自带的复杂pbr光照,或者实现一个简单的billboard shader,做为程序员为了能继续方便的打磨别人的轮子,搞明白这些内置函数&变量的数据计算过程就成了必要条件。
这篇文章包含了左&右手坐标系 行主序(row major) 列主序(column major)左乘 右乘 坐标系变换的相关推导和使用注意事项。
相信我,我高考数学选择错了一半都能搞懂,你肯定也可以。
左手坐标系&右手坐标系
左手坐标系是指在空间直角坐标系中,让左手拇指指向x轴的正方向,食指指向y轴的正方向,如果中指能指向z轴的正方向,则称这个坐标系为左手直角坐标系。反之则是右手直角坐标系。
定义左右手坐标系的作用:
在三维世界中,我们给定一个平面XOY,针对这个平面的旋转角度θ就产生两种情况--顺时针和逆时针。所以对坐标系使用左手与右手的命名,这种命名规则的作用就是用来方便判断旋转的正方向,这就是左手法则和右手法则。
针对上图来说,在左手坐标系下,XOY面的旋转正方向这样获得:大拇指朝向z轴正方向,四指弯曲的方向就是左手坐标系下,旋转的正方向。所以本文的所有旋转在给定左右手坐标系的情况下,旋转角度θ,就是沿着当前坐标系的正方向进行旋转θ角度。
测试可知:左手坐标下,顺时针就是旋转的正方向,右手坐标系则正好相反
ps:unity就是基于左手坐标系的,我们可以通过简单的代码进行观察物体是否沿着XOZ面顺时针旋转
transform.rotation = Quaternion.Euler(0, 45, 0);
1. 点,向量,齐次坐标
为了理解简单点,本文大部分都会先考虑二维的情况,三维世界的情况其实就是二维世界的扩展
在二维世界中,点P&向量V的定义:
p=\left\{x, y, 1\right\} v=\left\{x, y, 0\right\}
这里你可能会提出两个问题:
- 为什么要用三维向量去表示二维世界的P&V
- 为什么P的第三维度值是1,而V的第三维度值是0
1.1 二维坐标系的Rotate Translate Scale Formula
要回答这些问题我们需要考虑这样的问题,在二维世界中如何对点P完成平移(translate)旋转(rotate)以及缩放(scale)操作。
考虑下图中的点P移动到P',有公式如下:
考虑下图中的点P旋转到P'-- 在左手坐标系下旋转角度θ的计算公式
接下來我們考虑缩放的情况,对二维坐标系内上X,Y轴分别进行放缩Sx,Sy,计算公式如下:
1.2 二维坐标系的Rotate Translate Scale Matrix
我们接下来考虑一个问题,如何方便的把RTS运算结合在一起呢?答案就是矩阵。原因很简单,矩阵运算天生满足结合律,我们把上述公式转换为矩阵运算后就可以用一个矩阵来表示RTS行为了,这为以后的复杂运算提供了便利。
根据上述的公式,我们可以很简单的得到Rotate Matrix&Scale Matrix
Rotate Matirx in left hand coordinate
Scale Matirx in left hand coordinate
在n维坐标系中,平移矩阵需要n+1维的向量完成平移操作。所谓的齐次坐标就是就是将一个原本是n维的向量用一个n+1维向量来表示。
Translate Matirx in left hand coordinate
注意:以上的计算公式计算结果都是在同一个坐标系内部的,当我们使用XOY为basic coordinate的情况下,以上公式的计算结果都是在basic coordinate下的
进而我们把旋转矩阵和平移矩阵也引入齐次坐标来使得Rts Matrix可以结合,公式为:
Rotate Matirx in left hand coordinate
Scale Matirx in left hand coordinate
齐次坐标除了方便用于进行仿射(线性)几何变换以后。它还能够能够用来明确区分向量和点。
向量v是矢量,它没有平移的概念,通过齐次坐标的N+1维 = 0,使得它无法完成平移操作,但是仍然可以受到RS Matrix影响。
点P的齐次坐标的N+1维 = 1, 这就回答了问题2。
1.3 矩阵运算的左乘和右乘
由于矩阵运算满足如下规律:
我们以平移矩阵为例,根据上述公式可以改写成如下形式,这就是矩阵左乘:
我们把向量在左边的矩阵乘法称之为:矩阵左乘**
我们把向量在右边的矩阵乘法称之为:矩阵右乘**
ps:unity就是基于矩阵右乘的,我们可以通过简单的创建translate matrix来查看translate系数的所在位置来推测unity的矩阵是否右乘
Matrix4x4 m = Matrix4x4.Translate(new Vector3(5, 6, 7));
左乘和右乘在计算效率上有深入的考量,同时左乘右乘影响着rts矩阵的运算顺序,详情请看下文
1.4 矩阵的存储方式,行主序&列主序
针对一个特定的矩阵,它在内存中的线性存储的方式有两种:行主序 & 列主序
对于一个数组float[9] array
行主序的存储方式是:a - b - c - d - e - f - g - h - i
列主序的存储方式是:a - d - g - b - e - h - c - f - i
ps:unity的matrix4x4就是列主序的,请注意m.xy中x是行位置,y是列位置,他们和行主序列主序无关
2. 坐标系转换
上文讲述了在basic coordinate下针对一个点P的Rts Formula,计算结果一直都在同一个坐标系下。
现在我们考虑一个新的问题:
在一个给定的basic coordinate(左手坐标系or右手坐标系)下有一个点P,计算新的坐标系A下的点P'的值
举个例子,我们提供两个坐标系X''_O'_Y''和X_O_Y,如何计算在X''_O'_Y''坐标系下的P''点在X'_O'_Y'中的值呢?
复杂的问题简单化,我们先计算X''_O'_Y''中的P''在X'_O'_Y'中的P'值:
然后,我们在X'_O'_Y'中的点P'计算 X_O_Y的最后结果P
把上述公式通过矩阵左乘的方式表达:
通过矩阵的转置计算公式,我们可以得到矩阵右乘的版本:
通过上述推导结果,我们能够观察到一些坐标系变换必须要注意的性质:
- 矩阵的左乘&右乘直接影响了坐标系变化下的rotate translate顺序。设X_O_Y是basic coordinate,X''O''Y''是local coordinate,那么上述公式就成了local 2 world coordinate formula,通过矩阵右乘,局部坐标系P''变换到P需要先乘Mt,再乘Mr
从P_local变换到P_world坐标系下,可以分成三个步骤:
- X''_O'_Y'' 旋转到与X'O''Y'重合 -- 计算P''在X'O''Y'中的值P'
- X'_O'_Y' 缩放到与X_O_Y一致
- X'_O'_Y' 平移到与X_O_Y重合 -- 计算P'在X_O_Y的值P
- unity中game object的transform.rotation是basic coordinate下旋转θ到transform local coordinate的旋转四元数。
vector3 p' = Matrix4x4.rotate(transform.rotation).multiPoint(p)
注意:如果p是basic coordinate下, p'仍然是basic coordinate下的,上述这样使用旋转四元数并不会让点实现坐标系变换
由于在左手坐标系下,世界坐标旋转θ的公式已知
将上述公式带入坐标系变换公式,就可以得到
所以给定一个transform和基于这个transform的local coordinate点P,计算这个P在世界坐标系的代码为:
protected Vector3 Coordinate2World(Transform a, Vector3 a_local_p)
{
// transform.rotation equals FromToRotation(Vector3.forward, a.forward)
//Quaternion q = Quaternion.FromToRotation(Vector3.forward, a.forward);
//Matrix4x4 m_q = Matrix4x4.Rotate(q);
Matrix4x4 m_q = Matrix4x4.Rotate(a.rotation);
Matrix4x4 m_t = Matrix4x4.Translate(a.position);
return (m_t * m_q).MultiplyPoint(a_local_p);
}
这个旋转应用于一些特殊的向量就会有特殊的几何意义,比如vector.forward使用下面的代码
vector3 v' = Matrix4x4.rotate(transform.rotation).multiVector(v)
得到的V'就是local coordinate下vector.forward在basic coordinate中的值
- 矩阵右乘的local coordinate <--> world coordinate matrix & 中,矩阵的相关位置对应着相应的功能,旋转&缩放相关的参数为R,平移相关的参数为T
矩阵右乘 --- 在local to world matrix中,T = obj.position - vector3.zero = obj.position = vec2(m02, m12)
矩阵右乘 --- 在world to local matrix中,T = -obj.position + vector3.zero = -obj.position = vec2(m02, m12)
小提示:由于unity是使用矩阵右乘的,我们在shader中可以很方便的在unity_ObjectToWorld获取物体的world position。
float4 worldCoord = float4(unity_ObjectToWorld._m03, unity_ObjectToWorld._m13, unity_ObjectToWorld._m23, 1);
- 设X_O_Y和X''_O'_Y''都是local coordinate,这样我们就得到了一个广义的旋转矩阵推导,同时回答了第二章一开始提出的问题
在一个给定的坐标系A(左手坐标系or右手坐标系)下有一个点P,计算新的坐标系B下的点P'的值
从上文可以得到坐标系变换的头一步需要将 X''_O'_Y'' 的P''点变换到一个虚拟的坐标系X'O''Y'下,这一步需要X'O''Y'下旋转角度θ到X''_O'_Y'' 的矩阵和translate(O''_inworld - O'_inworld),计算代码如下:
protected Vector3 Coordinate2Coordinate(Transform a, Vector3 a_local_p, Transform b)
{
Quaternion q = Quaternion.FromToRotation(b.forward, a.forward);
Matrix4x4 m_q = Matrix4x4.Rotate(q);
Matrix4x4 m_t = Matrix4x4.Translate(a.position - b.position);
return (m_t * m_q).MultiplyPoint(a_local_p);
}
3. 总结
上文中的公式推导都是基于二维坐标系的,但是RTS的顺序,坐标系变换原理都是一样的,所有的rts变换在计算目标不同的时候公式是不同的。
在baisc coordinate下对P点进行旋转,平移,缩放得到的结果P'仍然是在basic coordinate中,而basic coordinate下有点P,获取local coordinate的坐标P'是另外一回事,不要搞混了。
下一篇文章会仔细推导一下三维坐标系的rts矩阵,和上述两种情况下的计算公式。