关键词:向量,点,阵列,法线,变换,笛卡尔坐标系,笛卡尔坐标,球面坐标,坐标系。

几何,是一个涉及形状,大小,图形相对位置,还有空间性质的数学分支。

一个小提醒

对于大部分读者而言,这个课程会很长而且很枯燥。如果对你来说计算机图形学是一个新的领域,请花时间仔细阅读这个课程。因为完全理解CG管线中的这部分是非常重要的,而且以后会给你节省很多时间。

点,向量和法线

几何入门

点,向量,阵列和法线对于计算机图形学来说,是犹如字母般的基础。请别因为数学或者阵列弃疗。

我们开始“3d渲染基础”部分的一系列课程不需要之前的知识背景。虽然这是一个相当新奇的教学方法,但我们认为这样会更高效有趣:比如说,光线追踪的介绍,就需要很少的数学和编程。写一个渲染器更有收获也更有趣,因为你可以慢慢看到如何用确定的逻辑来生成一个确定的结果。所以,点,向量,和阵列是至关重要的。我们每一课都会用到。

这一课,你会明白这些名词是什么意思,他们是如何工作还有操作他们的技巧。这个课程会解释CG研究者多年来在解决问题和写代码中使用线性代数中的各种惯例。你需要理解这些概念因为他们经常不会在书中提到。这些概念很重要。你阅读其他开发者的代码和技术之前,你必须先确认他们用的命名约定。

开始之前,如果你数学狂人,你也许会觉得很奇怪为什么很多事情不是用线性代数来解释的。我们会希望把这个课程的范围控制在CG里常见而简单数学技巧,可能会和线性代数关系不大。比如一个点,在数学上和线性代数可能没关系(有的分支只考虑向量)。我们选择涉及点,是因为他们在CG中太太太太常见了(操控点的技术也很常见)。如果你还不理解点和向量的区别,没事,我们会在这章仔细讲的。

线性代数是什么?介绍向量

线性代数到底是啥,这课我们要学什么?前面提到,线性代数是和向量有关的数学分支。你也许会问,向量是什么,对CG世界有什么用?我们不需要现在就搞太细,向量可以代表一组数字,这些数字的数组,可以预设任何维度,有时候数学上也称之为元组。如果我们想要说明向量的维度,我们最好选择n-tuplen描述元素的数量。例如以下是一个6维向量的数学记法:
V = (a,b,c,d,e,f)
a,b,c,d,e,f都是实数。
这个记法的用处在于可以在解决问题的时候作为一个整体来表示另外一个数值或者概念。例如,计算机图形学中,向量可以用于表示空间中的一个位置或者方向。我们也可以通过一系列的操作,有效而整体地转化这些向量。这个转换的过程是通过线性变换做到的。我们后面会花很多时间来探讨这些变换。目前,我们只要知道他们重要就行了。

点和向量

点和向量这些术语在不同的科学领域里面都有使用.在这一节,我们会解释两个术语跟本教程和计算机图形学的关联.
这里,一个点是一个三维空间的点.一个向量,通常意味着一个三维空间的方向(有时候会有长度).向量可以被认为是各个方向的箭.三维点和向量是很类似的,因为他们都是通过一个三维元组标记的.
V=(x,y,z)
x,y,z是实数.
记住,跟数学家或者物理学家讨论的时候,他们概念里的点和向量可能完全不一样.他们不一定会沿用我们在CG领域的概念.对于他们而言,向量可以是任意长度或者无限维度.
我们在结束这个章节之前会简单提到齐次点.有时候我们会发现增加第四个元素在数学计算的时候会很方便.一个齐次坐标的例子:
PH=(x,y,z,w)

图 1: 点描述位置, 向量描述方向.

在对点做阵列乘法的时候就会用到齐次点.目前不要担心.我们只是提一下,后面会经常在文章中出现,而且可能会让你混淆.后面会讲的.

快速介绍一下变换

你有可能还是在纠结在点和向量上做线性变换是啥意思.其实蛮简单的.在CG中,对于点最常见的处理就是移动他们.Transform更特别的叫法是translation,它在渲染过程中起到很重要的作用.
变换操作无外乎是一个在一个原始输入的点(可以看做一个位置)上做线性变换.对于向量来说,Translation没有意义.这是因为向量的起点不重要.不考虑位置,所有的长度相等,方向相同的向量,是等价的.不过我们通常会对向量进行旋转操作.
P->Translate->PT
V->Rotate->VT
下标T意思是”Transformed”
你也许会注意到,至今为止我们还没讨论过向量的长度或者说模数是什么意思.实际上,向量的长度在CG中有很重要的意义.当一个向量的长度是1的时候,我们会说这个向量是归一化了(你会无数次遇到这个概念).归一化一个向量就是把向量的长度变为1而方向不变.大多数时候我们希望我们的向量是归一化的.然而有时候,不归一化他们使他们能保持长度信息也许更有用.
比如说,想象你追踪一条光线从点A到点B.这条线可用向量表示,表示了B相对于A的位置.也就是说它其实说明了B的位置,长度即是AB的长度.这个距离很多时候是需要用到的.
归一化向量通常是BUG之源,每一次你声明一个向量(或者使用),我们建议你问一问自己,到底要不要归一化.

法线


