本篇对《Real-Time Rendering》一书中延迟渲染相关知识进行概括总结和扩展。主要内容包括:
- 延迟渲染的概念、G-buffer、延迟渲染的过程
- 延迟渲染 vs 正向渲染、延迟渲染 vs Z-Prepass
- 延迟渲染的优缺点、延迟渲染透明物体、延迟渲染与 MSAA
- 延迟渲染的改进:延迟光照(Light Pre-Pass / Deferred Lighting)、分块延迟渲染(Tile-Based Deferred Rendering)
- 延迟渲染 vs 延迟光照
1 延迟渲染(Deferred Rendering)
在计算机图形学中,延迟渲染(Deferred Rendering),又称延迟着色(Deferred Shading),是将着色计算延迟到深度测试之后进行处理的一种渲染方法。延迟渲染技术的最大的优势就是将光源的数目和场景中物体的数目在复杂度层面上完全分开,能够在渲染拥有成百上千光源的场景的同时依然保持很高的帧率,给我们渲染拥有大量光源的场景提供了很多可能性。
我们知道,正向渲染(Forward Rendering),或称正向着色(Forward Shading),是渲染物体的一种非常直接的方式,在场景中我们根据所有光源照亮一个物体,之后再渲染下一个物体,以此类推。
传统的正向渲染思路是,先进行着色,再进行深度测试。其主要缺点就是光照计算跟场景复杂度和光源个数有很大关系。假设有 n 个物体,m 个光源,且每个每个物体受所有光源的影响,那么复杂度就是 O(m*n)。
正向渲染简单直接,也很容易实现,但是同时它对程序性能的影响也很大,因为对每一个需要渲染的物体,程序都要对每个光源下每一个需要渲染的片元进行迭代,如果旧的片元完全被一些新的片元覆盖,最终无需显示出来,那么其着色计算花费的时间就完全浪费掉了。
而延迟渲染的提出,就是为了解决上述问题而诞生(尤其是在场景中存在大量光源的情况下)。延迟渲染给我们优化拥有大量光源的场景提供了很多可能性,因为它能够在渲染拥有成百上千光源的场景的同时还能够保持能让人接受的帧率。下面这张图展示了一个基于延迟着色渲染出的场景,这个场景中包含了 1000 个点光源,对于目前的硬件设备而言,用传统的正向渲染来实现将是极其耗时的。
可以将延迟渲染理解为先将所有物体都绘制到屏幕空间的缓冲(即 G-buffer,Geometric Buffer,几何缓冲区)中,再逐光源对该缓冲进行着色的过程,从而避免了因计算被深度测试丢弃的片元的着色而产⽣的不必要的开销。也就是说延迟渲染基本思想是,先执行深度测试,再进行着色计算,将本来在物空间(三维空间)进行的光照计算放到了像空间(二维空间)处理。相较于正向渲染 O(m*n) 的复杂度,经典的延迟渲染复杂度为 O(n+m)。
2 几何缓冲区(G-buffer)
G-Buffer,全称 Geometric Buffer,几何缓冲区。它主要用于存储每个像素对应的位置(Position),法线(Normal),漫反射颜色(Diffuse Color)以及其他有用的材质参数。根据这些信息,就可以在像空间(二维空间)中对每个像素进行光照处理。下图展示了一个典型的 G-Buffer 布局:
下图是一帧中 G-Buffer 中存储的部分内容可视化结果:
G-Buffer 是一个宏观的概念,并不是一整个缓冲区,而是由多个缓冲区共同组成,或者由多张纹理共同组成,比如后文会提到的 MRT,就是将不同的信息渲染到多个纹理,这些纹理被称为渲染目标(Render Targte,RT),这些 RT 共同组成了 G-Buffer。
3 延迟渲染的过程
可以将延迟渲染理解为两个 Pass 的过程:
1、几何处理阶段(Geometry Pass)。这个阶段中,我们获取对象的各种几何信息,并将第二步(也就是渲染)所需的各种数据储存到多个渲染目标中;
2、光照处理阶段(Lighting Pass)。在这个 pass 中,我们只需渲染出一个屏幕大小的二维矩形,使用第一步在 G-buffer 中存储的数据对此矩阵的每一个片元计算场景的光照;光照计算的过程还是和正向渲染一样,只是现在我们需要从对应的 G-buffer 而不是顶点着色器(和一些 uniform 变量)那里获取输入变量了。
下图展示了延迟渲染的过程:
延迟渲染方法一个很大的好处就是能保证在 G-buffer 中的片元和在屏幕上呈现的像素所包含的片元信息是一样的,因为深度测试已经最终将这里的片元信息作为最顶层的片元。这样保证了对于在光照处理阶段中处理的每一个像素都只处理一次,所以我们能够省下很多无用的渲染调用。除此之外,延迟渲染还允许我们做更多的优化,从而渲染更多的光源。
在几何处理阶段中填充 G-buffer 非常高效,因为我们直接储存位置,颜色,法线等对象信息到帧缓冲中,这个过程几乎不消耗处理时间。
对于多个光源的情况,不同的光源对场景的影响不同,所以 G-Buffer 中存储的片元信息也可能不同,因此我们可以对每个光源创建一个屏幕空间包围矩形,然后用光照 shader 渲染这个矩形,最后融合起来即可。
而在此基础上使用多渲染目标(Multiple Render Targets, MRT)技术,我们可以在一个 Pass 之内完成所有渲染工作。渲染目标就是指纹理,也就是将渲染结果存到纹理中而不是输出到屏幕上,之后再用这些纹理进行各种后处理,在 Unity Shader 部分中我们早就已经这样做过了,这也是极其常见的做法。多渲染目标就是指将各种信息(位置、法线、反射率等)分别存入一张纹理中,然后结合多张纹理的信息进行着色计算,实际上和 G-Buffer 的效果一样,也可以说这些纹理组成了 G-Buffer,而且存入纹理中的信息还可以反复使用,用来实现各种更高级的效果。
4 延迟渲染 vs 正向渲染
这是一个经常被问起的话题,因此这里对二者的特性做一个总结。
4.1 正向渲染
- 正向渲染(Forward Rendering),先执行着色计算,再执行深度测试
- 正向渲染渲染 n 个物体在 m 个光源下的着色,复杂度为 O(n*m)
- 正向渲染中光源数量对计算复杂度影响巨大,所以比较适合户外这种光源较少的场景
- Forward Rendering 的核心伪代码可以表示为:
1 | For each light: |
- Forward Rendering 的管线流程如下图所示:
4.2 延迟渲染
- 延迟渲染(Deferred Rendering),先执行深度测试,再执行着色计算
- 延迟渲染渲染 n 个物体在 m 个光源下的着色,复杂度为 O(n+m)
- Deferred Rendering 的最大的优势就是将光源的数目和场景中物体的数目在复杂度层面上完全分开。也就是说场景中不管是一个三角形还是一百万个三角形,最后的复杂度不会随光源数目变化而产生巨大变化
- Deferred Rendering 的核心伪代码可以表示如下:
1 | For each object: |
- Deferred Rendering 的管线流程如图所示:
4.3 延迟渲染 vs Z-Prepass
延迟渲染和之前学过的 Early-Z 非常相似,二者的区别可以查看之前的笔记【Real-Time Rendering】模板测试和深度测试的最后部分。
5 延迟渲染的优缺点
总结一下延迟渲染的优缺点。
5.1 延迟渲染的优点
- Deferred Rendering 最大的优势就是将光源的数目和场景中物体的数目在复杂度层面上完全分开。也就是说场景中不管是一个三角形还是一百万个三角形,最后的复杂度不会随光源数目变化而产生巨大变化
- 复杂度仅 O(n+m)
- 只渲染可见的像素,节省计算量
- 用更少的 shader
- 对后处理支持良好
- 在大量光源的场景优势尤其明显
5.2 延迟渲染的缺点
- 内存开销大
- 读写 G-buffer 的内存带宽用量是性能瓶颈
- 对透明物体的渲染存在问题,在这点上需要结合正向渲染。后面细说。
- 对多重采样抗锯齿(MultiSampling Anti-Aliasing, MSAA)的支持不友好。后面细说。
6 延迟渲染透明物体
延迟渲染要渲染透明物体需要与前向渲染结合。首先,我们需要明确一个问题,为什么延迟渲染不适用于透明物体?
因为延迟渲染只计算了离视野最近的物体像素,并对其进行光照计算和着色。因此,这会导致:
- 透明物体和不透明物体重叠,且透明物体在后时,仅渲染不透明物体,效果正确
- 透明物体和不透明物体重叠,且透明物体在前时,仅渲染透明物体,效果错误,看不到透明物体后面的物体
- 透明物体之间重叠时,仅渲染最前面的透明物体,效果错误
解决以上问题一个常见的思路是:使用延迟渲染的框架,分别渲染不透明物体,透明物体背面,透明物体正面,再把三者按照 alpha 合并。
在这种情况下,我们可以基本保证第二种情况的正确;而对于第三种情况,由于延迟渲染仅对离相机最近的像素进行光照/着色计算,我们依然只能计算(特别地,若最近的像素透明度为0,我们忽略这一像素)最近的透明物体的光照。对于这种情况,采取的解决方案是写入 G-Buffer 时仅混合物体颜色,在延迟渲染过程中,依然只计算最近物体的光照,但把混合后的颜色作为最近物体的基本颜色进行光照计算。
7 延迟渲染与 MSAA
为什么延迟渲染中不支持 MSAA?这又是一个经常被问起的话题,延迟渲染不支持 MSAA 的说法实际上并不准确,准确的说是延迟渲染对 MSAA 的支持并不好,或者说在延迟渲染中做 MSAA 不方便。
首先来回顾 MSAA 的原理,MSAA 是在 SSAA 的基础上发展来的硬件抗锯齿技术。SSAA 是理论上效果最好的抗锯齿方案,以 4x 为例(下同),4xSSAA 对于每个像素计算 4 个子像素,将 4 个子像素的颜色求平均值,便能获得抗锯齿后的颜色。实际上 SSAA 等于暴力渲染了 4 倍分辨率的图像,在目前的硬件条件下这种性能开销是不可接受的,因此在 SSAA 的基础上发展出了 MSAA。
MSAA 与 SSAA 的区别在于像素着色器(Pixel Shader)的运行次数。MSAA 同样对于每个像素进行了 4 次子采样(Sample),但是只在像素中心位置运行一次像素着色,然后根据有多少 Sample 被三角形覆盖而对颜色进行一个加权处理,也就是像素中有多少子像素被三角形覆盖,就用该像素的颜色乘以被覆盖的子像素的比例,相比于 SSAA 每个子像素单独计算颜色,效率大幅提升。
下面以一个例子来看 MSAA 的具体过程:
在前向渲染中,三角形的绘制是依次进行的。绘制蓝色三角形时,MSAA 的具体执行步骤如下:
- 光栅化阶段,对四个 X 位置的 Sample 执行三角形覆盖判断,在一个四倍分辨率大小的 coverage mask 中记录每个 Sample 被覆盖的情况;
- 像素着色阶段,在像素中心圆点处执行像素着色器。该点的位置、深度、法线、纹理坐标等信息由三角形三个顶点重心插值得到。图中计算得到像素颜色为紫色;
- 对四个 Sample 执行模板测试与深度测试,并将测试通过的 Sample 数据写入四倍分辨率的模板缓冲与深度缓冲。每个 Sample 都拥有自己的深度值,依然是重心插值得到;
- 上图中左下两个 Sample 通过了深度测试,并且 coverage mask 为 1,因此将紫色复制到这两个 Sample 对应的颜色缓冲中(依然是每个 Sample 一个颜色,颜色缓冲也需要四倍大小)。其他两个 Sample 暂为背景色;
- 重复上述流程绘制第二个黄色三角形,将像素着色获得的黄色复制到右上角的 Sample 中;
- 所有绘制结束之后,通过一个对高层透明的 pass,将四个 Sample 的颜色混合获得最终输出的像素颜色。
可以看到在 MSAA 流程中所使用的所有缓冲区都变成了原来的四倍大小,这也是为什么 MSAA 增加了非常多的显存和带宽消耗。上述流程中第 4 步如果改成对每个 Sample 单独进行像素着色,MSAA 就变成了 SSAA。
理通了 MSAA 的具体流程,接下来回答问什么延迟渲染不好做 MSAA:
延迟渲染的光照计算阶段使用的输入是 G-Buffer,如果还像前向渲染一样,在光照计算以后执行 MSAA,会得到错误的结果。具体来说,使用单倍 G-Buffer 来进行计算,会因为得不到三角形的覆盖信息而无法判定应该将该像素的颜色值复制到哪几个子 Sample 上,也不会出现同一个像素的子 Sample 会被不同面片覆盖的情况,因为 G-Buffer 就是一张图,已经不知道该点被几个三角形覆盖了。而使用多倍大小的 G-Buffer 的话,又无法通过顶点插值获取中心处原始像素的位置、深度、法线、纹理坐标等数据,因为原始三个顶点的信息已经没有了。更重要的是,在多倍大小的 G-Buffer 上我们是没办法判断哪几个子 Sample 是与中心像素在同一三角形上的,如果试图使用四个子 Sample 的数据插值获得中心像素,对深度和法线进行插值会导致意料之外的后果。上面两个原因综合起来,就是“丢失的其他像素信息导致无法使用 MSAA” 这种说法的来源了。
总结起来,延迟渲染对 MSAA 支持不友好的原因在于:
- MSAA 本质上是一种发生在光栅化阶段的技术,也就是几何阶段后,着色阶段前,用这个技术需要用到场景中的几何信息
- 延迟渲染因为需要节省光照计算的原因,事先把所有信息都放在了 G-Buffer 上,着色计算的时候已经丢失了几何信息
如果要在延迟渲染中使用 MSAA,需要将 G 缓存的 MRT 以多重采样的形式保存使得每个样本的信息不被丢失从而可以进行解析操作。而如果直接对 G 缓存中的属性比如法线和深度进行解析的话则可能产生错误结果,比如说如果一个给定像素中的样本在 G 缓存中有着不同的深度值,那么取均值后的结果可能和场景真实的几何信息无关,法线同理。正确的做法是对每个样本进行光照计算后再解析,对每个样本的光照结果取均值。因此需要保证像素着色器逐样本执行然后将每个样本输出写入多重采样RT。如下图所示。
事实上,从 DirectX 10 开始就已经允许在高达 8 个 MRT 的情况下使用 MSAA 了。
8 延迟渲染的改进
上文提到过,延迟渲染的性能瓶颈在于读写 G-buffer 的内存带宽,因此延迟渲染的改进也主要是从这方面入手,下面简单介绍一些降低延迟渲染存取带宽的改进方案。最简单也是最容易想到的就是将存取的 G-Buffer 数据结构最小化,这也就衍生出了 Light Pre-Pass,即延迟光照(Deferred Lighting)方法。另一种方式是将多个光照组成一组,然后一起处理,这种方法衍生出了分块延迟渲染(Tile-Based Deferred Rendering)。
8.1 延迟光照
Light Pre-Pass 即 Deferred Lighting(延迟光照),旨在减少传统 Defferred Rendering 使用 G-buffer 时占用的过多开销,延迟光照的具体的思路是:
- 渲染场景中不透明(opaque )的几何体。将法线向量 n 和镜面扩展因子(specular spread factor)m 写入缓冲区。这个 n/m-buffer 缓冲区是一个类似 G-Buffer 的缓冲区,但包含的信息更少,更轻量,可以用单个输出颜色缓冲区存储,因此不需要 MRT 支持。
- 渲染光照。计算漫反射和镜面着色方程,并将结果写入不同的漫反射和镜面反射累积缓冲区。这个过程可以在一个单独的 pass 中完成(使用 MRT),或者用两个单独的 pass。环境光照明可以在这个阶段使用一个 full-screen pass 进行计算。
- 对场景中的不透明几何体进行第二次渲染。从纹理中读取漫反射和镜面反射值,对前面步骤中漫反射和镜面反射累积缓冲区的值进行调制,并将最终结果写入最终的颜色缓冲区。若在上一阶段没有处理环境光照明,则在此阶段应用环境光照明。
- 使用非延迟渲染方法渲染半透明几何体。
总结来说相当于把每个像素的光照计算结果也存入纹理中,最后着色时对光照结果进行调制或者后处理。相对于传统的 Deferred Render,使用 Light Pre-Pass 可以对每个不同的几何体使用不同的 shader 进行渲染,所以每个物体的 material properties 将有更多变化。
传统的 Deferred Render 的第二步是遍历每个光源,这样就增加了光源设置的灵活性,而 Light Pre-Pass 第三步使用的其实是 forward rendering,所以可以对每个 mesh 设置其材质,这两者是相辅相成的,有利有弊。
另一个 Light Pre-Pass 的优点是在使用 MSAA 上很有利。虽然并不是完全使用上了 MSAA(除非使用 DX10 以上的特性),但是由于使用了 Z 值和 Normal 值,就可以很容易找到边缘,并进行采样。
8.2 分块延迟渲染
作为传统 Defferred Rendering 的另一种主要改进,分块延迟渲染(Tile-Based Deferred Rendering,TBDR)旨在合理分摊开销。实验数据表明 TBDR 在大量光源存在的情况下明显优于上述的 Light Pre-Pass。
我们知道,延迟渲染的瓶颈在于读写 G-buffer,在大量光源下,具体的瓶颈将会在于每个光源对 G-buffer 的读取及与颜色缓冲区混合。这里的问题是,每个光源,即使它们的影响范围在屏幕空间上有重叠,因为每个光源是在不同的绘制中进行,所以会重复读取 G-buffer 中相同位置的数据,计算后以相加混合方式写入颜色缓冲。光源越多,内存带宽用量越大。
而分块延迟渲染的主要思想则是把屏幕分拆成细小的栅格,例如每 32 × 32 像素作为一个分块(tile),然后计算每个分块会受到哪些光源影响,把那些光源的索引储存在分块的光源列表里。最后,逐个分块进行着色,对每个像素读取 G-buffer 和光源列表及相关的光源信息。因此,G-buffer 的数据只会被读取 1 次且仅 1 次,写入 color buffer 也是 1 次且仅 1 次,大幅降低内存带宽用量。不过,这种方法需要计算光源会影响哪些分块,这个计算又称为光源剔除(light culling),可以在 CPU 或 GPU(通常以 compute shader 实现)中进行。用 GPU 计算的好处是,GPU 计算这类工作比 CPU 更快,也可以减少 CPU 和 GPU 之间的数据传输。此外,还可以计算每个分块的深度范围(depth range),作更有效的剔除。
总结来说,分块延迟渲染将屏幕分成一个个小块 tile 并计算每个分块的深度范围。然后根据深度范围和分块大小,可以求得每个 tile 的 bounding volume。对每个 tile 的 bounding volume 和光源进行求交点,这样就得到了对该 tile 有作用的光源的序列。最后根据得到的序列计算所在 tile 的光照效果。整个过程如下图所示:
对比传统 Deferred Rendering,之前是对每个光源求其作用区域 light volume,然后决定其作用的 pixel,也就是说每个光源要求取一次。而使用 TBDR,只要遍历每个 pixel,让其所属 tile 与光线求交,来计算作用其上的光源,并利用 G-Buffer 进行着色。这样做一方面减少了所需考虑的光源个数,另一方面与传统的 Deferred Rendering 相比减少了存取的带宽。因此分块渲染现在广泛应用于移动设备渲染中。
9 延迟渲染 vs 延迟光照
关于延迟着色和延迟光照,经常会被弄混,这里简单区分一下。
- 延迟渲染需要更大的 G-Buffer 来完成对 Deferred 阶段的前期准备,而且一般需要硬件有 MRT 的支持,可以说是硬件要求更高。
- 延迟光照需要两个几何体元的绘制过程来完成整个渲染操作:G-Pass 与 Shading pass。这个既是劣势也是优势:由于延迟渲染中的 Deffered 阶段是在完全基于 G-Buffer 的屏幕空间进行,这也导致了物体材质信息的缺失,这样在处理多变的渲染风格时就需要额外的操作;而延迟光照却可以在 Shading 阶段得到物体的材质信息进而使这一问题的处理变得较简单。
- 两种方法的上述操作均是只能完成对不透明物体的渲染,而透明或半透明的物体则需额外的传统 Pass 来完成。
延迟渲染的流程图如下,根据 G-Buffer 中的信息直接计算光照和着色:
延迟光照的流程图如下,提前计算光照并存入纹理,在着色时直接对纹理采样得到光照结果,不再计算光照,只进行其他后处理,从这个流程上来说,Light Pre-Pass 这个名称更为合适:
无论是正向渲染,延迟渲染、延迟光照、分块延迟渲染,都属于不同的渲染路径(Rendering Path)。