23.1 简介
对于任何WebGL应用,相机的实现都是需要着重考虑的。相机必需执行一系列的矩阵变换来把世界和几何体移动到合适的位置。但是,原生WebGL并没有提供可以表示相机的结构,这就要求开发者自己去实现。这项工作是有挑战的,特别是对于没有计算机图形学经验的JavaScript开发人员。相机操作背后所蕴含的数学知识,对于这些开发人员而言并不友好,而这往往造成了开发人员的困惑以及bug的产生。此外,原生JavaScript语言并不支持这些数学计算,因此就需要做一些额外的工作:或者自己编写代码,或者是引用第三方库。尽管一些开源的WebGL库都对这些问题进行了解决,它们都以特定的方式实现了自己的相机,但这也未必能满足我们手头项目的具体需求。
本章,我们将采用自底向上的方式来讲解如何用JavaScript为WebGL应用设计相机。首先我们将介绍与计算机图形学相关的一些矩阵,然后我们将深入到相机的具体实现细节:首先我们将讨论一些有关线性代数运算的第三方库,基于这些库,我们可以用JavaScript来编写相机的操作(平移、旋转等);然后我们将讨论基本的设计原则,这部分我们将结合一些现有的WebGL库来分析。由于封装性在JavaScript这种弱类型语言中并没有严格实施,这就使得我们可以设计一种相机,其各种属性均可在相机实例外边被自由获取并被修改(我们称之为透明相机)。在这一部分,我们将探索这种设计原则的利弊以及这对于JavaScript开发人员而言又意味着什么,同时我们还将与另外一种设计原则做对比:在这种原则下相机的设计严格遵循封装性原则(我们称之为负责任的相机)。
接着,我们将介绍“可恢复相机”的设计和实现细节。
最后我们将对现有的一些WebGL第三方库的相机做一个总结,并对影响未来WebGL应用中相机的设计和角色的一些前沿性的研究做一个概述。
23.2 变换场景几何
为了创建一个合适的视角,就需要对几何进行旋转和平移等操作,而这是通过模型-视图变换(Q)来实现的。所谓模型-视图变换实际是由两个基本变换所组成的:模型变换()将顶点从对象坐标系转换至世界坐标系,视图变换()则将顶点从世界坐标系转换至眼坐标系。模型-视图变换会作用于场景中的每个顶点,其公式如下:
( 23.1 )
模型-视图变换属于一种特殊的运算,我们称之为仿射变换。这类变换由一系列线性运算所组成,包括旋转、缩放及平移。
23.2.1 模型-视图变换的矩阵表示
模型-视图变换在齐次坐标系下可被表示为一个 4 * 4 矩阵。其中左上角的 3 * 3 矩阵表示旋转,它可以拆分为三个列向量:,分别代表旋转之后新坐标系的三个坐标轴。模型-视图矩阵的最后一列表示平移向量(T),它的三个值分别代表了世界坐标系原点与模式-视图变换后的新坐标系原点之间的位移在新坐标系下的三个分量(如图23.1所示)。
23.3 构建相机变换
既然相机将以和模型-视图变换类似的方式来实现对场景的平移和旋转,那我们就可以肯定相机的这些操作也是仿射变换。下面让我们对比一下已知的模型-视图变换和我们所期望的相机变换之间的异同。
23.3.1 旋转部分
模型-视图变换通过旋转世界使得场景的物体在屏幕上可见。如图23.2a所示,一个圆柱立在虚拟世界的中心,然后世界被沿X轴旋转了角度,这就使得我们可以看到圆柱的上表面。而与之相反,如图23.2b所示,虚拟世界没有动,而是虚拟相机沿相同的轴旋转了度,结果在屏幕上得到了与图23.2a相同的场景。
23.3.2 平移部分
有了模型-视图变换,我们就可以通过平移来观察当前不在屏幕里边的物体,而这一功能在大多数APP中都是必需的,因为世界不一定能完全落在单一的视口内。在这种情况下,我们就需要对物体进行平移,从而让它落在视口中心,我们就能观察到该物体了,如图23.3a所示。与此同时,我们也可以通过平移虚拟相机来达到相同的目的:如图23.3b所示,世界保持不动,而是改变相机的位置使其靠近物体。
23.3.3 相机矩阵
从数学上来讲,模型-视图矩阵(Q)与相机矩阵(C)之间互为逆矩阵:
(23.2)
前边我们提到,旋转矩阵的三个列向量分别对应眼坐标系的三个坐标轴,因此,这三个列向量是彼此正交的,由此可知旋转矩阵是正交矩阵,它的逆矩阵就是它的转置矩阵,即。而相机矩阵的平移分量与模型-视图矩阵的平移分量之间并不是简单的互为逆矩阵的关系。模型-视图矩阵的平移分量T实际上描述了旋转前后新旧坐标系原点之间的距离在新坐标系下的三个分量值。因此为了正确得到在相机坐标系下的平移分量,我们就必须用左乘,如式23.2所示。
既然相机矩阵是模型-视图矩阵的逆矩阵,那么相机矩阵所描述的也是一种仿射变换。因此,它的结构与模型-视图矩阵的结构是相同的:左上角的 3 * 3 矩阵描述了相机坐标系的三个坐标轴:,最后一列为平移向量,其结构如图23.4所示。
相机坐标系统可能还会包含一个缩放因子,此时相机矩阵最后一列的最后一个元素将不再是1。但这对于旋转本身并没有影响,坐标系统也还是有效的(正交的)。
23.3.4 投影变换
在获得合适的视角之后,接下来要执行的就是投影变换。投影变换是由一个平截头体来决定的,其定义了哪一块空间可以在屏幕上显示。该平截头体的几何形状决定了投影的类型:正投影和平行投影。
基于不同的场景,在WebGL应用中,投影变换可以是相机的一部分,也可以不是。比如,有些实现就没有把投影变换放在相机中,而是作为场景、视图或渲染组件的一部分。出现上述情况的原因是通常情况下投影变换在WebGL应用的整个生命周期内是不变的,因此它可以放在别的地方。也有人认为应该把投影变换放在相机中作为其一个属性而存在,持这种观点的人认为决定投影类型的平截头体是有相机本身的一些属性定义的(如:焦距、焦点等)。在本章剩余部分,我们假设WebGL应用中的相机包含两个矩阵:相机变换矩阵和投影变换矩阵。
关于投影变换与相机自身属性间关系的一个例子:
http://ksimek.github.io/perspective_camera_toy.html.
通过下边这个例子可以观察平截头体属性对场景显示的影响:
http://www.realtimerendering.com/udacity/transforms.html
23.4 WebGL应用中的相机
有了相机矩阵,我就可以在虚拟世界中移动并从任意方位观察物体。移动相机要比沿相反方形移动世界直观得多。尽管如此,虚拟世界中三维物体的运动是与视点无关的。这些变换需要与模型-视图矩阵相乘。总而言之,对于渲染过程的每一帧,我们都需要做如下三件事情:
a. 根据用户交互事件(鼠标、键盘、手势等)更新相机矩阵;
b. 计算相机矩阵的逆矩阵,也就是模型-视图矩阵;
c. 使用模型-视图变换来更新几何体并用投影变换来定义平截头体;
最后一步通常是在顶点着色器中执行的,模型-视图矩阵和投影矩阵会以Mat4 uniforms的形式被传入顶点着色器中以供计算使用。
通常的做法是用局部变换矩阵(例如单部件的平移、旋转等)右乘模型-视图矩阵,然后再讲计算结构传入顶点着色器。这样,对于每个移动的物体,上述计算只需计算一次。而如果将两个矩阵分别传入顶点着色器,则对每个顶点都需要执行一次矩阵相乘运算。特别是对于负责的几何模型,这样一个优化的效果将更加明显。
23.4.1 在JavaScript中操作矩阵
在开始研究相机的具体实现之前,我们得首先解决矩阵运算的问题。因为相机的许多操作最后都归结为 3 * 3 或 4 * 4 的矩阵运算,而原生JavaScript语言是不支持矩阵运算的。
那么我们是自己写呢还是使用第三方库?
我们先来看一下现有的一些JavaScript矩阵运算库。这些库都可以用来执行相机操作相关的矩阵运算:
gl-matrix是一个非常完整的库,它甚至提供了lookAt、perspectiveFromFieldOfView等接口来简化相机的实现。
mjs也是一个很好的矩阵运算库,且针对WebGL做了优化,它提供可以直接操作矩阵中旋转分量和平移分量的接口(例如:M4x4.inverseTo3x3)。
Sylvester拥有丰富的同义名接口用于矩阵及向量运算,例如矩阵相乘可以写作:,但是它缺少一些基本的运算:如平移、缩放等。
ewgl-matrices正在开发当中,它是为了解决Sylvester运算慢的问题而开发的。
Closure是谷歌(google)为Web应用开发而创建的一个工具集。Closure在命名空间goog.vec中提供了一系列用于矩阵和向量运算的类,这些类不仅高效而且有良好的文档。
Numerics是一个科学计算库,它提供了各种矩阵分解算法(如SVD、LU分解等)、常微分方程求解器、无约束优化算法等。尽管不是针对WebGL而设计,但是它声称自己胜过 Closure。
另一方面,一些WebGL库向three.js、、Babylon.js等都开发了各自的矩阵和向量运算的类。
有关JavaScript矩阵运算库性能方面的对比参见:https://github.com/stepheneb/webgl-matrix-benchmarks。由对比结果可以看到在MacBook Pro(OS X 10.9.5, 2.4 GHz Intel Core i7,8 GB 1333 MHz DDR3)上,性能排在前三位的分别是TDL、Closure以及gl-matrix。TDL是一个底层库,它更关注渲染速度而不是易用性。如果你只是想着手开发你的第一个WebGL应用,那么我们推荐你使用gl-matrix或Closure。
23.4.2 相机类型
在选择好矩阵运算库之后,接下来我们就得决定导航的类型。这将对最终产品的易用性产生很大的影响。同时如何选择导航类型是由用户想要完成的任务决定的,比如移动到头顶、观察正前方以及漫游、飞行等都是用户在观察虚拟世界时希望去体验的功能。
表23.1列出了一些常用的基于导航类型的相机。这个表不是用来对各种相机进行比较的,而是为了方便我们在适当的场景下选用合适的相机。
除非你开发的应用需要模型特殊的导航策略,否则轨道相机(Orbiting Camera)和漫游相机(Exploring Camera)已经可以满足大多数的需求。同时需要注意的是,在同一个应用中可能会包含多种类型的相机。比如,我们可能希望从世界视图视角切换得到第一人称视角。
23.5 为WebGL应用设计相机
有了矩阵运算库并且决定了导航策略,接下来我们就可以着手设计相机了。本小结我们将回答以下问题:
我们该如何定义一个相机?
相机的属性应该是公有的还是私有的?
相机需要提供哪些操作?
如何始终保持一致的相机状态?
本小结,我们提供了两种主要的相机设计方法:一种采用传统的面向对象的设计方法,这种方法设计的相机可以始终保持一致的内部状态,它的属性是私有的,同时提供各种接口以供外部调用。另外一种采用JavaScript风格的设计方法,这种方法设计的相机实际上是一个包含一系列属性的对象(Object),这些属性可以再代码的任意位置被修改。需要指出的是,这两种设计方法并不是互斥的,多数WebGL第三方库会综合采用两种方法来设计相机。
23.5.1 负责任的相机
让我们从一个简单的想法开始:一个相机实例包含两个私有属性:cameraTransform和projectionTransform,以及两个公有属性:getModelViewTransform()和getProjectionTransform()(如图23.5a所示)。在此设计中,相机实体承担更新矩阵的责任,以响应应用程序中的事件,例如用户输入或应用程序逻辑生成的事件。因此它需要提供相关的接口去处理这些事件。基于这样一个想法,我们还需要给相机实体添加一些公有接口用来操作内部矩阵(如图23.5b所示)。
23.5.1.1 优缺点
负责任的相机遵循了面向对象程序设计中的封装性原则:属性及操作属性的方法必须属于同一个实例。此外,相机的属性都是私有的,且只能通过相机实例提供的公有接口来修改。随着应用复杂性的提高,可以创建新的函数来实现新的行为,如果这个行为表示对相机操作的一个重要改变,则我们可以通过派生来创建新的相机类,如图23.6所示。在任何情况下,我们都需要为负责更新相机矩阵和投影矩阵的接口添加校验代码,以避免相机产生不可预知的行为。