图 2: 法线在P点垂直于面的切线.
法线是一个CG中的技术术语描述了一个几何物体表面的方向.专业一点说是几何体上某点P的面法线,可以看做是在P点垂直于面的切线的一个向量.法线在着色中特别重要,因为他们会用于计算物体的亮度(之后的课程会讲到光照和着色).
法线可以被看成向量,但有个小小提示:他们不能像普通向量一样变换.这也是我们为什么要花时间来辨别他们.你以后会在TransformingNormals的章节得到更多信息.不过暂时我们知道他们是什么意思就好了.

从理论到C++

在我们的C++代码中,我们不会特别指明点,向量还有法线.我们用一个三维的类来表示他们(这是一个类模板,所以我们可以根据需要创建浮点,整数或者双精度浮点).有一些开发者更喜欢区别他们.这个会显著限制犯错的可能.从经验来说,我们觉得一开始写少一点的代码会更高效我们只用一个类(就如OpenEXR库一样).不过我们还是会根据点,向量,法线来小心调用一些特别的函数.你应该记得,在变换中这是很重要的.完整的源代码在下面可供下载.

  1. template<typename T>
  2. class Vec3
  3. {
  4. public:
  5. // 3 most basic ways of initializing a vector
  6. Vec3() : x(T(0)), y(T(0)), z(T(0)) {}
  7. Vec3(const T &xx) : x(xx), y(xx), z(xx) {}
  8. Vec3(T xx, T yy, T zz) : x(xx), y(yy), z(zz) {}
  9. T x, y, z;
  10. };
  11. typedef Vec3<float> Vec3f;
  12. Vec3<float> a;
  13. Vec3f b;

总结

从这章节,你应该记住数学上向量可以是任意维度.但在CG领域,向量是3维空间的方向(因此是三维).还有,我们谈论的,是空间中的位置(也是三维).齐次点是四维,但是这是一个特殊情况,以后会学.
点和向量可以通过线性变换而改变.

  1. 你会经常使用线性变换.如果线在变换的时候能够保持, 那么我们说这是线性变换(比如乘以一个阵列是线性变换)

通常,这样的变换是点的位移和向量的旋转.向量长度可以是1,这样的向量就是归一化向量.向量长度(归一化之前)代表两个点之间的距离,有时候在算法中会用到.因为这个原因,程序员应该小心是否选择去归一化一个向量.

下一课是什么?

一个很重要的概念我们还没提到,就是三维数组定义的点和向量代表了什么.这些数字代表了相对于某个参照物的一个点(2D或者3D).这个参照物,我们叫它坐标体系,后面会探讨.

坐标系统

介绍坐标系统

坐标系统在图形管线中起到一个重要作用.他们不是很复杂.坐标是我们在学校里学的第一件事.然而学习一些基础有助于理解阵列.

在过去的章节我们提到点和向量(CG里的)是由三维实数表示的.但是这些数字有什么含义?每一个数字代表了一个有符号距离,由一条线的起点到那个位置的距离.比如说,想象画一条线,然后在中间标记一下.我们可以把它当做原点.这个标记就成为了我们点的参照:我们由这个参照来衡量到其他点的距离.如果一个点被放在这个坐标的右边,我们就说它是正值,反之如果在左边,我们称之为负值.

我们假设这条线往两端无限延伸.因此,理论上,两个点之间的距离可以是无穷大.然而这就引申出一个问题:在计算机世界中,有一个理论上限(与编码的字节有关).幸好,最大值一般都足以构建大部分的3D场景了.所有的CG世界都是有一个极限的.所以,我们不需要太关心数值局限.

既然我们有了一条轴线和原点,我们可在两侧添加一些标记,并让这些标记以单位长度为间隔,这样我们很方便的就把轴线变成了一把尺子.有了尺子,我们很简单就可以测量一个点的坐标(另一种说法是由原点到点的符号坐标).在计算机和数学领域,这把尺子,就定义了一个.

图1: 一个点的位置,是由坐标系的轴向上的距离决定的. 这个轴从负无穷到正无穷.
如果我们感兴趣的点不在轴上,我们仍然可以找到点的坐标,通过把点垂直于轴线去做投射.原点到投射点的距离就是该点在轴线上的坐标.这就是如何定义一个轴上的坐标.

维度和笛卡尔坐标系

现在我们把之前的水平的轴线叫做X轴. 我们可以X轴零点起始, 画一条垂直于X轴的轴线, 这样我们就有了Y轴.对于任意点,我们可以针对每条轴线做出垂线,这样就可以分别得到X,Y轴的坐标.我们可以找到两个数字,或者说两个坐标,对于任意一点:一个是X轴,一个是Y轴.因此,有了两条轴线,我们就可以定义这个二维空间为一个平面.

例如,假设在一片纸上画了一系列的点.这个纸占用了两个维度的空间,即一个平面.我们可以为这两个维度分别画两条轴.如果我们可以使用XY轴去衡量一每一个点,这两条轴,我们就认为定义了一个坐标系.如果这两条轴相互垂直,那么我们可以说这是笛卡尔坐标系.

记住我们通常使用一个简单的助记法,叫做有序对来书写点的坐标.一个有序对仅仅是两个数字,被逗号分隔.对于笛卡尔坐标系来说.习惯是先写X坐标再写Y坐标.例如,我们会写(2.5,2.25)来表示一个x坐标为2.5,y坐标为2.25的点(见图2).不要受惊.记住,这就跟往右2.5单位,往上2.25单位一样,我们以后会经常用的.

