《视觉开发专题》之 OpenGL 3D动画绘制&图形学概念的理解

4,606 阅读26分钟

知识前导

计算机图形学的终极目标——讨好人类视觉系统

  最近在啃的书中有一本叫《计算机图形学 原理及实践》,这本书让我深有感触的是其绪论中对计算机构建真实感图像的理解:我们所致力的最终目标,是视觉形式的交流,而且主要都是与人的交流。 这句话的潜台词是:在求解图形学问题和构建模型时需考虑人类视觉系统的影响。 我个人还是深有启发的。

  比如视觉停留,60HZ刷新频率就不会感到卡顿;比如最小角分辨率约为1弧度,300ppi以上的屏幕就很难有颗粒感了;比如只能感受一定频率范围的可见光,红外紫外各种射线人类都看不到;比如人感光细胞每次接收光子的能量累积到一定量才会产生神经信号,微弱的光需要放大瞳孔注视良久才看得清;比如一幅平面素描画也能让人产生立体感,这不仅仅是光阴特效可以做到,其实任何刺激,只要能触发视觉系统大致正确的反应,都能被识别并在大脑中形成某种感知,也正是我们视觉系统的强大自适应能力,允许我们在很多计算做不到完全仿真(比如光照)的情况下,采取一种合理的近似,依然让结果看起来令人满意。

  正是人类视觉系统的特点,决定了当今所有可视设备从硬件到软件一切的设计和进化方向。建议大家在学习一门新知识时也可以尝试跳出来,俯瞰一下这个领域的知识版图和发展方向,了解一下“从哪里来,到哪里去的”的背景,说不定会让你有新发现,比如再看到某些复杂的设计,会豁然开朗。开胃菜就到这了,下面进入我们今天的正题。

案例&知识点一览

几种图元:

3D 动画:

上面两个效果的源码实现我都放在 这里了。如果你有收获,记得留个 star 以资鼓励~

这是 视觉专题 的第二篇,上面两个案例是使用固定管线实现的。之所以用固定管线实现,一是因为可以更快的实现效果,再者可以避免因不熟悉 GLSL 语法而干扰到对渲染流程的理解。 案例涉及到的知识点有:

  1. OpenGL 坐标系统
  2. OpenGL 矩阵变换
  3. OpenGL 基本图元
  4. OpenGL 渲染技巧
  5. OpenGL 纹理映射

结合提供的两个案例阅读完(还不够 需要你带着思考去实践、求证)本篇内容,你将收获:

  1. OpenGL 固定管线的使用
  2. OpenGL 基本图元的使用
  3. OpenGL 纹理的简单使用
  4. OpenGL 几种渲染技巧的优劣及使用
  5. OpenGL 矩阵变换与坐标转换的关系

  这里感谢一下 CC老师_HelloCoder (一只温柔的胖C) 提供的视觉课程和资源,为我快速入门图形编程找到了方向。

  强烈建议下载我为你准备好的 案例源码,先跑起来看看每一种效果,再结合文章和自己的理解,亲自尝试不同的参数带来的变化,然后改成你希望的样子。

注: 这不是一篇代码注释和API用法讲解的文章,该有的注释源码里都为你写好了,搜索引擎一次就能找到答案的问题也不需要我在这浪费篇幅来写。此篇的重点是:对各个知识模块有初步认识的基础上,进一步加深对重点知识点的理解。


一. OpenGL 坐标系

1. 为什么需要坐标系?

  简言之,我们现实世界里的一切,都因坐标系的存在才能被抽象成可量化的数值:位置、长度、速度、空间、时间。往深了讲,这要扯到爱因斯坦的相对论,通俗点说,坐标系就是我们认知多维世界的基石,它能为我们提供直观的时空上的描述。当然,四维及以上的坐标系如果从空间上去想象,以目前人类普遍的认知,是很难理解的,暂且将它们都看做矩阵罢,相信大佬们的线性代数一定都比我好。

2. 有哪几种坐标系

模型/对象空间坐标系:为了方便计算,通常会选取建模对象的中心作为该坐标系的原点,使得模型的所有点在 x y z坐标均位于 -0.5 ~ 0.5 之间。比如我们将一个骰子建模为立方体,以立方体的中心为原点(不是规定的,只是为了更方便计算而已),建立一个 右手坐标系,作为骰子的模型空间坐标系。这个坐标系为骰子的所有顶点赋予了坐标信息。

