渲染路径:Deferred Texturing

Deferred Texturing

forward rendering 将获取 material 相关属性的计算和 lighting 计算都放在光栅化 pass 的 pixel shader 中;deferred rendering 则将 lighting 计算从 pixel shader 抽离开来并延迟到后面的一个 compute shader。

Deferred Texturing 则主要把获取 material 相关属性的计算从 pixel shader 中抽离,并延迟到后面的某个 compute shader pass。

常见渲染路径 material 计算 lighting 计算
Forward Rendering PS PS
Deferred Rendering PS CS
Deferred Texturing CS CS

那么问题来了:为什么我们需要 Deferred Texturing 这种全新的渲染路径?

光栅化的 Helper Lane 开销

PS(Pixel Shader)很容易出现 overdraw 问题,虽然一个额外的 z-prepass 能够避免这个问题,但是其实 PS 还有另一个性能浪费的点,下面就详细阐述一下。

现代硬件光栅化往往经历两个阶段:

  • Coarse Raster : 以 8x8 像素为一块;将三角形光栅化,输出若干个块(即相当于在 \(\frac{1}{8}*\frac{1}{8}\) 分辨率的 framebuffer 上进行光栅化),并进行一次 z cull。

z cull:利用低分辨率的 z-buffer 来剔除 8x8 像素的块。

  • Fine Raster :以 2x2 像素为一个 quad;在经过 coarse raster 阶段且通过 Z Cull 后剩下的块内,再次将三角形光栅化,输出若干个 pixel quad,并且对 quad 里的 4 个像素执行 PS 和 z test。

因为采样贴图时需要计算 lod 层级,就需要知道相邻像素的 uv 并计算它们之间的差分,因此光栅化硬件在 fine raster 阶段采用 2x2 pixels 的并行单位而非 1 pixel 的并行单位。

然而在三角形的边缘情况下,fine raster 很容易出现浪费:实际上只需要 3 个 pixels 执行 PS,由于光栅化硬件的设计,实际上执行了 4 个 pixels 的 PS,这造成了浪费。

image-20230505172345096

这种性能浪费尤其体现在细长三角形或者小三角形的情况下:

image-20230505172512041

与此同时随着游戏模型的日益精细,三角面数越来越多,一个三角面覆盖的 pixels 数量也越来越少,这也就越来越造成更大的性能浪费。

例如在线段的 case 中,fine raster 的开销差不多是两倍:

常见渲染路径 material 开销 lighting 开销
forward rendering 2x 2x
deferred rendering 2x 1x
deferred texturing 1x 1x

为此,我们希望尽可能减少 PS 的工作量,从而尽可能减少性能的浪费,这也是 deferred texturing 为什么会将获取 material 相关属性的计算从 PS 中抽离走。尤其是现在游戏的 material 越来越来复杂,其计算量也越来越不能忽视。

Draw Call 更容易合批

将 material 计算从 PS 抽离出来后,material 不同的不透明物体都能共用少数几个 pixel shader( PS 里的代码与 material 大部分属性都无相关了,除了背面剔除,alpha测试等属性),这可以大大减少光栅化的 PSO 切换。

利用 V-Buffer 可以做更多事情

V-Buffer 的各种 ID 可以用于实现更多的算法,例如本文后续介绍的 upsampling、AA 和 software VRS,用于 temporal filtering 避免产生鬼影的 id detection,基于 visibility id 的 surfel GI 方案等。随着 deferred texturing 渲染路径的普及,也会产生越来越多利用 V-Buffer 的新算法。

即便是传统的延迟渲染路径,也可以在 base pass 额外渲染多一个轻量级的 V-Buffer(也算是 G-Buffer 多一张 texture),来支持很多算法。

常见的 Deferred Texturing 流程

Z-prepass(砍掉)

