随着 3D 技术在web 领域的广泛应用,越来越多的开发者开始在业务中使用图形引擎或者相关api。在调用相机、灯光组件搭建场景时候,很难不去好奇背后的原理,特别是计算机如何把三维空间中的点显示在二维的屏幕上,图象又是如何动起来的呢? 本文会带大家以实现一个软光栅的形式,来了解背后的渲染原理。第一个部分了解图像是怎么动起来的,以及三维空间中的点是如何经过一系列变换到屏幕空间中的。第二个部分是软光栅的核心部分——光栅化,即将连续的点采样映射到离散的屏幕上。第三部分则着重在给模型着色、添加材质上。
npm i @oasis-engine/oasis-app
因为课程重点是通过做一个软光栅渲染器以了解基础渲染原理,不在于工程搭建或者数学计算,所以我们直接使用oasis engine提供的项目模版,以及oasis提供的数学库。
和大部分 WebGL 程序的执行流程类似,在main()函数中获取 canvas元素,获取 WebGL 绘图上下文,设置背景色,清空canvas,绘制图像。未来软光栅渲染得到的结果作为 ImageData,用 putImageData 的方式将数据传入渲染上下文,进行图像绘制。 这时一个简化的渲染结果到绘制图像的过程。真实情况中,很多复杂图形需要大量计算。如果我们把得到的结果立刻显示到屏幕上,渲染结果很可能是逐步地显示出来的,造成的图像闪烁不连贯。为了避免这一问题,Web 图形底层api OpenGL 具有双buffer的机制。front buffer 保存着最终输出的图像,它显示在屏幕上;而所有的的渲染指令在 backbuffer 上绘制。当所有的渲染指令执行完毕后,我们交换 (Swap) front buffer和 back buffer,这样图像就立即呈显出来。 那么动态图像呢?和动画类似,图片连续快速的切换时,基于人眼的视觉暂留效果,就会被认为是连续播放的。浏览器提供了requestAnimationFrame接口,传入的回调函数会在浏览器下一次重绘之前执行,执行次数通常是每秒 60 次。 这个每秒60次是图像领域的一个重要的指标,即每秒传输帧数 (Frames Per Second) FPS。每秒钟帧数越多,所显示的动作就会越流畅。通常,要避免动作不流畅的最低FPS是30。
在完成了主循环后,我们进入每一帧,看一看图形流水线是如何把三维空间中的模型显示为最终的图像。它大致可以分为这几个操作过程:
输入空间中的点,通过 vertex processing 中的一系列变换将它投影到平面上;在 triangle processing中这些点进行图元组装形成三角形;通过光栅化把空间中连续的三角形离散为屏幕上的像素;最后对每一个像素进行着色,就得到了我们看到的图像。
按照顺序我们先进行第一步 vertex processing。
它经历从本地空间,经过旋转、缩放、位移变换后到世界空间;从世界空间相对相机进行位置变换到相机空间;从相机空间把视椎体规范化为剪裁空间,由GPU自己做透视除法将顶点转到归一化的设备坐标空间;最终进行视口变换移动原点和坐标轴范围到屏幕空间。
在真正进行空间变换前,先回顾这一系列变换会用到的数学概念。
在空间中我们用三维向量来描述位置,在屏幕上则用二维向量来表示。既然都是向量,为什么常常会在图形学中看到的是矩阵,矩阵又和这些向量有什么关系呢? 在回答这两个问题之前,我们先回顾一下如何描述空间。以平面为例,我们最熟悉的是用x轴、y轴作为坐标系,平面中的每个点以x轴、y轴上的位置进行描述。如果用向量的语言表述,那么可以看作沿着x轴正方向有一个单位向量 i(1,0),沿y轴正方向有一个单位向量 j(0,1),空间中任意一点都可以通过i、j的加法和标量乘法的组合得到。我们把i、j称作基向量,它们张成的空间即我们想要描述的这种平面空间。如果把这一组基向量竖着写,就构成矩阵。 $$ $\begin{pmatrix} 1 & 0 \ 0 & 1 \end{pmatrix}$ $$ 如果有另一种平面空间,相比于这种我们熟悉的平面空间,它顺时针旋转了90度呢?虽然都是二维空间,这种空间确实和前一种完全不同。如果空间中有居民,是前一种居民是沿着地面移动,后一种则沿着悬崖峭壁竖直移动的区别。描述这个空间的基向量可以记作i(0,-1) j(1,0)。把前一种空间,变为后一种空间这个顺时针旋转90度的变换,因为原点不变、直线依旧是直线、空间里的网格依旧是平行并且等距分布的,我们称之为线性变换。 变换可以看作是函数的另一种说法。我们输入一个向量,经过如上变化,得到了在新空间中等价的新向量。矩阵就是用来描述这种线性变化的。新空间的这组基向量为列,构成的这个矩阵,就用来代表这个特定的线性变化。举例来说,在原始空间中有一个点(2,2) 。空间旋转了90度后,它会落到(2,-2),这和矩阵和向量的乘法得到的结果一致。如果不止变化一次,即多个线性变化相继作用,我们称之为线性复合变化,即矩阵的乘法。
我们之前提到的,不同空间之间的变换就可以使用矩阵方便的进行描述。
刚才提到矩阵的几何意义是对空间的一种特定的变换,它的表达式可以看作是一组基向量为列构成的。那么我们描述这些线性变换的时候,可能会很自然的想到这些是由一组 3x3 的矩阵构成的复合变换。这似乎和我们在这见到的4x4的常见表达不符合。这又是为什么呢? 这要从线性变换的定义说起。线性变换的一个要求是原点不变。而在空间中我们除了旋转、缩放以外,还有非常频繁的位移操作,它无法以矩阵的形式进行表示。为了用统一的形式表示变换,在尝试了多种方式后,目前约定俗成的办法是增加一个w坐标的方式。即点以(x, y, z, w) =>(x/w, y/w, z/w, 1), w!=0 表示,向量以(x, y, z, 0)表示。遵循向量和点的运算规则。这就是所谓的齐次坐标。 使用齐次坐标,显著的优点是形式统一,缺点是数据增多,从 3x3 变为了 4x4。但增多的数字是固定的,方便优化。 $$ $\begin{pmatrix} x' \ y' \ z' \ 1 \end{pmatrix} = \begin{pmatrix} a & b & c & tx \ d & e & f & ty \ g & h & i & tz \ 0 & 0 & 0 & 1 \end{pmatrix} · \begin{pmatrix} x \ y \ z \ 1 \end{pmatrix}$ $$ 用齐次坐标表示的这种变换我们叫做仿射变换,它先进行线性变换,再进行位移变换。 需要注意的是,由这三种变换合成的矩阵是唯一的;分解时因为算法不同,得到的结果并不唯一。如果直接使用分解得到的值,可能会出现跳变。
// 世界矩阵 旋转变换早于位移变化
Quaternion.rotationYawPitchRoll(point.Rotation.y,point.Rotation.x,point.Rotation.z,rotationQuat)
Matrix.rotationQuaternion(rotationQuat,rotationMat)
Matrix.multiply(translateMat,rotationMat,worldMatrix)
首先我们进行从本地空间,经过旋转、缩放、位移变换后到世界空间的模型变换。如果用照相过程进行类比,那么这一步就是在摆放模型、搭建场景。 一般来说模型中会提供旋转、缩放、位移的数值,我们在将其组合成 modelMatrix 时,变换的顺序是会影响最终结果的。旋转后位置,和位移后再旋转得到的结果并不相同。我们在这里约定先旋转,再进行位移变换。 在实际业务中,我们也常常会在代码中调整美术资产在空间中的位置。位移、缩放都可以直观的以三维向量表示,而旋转我们除了以三维向量表示欧拉角,还常常会用到四元数的形式。 三维向量表示的是欧拉角,它来自于模拟飞行,由俯仰角pitch、偏航角yaw、翻转角roll按照一定顺序组合而成。 既然可以用欧拉角直观的描述旋转,为什么还要用四元数呢?因为欧拉角存在万向锁问题的缺点,而四元素更适合计算机储存数据,也适合做插值。
// 视图矩阵
Matrix.lookAt(camera.Position,camera.Target,upVector,viewMatrix)
接下里我们进行从世界空间,经过相对相机的变换到观测空间的相机变换。如果用照相过程进行类比,那么这一步就是在以相机为锚点,将相机移动到指定位置,并且连带场景中已经摆放好的物体进行相同的变换。
提到相机,我们现在想到还是一个模糊的、辅助理解的比喻。在图形学里,我们用位置、朝向、上方向来定义一个相机。相机被固定在一个标准的位置原点,上方向沿着 y轴正方向,朝向沿着-z轴。那么将相机从世界空间中移动到这个约定俗成的方位的变换就是视图变换。场景中其他物体也跟随相机进行这一变换,保持相对位置不变。 投影变换
// 投影矩阵
Matrix.perspective(0.78,canvasWidth,canvasHeight,0.01,100,projectionMatrix)
把相机放到标准位置后,下一步就是进行投影变换,这是所有变换里最为复杂的一步。用照相作类比,这就是拍照说茄子的一步。
为了模拟物理世界,我们将投影抽象为一个椎体模型。摄像机位于棱锥的椎顶。该棱锥被前后两个平面截断,形成一个棱台,叫做视锥体,只有位于锥体内部的模型才是可见的。 透视投影视锥体内的物体投影到视平面上的物体满足近大远小的规律,而正交投影的所有投影射线都平行,物体大小不随距离变化而变化。
透视投影的目的就是将上面的棱台转换为一个立方体,即归一化的设备坐标空间NDC。NDC约定俗成的中心位于原点,以前、后、左、右、上、下定义。这个变换的过程是将棱台较小的部分放大,较大的部分缩小,以形成最终的立方体。这就是投影变换会产生近大远小的效果的原因。
以上三步合称作MVP变换。到这里空间被转换为一个(-1, 1)³的立方体中,接下来,需要将这个立方体转换为屏幕空间。
Vector3.transformCoordinate(vertex.Coordinates, mvpMat, this._point);
const x = this._point.x * this._workingWidth + this._workingWidth / 2.0;
const y = -this._point.y * this._workingHeight + this._workingHeight / 2.0;
对于标准立方体(-1, 1)³,先不考虑z轴,需要将X和Y轴 (-1, 1)² 映射到屏幕坐标 (0, width) x (0, height)。和MVP变换类似,通过齐次坐标的矩阵,先将 (-1, 1)² 缩放至 (width, height),因为标准立方体中心在原点,而屏幕原点在左下角,所以还需要经过一个平移使得原点坐标对齐,将标准立方体转换成屏幕空间,变为窗口坐标系。 小结 到了这里,我们经过了mvp变换以及视口变换,已经将输入的点变换至屏幕空间,做好了绘制到屏幕上的准备。