WebGL原来如此:三维坐标系变换的含义

2,363 阅读9分钟

还记得刚入职就迷迷糊糊接手一个原生WebGL项目,对于没有任何图形学基础的我,只是葫芦画瓢地去凑着色器需要的顶点数据,对于控制台输出的那些0-1之间的小数似懂非懂,对于为什么传给着色器这个变换矩阵不求甚解,拼拼凑凑实现了功能也很开心。

随着逐渐学习,才发现那时候的我欠缺了对图形渲染最基础的认知。

对于做前端的同学而言,在web框架日新月异的迭代之间,图形渲染是个相对小众且门槛略高的方向,除了必备的编程能力外还要应用高等数学与计算机图形学知识。但随着浏览器引擎渲染能力增强,对交互效果的要求越来越高,并且有Three.js等库的协助,三维渲染也逐渐成为前端研发的一项重要技能。

那么,既然要做图形渲染,就一起知其然且知其所以然吧~

正文📚

三维渲染,顾名思义,就是要将多角度可观测的三维物体绘制到一个固定的二维矩形屏幕上,那么三维到二维是如何映射的呢?想必这之间必然经历了一些坐标转换。这也就是前面我说的对图形渲染最基础的认知———坐标转换。

先贴一张我画的WebGL/OpenGL坐标变换流程图随意感受一下:

zobn.png

这也太复杂了吧?

不妨想象一个生活中常见的场景——拍照。

  1. 你发现了一处适合拍照的场景,并站过去摆好姿势。(模型变换,model transform)

  2. 摄影师找到一个好角度,举起相机对准你。(视图变换,view transform)

  3. 摄影师调整焦距,摒除掉干扰物进行拍摄。(投影变换,projection transform)

这就是所谓的MVP变换,整个坐标系变换的核心。

大致有个概念之后,我们再来逐个了解这些变换到底是怎么一回事呢。

1、物体坐标系 -> 世界坐标系(模型变换)

假设我们想要在屏幕上渲染一只喵和一只汪,对于小喵和小汪这两个模型,他们都有以自己为原点的坐标系,这就是物体坐标系

世界坐标系可以理解为是这个所有模型共享的虚拟场景空间,要将喵和汪这两个模型都导入到这个大场景中来,并指定其各自的位置。这就需要对物体进行平移、旋转等操作,从而将其摆放到合适的位置上。

这里对物体的操作就是模型变换(Model Transform),将物体坐标与模型矩阵相乘就得到了其在世界坐标系下的坐标。

52035758f536b409cae4cf35942b05e0.png

备注:

具体编程中,我们会发现着色器中接收到的顶点坐标在原来(x,y,z)基础上增加了一维1,即齐次坐标,代码如下:

attribute vec3 a_position;
void main() {
    gl_Position = vec4(a_position, 1);
}

之所以使用齐次坐标是由于平移不同于缩放和旋转的线性变换,为了将平移统一进行矩阵计算,就增加一维来做平移,统称为仿射变换

2、世界坐标系->观察空间(视图变换)

如同拍摄,对于多角度可观测的三维场景,在屏幕上,我们并不能一下子看到所有视角画面,而是经过摄像机模拟人眼裁剪后所呈现的特定场景。这就需要将坐标变换到以摄像机为原点的观察空间中,该变换过程称为视图变换(View/Camera Transformation)

我们很容易理解的一个现象,如果物体和相机的相对位置不变的情况下,同时移动相机和物体拍摄出来的结果是一样的。

所以为了计算方便,我们先定义相机的三要素:放置位置在原点(0,0,0)、朝向-z、向上方向为y。

0343abea04e29400a411cfc8a47c994c.png

如上图所示,经过平移和旋转将相机变换到约定的位置上来,物体只要相对跟着变换。将平移矩阵与旋转矩阵相乘,就得到了视图变换矩阵Mview=RviewTviewMview = RviewTview

备注:

1.观察空间使用的是右手坐标系,z轴是摄像机的正前方,故z轴数值表示物体距离摄像机的远近,即深度,此时的深度值还是线性的。

2.如何得到这里的旋转矩阵RviewRview?如果是将任意位置的-g旋转到-z不容易,但反过来容易。所以这里求逆矩阵,并利用“正交矩阵的逆等于转置”性质就得到RviewRview

3.这样约定相机位置后,结合前一步,其实变换都是作用在了物体上,可以将两个矩阵合并成一个矩阵,也就是大家常说的模型视图变换(ModelView Transform)

3、观察空间->裁剪空间(投影变换)

在观察空间中,我们知道只有位于视椎体内的物体才会被摄像机渲染可见,那么对于可见的3D物体如何将其映射到2D平面上呢?就需要接下来的投影变换(Projection Transform)

投影变换包括了 正交投影(Orthographic Projection)透视投影(Perspective Projection),两者区别由下图显见,正交投影构造的是一个立方体,透视投影构造的是一个上下左右面不平行、远近面大小不一的 视椎体(frustum),所以正交投影变换得到的远近物体大小都一样,而透视投影变换能产生近大远小的效果。