Z-prepass 是老生常谈的优化 overdraw 手段,但 Deferred Texturing 并不需要 Z-prepass。原因如下:

  1. 以前 deferred rendering 和 forward rendering 的 PS 都是计算量比较大的,因此减少这些 PS 的 overdraw 是收益明显的;然而对于 deferred texturing 来说,其 PS 就只是写入 visibility ID,计算量很少,减少这些 PS 的 overdraw 其实收益不明显。
  2. Z-prepass 的代价是多一倍的 draw call。减少 Visibility Passes 带来的额外 overdraw 开销大概率不如减少 draw call 数量。

Visibility Pass

绘制场景中所有的不透明几何体,将每个 fragment 的 instance id、primitive id、barycentric coord、material id 写入到 V-Buffer 中去。

那 deferred texturing 如何抽离 material 计算呢?

首先它要把 render pass 的 Render Target 从 G-Buffer 换成了 V-Buffer(Visibility Buffer),并往 V-Buffer 里面每个 pixel 填充以下内容:

  • InstanceID(16~24 bits):表示当前 pixel 属于哪个 instance(也包含了属于哪个 mesh 的信息)
  • PrimitiveID(8~16 bits):表示当前 pixel 属于 instance 的哪个三角形
  • Barycentric Coord(16 bits):代表当前 pixel 位于三角形内的位置,用重心坐标表示
  • MaterialID(8~16 bits):表示当前 pixel 属于哪个材质

这些属性并不需要从各种贴图中计算而得,而是能直接从 VS 传给 PS 的三角形属性获得。

image-20230517145214626

同时,我们还需要维护一个全局的 vertex buffer 和材质贴图表,表中存储了当前帧所有几何体的顶点数据,以及材质参数和贴图。不过全局材质贴图表会增加引擎设计的复杂度和降低材质系统的灵活度,并且往往还需要硬件支持如下特性之一: Bindless Texture/Sparse Texture/Texture Array,亦或者软件实现 Virtual Texture 特性。

这样利用总共 **8~12 Bytes/Pixel **的 V-Buffer,就可以在后续的某个阶段进行 material 计算

  1. 通过 InstanceID 和 PrimitiveID 从全局的 vertex buffer 中索引到相关三角形的信息。
  2. 对 vertex buffer 内的顶点信息(UV,tangent space等)进行重心坐标插值得到插值后的 pixel 信息。
  3. 根据 MaterialID 去全局的材质贴图表索引到相关的材质信息,并执行贴图采样等操作得到 material 信息。

Tile-based Material Culling

但是 deferred texturing 的一个问题是:由于实际 materials 是多种多样的,不同 material id 的 material 计算逻辑都是不同的。

  • 要是这些逻辑全部都塞到一个 full-screen 的着色 shader,那么这个 uber shader 将会产生众多分支,导致 GPU divergence,硬件利用率低。
  • 要是针对每个 material 分别做一次 full-screen 的着色,那么又会导致大量的 GPU 线程空转,因为实际上绝大部分 material 不会覆盖整个屏幕的 pixels。
  • 要是针对每个 material 分别记录一个所覆盖的 pixels 列表,这样每个 material 在着色时只会对有效覆盖的 pixels 进行着色,但是维护 pixels 列表的开销就会非常巨大(pixels 数量太多了)。

一个折衷的做法是,使用基于 tile 的 material culling:针对每个 material 分别记录一个该 material 所覆盖的 tiles 列表。

我们只对有效覆盖的 tiles 进行着色,维护 tiles 列表的开销是可以接受的(相比 pixels 远远要少得多);并且实际上,相同 material id 的 pixels 并非随机散落在各个地方,而往往是聚集在局部区域,这对 tile 类方法是非常友好的(产生较少的 divergence)。

接下来讲解业界一些常见的 material culling 方法:

Worklist Material Culling

最原始的 material culling 方法由以下两个 pass 组成:

  • Worklist Build Pass:基于 CS ,将屏幕划分成若干个 tiles,每个 tile 就是一个 thread group,每个 pixel 就是一个 thread。pixel 根据自身使用到的 material id,以 tile-material pair(8字节) 的形式记录到全局的 record list 里。
  • Worklist Sort Pass:基于 CS,对 record list 进行排序去重,并将去重后的 tile-material pair 中的 tile id 添加到 material 对应的 list 中。

