从问题出发

且不论游戏引擎这样的庞然巨物,就只说渲染引擎所需要涉及的内容就非常,你可能要考虑怎么去封装渲染API,还要考虑怎么去整合各类效果系统,初版效果出来了,要不要考虑搞搞Vulkan搞搞Metal,什么样的方案才是最佳的等等。

因此如果没有一个方向就开始寻找资料的话和大海捞针没有什么区别,也容易因为一时间接收的资讯过多进而打击信心和兴趣,因此在开始找资料之前我给自己定了几个问题,以这个几个问题为方向去找资料为自己解答:

  • 一个渲染引擎是由哪些系统组成的?这些系统都是不可或缺的吗?
  • 实现一个渲染引擎需要点亮哪些技能点?哪些是必须技?哪些可以边做边点亮?
  • 渲染引擎的基本渲染流程是什么样的?
  • 目前有哪些出色的渲染引擎?怎么筛选参考引擎?参考哪些?

而在寻找答案的过程中,自然而然就也会接触到其他方面的内容,比如一些自己之前见都没见过的名词,或者是大多数引擎都在用的成熟方案等等,这些也都会一并记录下来。

启程

其实这篇文章是有一个检验收获的方式的,这个方式在一开始是没有的,但是在找资料的过程中看到知乎大佬的回答有所感悟,在这篇文章结束时再回过头来看看下面这句话,如果大致明白了,那么这篇文章就起到作用了:

渲染流程概要

而这段话正是这一轮资料搜寻下来个人觉得最精炼的对渲染流程的描述了,至于里面具体各个名词所指则会在后面的内容进行补充,当然各个渲染引擎的流程在细节之处甚至一些环节上都会做出有所区别,这些则会在后面针对一些参考引擎做分析的时候再逐步整理出来。

渲染引擎的技能树

先贴一下找到的 知乎上的回答

你需要具备的技能

是不是有种看一眼就想放弃的冲动 ?(╯°Д°)╯︵ ┻━┻

但是正如我们在日常开发中突然遇到一个新的技术需求的时候一样,一般我们也不会把一个技术学到精通来才开始落地,而是会先预研一下方案,做下对比,掌握好基础,对一些风险提前做好功课,保证好大方向上的正确性,然后就会开始逐步落地,在过程中慢慢打磨。

类比到上面的内容也是如此,按照我自己的想法,我觉得首先C++的基本语法、内存管理、标准库的使用等基础内容以及OpenGL的渲染管线和基本的glsl、矩阵这些是在开始之前必须要先打好底子的,否则可能会连参考第三方开源引擎都成问题,更不要说自己去写了,而在自己写项目或学习第三方开源项目的过程中则可以对遇到的不明白或者不完全理解的内容进行进一步的学习,不求快但求吃透每一点,以这样的方式去持续扩大或加深自己的技能,在这个过程中尽量以点及面,每一个细节尽可能多去了解一些边边角角涉及到的地方,以便自己能够尽快完成一个又一个的闭环,最终对整个领域的理解越来越清晰。

其次就是合理安排出一部分时间来进行理论方面的系统学习,比如计算机图形学等,系统性的学习个人还是比较推荐通过书籍来进行,虽然周期更长,但是可以让你对这个领域的方方面面能够有一个更清晰的认识,从而形成一个更大的闭环。

而至于dx、metal、编译知识这些如果暂时不需要做这方面的需求的话倒不是非常关键,可以放到上面的技能学习之后,甚至可能很长一段时间你都不会使用到,当然也可能你现在是需要用dx而不需要opengl,那么就是dx的基础知识是必须的opengl排到后面,总之因人而异吧。

3D引擎着色方式的演化史

这部分内容是我在寻找渲染流程过程中的额外收获,虽然说的是演化史,但是里面的内容正好是对 启程 章节里对渲染引擎主要流程的描述的扩展,通过对多种着色方式的了解,可以间接对渲染流程里的几个主要步骤有一个初步的感知。