图2: 一个2D的笛卡尔坐标系由两个相互垂直的轴定义.轴上被间隔相等地划分了单位.计算一个点的坐标仅仅是在一维上扩展到二维.我们把符号坐标定义为点沿xy轴到原点的距离.

实际上,我们可以选择定义无穷个这样的坐标系.但为了简化, 我们可以假设我们画了两个这样的笛卡尔坐标系.在这张纸上我们标记了一个点.那个点的坐标会因为两个坐标系的不同而发生改变.例如在图3中, 点P在A坐标系中的坐标是(-1,3),在B中就是(2,4).我们的点没变, 但坐标变了.

图3: 同样的点,在不同的坐标系. 我们可以把A坐标系(红色)中的点转移到B坐标系中(绿色), 只要在坐标上加上(3,1)即可.
所以如果你知道P在A系统里的坐标,你需要怎样知道它在B坐标系的坐标呢? 这就是CG中一个非常重要的操作. 我们很快就会学到为什么以及如何把一个点的坐标变换到另一个坐标系的坐标.(请看这一章 Transforming Points and Vectors)

图4: 缩放, 或者说位移一个点, 就是改变它的坐标.一个缩放是在点坐标上分别乘以一个数. 位移是在点上加上一个数.
另一个很常见的操作是, 移动一个点的位置. 这个被称之为位移,而且绝对是关于点最常用的操作.记住所有类型的线性操作对于点来说都是可行的.一个实数乘以一个点, 会起到缩放(图4)的效果.缩放会把点P沿着原点到P的这个方向上移动.后面会提到.

第三个维度

三维坐标系是二维坐标系的简单扩展. 我们只要在XY轴基础上再加上一个Z轴(代表深度)即可,X指向右边,Y指向上,Z指向后.当然其他的规范也是可以接受的, 但我们这整个课程都使用这一套体系.几何学上,这个系统叫欧几里得空间.

图5 一个三维空间系统.一个点是由三个轴向上的坐标来定义的.
结尾,我会总结其他的三维坐标系的定义方式.线性代数中, 三个轴, 就是我们坐标系的基础. 它是由三个相互独立的向量, 可以通过线性组合表示所有的向量(或者点).如果一些向量相互之间都不能通过线性变换得到,我们就说这些向量之间是线性独立的. 改变基本量,或者说改变坐标系,是数学和图形学中常见操作.

左手坐标系VS右手坐标系

不爽的是, 由于各种规范的手性不同, 坐标系统不是那么简单的. 下面你就会看到问题: 当向上和向前的轴向是一样的方向时,”向右”的方向可以是左或者右.

为了辨别这两个命名规范, 我们把第一个叫做左手坐标系, 第二种叫做右手坐标系. 左,右手的概念是由物理学家John Ambrose Fleming提出的.

为什么叫做手性?如果你如下图摆放手指, 你会发现你的手指指向右,上,前向量.很自然的, 左手符合左手坐标系的特征, 右手符合右手坐标系的特征.不过我们一般会把坐标转一下, 这是为了把右向量指向右边, 然后你会发现前向量变成指向你身后的方向, 图6即展示了这个过程.

图6 一般来说右手坐标系的右轴(红色),会指向右边

记住, 在检测手性的时候,中指永远代表右向量. 先让中指指向右边, 然后让另外两个指头分别垂直. 这时候的坐标系就是右手坐标系.

手性也在面法线计算的方向上有重要作用. 如果是右手, 则几何形体的点序是顺时针, 那么法线就是正的, 这些以后会解释.

右,上,前向量

笛卡尔坐标系仅仅是被三个相互垂直的单位长度的向量定义的.数学上来说,这个坐标系统没有说明这三个轴的具体意义.意义是由开发者是决定的.所以要清楚的分辨出手性和轴向的命名规则.
朝上向量是Z还是Y?我们来看一看图7b 然后架设X是右向量.我们怎么描述右手坐标系?这是一个右手坐标系.你可以看到,我们做的就只是挑一个右手或左手坐标系,然后把这些轴定义为XYZ.命名规则跟右手坐标系毫无关系.完全是为了表明他们之间的区别.许多人经常会认为系统使用的命名规则是以Z为朝上(而不是很流行的Y轴朝上).一个系统是左手坐标系还是右手, 完全没关系.
唯一决定一个坐标系手性的是左(或右)向量相对于上和前向量的方向,和它们叫什么名字无关.
手性和命名规则是两个不同的事情.
坐标系的命名规则对于渲染和其他3D软件是很重要的.目前,行业标准倾向于右手XYZ坐标系,即X朝右,Y朝上,Z朝外(与视线相反).程序和3D API例如Maya和Opengl使用右手坐标系.然而DirectX,pbrt和PRMan使用左手坐标系.注意Maya和PRMan使用的坐标系朝上的都是Y周, 然后朝前叫Z周.重要的是这意味着Z轴的坐标如果是3, 另一个系统里Z坐标就是-3.因此,当我们需要导入到渲染器的时候, 我们需要翻转一个物体的Z坐标.选择坐标系统的手性在旋转和叉乘的时候也很重要.我们下面的章节会谈论更多.其实把一个东西从一个坐标变为另一个是挺简单的(但很难受).需要做的就是把点的坐标以(1,1,-1)缩放到相机世界的阵列.
目前为止,你只要知道Scratchapixel使用的是右手坐标系就行了, 因为这个与Maya兼容,并且好像已经成了一个事实上的行业标准(我们希望所有人都用这套规范).

世界坐标系