worklist sort pass 应当充分利用 group shared memory 来做局部排序去重,而非做全局排序去重。

UE5 Nanite Material Culling

UE5 Nanite 利用了 R32G32 的 material id range texture(存放了 tile 内 material 的最大值最小值)来粗略地表示 tile 包含了最大最小值范围内的 materials。

img

并在后续的 material passes 中,使用了 render pass 而非 CS pass,即 1 个 draw call 对应 1 个 material 计算,render pass 行为如下:

  • VS 采样 material id range texture 来判断当前材质是否在该 tile(4个 vertexes 对应一个 tile)的 id range 内,若不在则将 vertex 位置设置为 NaN,那么后续 vertex 所在的这个三角形就会被剔除,从而不会进入 PS。
  • PS 采样 V-Buffer 来获得当前 pixel 的 material id 来判断是否匹配当前材质,并由此决定是否 discard,如果不 discard 则执行 material 计算。

这种方式的特点在于:

  • [√] 优势:通过 range texture 的方式省去了维护 tile 列表的开销。
  • [×] 缺点:range 的表示方式可能会包含一些实际并不存在的 materials,从而让一些无关 material 的 VS 通过剔除测试。

Uncharted4 Material Culling

《Uncharted 4》利用了 R8 的 features bitmask texture 来表示 tile 包含了哪些 shader features(8bit 就是对应共八种 features)。而不同的 material 实际上就是使用了不同的 features 组合。

ShaderMaterial_1()
{
    DoFeatureA();
    DoFeatureB();
}

ShaderMaterial_2()
{
    DoFeatureB();
    DoFeatureC();
    DoFeatureD();
}

img

一般来讲,一个 material 就是一个 shader,但在《Uncharted 4》中多个 materials 是可以合并成一个 shader 的:

ShaderMaterial_Merged_ABCD()
{
    uint materialID = GetPixelMaterialID();
    if(materialID & (1u<<MaskFeatureA)){DoFeatureA();}
    if(materialID & (1u<<MaskFeatureB)){DoFeatureB();}
    if(materialID & (1u<<MaskFeatureC)){DoFeatureC();}
    if(materialID & (1u<<MaskFeatureD)){DoFeatureD();}
}

可以把多个 materials 的 features bitmask 通过或运算起来获得一个合并后的 features bitmask,并由该 bitmask 来找到对应的 merged shader;8 bits 的 bitmask 组合就可以有 256 种 merged shader 变体。

  • 当一个 tile 内的 pixels 存在不同种 features 组合的时候,就可以通过 merged shader 一次处理好,而不需要被 material passes 处理两次或更多次了。
  • 如果 tile 内的 pixels 都属于同种 features 组合,则可以调用 branch-less(无分支)的 shader,来减少分支开销。实际上,游戏画面中的大部分 tiles 也确实是只包含了单一的 features 组合。但是,可能需要额外生成 256 种 branch-less shader 变体。
ShaderMaterial_BranchLess_BCD()
{
    DoFeatureB();
    DoFeatureC();
    DoFeatureD();
}

这种方式的特点在于:

  • [√] 同一个 tile 不会被 material passes 处理两次或更多次,性能可控。
  • [×] merged shader 的分支判断可能会带来一定的 divergence 性能问题。

Material Passes

对每个 material 分别进行一个 pass(可 PS 也可 CS),并根据 material id 获取 worklist 里面的 tiles,对这些 tiles 进行着色:

  • 如果 pixel 的 material id 不是当前 material,则不着色。
  • 如果 pixel 的 material id 正好是当前 material,则进行 material 计算,输出到 G-Buffer。

Lighting Pass

进行一个 full-screen 的 pass(可 PS 也可 CS),根据 G-Buffer 对 pixel 进行 lighting 计算,输出到最终颜色图像。

基于高分辨率 V-Buffer 的 AA/Upsampling