6fa4fbd7e3e0db86bf78f12951d08f4b.png

要实现远近裁剪面内的物体能够投影到近平面上,正交投影的标准立方体就很容易实现,但对于透视投影远平面大于近平面的情况就比较复杂。那么拆解问题,首先挤压椎体上下左右平面将其变成跟正交投影一样的立方体,然后再进行正交投影即可。

挤压规则约定三点:① 近平面不变;② 远平面z值不变;③ 远平面中心点不变;

再结合相似三角形性质,可以推导出将透视投影变换到正交投影的矩阵。

此外,对于视椎体定义两个变量:① 宽高比aspect ratio = width / height; ② 垂直可视角fovY

7A323DE1-1918-4F6F-8B49-4B1ABF94E8F1.png

最终得到的透视矩阵:

image.png

  • near:近裁剪平面距离
  • far:远裁剪平面距离
  • fov:椎体竖直方向的张开角度(当视野更大时,物体通常变小)
  • aspect:摄像机的宽高比(该参数解决了当画布调整大小和形状时模型的变形问题)

至此,经过F3F9ADD8-7647-4055-B1D8-26A3EFACE89E.gif变换得到了裁剪坐标。转换过程中对x,y,z分量都进行了不同程度的缩放和平移,x,y是屏幕横纵坐标,z是垂直屏幕的深度坐标。裁剪是将变换后的x,y,z与w值作比较,如果位于[-w,w]范围内保留,否则剔除。

备注:

1、F3F9ADD8-7647-4055-B1D8-26A3EFACE89E.gif,由于矩阵乘法很耗时,并且矩阵具有结合律,通常我们会将MV矩阵先相乘得到一个矩阵在与点向量坐标相乘。

2、性质:将齐次坐标(x,y,z,1)每个分量都乘以不等于0的常数k,得到的(kx,ky,kz,k)在3D空间中与(x,y,z,1)表示同一个点。

3、透视矩阵需要注意的是,会翻转z轴。裁剪空间坐标系是左手坐标系(z轴指向远离观察者并指入屏幕的位置)

4、裁剪空间 -> 标准化设备空间(齐次除法)

经过前面的变换,我们在视椎体中得到裁剪后要展示的部分,接下来要将视椎体内物体映射到近平面上,以在2D平面上展示。

就需要将坐标转换到一个与硬件设备无关的 规范化设备坐标(NDC, Normalized Deviced Coordinates),以描述映射到近平面上的坐标,这一步的变换称为 齐次除法透视除法,即将x,y,z分量分别除以w分量,将其变换到[1,1]3[-1,1]^3范围内。以x轴为例转换公式: (公式)

在这之前,为了方便仿射变换,我们一直使用的是齐次坐标(x,y,z,w),经过齐次除法变换回笛卡尔坐标(x,y,z)。如下图所示,变换后的原点在(0,0,0),xyz分量均为2个单位的立方体中,并且z轴进行了翻转(由右手系变成左手系)。

6599a01cde3ee004676dba59768117e5.png

备注:

1.做完投影变换与齐次除法后,物体坐标都变换到[1,1]3[-1, 1]^3范围内,会导致物体拉伸,后面还会进行一次视口变换再拉伸回来。

5、标准设备空间 -> 屏幕坐标(视口变换)

最后一步视口变换,将NDC的[1,1]3[-1,1]^3立方体中的坐标变换为视口坐标(屏幕坐标),从而在屏幕上进行像素绘制。

WebGL绘制的画布是canvas元素,定义左下角为坐标原点,右上角像素坐标为(pixelWidth, pixelHeight)。

C0B423CE-855F-4075-84D9-DA277247857C.png

这个坐标变换只对x,y进行操作,由 [1,1]2[-1,1]^2 变换到 [0,width]*[0,height] 范围。如下面矩阵所示,对x和y进行缩放拉伸,并将其移到屏幕的原点。实现了标准设备坐标与屏幕窗口像素的一一对应。

D2C62E61-97EA-44D3-924E-3929CAB8A8CE.png

在webgl中有直接设置视口(ViewPort)的方法:

gl.viewport(0, 0, this.cvs.width, this.cvs.height);

等等,那z坐标呢?经过前面齐次除法得到的z分量用来表示深度信息,将被用于深度缓冲(Z-buffer)算法,计算每个像素的深度测试,以实现正确的遮挡效果。这属于着色内容,本文不多赘述。

总结

哇,你好棒,耐心看到这里~

最后再贴一张图总结一下,读完上面的变换原理后,这张图是不是一下子就明白了呢?!

图片 1.png

那么在WebGL中,着色器是如何应用上述坐标转换并结合纹理渲染成像的呢,这里先贴一张图,在之后的着色原理中详述。

image.png