我们已经学到点和向量的坐标与笛卡尔坐标系之间的关联.我们也结识了我们可以创建无数个坐标系统. 然而大部分3D软件,每一个不同的类型都对应了一个主坐标系,称之为世界坐标系.它定义了原点还有主要的XYZ轴.世界坐标系在渲染管线也许是最重要的坐标系.这包括了物体,本地(在着色器中使用),相机和屏幕坐标系.我们后面会解释所有的概念.

我们需要记得的事情

我们知道大部分读者不需要解释这些概念.但是我们强调的是整套教程里正确的命名而不是基础的集合知识.这一张,重要的术语有坐标,轴,笛卡尔坐标系.我们也介绍了线性操作的意思(缩放和平移).最重要的概念是点坐标与坐标系的关系,可以定义无数个坐标系,点可以在这些坐标系中有不同的坐标.分辨出你在使用的是什么坐标系统(不论是你的程序还是在你要用来烘焙的API中)是左手还是右手坐标系,也是很重要的.不要把左右手坐标系的概念和命名概念搞混了.

点和向量的数学操作

我们已经解释过笛卡尔坐标系还有坐标和坐标系的关系.我们可以看看点和向量的常规操作是如何的.这个部分会涵盖大部分你在3D软件和渲染器中看到的函数.

向量在C++里的类