Decoupled Visibility Multisampling (DVM) 是一种基于高分辨率 visibility buffer 的多重采样方法,可用于 upsampling(升采样)或 AA(anti-alias,抗锯齿)。

其核心思路是:现在有低分辨率的 G-Buffer 以及低分辨率的 shading 结果,然后我们利用高分辨率的 V-Buffer来指导,来对 color pixel(颜色像素)间的 pore pixel(空隙像素)填充颜色,从而输出高分辨率的 shading 结果。

DVM Upsampling

Pore Pixels Filling

例如,我们有 4*4 分辨率的颜色图像,希望进行 4x DVM upsampling。一个直接的做法就是:

  • 第一轮 upsampling pass:让位于 4 color pixels 正中间的每个 pore pixel 获取其对角处 4 个 color pixels,并根据它们 visibility id 是否一致(与 pore pixel 相同 visibility id 的 color pixel 权重为1,id 不同的权重则为一个接近于0的小数)来混合出一个新的颜色并填入 pore pixel。
  • 第二轮 upsampling pass:让剩余未着色的每个 pore pixel 获取其邻近 2 个 color pixels 和 2 个上一轮着色好的 pore pixels,并同样地根据它们 visibility id 是否一致来混合出一个新的颜色并填入 pore pixel。

经过这两轮 upsampling pass 就可以得到 upsampling 的图像:

image-20241215154735616

当然在混合时也可能存在最坏情况:若 4 个 color pixels 的 visibility id 均与目标像素的 visibility id 不同,这时候则只能平均混合了。

visibility id 不同的 color pixel 的权重都是一个接近于0的小数(例如0.0001),当需要进行混合的四个样本都是 0.0001 权重时,就相当于平均混合了。

One-pass Filling

当然还有一种更便捷但质量次一点点的 DVM upsampling 方法:

  • 每个 pore pixel 去访问附近 4 个 color pixels,若 visibility id 相同则参与基于 uv 距离的混合;若 4 个 pixels visibility id 均与本 pore pixel 的不同,则直接使用双线性混合。
image-20241215154751717

每个 pore pixel 都需要获取附近 4 个 color pixels,而在 G-Buffer/V-Buffer 中这些 color pixels 的属性又往往被重复读取,导致约 4 倍的 G-Buffer 采样指令和 V-Buffer 采样指令。为了节省采样次数,在 PC 端上可以考虑 group shared memory 优化。

另一种做法是将 DVM 4x upsampling 的线程单位从处理单个 pixel 换成处理 2x2 pixels 组成的 quad,然后每个 quad 内的 pore pixels 必定访问相同的 4 个 color pixels,如此来避免 4 倍的重复读。

DVM AA

DVM AA 的思路与 DVM upsampling 差不多,只不过 upsampling 中的 pore pixel 变成了 AA 中的 subsample;不过和一般的 AA 方法不同,DVM AA 的处理单位不是 pixel 而是 2x2 pixels 组成的 quad。

Subsamples Filling

如果我们想要做 8x 的效果,就相当于一个 quad 要包含 32 个 subsamples,但是我们只对其中 4 个做 shading(可以称这 4 个为 color sample)。

img

注意:每个 pixel 的 subsamples 位置分布是一致的,这样每帧给 pixel 选择某一个 subsample 成为 color sample时,另外三个 pixels 也应选择相对位置一样的 subsamples 成为 color samples。

img

将 4 个 color samples 的着色结果算出来后,根据它们对应的 visibility id,首先给同一 pixel 内相同 visibility id 的 subsamples 赋予相同的着色结果:

img

接着,同一 pixel 内不同 visibility id 的 subsamples 则通过访问其它 pixels 的 color sample 来获得对应的着色结果:

img

接着就可以根据单个 pixel 内 subsamples 的 color 混合后得到该 pixel 经过抗锯齿处理后的 color 值 。

Sample Switching

此外,DVM AA 可能出现这样一种 bad case:4 个 color sample 不能覆盖所有的 subsamples 的情况。

如下图 4 个 color samples 没有一个是位于左上角的三角形的,也就是说左上角共 9 个 subsamples 丢失了 color 信息:

img

这时候只要随机选 1 个 color sample 来切换(switch)到丢失 color 信息的 subsamples 中的随机一个,从而补充多一个 color 信息。这里不选择 1 个最近邻的 color sample 进行切换,而是选择随机 1 个 color sample,这样做是为了让每个 color sample 都有相同的概率被选中,以免对最终图像产生偏差。

img

解决这种 bad case 的方法除了 sample switching,还有更直接的增加额外样本的方法:只不过增加额外样本的方法会引入额外的复杂度以及 shading 开销,往往不推荐这么做。

此外,最糟糕的情况出现在 subsamples 所需的 color 信息大于 4 个的时候,无论怎么 switch sample 也无济于事,因为要彻底解决这个问题就必须得增加额外的 color samples。DVM AA 为了不引入额外的着色,选择忽视丢失 color 信息的 subsamples(即不参与颜色混合)。

img

但是,实际上很少会出现 4 个 pixels 占据五个及更多的 triangles。即使有,现有的 DVM 算法也足以呈现还可以的抗锯齿效果(因为最多已经包含了四种 color 的信息,大部分 subsamples 都能被覆盖到)。

更进一步地,DVM 每帧都会抖动一下 color sample 的基准位置,结合上 TAA 时序上复用的思路,其实也能实现近似全 subsamples 覆盖的抗锯齿效果。

4x DVM AA 流程

下面是一个 4x DVM AA 流程的示例。

  • 4x Visibility Pass:需要先生成 4x 分辨率的 V-Buffer。

  • Coverage Pass:需要生成一张 1x 分辨率的 Coverage Texture,其布局如下:

    image-20230517164318647
    1. 每个 pixel 先记录 subsample location 为当前帧 pattern 的基准位置;并遍历本 pixel 内覆盖的 4 个 subsamples,检测是否与 subsample location 的 subsample visibility id 一致,并以 16 bits coverage mask 记录(此时只用到了 mask 中的 4 个 bits,因为一个 pixel 就 4 个 subsamples)。
    2. 每个 pixel 可以通过 coverage mask 知道有哪些本 pixel 内没有被覆盖到的 subsamples,对这些 subsamples 进行遍历,每次遍历尝试与另外三个 color sample 的 visibility id 进行比较:
      • 若存在相同 visibility id 的 color sample,则让该 subsample 在 coverage mask 对应 location (相当于说是在 bitmask 中的第几个 bit)的 bit 赋为 1,并结束本次遍历。
      • 若三个均不同,则意味着该 subsample 丢失了 color 信息,将该 subsample 的 location 添加到一个 fail subsamples list(其实用一个局部数组 uint[4] 表示就够了)。
    3. 接着,4个 pixel 将它们的 color sample visibility id 依次和 test = 0 进行合并测试(检查位与运算的结果是否为 0):
      • 若合并测试通过,则再用位或运算合并到 test 变量。
      • 若合并测试不通过,意味着已经存在有重复的 visibility id 了,则去 fail subsamples list 挑一个 subsample,并 switch 过去(其实就是更新 subsample location),之后 switch sample 后重新计算其覆盖的 subsamples,更新其 coverage mask;当然如果 fail subsamples list 没有任何元素,那就无需 switch,直接通过。
  • Shading Pass:每个 pixel 先根据 Coverage Texture 的 subsample location 来重新调整自己对应的着色位置(relocation)。relocation 之后就可以进行 shading 了。

  • DVM AA Pass:处理每 4 个 pixels 组成的 quad,进行前面提到的 Subsamples Filling 操作。

可以看到 DVM AA 的流程因为引入了 sample switching 会变得比较麻烦。因此个人建议如果不是需要极高的抗锯齿效果,可以直接用 DVM upsampling 再 down sampling,比较方便快捷。

基于 V-Buffer 的 software VRS

Hardware VRS

可变速率着色(Variable Rate Shading,VRS) 是一个用在光栅化上的硬件技术,通过控制在不同屏幕区域的光栅化分辨率从而来控制 pixel shader 的着色频率(shading rate)。

