Skip to main content

3.2 延迟着色

传统的渲染管线使用一个着色器进行整个渲染工作,这个渲染过程其实包括两部分的内容:通过深度测试找出场景中的所有可视点,以及对每个表面点进行着色。利用光栅化技术进行渲染的过程中深度测试是不可避免的(它是图形处理器存在的最重要理由之一),而过度绘制的计算发生于表面着色计算过程,如果我们能够将着色过程和深度测试过程分开,将着色计算延迟到深度测试之后,我们就能避免因被深度测试丢弃片元产生的不必要的着色计算,这就是延迟着色(deferred shading)技术的原理,与之相对应,我们将传统用于深度测试以确定可视区域的渲染管线称为前向着色(前向着色的概念其实并不准确,因为第一阶段只是进行深度测试以确定场景中的可视区域,并不涉及表面着色的计算,这里仅仅是为了区分延迟着色中的不同阶段。)(forward shading)。

分析第3.1节式(2),要想实现着色计算和深度测试的分离,我们唯一需要做的是对每个深度测试通过的像素使用额外的方式记录下所有这些着色参数,然后使用一个单独的渲染通道来仅对这些可视的像素点进行着色计算。这些包含着色参数的缓存对象称为几何缓存(Geometry buffer,G-buffer),这些着色参数可以使用现代图形处理器接口提供的MRT特性进行存储。

图(1):延迟着色的过程,它在传统的渲染管线基础上,将着色过程和深度测试过程分离,在前向着色阶段仅将着色参数写入到G-buffer中,然后使用一个额外的延迟着色阶段来对像素进行着色计算

延迟着色的过程如图(1)所示,它可以简述如下:

  1. 绘制场景中所有的不透明几何体,并将每个片元对应的法线矢量,漫反射折射率,以及高光扩散系数等存储到G-buffer中,如本书后面图(2)所示。此过程涉及在片元着色器中输出多个颜色值,因此需要用到图形处理器接口中的MRT特性。此过程称为前向着色阶段。
  2. 分别计算每个光源对可见表面点的影响。这通过分别绘制包围每个光源影响范围的几何体来实现,此时我们应该关闭深度测试,因为我们只需要找出该光源影响的屏幕上的区域,同时每个光源包围几何体有两个面,我们应该根据情况绘制光源包围几何体的其中一面,例如当摄像机位于光源包围几何体内部时,我们应该绘制该几何体的背面,否则只需要绘制正面。对于一些没有体积的光源如直线光源,以及光源影响范围同时包括视锥体近平面和远平面时,我们直接绘制一个2D的包括全屏的平面。在着色器中,前向着色阶段输出的G-buffer将作为纹理数据被读入,G-buffer中的深度值用来计算像素的3D位置,以此用来计算光源的距离递减函数以及查询阴影贴图。最后该光源对每个像素点的颜色计算结果被写入到一个累积缓存(accumulate buffer)。此过程称为延迟着色阶段。
  3. 按传统的渲染管线绘制所有半透明的物体。
深度+模板高光扩展系数漫反射率表面法线

图(2):一个基本的几何缓存包含深度,漫反射率,表面法线矢量,以及高光扩展系数几个基本的第3.1节式(2)中的着色参数,这些着色参数可以被延迟着色(或其他后处理)阶段用于着色计算(图片来自Dice)

延迟着色技术解决了传统渲染管线中的过度绘制的问题,这是通过牺牲内存占用来实现的,它用一个巨大的几何缓存对象将那些着色参数暂存起来,以便能够在稍后待所有不可见的像素被深度测试剔除之后再进行必要的光照计算,能这样做的原因是深度测试本身和着色计算几乎是完全独立的,所以延迟着色计算的结构几乎和传统渲染管线是一致的,并且延迟着色使渲染性能不再与场景的复杂度像耦合(尤其延迟着色支持数量巨大的光源),能够保证稳定的帧率,稳定的帧率是实时渲染领域中的一个重要的衡量指标。