该章节的内容起源是3D渲染引擎着色方式的演化史,原文对各个着色方式的介绍比较简明扼要,在阅读完这篇文章后我又自己去对里面的各个名词和许多不理解的地方进行了搜索,因此大纲上还是会按照这篇文章的几大部分进行,在内容里面再补上自己找到的额外的资料,主要来源于 实时渲染中常用的几种Rendering Path 和其他针对其中细节讲解的文章。

这部分个人认为在这篇文章的阶段不需要深究里面的实现细节,这个可以在后续分析开源引擎流程以及自主实现的时候去深入研究,在这里更多地是对这些Render path有一个印象并且知道他们的大致渲染原理、彼此之间的区别以及能解决什么样的问题,从而能在后面的引擎中因地制宜地使用不同的着色方式。

Rendering Path

Rendering Path 其实指的就是渲染场景中光照的方式。由于场景中的光源可能很多,甚至是动态的光源。所以怎么在速度效果上达到一个最好的结果确实很困难。以当今的显卡发展为契机,人们才衍生出了这么多的 Rendering Path 来处理各种光照。

在介绍各种光照渲染方式之前,首先必须介绍一下现代的图形渲染管线。这是下面提到的几种 Rendering Path 的技术基础。

图形渲染管线

现代的渲染管线也称为可编程管线(Programmable Pipeline),简单点 说就是将以前固定管线写死的部分(比如顶点的处理,像素颜色的处理等等)变成在 GPU 上可以进行用户自定义编程的部分,好处就是用户可以自由发挥的空间增大,缺点就是必须用户自己实现很多功能。

下面简单介绍下可编程管线的流程。以 OpenGL 绘制一个三角形举例。首先用户指定三 个顶点传给 Vertex Shader。然后用户可以选择是否进行Tessellation Shader(曲面细分可能会用到)和 Geometry Shader(可以在 GPU 上增删几何信息)。紧接着进行光栅化,再将光栅化后的结果传给 Fragment Shader 进行 pixel 级别的处理。 最后将处理的像素传给 FrameBuffer 并显示到屏幕上。

名词解释

Geometry:即我们所要渲染的一个几何图形

Vertex Shader:顶点着色器,处理每个顶点,将顶点的空间位置投影在屏幕上,即计算顶点的二维坐标。

Tessellation Shader:曲面细分着色器,是一个可选的着色器,用于细分图元

Geometry Shader:几何着色器,是一个可选的着色器,用于逐图元的着色,可以产生更多的图元

Fragment Shader:片段着色器,也称为像素着色器(Pixel Shader),用于计算“片段”的颜色和其它属性,此处的“片段”通常是指单独的像素

后面文章内容中出现的VS、TS、GS、FS(PS)即对应上图中的Vertex Shader、Tessellation Shader、Geometry Shader、Fragment Shader

FrameBuffer:帧缓冲存储器,简称帧缓存或显存,它是屏幕所显示画面的一个直接映象,又称为位映射图(Bit Map)或光栅。帧缓存的每一存储单元对应屏幕上的一个像素,整个帧缓存对应一帧图像。

Forward rendering

Forward rendering

这是最初始的渲染方式,原理是以mesh为单位进行渲染,在光栅化后,对每个PS进行计算时,根据光照进行着色计算,所以这种方式称为前向着色

Forward Rendering 是绝大数引擎都含有的一种渲染方式。要使用 Forward Rendering,一般在 Vertex ShaderFragment Shader 阶段对每个顶点或每个像素进行光照计算,并且是对每个光源进行计算产生最终结果。下面是 Forward Rendering 的核心伪代码:

1
2
3
For each light:
For each object affected by the light:
framebuffer += object * light

比如在 Unity3D 4.x 引擎中,对于下图中的圆圈(表示一个 Geometry),进行 Forward Rendering 处理:

Unity3D 4.x 圆圈

将得到下面的处理结果:

Unity3D 4.x 圆圈 着色结果

也就是说,对于 ABCD 四个光源我们在 Fragment Shader 中我们对每个 pixel 处理光照, 对于 DEFG 光源我们在 Vertex Shader 中对每个 vertex 处理光照,而对于 GH 光源,我们采用球调和(SH)函数进行处理。

这种方式存在以下弊端:

  • 如果像素被其他像素遮蔽了,就浪费了宝贵的处理结果
  • 光源多起来后管理很麻烦,Shader也不好写。