我们将屏幕划分成一块块 tiles,并生成一张记录每个 tile 着色频率的 texture;在应用中往往是对不那么重要的屏幕区域(即某些 tiles)设置成较低的着色频率(例如图像颜色变化平滑的地方,用户非聚焦的部分,被 UI 遮挡的地方等),最常见计算着色频率的方法是边缘检测算法:即根据上一帧的 luminance 图像来进行 sobel 边缘检测,如果边缘因数较大,那么意味着该处 tile 是高频信息较多,应当设置高的着色频率,否则设置低的着色频率。

最后通过这张 shading rate texture 来指导 hardware VRS 在不同的 tile 使用不同的着色频率。

nVidia_DX12_Variable-Rate-Shading

虽然 hardware VRS 可以通过降低不重要区域的着色频率来提升相当的性能,但仍然有以下缺点:

  • hardware VRS 由于与光栅化捆绑在一起,VRS 也避不开 helper lane 的额外开销,如果小三角形特别多会很浪费。
  • hardware VRS 在低着色率下的 pore pixels 会直接取 color pixel 的颜色作为自己的颜色,而非靠某种插值混合算法,因此锯齿感很重。
  • hardware VRS 每帧都是固定的着色 pattern,其无法收敛到原始着色频率的图像效果。

不采用 VRS 时,光栅化的单位为 pixel(灰色为 pixel 的 helper lane);而采用 2x2 VRS 时,光栅化的单位为 2x2 pixels 组成的 quad(蓝色+灰色为 quad 的 helper lane):

image-20230601162516022

再例如,一个小三角形本来只占据 1 个 pixel,在 2x2 VRS 下占据 1 个 quad(期望着色频率为 \(\frac{1}{4}\) PS/pixel),但由于 helper lane 机制,却不得不计算 4 个 quad 的着色(即运行 4 次 PS),填充完所占据的 quad 颜色后发现到头来着色频率还是 1 PS/pixel。

Software VRS

而如果借助 V-Buffer ,我们可以实现一种 software VRS,其大致思路就是:

  1. 生成一张 shading rate texture(可以是 tile 粒度的,也可以是 pixel 粒度的)
  2. shading pass 只对 important 的 pixels 进行着色,着色后的 pixels 称为 color pixels。
  3. 随后用另外一个 pass 来对其它对 pore pixels 进行填充(与 upsampling 类似)。

它相对于 hardware VRS 来说有如下好处:

  • software VRS 不需要与光栅化捆绑,因此可以做到真正 \(\frac{1}{n}\) 的 pixel 着色频率。
  • software VRS 在低着色率下的 pore pixels 可以采用基于 visibility id 的插值混合算法,能够获得更高的图像质量。
  • software VRS 随着帧数累积,搭配 TAA 可以收敛到原始着色频率的图像效果。

Jittering Pattern

为了实现收敛到原着色频率的效果,我们在低着色率的地方得至少保证每帧的 pattern 有所不同,并且这些帧的 patterns 合并起来能够覆盖全像素。

如果每帧的 pattern 都是固定的,那么可能一些着色频率低的 pixels 每帧都不会被着色出来,而只能靠插值混合出来,从而导致无法收敛到原始频率着色效果。

因此一个 naive 的想法是,我们可以每帧整体移动一下着色位置,使其在若干帧内的着色位置的并集能填满整个屏幕:

image-20230602111536896

整齐划分的 pattern 可能会导致锯齿状 artifact,我们还可以使用抖动的 pattern 去避免这种问题:

image-20230602111548184

有了每帧不同的 pattern,再搭配上 TAA,software VRS 产生的图像过几帧就能基本收敛到非 VRS 图像。而 hardware VRS 搭配 TAA 是不能收敛到非 VRS 图像(这是因为 hardware VRS 每帧都是固定 pattern),而只能一直呈现模糊的图像。