虽然有上述这些优点,并且从渲染结果上看延迟渲染和传统渲染管线的结果是一致的(因为它并没有对第3.1节式(2)作任何修改),但是延迟渲染还是带来了一些新的问题,其中一些主要的问题包括:

  • 不支持半透明物体。一个包含半透明表面的像素点的颜色值是两个(或多个)表面点颜色值混合的结果,由于G-buffer只保存每个像素点的一个表面点的值,所以它不能支持半透明物体,在延迟渲染中我们必须对半透明物体单独采用传统的渲染管线来处理。
  • 巨大的帧缓存存储占用。通常一个G-buffer中每个像素可以占用多达128bits甚至以上的内存占用,当使用多重采样时更是会占用巨大的内存(我们将在第(3.5)节讨论延迟着色中多重采样的问题);此外,为了保证多个光源累加结果的精确性,颜色累积缓存还必须使用更高精度的缓存对象。
  • 对屏幕区域的像素点(而不是根据每个物体自身的类型)进行着色计算,这使得我们很难针对每种物体使用自定义的着色器,因为各种类型的物体被混在一个屏幕区域,我们必须使用统一的着色器,这使得自定义着色器变得非常困难,我们将在第(3.4)节中讨论延迟着色中的着色器管理。
  • 最后一个问题是内存访问的高带宽占用,这将在本节及接下来的内容中重点介绍。

带宽问题是延迟渲染方法带来的新的问题,它带来了一种新的形式的过度绘制,以下是一个传统延迟着色中的着色计算阶段,它对每个光源包围盒形成的几何体绘制一次,然后在其覆盖的屏幕2D区域内对每个像素执行着色计算:

for each light
for each covered pixel
read G-buffer
compute shading
read + write frame buffer

从以上的伪代码中我们看到,对于每个光源覆盖的每个像素点,着色器都要分别对帧缓存执行:读取-->计算-->写入的操作,如果一个像素点被多个光源所覆盖,这在整个着色计算过程中这个像素点对应的着色数据会被重复读写多次,这就导致一种新的过度绘制。

图(3):GPU性能发展趋势,其计算能力的提升速度会大大高于带宽的提升速度,这其实对CPU也是一样的,所以应用程序应该充分优化以更紧密地对数据进行利用,而不是频繁重复地读写

通过第(2)章的内容可知,处理器对任何寄存器以外的内存读取都会导致延迟,这些延迟包括存储器本身处理数据输入输出的延迟,以及数据由存储器向处理器传输过程中带宽的限制。图(2)是近十几年GPU性能发展的趋势,我们可以看出GPU计算能力提升的速度会大大高于其传输带宽的提升,虽然缓存可以在一定程度上减少带宽导致的延迟,但是它仍然比寄存器要慢得多;同时在GPU中,缓存是基于内核内多个线程共享的,它还必须处理同步的问题。所以在GPU编程中,我们要充分优化内存读写的算法,尽可能地将数据读取到寄存器(回想第(2)章的内容,GPU拥有数量众多的寄存器,将数据读取到本地寄存器,并在单线程内进行足够的计算,然后将最终计算结果写入全局内部是GPU并行计算的基本策略。),对其进行更多计算使用,然后再写入到全局内存中。

针对带宽的问题,我们有两种解决方案:比较好的解决方案是将循环结构中光源的循环放入到双重循环结构的内部,这样针对所有覆盖该表面点的光源只需要对G-buffer中的数据读写一次,但是这需要额外的工作来找出每个表面点被哪些光源覆盖,即所谓的光源分配(light assignment),这种解决方案正是本章后面第(3.3.1)和(3.3.2)节的内容,这些方法的重点内容就是解决光源分配。

另一种解决方案,则是减少光源循环中读取数据的数量,即进一步将着色计算中的光照计算分离出来,例如后面的延迟光照方法中,G-buffer中只需要32位存储一个法线矢量即可,大大减少了带宽的占用,本节剩下的内容就将讨论这种减少光源循环中对G-buffer数据读取的数量的方案。