因此,Deferred rendering就应运而生了。

很明显,对于 Forward Rendering,光源数量对计算复杂度影响巨大,所以比较适合户外这种光源较少的场景(一般只有太阳光)。

但是对于多光源,我们使用 Forward Rendering 的效率会极其低下。因为如果在 Vertex Shader 中计算光照,其复杂度将是O(num_geometry_vertexes ∗ num_lights),而如果在 Fragment Shader 中计算光照,其复杂度为O(num_geometry_fragments ∗ num_lights) 。可见光源数目和复杂度是成线性增长的

对此,我们需要进行必要的优化。比如

  • 多在 Vertex Shader 中进行光照处理,因为有一个几何体有 10000 个顶点,那么对于 n 个光源,至少要在 Vertex Shader 中计算 10000n 次。而对于在 Fragment Shader 中进行处理,这种消耗会更多,因为对于一个普通的 1024x768 屏幕,将近有 8 百万的像素 要处理。所以如果顶点数小于像素个数的话,尽量在 Vertex shader 中进行光照。

  • 如果要在 fragment shader 中处理光照,我们大可不必对每个光源进行计算时,把所有像素都对该光源进行处理一次。因为每个光源都有其自己的作用区域。比如点光源的作用区域是一个球体,而平行光的作用区域就是整个空间了。对于不在此光照作用区域的像素就不进行处理。但是这样做的话,CPU 端的负担将加重。

  • 对于某个几何体,光源对其作用的程度是不同,所以有些作用程度特别小的光源可以不进行考虑。典型的例子就是 Unity 中只考虑重要程度最大的 4 个光源。

名词解释

Mesh:网格,任何一个模型都是由若干网格面组成,而每一个面又有若干个三角形组成,也就是说,模型是由若干个三角形面组成的

Deferred rendering

Deferred rendering

Deferred Rendering(延迟渲染)顾名思义,就是将光照处理这一步骤延迟一段时间再处理。

具体做法就是将光照放在已经将三维物体生成二维图片之后进行处理。也就是说将物空间的光照处理放到了像空间进行处理。要做到这一步,需要一个重要的辅助工具——G-Buffer

G-Buffer 主要是用来存储每个像素对应的 Position,Normal,Diffuse Color 和其他 Material parameters。根据这些信息,我们就可以在像空间中对每个像素进行光照处理。

下面是 Deferred Rendering 的核心伪代码。

1
2
3
4
For each object:
Render to multiple targets
For each light:
Apply light as a 2D postprocess