2x2 hardware VRS 一直都是呈现模糊的图形,无法随着时间收敛;2x2 VRS(对应0.25)第一帧可能会模糊,但是很快几帧后就能收敛。

额外

是否抛弃 G-Buffer?

image-20230417162351245

传统 deferred rendering 管线的 G-Buffer 往往需要占据大量带宽,甚至随着材质复杂度的提升和精确度的要求,G-Buffer 大小还在变得越来越庞大。

像 UE5.1 中的延迟渲染管线就已经占了 16 Bytes/Pixel:

image-20230517145020219

甚至在 2016 年的 《Uncharted 4》中,G-Buffer 更是达到了惊人的 32 bytes/pixel:

抛弃 G-Buffer 的 Deferred Texturing

  • deferred texturing 的原始流程可以将 material 计算和 lighting 计算合在一个 pass 里,利用计算出 material 属性后立即进行 lighting 计算,从而无需庞大的 G-Buffer 传递材质和几何数据
  • 相比于延迟渲染管线将几何属性和材质属性压进有限大小的 G-Buffer,deferred texturing 的做法显然要更精确且带宽开销低。尤其是在分辨率较高的情况下,带宽开销将大大降低。

保留 G-Buffer 的 Deferred Texturing:将 material 计算和 lighting 计算放在同一个 pass 可以避免引入 G-Buffer 及其带来的带宽开销,听上去很理想,但在实际工作流中,可能会有以下问题:

  • G-Buffer 是很重要的部件,盲目移除它可能会导致很多效果都无法实现(如屏幕后处理效果)。
  • material 是多种多样的,lighting 也可能包含多种变体,如果组合起来可能将导致巨量的 shader 变体。

因此,个人觉得要想推广 deferred texturing 这种全新的渲染路径,最好是兼容过去的工作流,即包含 G-Buffer 的流程。

V-Buffer 压缩

在 DVM 算法中所需要用的 visibility id 实际上只需要 draw call id 和 primitive id,并非需要完整的 V-Buffer,因此我们可以只生成 4x 分辨率的 draw call id + primitive id texture。

image-20241216160251857

更进一步,primitive id 和 draw call id 组成的 visibility id 仅仅只是用于比较是否相同的,可以不必完整的 visibility id 表示,即用占用空间更小的 hashed id(甚至可以是 8 bits)表示。

image-20241216160718722

不过注意,相邻三角形的 primitive id 几乎是非常接近的,因此不宜用它的低位进行做太多 hash 变化,因此可以直接让 draw call id 的比特顺序翻转并异或到 primitive id 上(这样 draw call id 频繁变化的低位就能并入到 primitive id 的高位了),合成一个 hashed id。

uint16 hashedID = primitiveID ^ BitReversal(drawcallID);

基于 Compressed V-Buffer 的 Filtering

前面我们提到了压缩的 V-Buffer,实际上这种压缩的 V-Buffer 完全也可以用在延迟管线上甚至前向管线上。举个例子,例如 UE5 延迟渲染管线的 G-Buffer:

image-20230517145020219
  • 当 shading model id 不是 default 的时候,往往会把一些额外数据塞到 GBufferA.b(10bits 里 4bits 留给 shading model id,6bits 留给额外数据)、OutGBufferB.a、OutGBufferC.a 上。
  • 当 shading model id 是 default 的时候,GBufferA.b、OutGBufferB.a、OutGBufferC.a 很多位都空出来,没用上。那不妨把 hashed id 压缩成 8bits,并塞到这些位置上。

另外,由于鬼影往往是发生在物体间的遮挡,因此 visibility id 可以只包含 draw call id + instance id,而不用考虑 primitive id。

以前我们在 filtering pass 通过读取 depth 和 normal 属性来作为滤波的指导,尤其是采 history normal texture 特别费,鬼影现象又没有好到哪里去。现在我们可以通过读取 depth 和 hashed id 属性来作为滤波的指导,还能大大减少鬼影现象。

参考

posted @ 2023-07-11 14:47  KillerAery  阅读(1443)  评论(4编辑  收藏  举报