场景空间坐标系:所有的物体模型一定会存在于一个或多个场景(一系列物体和光源组成的模型)中,包含了某个场景空间的坐标系就是场景空间坐标系,具体来说(打开你的空间想象力):对于上述骰子,如果它当前在一张桌子上(假设该桌面是规则矩形),桌子位于房间的正中心(假设房间是个长方体),骰子位于桌面的正中心,此时以房间的地面中心处为原点建立 右手坐标系 作为 场景空间坐标系,假设桌面的 y 坐标为 5,那么将上面模型坐标系中骰子的所有顶点坐标 y 值加上 5,就得到了它相对于这个场景空间坐标系的坐标了。

相机空间坐标系:依然使用上述房间作为场景,如果我们需要显示(看到)桌面上的骰子,那么就一定要在房间中合适的位置放置相机(我们的眼睛),这个合适的位置(坐标)也是相对于场景坐标系的。现在以相机镜头(我们的眼睛)所在点为原点,新建立一个 右手坐标系(镜头朝向/眼睛直视的方向为 z 轴负方向),使得上述场景空间中的所有景物均可以表示为这一坐标系中的坐标,该坐标系我们称为相机空间坐标系

像素空间坐标系:上述场景中所有可见景物(落入平截椎体内的点)的相机坐标最后会被转换为规格化设备坐标 (NDC),在该坐标系中,可见景物的 x、y 坐标被表示成 -1 和 1 之间的浮点值,z 坐标为负值。( x、y超出[-1,1]范围的不在相机 (眼睛) 的视域内;z > 0 的景物在相机 (眼睛) 后面),然后这些可见的点会被变换为像素坐标,就是我们看得到的屏幕上的像素点(视网膜里对应的图像),屏幕上的像素点坐标系(通常(0,0)位于左上角)就是像素空间坐标系
  注意:在标准化设备坐标系中 OpenGL 使用的是左手坐标系(投影矩阵交换了左右手)。

3. 坐标系之间的变换关系

  上述坐标系在计算机图形学中,与图形数据到显示输出的几个步骤一一对应,仅凭这段描述肯定不足以完全理解它们之间的变换关系,这张图展示了整个流程以及各个变换过程做了什么:

  简单说明下坐标系变换的过程:

  1. 将 camera 移到准备拍摄的位置并调好方向(视图变换,view transform)
  2. 将准备拍摄的对象移到场景必要的位置上(模型变换,model transform)

第1、2步是同一件事的两种实现方式:物体不动相机动/相机不动物体动,所以通常将这两步合并为一步——视图模型变换(model-view transform),该过程的结果就是构建一个独立的、统一的空间系统,将场景中所有的物体都变换到场景空间坐标系中。

  1. 设置相机的焦距,或调整缩放比例(投影变换,projection transform)
  2. 对结果图像进行拉伸或挤压,将它变换到需要的大小(视口变换,viewport transform),与第3步不同的是,3是改变捕获场景的范围,而这里是对结果的变形。

  重点来了,敲黑板:第3步设置的焦距或缩放比例,其实是在设置相机取景时 视锥体(上图中的四角椎体) 的大小,图中阴影部分为平截椎体(frustum),在平截椎体以外的物体都会被去除,如果某个图元正好穿过平截体的某个面,OpenGL 将会对此图元进行剪切(clip),这是 OpenGL 的 特性,可以解决靠近相机的物体会无限大的问题,再者,不绘制过远或超出视域范围的物体,可以改善渲染的性能以及深度精度。此外,第3步还计算了用于透视投影(近大远小效果)的参数(齐次坐标(x,y,z,w)中的 w)。

4. 补充

补充(一):投影方式
  上述过程是透视投影的形式,还有一种叫正交投影/正射投影/正投影,主要用于保持物体真实大小以及相互之间角度的场景,比如建筑设计图和计算机辅助设计的领域,这里就不赘述了。它们投影方式的区别如下:

补充(二):齐次坐标
  上面提到的透视投影,相信大家对 近大远小 的效果一定不陌生,因为一切景物的光线透过我们的瞳孔最终在视网膜上成的像,都是透视投影的结果。但在投影空间中,会产生很多与欧式空间中相悖的感性结果,比如两条共面平行线会相交:

两条铁轨的间距随着视线变远而减小,直至在地平线处(无限远点)相交。

  透视空间中的图形几何变换,如果不能合并矩阵的乘法和加法运算,计算会十分复杂。为了解决这一问题,德国数学家(August Ferdinand Möbius)提出了 齐次坐标系:采用 N+1 个量来表示 N 维坐标。例如,在二维齐次坐标系中,我们引入一个分量 w,将一个二维点 (x,y) 表示为 (X,Y,w) 的形式,其转换关系是:

x = X/w
y = Y/w

  在齐次坐标系中最后一个分量为 0 可以表示无穷远点:(x,y,0)。齐次坐标允许将平移、旋转、缩放及透视投影等表示为矩阵与向量相乘的一般向量运算,这是笛卡尔坐标所不具备的优点。总之,其重要性主要有二:其一是区分向量和点;其二是易于进行 仿射变换(Affine Transformation)。

那为什么能区分向量和点?为什么更易于仿射变换呢?这篇总结为你解惑: 齐次坐标与变换矩阵。如果你没权限下载该文档请在评论区留言,我私发完整的 PDF 给你。

二. OpenGL 矩阵变换

扯了这么多,那矩阵运算与物体的仿射变换之间到底是怎样的对应关系呢,下面我们来一一拆解。

1. 矩阵回顾

  矩阵乘法的基本规则:

  从上图规则中不难得出以下结论:

  • m x n * n x k = m x k
  • 矩阵乘法的前提条件需满足 m x n * n x k 的形式, 即被乘矩阵的列数要等于相乘矩阵的维数,n维向量也可以看做 n x 1 的矩阵
  • 矩阵的乘法是不可交换的,即AB≠BA
  • 矩阵的乘法遵循结合律:C(BA)=(CB)A=CBA,所以多个变换矩阵可以直接按序相乘,得到最终的变换矩阵,进而减少计算次数来提升程序性能

2. 各种变换与矩阵的映射关系

平移

  上图所示的是一个4x4的单元矩阵,任何4维向量乘以该矩阵得到的仍然是其本身,类似的,如果我们希望将物体沿着 x 轴正方向平移1,而 y 和 z 保持不变,就将该物体的所有顶点 v:(x,y,z,1) 乘以该矩阵:
类似地,其他轴方向的平移以此类推。

缩放

  如果你理解了上面平移的计算过程,缩放的矩阵其实也很好推理,比如我们希望将一个物体变换为原来大小的3倍,就将该物体的所有顶点 v:(x,y,z,1) 乘以该矩阵:

  因为每个分量是单独控制的,你完全可以通过修改不同分量的值来达到不同方向不同缩放比例的效果。另外,如果缩放时物体中心不在 (0,0,0) 处,那么缩放也会使物体远离或者靠近 (0,0,0),如果不想改变物体距离原点的距离,可以用上面刚学过的平移矩阵先将物体中心移至原点处,然后再缩放,最后再用平移矩阵的逆矩阵将物体移回原位即可。