这种渲染方式相比 Forward rendering 就是在渲染mesh时,并不进行光照计算,而是按照以下步骤进行:

  • 将深度、法线、Diffuse、Specular等材质属性分别输出到GBuffer里(其实就是几张RT

  • 然后GBuffer里的深度和法线信息,累加所有光照的强度到一张光照强度RT

  • 根据GBuffer里的Diffuse和Specular信息,以及光照强度RT,进行着色计算

名词解释

GBuffer:指Geometry Buffer,亦即“物体缓冲”。区别于普通的仅将颜色渲染到纹理中,G-Buffer指包含颜色、法线、世界空间坐标的缓冲区,亦即指包含颜色、法线、世界空间坐标的纹理。由于G-Buffer需要的向量长度超出通常纹理能包含的向量的长度,通常在游戏开发中,使用多渲染目标技术来生成G-Buffer,即在一次绘制中将颜色、法线、世界空间坐标分别渲染到三张浮点纹理中

下面简单举个例子:

首先我们用存储各种信息的纹理图。比如下面这张 Depth Buffer,主要是用来确定该像 素距离视点的远近的。

Depth Buffer

根据反射光的密度/强度分度图来计算反射效果。

Specular Intensity/Power

下图表示法向数据,这个很关键。进行光照计算最重要的一组数据。

Normal Buffer

下图使用了 Diffuse Color Buffer。

Diffuse Color Buffer

这是使用 Deferred Rendering 最终的结果。

Deferred Lighting Results

Deferred rendering 的最大的优势就是将光源的数目和场景中物体的数目在复杂度层面上完全分开,也就是说场景中不管是一个三角形还是一百万个三角形,最后的复杂度不会随光源数目变化而产生巨大变化。从上面的伪代码可以看出 Deferred rendering 的复杂度为 O(screen_resolution + num_lights)

这种渲染方式也有一些弊端:

  • 由于硬件限制或者性能限制,GBuffer里保存的材质信息有限,对于特殊材质来说,例如人的皮肤、翡翠等,渲染结果很不好
  • 延迟计算光照会大幅增加纹理带宽和帧缓冲区带宽的开销
  • 当光源数量很多时,光源会不断对光照强度RT进行累加,也会大幅增加帧缓冲区带宽开销
  • 由于硬件限制或者性能限制,不能使用硬件支持的MSAA,只能使用类似后期处理的FXAA或者Temporal AA

名词解释

MSAA、FXAA、Temporal AA都是抗锯齿(Anti-Aliasing)技术,锯齿的来源是因为场景的定义在三维空间中是连续的,而最终显示的像素则是一个离散的二维数组。所以判断一个点到底没有被某个像素覆盖的时候单纯是一个“有”或者“没有”问题,丢失了连续性的信息,导致锯齿。

具体区别可见FXAA、FSAA与MSAA有什么区别?

Deferred Rendering 局限性是显而易见的。比如我在 G-Buffer 存储以下数据:

G-Buffer 数据格式

这样的话,对于一个普通的 1024x768 的屏幕分辨率。总共得使用 1024x768x128bit=20MB, 对于目前的动则上 GB 的显卡内存可能不算什么,但是使用 G-Buffer 耗费的显存还是很多的。一方面,对于低端显卡,这么大的显卡内存确实很耗费资源;另一方面,如果要渲染更酷的特效,使用的 G-Buffer 大小将增加,并且其增加的幅度也是很可观的;并且存取 G-Buffer 耗费的带宽也是一个不可忽视的缺陷。

对于 Deferred Rendering 的优化也是一个很有挑战的问题。 下面简单介绍几种降低 Deferred Rendering 存取带宽的方式。最简单也是最容易想到的就是将存取的 G-Buffer 数据结构最小化,这也就衍生除了 Light Pre-Pass 方法。另一种方式是将多个光照组成一组,然后一起处理,这种方法衍生了 Tile-based deferred Rendering

Light Pre-Pass / Deferred Lighting

这个技术是CryTek这个团队(该团队开发了CryENGINE游戏引擎,即下面简称的CE,如果还不熟悉的话,那么这个团队开发了《孤岛危机》、《孤岛惊魂》等游戏)原创的,由 Wolfgang Engel 在他的 博客 中提到的,也用于解决Deferred rendering渲染方式里的第一个弊端。原理跟Deferred rendering差不多,只是有几处不同:

  • GBuffer中只有深度(Z)和法线(Normal)数据,对比 Deferred Rendering,少了 Diffuse Color, Specular Color 以及对应位置的材质索引值
  • 在 FS 阶段利用上面的 G-Buffer 计算出所必须的 Light properties,比如 Normal * LightDir, LightColor, Specular 等 Light properties,将这些计算出的光照进行 alpha-blend 并存入 LightBuffer(就是用来存储 Light properties 的 buffer)

  • 着色过程不是Deferred rendering中类似于后处理的方式,而是渲染mesh,即将结果送到 Forward rendering 渲染方式计算最后的光照效果

相对于传统的 Deferred Render,使用 Light Pre-Pass 可以对每个不同的几何体使用不同 的 Shader 进行渲染,所以每个物体的 Material properties 将有更多变化。这里我们可以看出对于传统的 Deferred Rendering,它的第二步是遍历每个光源,这样就增加了光源设置的灵活性,而 Light Pre-Pass 第三步使用的其实是 Forward rendering,所以可以对每个 mesh 设置其材质,这两者是相辅相成的,有利有弊。

另一个 Light Pre-Pass 的优点是在使用 MSAA 上很有利。虽然并不是 100%使用上了 MSAA(除非使用 DX10/11 的特性),但是由于使用了 Z 值和 Normal 值,就可以很容易找到边缘,并进行采样。

下面这两张图,上边是使用传统 Deferred Render 绘制的,下边是使用 Light Pre-Pass 绘 制的。这两张图在效果上不应该有太大区别。

Deferred Renderer Result

Light Pre-Pass Result

其实这种方式也有弊端:

  • 由于不透明物体在主视口中被渲染了两次,会大幅增加渲染批次,不过好在CE对状态切换管理的非常好,所以渲染批次的承载力很高

  • 由于某些特殊材质需要对光照进行特殊处理,比如说树叶的背光面也会有一定的光照,所以这种方式也不太完美

印象里貌似CE对主光,例如太阳光,不累加进光照强度RT,而是着色时单独处理,这样的话效果会提升不少,至少室外场景是完全能够解决问题的;而对于点光源比较多的室内场景,主光着色好看了就会效果很好了,毕竟其他光照的影响占比比较小。

Tile-based deferred rendering

这个方案是对Deferred rendering渲染方式里的第三个弊端进行优化的。原理就是:

  • 先将整个光照强度RT分成很多个正方形区域,计算每个区域受哪些光源影响,并保存起来
  • 然后以每个区域为单位,在一个批次里累加所有的光照

这样就能减少对光照强度RT上某个像素频繁读写的次数。

TBDR 主要思想就是将屏幕分成一个个小块 tile,然后根据这些 Depth 求得每个 tile 的 bounding box。对每个 tile 的 bounding box 和 light 进行求交,这样就得到了对该 tile 有作用 的 light 的序列。最后根据得到的序列计算所在 tile 的光照效果。

对比 Deferred Render,之前是对每个光源求取其作用区域 light volume,然后决定其作用的的 pixel,也就是说每个光源要求取一次。而使用 TBDR,只要遍历每个 pixel,让其所属 tile 与光线求交,来计算作用其上的 light,并利用 G-Buffer 进行 Shading。一方面这样做减少 了所需考虑的光源个数,另一方面与传统的 Deferred Rendering 相比,减少了存取的带宽。

一篇文章 中提到目前所有的移动设备都使用的是 Tile-Based Deferred Rendering(TBDR) 的渲染架构,,里面还提及了使用TBDR的一些注意事项,感兴趣的可以看看,以及 针对移动端TBDR架构GPU特性的渲染优化移动GPU渲染原理的流派——IMR、TBR及TBDR

名词解释

tile:区块,即将需要渲染的画面分成一个个的区块

bounding box:边界框,是一个矩形框,可以由矩形左上角的xx和yy轴坐标与右下角的xx和yy轴坐标确定。从技术上讲,边界框是包含一个物体的最小矩形

light volume:体积光,散射是一种非常美丽的自然现象,在自然界中光穿过潮湿或者含有杂质的介质时产生散射,散射的光线进入人眼,让这些介质看起来像拢住了光线一样,也就是所谓的体积光。可见 游戏开发相关实时渲染技术之体积光

Hybrid deferred rendering

为了解决Deferred lighting里面的第一个弊端,从CE3的某个版本开始,换成了这种方式。理由是,对于大多数物体来说,Deferred rendering的方式就很好了,而对于特殊材质,则使用Deferred lighting的方式。这样,既能保持很好的渲染效果,又能避免渲染批次激增。

更详细的内容可见 Hybrid-Deferred-Rendering.pdf

Forward+

有时候,你转了很大一个圈以后,发现又回到了原点。

好,那这就到了终极方式了——前向着色的改进版。这个方案是ATI(著名显卡生产商,06年被AMD收购)发明的,已经应用于Ogre 2.1(开源的面向对象的3D引擎)。UE4(大名鼎鼎的虚幻引擎)正在针对VR研发前向着色,不知道是不是也是这个。

原理也很简单:

  • 先用Tile-based deferred rendering里的方式计算好每个区域受哪些光照影响

  • 然后像传统的前向着色一样渲染每个mesh——当然,要去光照列表里查找影响当前区域的所有光照,并着色

这种方式只有上述提到的一个缺点,那就是可能和Deferred lighting一样需要渲染两遍场景,不过以后应该会有优化的方案。优点则有:

  • 渲染效果好

  • 带宽开销低,尤其适用于VR这种每帧需要渲染两遍场景的应用

  • 可以使用硬件支持的MSAA,质量最高。

Forward+的优势还有很多,其实大多就是传统 Forward Rendering 本身的优势,所以 Forward+更像一个集各种 Rendering Path 优势于一体的 Rendering Path。

Forward+ = Forward + Light Culling。Forward+ 很类似 Tiled-based Deferred Rendering。 其具体做法就是先对输入的场景进行 z-prepass,也就是说关闭写入 color,只向 z-buffer 写入 z 值。注意此步骤是 Forward+必须的,而其他渲染方式是可选的。接下来的步骤和 TBDR 很类似,都是划分 tiles,并计算 bounding box。只不过 TBDR 是在 G-Buffer 中完成这一步骤 的,而 Forward+是根据 Z-Buffer。最后一步其实使用的是 Forward rendering 方式,即在 FS 阶段对每个 pixel 根据其所在 tile 的 light 序列计算光照效果。而 TBDR 使用的是基于 G-Buffer 的 Deferred rendering

实际上,forward+比 deferred 运行的更快。我们可以看出由于 Forward+只要写深度缓存 就可以,而 Deferred Rendering 除了深度缓存,还要写入法向缓存。而在 Light Culling 步骤, Forward+只需要计算出哪些 light 对该 tile 有影响即可。而 Deferred Rendering 还在这一部分把光照处理给做了。而这一部分,Forward+是放在 Shading 阶段做的。所以 Shading 阶段 Forward+ 耗费更多时间。但是对目前硬件来说,Shading 耗费的时间没有那么多。

以下是 Forward+Deferred Rendering 的对比图:

Forward+ vs Deferred Rendering

感兴趣的可以再额外看看 forward框架的逆袭:解析forward渲染 这篇文章。

名词解释

Light Culling:剔除光照

渲染/游戏引擎调查

渲染引擎属于游戏引擎中的一部分,本章节主要简要整理一下找到的一些渲染引擎和游戏引擎,具体内在区别后续进一步深入了解的时候再整理补上。

渲染引擎

游戏引擎

在Wiki上也已经有整理了目前为止市面上已有的大量游戏引擎:Game Engine

Github上统计的开源游戏引擎:game-engines

参考引擎

通过上面的调查我们发现现在市面上的大小引擎数不胜数,一个个地去看的话时间周期估计要以年为单位,首先我们要先从自身的需求出发定出一些对参考引擎所需要具备的特性的要求,然后再根据要求来筛选出几个比较贴合我们需求的深入研究。

以我自身的角度出发,我列出来了以下一些要求:

  • 开源,但是项目规模还未到非常庞大的程度,避免研究周期过长
  • 具备一定规模的使用人数和影响力
  • 保持更新,所用方案不至于落后行业太久
  • 使用C++语言编写,具备跨平台特性
  • 支持2D/3D渲染,实现粒子系统、光源、动画系统、后处理等多项功能中的几种
  • 具备多平台自动切换渲染驱动的话更好

我从上面调查后的引擎列表里整理出了以下几个符合语言、使用人数、持续更新、支持效果等方面都比较符合的引擎来优先作为研究的对象,后续的分析系列文章也会先以这些引擎来作为目标:

  • 渲染引擎

    • bgfx
      • 可实现2D以及文字绘制,3D渲染,光照等效果
      • 可自动切换Metal等渲染驱动
    • OGRE 3D
      • 老牌渲染引擎,除了渲染之外还包含动画系统和粒子系统
    • OpenSceneGraph
      • 中文文档,粒子系统等功能可通过第三方插件实现
  • 游戏引擎

总结

至此我们完成了在迈出跨平台渲染引擎第一步之前的铺垫工作,我们梳理了渲染引擎的一个大致流程,以及这个流程里面的关于 Rendering path 等方面的细节信息,对这些内容有了一个初步的印象,同时列举了以下令人望而却步的技能树,但是我们可以一步一步地吃成胖子,重要地是迈出这第一步,最后我们整理了一下渲染/游戏引擎列表,并按照自身要求从中梳理了几个引擎来作为下一步分析研究的目标。