我们来定义一个C++中的向量类:

  1. template<typename T>
  2. class Vec3
  3. {
  4. public:
  5. Vec3(): x(T(0)), y(T(0)),z(T(0)){}
  6. Vec3(const T &xx) : x(xx), y(xx), z(xx) {}
  7. Vec3(T xx, T yy, T zz) : x(xx), y(yy, z(zz){}
  8. T x, y, z;
  9. }

向量长度

我们之前提到,一个向量的可以表示箭头从开始到结束.向量本身不止意味着从A到B的方向,同时也可以得到A到B的距离.这个长度可以很简单的通过公式计算出来:
||V||= V.xV.x+V.yV.y+V.zV.z\sqrt{V.xV.x+V.yV.y+V.z*V.z}

在数学上,双竖线代表的是一个向量的长度.向量长度有时候也叫定额,或者模数(图1)

图1:向量的长度是被双竖线标记的.一个归一化的向量长度是1

  1. template<template T>
  2. class Vec3
  3. {
  4. public:
  5. ...
  6. T length()
  7. {
  8. return sqrt(x * x + y * y + z * z);
  9. }
  10. ...
  11. };
  12. // ... 或者你可以通过函数来返回长度, 不需要用到类
  13. template<template T>
  14. T length(const Vec3<T> &v)
  15. { return sqrt(v.x * v.x + v.y * v.y + v.z * v.z); }

注意三维笛卡尔坐标系的三个轴是由三个单位长度向量组成的.

归一化向量

我们有时候会用normalise或者normalize.不过在程序世界里还是用的美国拼写,normalize.
一个归一化的向量,是一个长度为1的向量.这个向量被称作单位向量.归一化一个向量是很简单的.我们首先计算向量的长度,然后用这个向量的坐标去除以这个长度.数学的表达式是:
V^\hat{V} = VV\frac{V}{||V||}
注意C++的实现可以优化.首先我们只需要归一化长度大于零的向量.我们会计算一个临时变量,作为向量长度的倒数,然后与向量的每一个坐标相乘.你也许知道,乘法在一个程序里比除法要省资源.这个优化很重要,因为归一化向量在渲染中是一个非常常见的操作,也许会用到上百万个向量.在这个级别, 任何可能的优化都会对渲染时间产生重大影响.需要注意的是,有些编译器会默默帮你优化.不过你最好自己把这个工作做到位.

  1. template<typename T>
  2. class Vec3
  3. {
  4. public:
  5. ...
  6. // 作为Vec3类的一个方法
  7. Vec3<T>& normalize()
  8. {
  9. T len = length();
  10. if (len>0){
  11. T invLen = 1 / len;
  12. x *= invLen, y *= invLen, z *= invLen;
  13. }
  14. return *this
  15. }
  16. ...
  17. }
  18. // 或者作为一个通用函数
  19. template<typename T>
  20. void normalize(Vec3<T> &v)
  21. {
  22. T len2 = v.x * v.x + v.y * v.y + v.z * v.z;
  23. //避免除以0
  24. if (len2 > 0){
  25. T invLen = 1 / sqrt(len2);
  26. x *= invLen, y *= invLen, z *= invLen;
  27. }
  28. }

数学上,你也会使用norm来定义一个赋予向量长度的函数.刚刚我们描述的函数叫做欧几里得范数(Euclidean norm)

图2: 点乘可以看成是A向量投射到B向量上.如果两个向量的长度都是单位向量,则点乘的结果就是AB向量夹角的余弦值.

点乘

点乘,或者说标量积需要两个向量A和B,可以认为是一个向量投影到另一个向量的乘积.点乘的结果是一个实数(浮点或双精).点乘的书写是靠一个点作为标记: A\cdotpB(也有时候写作).点乘由A和B的对应元素相乘之后的和组成.
比如说如果是一个三维向量, 公式是这样:
A\cdotpB = A.x*B.x + A.y*B.y + A.z*B.z
注意这跟我们求长度有一点类似.如果我们用 AB\sqrt{A \cdotp B} 计算的两个矢量是完全相等的(方向和长度都相同), 则我们获取的是这个向量的长度.我们可以写:
||V|| = V \cdotp V

  1. template<typename T>
  2. class Vec3
  3. {
  4. public:
  5. ...
  6. T dot(const Vec3<T> &v) const
  7. {
  8. return x * v.x + y * v.y + z * v.z;
  9. }
  10. Vec3<T>& normalize()
  11. {
  12. T len2 = dot(*this);
  13. if (len2>0){
  14. T invLen = 1 / sqrt(len2);
  15. x *= invLen, y *= invLen, z *= invLen;
  16. }
  17. return *this;
  18. }
  19. ...
  20. }

两个向量的点乘非常重要,而且常见,因为这个操作的结果与这两个向量的夹角的余弦有关.图2就展示了几何意义上的点乘.在这个例子里面A在B向量上做了投射.

  • 如果B是一个单位向量,则A$$\cdotp$$B等于||A||cos(θ), 如果AB朝向相反, A向量的投射模数会是负值, 这就是所谓的A对于B的标量投射.
  • 如果A和B都不是单位向量,则可以写作A$$\cdotp$$B/||B||,因为B的单位向量是B/||B||
  • 如果两个向量都是单位向量, 则我们可以利用点乘计算出arc cosine的角度: θ = cos-1(A·B/||A||||B||) or θ = cos-1(A^·B^) ( 数学上, cos-1是cos函数的逆运算,计算机领域是acos() )

    点乘是一个非常有用的3D操作.他可以用于很多情况作为正交检测. 当两个向量相互垂直的时候, 它们的点乘为0,当两个向量方向相反的时候, 它们的点乘为-1. 当它们方向相同的时候点乘为1.这也被经常使用于找出两个向量之间的夹角以及向量与坐标系之间的夹角(这相当有用, 尤其是在向量坐标被转换到球面坐标的时候,这将在trigonometric functions章节讲到)

叉乘

叉乘也是两个向量的操作, 不过和点乘返回一个数字不同, 叉乘返回一个向量. 而且叉乘的向量结果是垂直于输入的两个向量的(如图3所示).叉乘的表达式是:
C = A × B
为了得到叉乘结果,我们需要用到下面公式:
CX = AY * BZ - AZ * BY
CY = AZ * BX - AX * BZ
CZ = AX * BY - AY * BX
叉乘的结果是一个与之前两个向量相垂直的向量.一个叉乘是由×号标记的.两个向量A和B定义了一个平面,然后所得结果C向量是垂直于该平面的.向量A和B不一定要相互垂直,但是当他们会使得AB和C形成一个笛卡尔坐标系.这个在将来需要生成坐标系的时候会很有用.我们会在Creating a Local Coordinate System中进行讲解.

  1. template<typename T>
  2. Vec3<T>
  3. {
  4. public:
  5. ...
  6. // as a method of the class ...
  7. Vec3<T> cross(const Vec3<T> &v) const
  8. {
  9. return Vec3<T>
  10. ( y*v.z - z*v.y,
  11. z*v.x - x*v.z,
  12. x*v.y - y*v.x);
  13. }
  14. ...
  15. };
  16. //或者用一个工具函数
  17. template<typename T>
  18. Vec3<T> cross(const Vec3<T> &a, const Vec3<T> &b)
  19. {
  20. return Vec3<T>(
  21. a.y*b.z - a.z*b.y,
  22. a.z*b.x - a.x*b.z,
  23. a.x*b.y - a.y*b.x);
  24. };

如果你需要一个记忆这个公式的方法,这个技巧就是问一个问题”why z?”, y 和z是用来计算向量C的x轴的. 专业点说, 从逻辑思考也很容易重建这个公式. 你知道叉乘是一个向量与另外两个向量都垂直,所以如果A和B是一个笛卡尔坐标系的X和Y轴,那么C就是(0,0,1).你能得到Cz=1的唯一情况是当Cz=A.x * B.y - A.y * B.x. 这样,你可以推算出其他用于计算Cx和Cy的坐标.最后,最简单的方法就是写一个下面这样的叉乘表.

一个向量表示在纵行表格中, 为了计算一个结果向量的一个分坐标,我们需要另外两个向量中的另外两个坐标.
重要的是,叉乘计算的结果跟顺序有关.如果我们看前例,你会发现A×B和B×A结果是不同的:
A×B = (1,0,0)×(0,1,0) = (0,0,1)
然而
B×A = (0,1,0)×(1,0,0) = (0,0,-1)
我们说叉乘是无交换律的(anticommutative)(交换两个参数的位置,结果会发生变化): A×B = C, B×A = -C. 请记住从之前的章节两个向量被用于定义一个坐标系, 第三个向量可以指向平面的的另一侧.我们之前也说过一个通过手性来描述坐标系的一个技巧.当你计算了两个向量的一个叉乘,你永远会得到一个特定的解.例如之前A(1,0,0)×B(0,1,0) C永远等于(0,0,1), 那你可能会说为什么我们要考虑手性呢.因为如果结果永远是一样的, 但是你画出的向量方向就要根据手性来决定.你可以用这个技巧确定叉乘结果向量的方向.在右手坐标系中,如果你食指指向A(切线),中指指向B(副法线),则大拇指则是C(法线)的方向.如果你把这个技巧用在左手上,你会发现大拇指朝向了相反的方向.不过你要记住, 这个只是个表达问题.

在数学上, 叉乘的结果被称作伪矢量.叉乘的顺序在需要计算面法线的时候很重要.基于这个顺序,法线的结果可能是朝里(inward-pointing normal)或者朝外(outward-pointing normal).你可以在这一章找到更多的信息Creating an Orientation Matrix

向量/点的加减
另外一个在点上的数学操作非常的直接.一个向量乘以一个标量或者其他的向量,会得到一个点.我们还可以把两个向量进行加减除的操作等等.注意有些3D应用接口会区别点,法线和向量.技术上来说他们有一些细微的差别,以至于说确实可以写三个C++的类来表示.例如:法线是不能像点或者向量那样变换的(一会就会学到),两个点的减法会得到一个向量,一个向量加上另一个向量或者一个点会得到一个点,等等.然而从实际上来说,我们会法线把这三个写成三个不同的向量是不值得的,搞复杂了.类似于如今已成为行业标准的OpenEXR,我们选择用一个类来表示所有的类型,我们把它叫做Vec3. 因此我们不会区别法线,向量,还有点(从程序上来说).我们仅仅需要在当向量表示不同类型的时候,记住管理异常(很少),但是下面声明的类型Vec3,应该用不同的方式处理.这里是一些表示了最常见操作的C++的代码(你会在章末找到完整的源代码)
template
class Vec3
{
public:

Vec3 operator + (const Vec3 &v) const
{return Vec3(x+v.x, y+v.y, z+v.z);}
Vec3 operator - (const Vec3 &v) const
{return Vec3(x-v.x, y-v.y, z-v.z);}
Vec3 operator (const T &r) const
{return Vec3(x
r, yr, zr);}

}

阵列

开始解释阵列的有趣之处之前,我们首先会告诉你,所有的物体如果都是以世界坐标原点为参照,会非常的局限.
阵列对于移动物体,灯光和摄像机非常重要,有了它,你就可以随心所欲地创建你的图像.我们的基础渲染器很难渲染出好的作品,如果我们忽视这些.你会在开发你的3D渲染器的过程中慢慢发掘阵列的重要性.你不会忽视他们太久的,我们马上就来学吧!

介绍阵列:变换本应轻而易举!

阵列一点不复杂,大家怕它的主要原因是他们没有完全理解阵列的意义和使用方法.它们在图形管线中有重要的作用,你会进场在3D软件的代码中到它们的存在.
之前的章节我们提到位移和旋转点是通过线性操作完成的.例如我们展示了如何给点坐标加上一个值. 我们也提到了,你可以通过一个三角函数旋转一个矢量.现在,简单来说,一个阵列是一种合并所有这些变换(缩放,旋转,位移),把它们变成一个单独的结构体.用阵列乘以一个点或者向量会给我们一个变换过的点或向量.合并这些变换意味着任何变换都是可以通过它表达.我们可以创建一个阵列,用来沿着X轴旋转一个点,沿着Z轴乘以2(1,1,2)然后把它位移(-2,3,1).我们可以通过线性变换来实施这个操作,但是这会需要很多代码.

  1. Vec3f translate(Vec3f P, Vec3f translateValue){...}
  2. Vec3f scale(Vec3f P, Vec3f scaleValue){...}
  3. Vec3f rotate(Vec3f P, Vec3f axis, float angle){...}
  4. ...
  5. Vec3f P = Vec3f(1,1,1);
  6. Vec3f translateVal(-1,2,4);
  7. Vec3f scaleVal(1,1,2);
  8. Vec3f axis(1,0,0);
  9. float angle = 90;
  10. Vec3f Pt;
  11. Pt = translate(P,translateVal); // 位移P
  12. Pt = scale(Pt,scaleVal); // 缩放点
  13. Pt = rotateValue(Pt,axis,angle); //旋转点

正如你所见,这些代码不是很紧凑.但是如果我们使用阵列,我们可以直接这么写:
Matrix4f M(…); // 设置含有位移,旋转,缩放的阵列
Vec3f P = Vec3f(1,1,1);
Vec3f Ptransformed = P * M; //一切操作一次解决

变换P可以通过用P乘以一个阵列来达到.我们在这里展示的是阵列在图形管线中的使用方法以及它的便捷.在那个特别的例子中,我们已经告诉过你它们可以合并三种基本几何变换,并且非常紧凑.现在我们要做的,就是向你解释他是怎么运作以及为何要如此.

啥是阵列?

阵列到底是什么?我们先不要管抽象的数学定义,先看一个真实的阵列例子.一旦我们看到真实的例子,会更容易理解.如果你已经读过一些CG书,你也许会法线阵列在一些地方都提到过,然后他们是二维的数组.为了定义一个二维数组,我们使用基本注记法m×n用m和n来表示数组的行列.行是水平上的数组长度,列是纵向的数组长度.这里是一个简单的[3×5]的数组:

我们现在要来标记阵列中的具体元素, 阵列中的常数(coefficients)(你可能会遇到entry,element但是coefficients会经常在CG中使用)而且我们经常会使用下标 i,j来指出阵列的系数.
阵列通常是写作大写(M,A,Bdengdeng ).
Mij中i代表行,j代表列.
我们会对阵列进行大量的简化,比如在CG中我们大部分用的都是方形阵列,就是说这些阵列里的m和n是相等的.通常来说,CG里用的都是3×3或者4×4矩阵.更常用的术语是矩阵(square matrices),它是由一个行列相等的矩阵组成的,这就是一个简化,因为我们排除了行列不等的阵列.

这是一个C++中实现的简单4×4矩阵(注意我们使用这个模板是为了以防可能用到浮点或者双精度)

  1. template<typename T>
  2. class Matrix44
  3. {
  4. public:
  5. Matrix44(){}
  6. const T* operator [] (uint8_t i) const{ return m[i];}
  7. T* operator [] (uint8_t i) {return m[i];}
  8. //初始化矩阵中的系数
  9. T m[4][4] = {{1,0,0,0},{0,1,0,0},{0,0,1,0},{0,0,0,1}};
  10. };
  11. typedef Matrix44<float> Matrix44f;

这是这个矩阵类里面的操作符:

  1. const T* operator [] (uint8_t i) const { return m[i]; }
  2. T* operator [] (uint8_i) { return m[i]; }

后时候也称作获取操作符(operator or accessor).他们是用来获取方阵中的系数的。比如说你可以用这样的方法获取系数:

  1. Matrix44f mat;
  2. mat.m[0][3] = 1.f;

但是使用了获取操作符,你可以这样写:

  1. mat[0][3] = 1.f;

阵列相乘

阵列可以和另一个阵列相乘,这是点和向量的核心操作。他们乘出的阵列结果被称作阵列积,两个矩阵的阵列积是另一个阵列:


图2: M3矩阵由M1M2组成,并且可以达到把A直接转换到C的效果。
你可以看到M4和M5也可以通过相乘来得到M3。

有一个法则很重要,不过一般在CG中不需要太注意。就是两个矩阵相乘,必须保证M1的列和M2中的行数相等。换句话说,如果两个矩阵如果书写形式是p×m 和 m×p,那么这两个就可以相乘,如果是p×m和n×p则不行。

我们接下来可以看看具体怎么做操作。
数列在C++中从0开始,为了计算M3(1,2)我们需要选择M1的第二行,以及M2的第三列。这样就给了我们两组4个数字的序列,然后我们可以把他们相乘之后求和。

我们看看这个在C++中如何实现。先定义一个2维4×4阵列,这里是一个可以把两个阵列相乘的功能函数:

  1. Matrix44 operator * (const Matrix44& rhs) const
  2. {
  3. Matrix44 mult:;
  4. for (uint8_t i=0; i < 4; ++i){
  5. for (uint8_t j = 0; j < 4; ++j){
  6. mult[i][j] = m[i][0] * rhs[0][j]+
  7. m[i][1] * rhs[1][j]+
  8. m[i][2] * rhs[2][j]+
  9. m[i][3] * rhs[3][j];
  10. }
  11. }
  12. return mult;
  13. }

应该可以发现,矩阵相乘的顺序也是没有交换律的。

总结

我们还没有解释为什么矩阵这样是可行的,下一章就会讲解。这一章你需要记得矩阵是2维数组阵列。矩阵维度是由m×n决定的,只有左参数的行与右参数的列数相等才可以。结果矩阵会合并两个矩阵的变换。如果M1是把一个点从A变换到B,M2是把一个点从B变换到C,则M3会直接把A变换到C。最后我们学习了如何计算一个矩阵的乘积的每一个参数。需要注意的是矩阵相乘是没有交换律的。实际上呀意味着我们需要小心如何去定义矩阵相乘的顺序。

矩阵工作原理:第一部分

我们可以把点和矢量写作一个一行三列的矩阵,于是可以和任何一个三行N列的矩阵相乘。比如说这里是一个[1×3][3×4]的矩阵:

这里有两个我们需要记住的东西。一个是点乘以矩阵会变换到一个新的位置。点的矩阵乘积结果必然是一个点。如果不是,我们不可能会把它当作一个很方便的方法。第二个是为了得到一个点,我们必须保证矩阵是3×3的,这样才能得到一个1×3的矩阵。

CG中,我们通常会用4×4的矩阵而不是3×3,以后会解释,不过目前先用3×3.这一节我会用一些伪代码来表明为什么可以用一个点去乘一个3×3矩阵来得到一个PT.
Ptransformed.x = P.x
c00 + P.y c10 + P.z c20
Ptransformed.y = P.x c01 + P.y c11 + P.z c21
Ptransformed.z = P.x
c02 + P.y c12 + P.z c22

单位矩阵

Identity matrix 或者说 unit matrix是一个除了对角线的参数为1以外,全部都是0的矩阵。

显而易见,P点乘以一个单位矩阵会得到P。

缩放矩阵

如果你看一下点矩阵的乘积,你可以看到点的坐标xyz是分别被R00,R11,R22所乘。当这些参数设为1的时候,我们得到的是单位矩阵。然而这些参数如果不是1,他们会对点的坐标产生一个缩放。这个缩放矩阵可以如下书写:

实数SX,SY,SZ是缩放
系数。

  1. Ptransformed.x = P.x * Sx + P.y * 0 + P.z * 0 = P.x * Sx
  2. Ptransformed.y = P.x * 0 + P.y * Sy + P.z * 0 = P.y * Sy
  3. Ptransformed.z = P.x * 0 + P.y * 0 + P.z * Sz = P.z * Sz

比如说,如果P坐标是(1,2,3)。我们的缩放系数是Sx=1,Sy=2,Sz=3,则P的结果是(1,4,9)
记住,如果某一个参数是复数,则点在该轴上就会翻转。

旋转矩阵

这一节我们会谈论笛卡尔坐标系中的旋转矩阵。要做到这一点我们需要三角函数。

我们现在先定义一个P点坐标为(1,0,0)。我们先忽略一下Z轴,只考虑XY的情况。我们想要做的是通过一个旋转的方式把点从P转换到Pt。Pt的坐标是(0,1,0)。你可以看到的是,在图1,实际上这就是沿着Z轴旋转了90度的结果。
逆时针。我们假设我们有一个R阵列。
Pt.x = Pt.x R00 + P.y R10 + P.z R20
Pt.y = Py.x
R01 + P.y R11 + P.z R21
Pt.z = Pt.x R02 + P.y R12 + P.z R22
正如我们所说,我们暂时不关心Pt.z,让我们先考虑xy上坐标的相对变换。x坐标由1变为0。如果我们看第一条用于计算Pt的代码,意味着R00需要是0.考虑到P.y和P.z本身就是0,我们暂时不需要管R10和R20到底是多少。从P到Pt,y轴从0变为1,因此R01必然是1.
我们来写一下Rz的矩阵:

不要担心你暂时还不懂这些参数的值。你可以看到
P = (1,0,0) 的时候你会得到 Pt(0,1,0).
Pt.x = P.x
0 + P.y1 + P.z0
Pt.y = P.x1 + P.y0 + P.z0
Pt.z = P.x
0 + P.y0 + P.z1
这里三角函数就很方便了。
如果我们看一个点,就可以发现,当θ=π/2的时候x=cos(θ)=0, y=sin(θ)=1.
所以你可以这么写

如果你只想旋转45度,你会得到(0.7071,0.7071)图2.因此你会发现一个通用矩阵:

我们知道,P通过R在现有的形式中可以成功转换为Pt,但是如果P是(0,1,0)然后Pt是(1,0,0)这样一个顺时针的旋转.会如何呢?

好像不对,轴向反了.
所以,我们的R01应该要加一个负数.

我们知道如果沿着Z轴旋转,点会留在xy平面(所以,我们不应该影响Pt的z坐标).当我们看P到Pt的代码,很容易就能发现第三行和列没有影响z的值.前两个值是R20 R21是0,R22是1.所以没有改变.我们可以总结一下沿着z轴旋转是有着下面的形式:

为了找到沿着x或者y轴旋转的矩阵,我们可以顺着刚才的逻辑找到.

谨记你乘的是矩阵列中的数字.你可以使用一个记忆方法来记住旋转的角度是正的时候是往哪个方向转.右手坐标系来说逆时针是正,而左手坐标系则相反.

图5 各轴旋转的正向

图6 右手法则

合并旋转矩阵

前面我们有学到,矩阵相乘可以合并变换.我们现在已经学会如何沿着单根轴旋转,所以我们可以把三个轴向上的旋转都合并.比如我们如果想合并两个旋转:
Rxy = Rx * Ry
注意顺序是很重要的.如果你先旋转x轴,再旋转y轴.假如相反,你会得到另一个结果.大部分3D软件例如maya 3dsmax softimage,houdini等,你可以指定旋转的顺序.

平移矩阵

要移动点,我们需要用到4×4的矩阵.我们会解释如何使用.

旋转任意一各轴

你可以写一段代码来在任意一各轴上旋转点或者向量.然而这对于写一个基本的光线追踪器是没有必要的.我们以后的课程中会继续深入这个话题.

矩阵工作原理:第二部分

笛卡尔坐标系与阵列的关系

如果你预设一个点Px的坐标是(1,0,0)并且想把它以z为轴顺时针旋转10度,你会得到的新坐标是?使用我们在旋转矩阵上学到的东西,我们明白新坐标可以通过三角函数简单获取.x的新坐标是cos(-10)而y的坐标是sin(-10)(别忘了三角函数的角度应该用弧度表达).如果我们对Py(0,1,0)做了同样的操作.则变换后的x坐标将会是-sin(-10)且它的y坐标将会是cos(-10).你可以观察到第一行旋转矩阵与Px旋转之后的新坐标相等.同样的现象第二行矩阵当中也存在于我们在计算Py的新坐标时.

如你所见,我们沿z轴旋转的时候,Px的新坐标就是矩阵的第一行.这个概念以后你会学到,你可以用这个方法来创建自己想要的旋转矩阵.

这是CG中的一个常见技巧.如果你理解了矩阵,它就不神秘了,它只是一种保存坐标和坐标系的方式,它的横向数列是坐标系的轴.

正交矩阵

实际上,我们刚描述的矩阵类型都被称作线性代数里面的正交矩阵.一个正交矩阵是一个矩形阵列,这个阵列的行和列都代表了单位向量.我们之前提到了每一行都是一个笛卡尔坐标.如果这个矩阵是一个旋转矩阵或者多个旋转矩阵的合并, 则每一行都代表了一个单位长度的轴(因为每一行都是由余弦或者正弦函数组成,会让点沿着一个单位圆弧旋转),而且会沿着这个轴或者任意轴旋转.正交矩阵有一些非常有趣而有用的特性.在CG中,调换一个正交矩阵顺序等于这个矩阵的逆矩阵.假设Q是一个正交矩阵,我们可以这样书写:
QT = Q-1, 需要满足的条件是QQT = I, 其中I是单位矩阵.

仿射变换

仿射变换就是能够保留直线的矩阵。平移旋转和剪切矩阵都是仿射变换。另一种变换叫投射变换。这种变换不一定会保留直线。

总结

你在这章学到了矩阵的概念和理解方式。

文档更新时间: 2018-09-11 00:34   作者:刘电