旋转

  假设你理解了上面提到的齐次坐标以及上述矩阵运算与线性变换的关系,那么将物体所有顶点坐标乘以下面这个矩阵,来猜猜是什么操作?

  答案是,将物体绕 z 轴逆时针旋转 θ° 。不明白?闭上眼睛,绕 z 轴旋转是不是将所有顶点在 xy 平面内做旋转?是不是等价于把每个点绕其所在的二维坐标系原点逆时针转 θ° ?这几个问题确定了还不清楚?这其实是道高中数学题:如何求点 p(x,y) 绕原点旋转 θ° 至(此处为逆时针旋转)点 p`(s,t) 的坐标。咱们直接来看证明吧...其实我也愣了半天才看懂(╮(╯▽╰)╭高中数学大概都还给数学老师了)
  上述绕 z 轴旋转的计算过程如果你弄清楚了,那么绕 x 轴、y 轴的旋转也都不在话下了。再结合上面已知的结合律,封装一个支持同时绕x、y、z旋转指定度数的函数也不难:将三个矩阵按序相乘即可。另外,与缩放效果类似,如果旋转时物体中心不在原点,会产生物体旋转的同时整体位置也绕原点旋转的效果:
解决方法也同样:将物体中心先移至原点,待旋转结束后再移回原位:

透视投影

  相比前面的平移、缩放与旋转,这个变换过程相对复杂,但也不用慌,我们只需要确定一个顶点的变换矩阵即可,因为线性关系是通用的。透视投影有两种情形需要考虑:

  1. 中心对称的视锥体,z 轴位与椎体的中央位置。
  2. 不对称的视锥体,类似我们坐在火车里看窗外的景色,窗户的正中心并不正对我们的眼睛。

我们先来考虑第一种情形:

我们的目的,就是计算视锥体中的点投射到近平面上对应的点的坐标。假设近平面的宽、高、Z 值分别为 width、height、Znear,远平面的 Z 值为 Zfar。结合空间几何知识不难得出其对应关系如下:
  对于第二种情形,其实就是基于前者在 xy 平面上平移了近平面的位置而已,如果近平面(一个矩形)的位置用 xy 平面内的 top、left、right、bottom来表示,那么将变换矩阵的 x 维和 y 维的第三分量 z 根据近平面的位置做对应调整即可:
如果理解了这些,你就彻底掌握frustum系列函数的用法。如果还没理解透彻也完全不用怀疑自己,我这笨脑子几乎是画完了一本演草纸才弄清楚~相信对有线性代数基础的你来说,这些都是小菜一碟。

3. 矩阵的理解

  目前我们接触到的矩阵都是图形学里最基本的简单应用,关于矩阵的深入理解,大学没有线性代数课的我这里就不误人子弟了(恶补中...),待学习小有心得再与大家分享。先推荐大家读一读孟岩老师写的 理解矩阵。(共三篇,建议从一开始看,博主通过直觉而非抽象的方式,把向量、坐标系与矩阵的关系阐述的十分透彻:空间的本质特征是容纳运动,矩阵的本质是线性空间中线性变换的一个描述。在一个线性空间中,只要我们选定一组基,那么对于任何一个线性变换,都能够用一个确定的矩阵来加以描述。)简而言之,在线性空间中选定基之后,向量刻画对象,矩阵刻画对象的变换,用矩阵与向量的乘法来完成变换。 当然,这些理解和思考,也不一定完全正确,或者说一定有某些不严谨之处,但它仍然可以为我们更好的理解矩阵提供诸多有价值的参考。

PS:烧脑原来真的可以有快感...emmm 别问我怎么知道的。

三. OpenGL 基本图元&绘制规则

前面的硬骨头都啃完了,咱们来学点轻松的缓一缓~

1. 图元是什么

  既然 OpenGL 的主要是作用是将图形渲染到帧缓存当中,那就需要将复杂的物体分解成小的基本单元,这些小单元就是图元。它包括三种形式:点、线,以及三角形。当它们的分布密度足够高时,就可以表达出2D以及3D物体的形态。OpenGL 中包括了很多渲染这类图元的函数,这些函数可以让我们决定图元在内存中的布局、渲染的数量和渲染所采取的形式,甚至是同一组图元在一个函数调用中所复制的数量。

  点、线以及三角形是大部分图形硬件设备(GPU)都支持直接进行光栅化操作的基础图元类型,OpenGL 还支持其他的图元类型,例如 Patch 和邻接图元,但这些是无法直接进行光栅化的,今天这里这介绍前面的三种图元类型。

2. 有哪些绘制方式

  一个点就是一个四维的齐次坐标值。因此,点实际上不存在面积,在 OpenGL 中它是通过显示屏(或绘制缓存)上的一个四边形区域来模拟的。当渲染点图元的时候,OpenGL 会通过一系列光栅化规则来判断所覆盖的像素位置,这个用来模拟的四边形边长,是通过glPointSize()来设置的,默认大小是1.0,也可以通过在着色器中写入值来改变。

线、条带线与循环线

  独立的线通过一对顶点来表达,多个顶点也可以进行链接来表示一系列连线,首尾闭合的叫循环线(line loop),首尾开放的叫条带线(line strip)。与类似的是,线从原理上来说也不存在面积,所以也需要特殊规则来判断光栅化会影响哪些像素的值。可以通过glLineWidth()来设置线段的宽度,默认值是1.0。假设线段的宽度为 n (n>1),那么线段将被水平或垂直复制 n 次,复制的方向取决于线段的主延伸方向是 X(水平) 还是 Y(垂直)。

  简单概括线段光栅化的规则diamond exit):假设每个像素在屏幕上的方形区域中都存在一个菱形,当对一条从点 A 到点 B 的线段进行光栅化时,若该线段穿过了菱形的假想边,那么这个像素就应当被影响——除非菱形中包含的正好是点B(即线段的末端点位于菱形内)。所以,此时继续绘制另一条从点 B 到点 C 的线段,B点所在的像素也只会更新一次。该规则也是边缘产生锯齿的本质原因——非水平非垂直的斜线段会有一些像素点的菱形假想边未被连线穿过,这些像素点就不会参与光栅化,进而出现一些像素的缺失,就是我们看到的锯齿。关于如何抗锯齿后面再详细说。

三角形、条带与扇面

  三角形之于 OpenGL 初学者而言,就像是每个程序员最初接触程序时的 hello world一样。它是构成一切复杂3D图形的基本结构,当我们渲染多个三角形时,每个三角形与其他三角形完全独立。三角形的渲染是通过三个顶点到屏幕的投影的连线来完成的,如果屏幕像素的采样值位于投影连线的三角形边的内侧半空间内,那么它才会受到光栅化处理,这意味着:

  • 两个三角形共享了一条边(即共享了一对顶点),那么不存在任何采样值能同时位于这两个三角形内。
  • 三角形共享边的光栅化过程不会产生任何裂缝,也不会重复进行绘制。(重复绘制会导致诸如颜色混合计算结果错误等问题)

  这对于三角形条带(triangle strip)或扇面(triangle fan)的光栅化过程非常重要。三角形条带从第四个顶点开始,会与上一个三角形的后两个顶点构成新的三角形,以此类推:

三角形条带
  渲染扇面时,第一个顶点会作为一个共享点存在(比如画圆它就是圆心),它将作为每一个后继三角形的组成部分,之后每两个顶点都会与这个共享点组成新的三角形:
  三角形条带与扇面可以表达任何复杂程度的凸多边形性状。

总结如下:

  图中 GL_POLYGONGL_QUADS 分别是多边形和四边形的链接方式。赶紧结合提供的 案例 ,亲自体验一下每种图元的使用和适用场景吧。

四. OpenGL 渲染技巧

1. z-fighting

  在渲染立体图形时,都会开启深度测试glEnable(GL_DEPTH_TEST),深度 z 的大小决定了物体距离观察者的远近,进而影响其在近平面上的投影。对于不透明的物体而言,当投射路径上有多个表面叠加时,最近的表面才会被看见。但深度不同的表面之间也并不是永远都是这么相安无事。

  由于硬件的浮点数精度支持是有限的,因此当投射到同一个点的两个顶点的 z 值非常接近时,虽然在数学上深度值是不同的,但在计算机中可能认为是相同的,特别是透视变换之后 z 值的精度可能会变低,这个现象对多个像素都有影响,所以会导致显示结果闪烁交叠的情况。尽管并不常见,但如果遇到也要记得排查这种可能。

  其实投影变换对每个方向的坐标都会在一定程度上降低精度:距离近平面越远则精度则越低。举个通俗的例子:越远的物体你越看不清细节。

2. 正背面剔除

  在基本图元的绘制方式中,每个多边形都有两个面:正面和背面,OpenGL 默认多边形正面的顶点方向是逆时针排列的:glFrontFace(GL_CW)。你也可以通过glFrontFace(GL_CW)来设置顺时针为正面,你要清楚这一设置的代价。

  像球、环形体和茶壶等都是由方向一致的多边形组成的,即完全是逆时针,或者完全顺时针的。而莫比乌斯带克莱因瓶则不是。假设现在有一个不透明的正方体,构成它表面的多边形顶点方向都是逆时针的,经过透视投影到近平面上之后,有一部分顶点的方向变成了顺时针:没错,就是这部分顶点构成了你看不见的那部分——背面,它们永远都会被正面所遮挡:

  看不见的顶点可用通过设置剔除背面glCullFace(GL_BACK)+开启面剔除glEnable(GL_CULL_FACE)直接抛弃掉,这可以极大的提高绘制性能。当然,你完全可以剔除正面:GL_FRONT 或者所有多边形:GL_FRONT_AND_BACK。必要的时候通过glDisable(GL_CULL_FACE)来关闭面剔除,比如绘制透明的物体。

  更多通过glEnable()glDisable()来开启和关闭的可选参数类型参考这篇

  从更专业的角度来讲,判断多边形的面是正面还是背面,是依赖这个多边形在窗口系统下的面积计算。计算公式及其说明(了解即可):

3. 颜色混合

颜色理论

  买过显示器的你多半见过这个参数:色数,标值通常是 1677万 或 10.7亿,那这个数字代表什么又是怎么来的呢?这与计算机图形学有什么关系呢?

  绝大多数显示器使用的都是组合三原色(红、蓝、绿)的方法来构成颜色值,它们构成了显示器的整个颜色域,我们称其为 RGB 颜色空间,并且使用这三种颜色的组合来表达每一种颜色。我们能只使用这三种颜色来表达如此庞大范围的可见光谱的理由是,这三种颜色非常接近于人眼光锥细胞的响应曲线的中心区域。这一点与本篇开篇提到的思想完全一致。

  OpenGL 中在 RGB 三个分量之外增加了第四个分量 alpha ,即 RGBA 颜色空间。作为补充 OpenGL 还支持 sRGB 颜色空间。

回答前面的几个问题。在真实物理世界中,光的频率和强度都是连续变化的,这使得颜色值的数量等于无穷大。但对于计算机而言,它的帧缓存资源是有限的,所以只能对连续变化的强度进行量子化来限制能显示的颜色数量。分配给每个分量用来表示强度变化范围的大小称为像素深度 (bit depth)。例如,每个颜色分量用 8bit ([0,255])来保存它的强度,那三个颜色分量(alpha 分量除外)一共可以表示的颜色数量为 2^8 * 2^8 * 2^8 = 2^24 = 16777216 ≈ 1677万,10bit 对应的颜色数量就是 10.7亿了。现在主流显示器都是 8bit 的。
  这意味着每个像素至少存储三个字节的数据,任何特定类型的颜色缓存记录到屏幕上每个像素的数据总量总是相同的。

融混
  如果一个输入的片元通过了所有相关的片元测试,那么它就可以与颜色缓存中当前的值通过某种算法进行合并了。最简单也是默认的方式,就是直接覆盖已有的值(不透明或未开启深度测试的情况下,这种也不能算是合并)。如果我们要实现多块有色玻璃叠加的透明效果,必须要先启用glEnable(GL_BLEND)颜色混合,然后指定一个颜色混合的算法,比如:glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA),各参数的可用值如下:

融混相关的API使用介绍以及参数说明这里就不展开了,在后续涉及到的章节中再详细讲解。先结合案例了解简单的使用即可。

五. 纹理

  关于纹理的概念和基本使用,这一节 讲解的已足够细致和易懂了,我再强行写点什么总有种画蛇添足的味道~所以这里就直接引用啦。当然,最好还是建议能结合案例源码看看具体的实现,然后自己动手改一改去验证自己的理解是否正确。

六. 总结

  此篇提供的两个案例涉及到的知识点比较多,透彻掌握每一个点都不容易,刚开始学习往往最忌讳一头扎进一个点死钻,这样很容易被难点卡住然后半途而废。所以此篇的主要目的是,先熟悉它们是什么如何用。我尝试将它们放到一起,融入实际场景中去描述,一是希望能促进你的理解,二来也能把各个知识点串起来,进而帮你对整个图形学有个基本的全局观。

  在全新的领域里真的到处都是知识盲区。实话说,在写这篇文章的过程中,脑子里充满了这个表情~

虽然文章更新的速度因此慢了很多,但为读者负责的底线必须坚守,痛并快乐着的感觉大抵如此吧。但因水平真的有限,还是难免文中会有表述不够清晰甚至理解错误和错别字这样的问题,希望你能见谅并指出问题所在,万分感谢~

愿能帮你多一点收获。

下图是之前在云南游玩时去玉龙雪山的一张沿途随拍,再次向你展示了“平行线在无限远处相交”的问题...哈哈哈~