《刺客信条:幻景》的 GPU-Driven Rendering

《GPU Zen 3: Advanced Rendering Techniques》第 1 章中文译稿。原文作者:William Bussière、Nicolas Lopez。


导读

Anvil 引擎的 GPU Instance Renderer(GPUIR)依靠 CPU/GPU 共享的 Database,组织 Batching、Culling、Bindless Material 与 Indirect Draw。文章从旧 BatchRenderer 的局限入手,依次介绍场景数据、Coarse CPU Culling、GPU Culling、Vertex Fetch、Bindless Resource Access 和实测结果。


1.1 引言

1.1.1 Anvil 引擎

Anvil 引擎以《刺客信条》系列闻名,也用于《彩虹六号:围攻》《幽灵行动:断点》和《荣耀战魂》。它主要服务于大型、系统化的游戏世界,因此特别重视远距离渲染、海量实例和系统性玩法(图 1.1)。

原书图 1.1:《刺客信条:幻景》中的大规模场景渲染。
图 1.1|《刺客信条:幻景》中的大规模场景渲染。

1.1.2 既有工作

Anvil 团队在 2014 年的《刺客信条:大革命》[Haar and Aaltonen 15] 和 2015 年的《彩虹六号:围攻》中率先使用 GPU-Driven Pipeline。此后,这类管线成为 Anvil 游戏的基础,并持续演进。

原书图 1.2:《刺客信条:大革命》中的 Cluster Culling。
图 1.2|《刺客信条:大革命》中的 Cluster Culling。

当时的实现将所有网格划分为 cluster(图 1.2),按材质动态合批,在 GPU 上进行实例级剔除、Cluster Culling 和 Triangle Culling,最后通过 MultiDrawIndexedInstancedIndirect 间接提交。Clustered Mesh 对游戏引擎而言是较新的方案,但其研究基础可追溯至 [Kumar et al. 96] 和 [Cignoni et al. 05]。此外,[Haar and Aaltonen 15] 基于 [Silvennoinen 12] 引入 camera depth reprojection,剔除那些不会在任何可见 shadow receiver 上投下阴影的 shadow caster,从而加速 Cascaded Shadow Map Rendering(图 1.3)。

原书图 1.3:使用 camera depth reprojection 优化 Cascaded Shadow Map Rendering。
图 1.3|使用 camera depth reprojection 优化 Cascaded Shadow Map Rendering。黄色箭头表示太阳方向,红线表示 shadow map cascade 的范围。红色物体是 shadow caster,但地面不可能成为它们的有效 shadow receiver;蓝色物体则遮住了所有可能的 shadow receiver。

随后,[Wihlidal 17] 在 [Haar and Aaltonen 15] 的基础上,以完全由 Compute Shader 实现的 Triangle Culling 取代按固定视角预计算的 cluster 内可见性掩码。[Karis 21] 则沿着 [Cignoni et al. 05] 的思路,提出带 cluster LOD 层次的虚拟几何管线,并按三角形大小分别使用 Mesh Shader 和 Software Rasterization。

1.1.3 重构动机

Anvil 第一代 GPU-Driven Pipeline 名为 BatchRenderer,针对 DirectX 11 类 API 设计(图 1.4)。它利用 MultiDrawIndexedInstancedIndirect,主要解决独立绘制调用在 DirectX 11 下开销过高的问题,但仍有四项限制:

  • 剔除紧邻渲染执行,无法提前放到 Async Compute 队列。
  • 不支持 Bindless Resources,只能按材质合批。
  • 对不适合合批、或本来不需要合批的图形对象,收益很小或实现过于复杂。
  • 对重复实例化成千上万次的对象,处理效率仍不足。
原书图 1.4:旧 BatchRenderer 中仍有大量工作由 CPU 完成。
图 1.4|旧 BatchRenderer 仍有大量工作由 CPU 完成。这里未画出 Cluster Expansion 与 Cluster Culling,因此实际 GPU 侧比图示更复杂。

团队为《刺客信条:英灵殿》和《刺客信条:幻景》围绕 DirectX 12 类 API 重写了这一系统,称为 GPU Instance Renderer(GPUIR)。新设计在加载或创建时预先合批,用 Bindless Resources 扩大按着色器合批的范围,并把更多处理步骤移到 GPU,以降低 CPU 成本。Per-Frame 和 Per-Pass 的实例剔除可异步执行;Cluster Culling 与 Triangle Culling 则在渲染时执行,以减少 GPU 上的无效工作。

GPUIR 有明确的覆盖范围:它处理不透明的静态或蒙皮网格实例及其 LOD 过渡,并与网格 LOD 选择和 Texture Streaming 紧密结合。通过 Bindless Material 和全局几何缓冲区,CPU 可以按着色器而非按材质或几何体组织批次。GPU 负责有状态的 LOD 过渡;输入只需实例组 LeafNode 列表,再在 GPU 上展开成 Z Pre-Pass、GBuffer 等各 Pass 的绘制调用(图 1.5)。

原书图 1.5:GPUIR 借助 GPU 端场景数据库,将更多工作移到 GPU。
图 1.5|GPUIR 借助 Database(第 1.2 节)在 GPU 上描述完整场景,将更多工作移到了 GPU。

1.2 场景数据库

GPUIR 依靠 Database 在 CPU 与 GPU 之间共享完整的场景描述。它是新管线的基础设施,使更多原本由 CPU 完成的工作能够转移到 GPU。

1.2.1 设计动机

这里的 Database 并非将 MySQL 一类通用数据库搬到 GPU,而是一套 CPU/GPU 共享的数据结构容器。

Anvil 在多个已发行项目中维护过复杂的 GPU 数据结构,例如 GPU-Driven Rendering Pipeline,以及使用持久树管理稀疏体积的烘焙 GI。此类结构在 GPU 上的分配、更新和维护通常需要大量重复代码。

Database 的目标是提供可复用的 CPU/GPU 容器接口:分配、存储和复制策略可以模块化组合,同时保留 C++ 风格的访问与调试便利。

1.2.2 数据布局

Shader Input Groups(SIG)编译器 [Rodrigues 17] 是 Anvil 用于根布局和着色器输入的内部工具,能够生成 C++ 与 HLSL 绑定代码。团队也让它为 Database 生成两端的访问器与数据布局(清单 1.1)。

清单 1.1|数据库表声明示例,SIG 编译器的输入。

databasetable MyObject
{
    float4x4      transform;
    uint           type;
    uint          flags;
    Row<MyObject> parent;
}

为了体现这套方法的数据并行特性,作者用数据库作类比来描述数据访问(图 1.6)。一张表就是一个数据容器实例:列的类型和数量固定,行数则可以变化。

借助 SIG 生成的代码,团队可以在 C++ 和 HLSL 中声明表并访问数据(清单 1.2)。HLSL 对运算符重载的支持有限,因此这里的接口沿用了 [Rodrigues 17] 所述 SIG 常量缓冲区接口的形式。

原书图 1.6:以表、列、行描述 Database 的数据组织方式。
图 1.6|清单 1.1 的数据库类比:结构体的每个成员对应一列,MyObject 数组中的每个实例对应表中的一行(第 1.2.3 节)。

清单 1.2|在 C++ 和 HLSL 中访问清单 1.1 所声明数据的示例。

// Table/column/row access in C++
MyObject::Table table;
Matrix44 transform = table.transform[15];
MyObject::Row parent = table.parent[1]

// Table/column/row access in HLSL
MyObjectRO myObjectTable = MyObjectRO::Create(byteAdressBuffer);
float4x4 transform = myObjectTable::GetTransformAt(15);
uint row = myObjectTable.GetParentAt(1);

SoA 数据布局。 只需修改表声明中的注解,就能把数据从 AoS(结构体数组)切换为 SoA(按字段分列存放),而访问接口不变(清单 1.3)。也可以根据访问模式混用两种布局。这样,优化缓存行为时无需逐处修改使用表的代码。

清单 1.3|SoA 表声明示例,重用清单 1.1。

databasetable MyObject <DefaultLayout=SoA>
{
    float4x4      transform;
    uint           type;
    uint          flags;
    Row<MyObject> parent;
}

分页 SoA。 GPU 资源的着色器绑定方式有限,为每列单独分配一块资源并不高效;每张表使用一个资源更合适。但如果没有虚拟内存,就必须预先确定表的最大容量。即使用了虚拟内存,普通 SoA 也要求每列连续存放;表扩容时,每列至少要增加一页,列数多时开销很大。分页 SoA 只要求每页内的列片段连续(图 1.7),增加一个物理页便能同时扩展所有列(清单 1.4)。

清单 1.4|SIG 中的分页 SoA 表声明示例;RowsPerPage 是指定页容量的注解。

databasetable MyObject <RowsPerPage = 16384>
{
    float4x4      transform;
    uint           type;
    uint          flags;
    Row<MyObject> parent;
}
原书图 1.7:分页 SoA 表的数据布局;不同颜色代表不同内存页。
图 1.7|清单 1.4 所示分页 SoA 表的数据布局。不同颜色表示不同的内存页。

1.2.3 关系

关系是不同表实例之间的链接。

一对一关系。 Row<T> 相当于指向另一张表某一行的类型化指针(清单 1.5)。它存储的本质是一个无符号行索引;访问时再结合目标表实例定位数据。C++ 一侧因此获得类型检查与更清楚的接口。

清单 1.5|Row 关系模拟指针式的一对一引用;行访问见清单 1.2。

// Database declaration
databasetable MyObject
{
    Row<MyObject> parent;
    uint          randomProperty;
};
// C++ analogy
struct MyObject
{
    MyObject*parent;
    U32       randomProperty;
};

一对多关系。 Range 将一对一的行引用扩展为带长度的连续行区间,用来表示一对多关系(清单 1.6)。PartialRange 进一步区分已用长度和最大容量,以模拟 std::vector 的扩容与缩容(清单 1.7)。

清单 1.6|Range 关系模拟带长度的指针,用于表示一对多关系。

// Database declaration
databasetable MyObject
{
    Range<MyObject> children;
};
// C++ analogy
struct MyObject
{
    MyObject*children;
    U32       childCount;
};

清单 1.7|PartialRange 关系分别记录一对多区间的已用长度和最大容量。

// Database declaration
databasetable MyObject
{
    PartialRange<MyObjectList> children;
};
// C++ analogy
struct MyObject
{
    Array<MyObject*> children;
};

所有权。 Row 与 Range 默认只是引用。加上 <owner> 注解后,删除所有者表中的一行时,系统会连带删除它在其他表中拥有的行(清单 1.8、1.9)。

清单 1.8|TestRange 通过 POD 这一 Range 关系,拥有 TestPOD 表中对应的行。

// Database declaration
databasetable TestPOD
{
    int     IntValue;
    float   FloatValue;
};
databasetable TestRange
{
    Range<TestPOD> POD; <owner>
};

清单 1.9|操作清单 1.8 所声明表的 C++ 示例。删除 tableRange 的一个条目时,<owner> 属性会使 tableRange::POD 引用的所有 tablePOD 行一并删除。

// Do things ...

// delete owned ranges
for(U32 i = 0; i < 16; i += 2)
    tableRange.Delete(i);

索引。 索引注解会在键列上生成辅助索引,使反向关系也能高效查询,作用类似 SQL 数据库的次级索引(清单 1.10、1.11)。

清单 1.10|索引关系:既能查询组件 n 的所有者,也能高效遍历所有属于实体 m 的组件。

// Database declaration
databasetable Component
{
    [index(dense,n)]
    Row<Entity> owner;
    uint        randomProperty;
    ...
};
databasetable Entity
{
    ...
};

// C++ analogy
struct Component
{
    Entity*owner;
    uint    randomProperty;
    ...
};
struct Entity
{
    ...
    Array<Component> components;
};

清单 1.11|使用清单 1.10 声明的表,在 CPU 上遍历实体 i 拥有的全部组件。这展示了数据库接口如何处理 GPU 场景描述中的复杂对象关系。

// Let's declare tables of Components and Entities
Entity::Table tableEntity(G4_KB(1));
Component::Table tableComponent(G4_KB(1));

// Some code fills the tables ...

for(U32 i = 0; i < entityCount; i++)
{
    // List holds the index list of all the components
    // referenced by the owner Entity i
    auto list = tableComponent.ownerIndex.Get(i);

    // Iterate over the list of Components and do some stuff ...
    for(tableComponent::Row row : list)
    {
        U32 randomPropertyValue = tableComponent.randomProperty[row];
        // Do some stuff ...
    }
}

团队还编写了一个库,让这些关系可以像数组一样操作。常用函数由代码生成器同时生成 C++ 和 HLSL 版本,因此 CPU 和 GPU 都能方便地访问表(见附录 1.9.1)。

1.2.4 复制

CPU 和 GPU 存储由不同的表实例管理。对于很大的表,CPU 无需分配完整副本,只需暂存待同步到 GPU 的脏行。Database 提供多种复制策略,在表实例之间传播数据,通常是从 CPU 传到 GPU。

直接复制。 这是最简单的策略。源表和目标表可以位于 CPU 或 GPU,均可采用分页或非分页布局。两个表都在 GPU 时,直接在 GPU 上复制;涉及 CPU 时,则按通常的 ByteAddressBuffer 传输方式处理,包括必要的缓冲区映射,并附加数据库所需的管理步骤。

DirtyRows 更新。 修改一行时,表会在脏行掩码中标记它;同步时只复制连续的脏行区间。这会产生许多较小的复制任务,但传输的数据量最低。

DirtyPages 更新。 复制流程与 DirtyRows 相似,但以页而非行为单位标记和复制。脏数据标记占用更少空间,代价是复制更多未修改的数据。

DirtyPageCopy。 前两种策略都需要在 CPU 上保存表数据:某个值发生变化,就上传所在的整行或整页。DirtyPageCopy 则把脏页单独分配在 CPU 内存中;同步时先复制到上传堆,再由 Compute Shader 写入最终的 GPU 表(清单 1.12)。这样,未修改的行无需占用 CPU 存储,CPU 侧表实例只需支持写入。

清单 1.12|数据库表从 CPU 复制、更新到 GPU 的示例;这里使用了 DirtyPages 注解。

// Table type declaration with the DirtyPages update policy
databasetable TestUpdatePage <RowsPerPage=64;
    Instance={Persistent;DirtyPages}>
{
    int     IntValue;
    uint    UIntValue;
    float   FloatValue;
    float4  FloatVectorValue;
};

// CPU table creation and data initialization
TestUpdatePage::Table TestUpdatePageCPU(G4_KB(1));
for(U32 i = 0; i < 16; i++)
    TestUpdatePageCPU.New(-S32(i), i, i*0.125f, Vector4(i*-0.5f));

// GPU table creation
TestUpdatePage::TableGPU TestUpdatePageGPU(G4_KB(1));
TestUpdatePageGPU.CreateGPUBuffer(device);

// Copy CPU to GPU
CopyTable(device, TestUpdatePageCPU , TestUpdatePageGPU);

// Update CPU to GPU
UpdateTable(device, TestUpdatePageCPU , TestUpdatePageGPU);
TestUpdatePageCPU.ClearDirtyElements();

1.3 CPU 数据管理

GPUIR 将输入整理成数据库表,便于快速读取和复制到 GPU。其中最重要的三张表是 CullMeshes、CullInstances 和 LeafNodes。本节介绍这些表的数据与管理方式,以及它们如何为 GPU 剔除提供起点。

1.3.1 渲染数据

在 BatchRenderer 中,一个 LOD 选择器包含五级几何细节。系统按物体与相机的距离选择当前 LOD,并随时间完成级别过渡。每级 LOD 有一个或多个子网格,每个子网格对应一种材质。GPUIR 保留 LOD 选择器这一概念,但仅用于内容制作和磁盘上的几何序列化。

为 GPUIR 创建一组实例时,首先要建立 CullMeshes 及其渲染批次。CullMesh 大体对应 LOD 选择器的 GPU 表示,同时记录它可进入哪些渲染 Pass。每个 LOD 节点 CullLODNode 引用数量不固定的 CullSubMeshes,由后者保存材质与几何描述。相关数据库表的结构见图 1.8。

为了让一次绘制调用容纳尽可能多的实例,系统为每个管线状态对象(PSO)建立一个批次槽。槽内包含绘制期间不能改变的状态:着色器模板、指定的 Shader Permutation 及其他管线标志。纹理通过 Bindless Descriptor 在渲染时索引,因此不必因材质纹理不同而拆分批次。系统在 CullSubMesh 级别确定几何体与 PSO 的组合,并将相关输入哈希为 BatchHash,据此在哈希映射中查找批次槽。一个 CullSubMesh 会保存适用于 GBuffer、仅深度、透明等不同管线配置的 BatchHash。

Render Pass Mask 由材质决定,但有些剔除发生在 CullMesh 级别,因此需要在这一层汇总信息。MergedBatchHash 汇集其各个 CullSubMesh 的 Pass Mask,并记录该 CullMesh 中实例和 triangle cluster 的数量上限;系统随后据此计算一个世界所需的间接绘制调用上限。

原书图 1.8:CullMesh 数据表及其关系。
图 1.8|CullMesh 的结构。各类型实现为数据库表,并通过 Row 或 Range 属性连接;聚合关系在表声明中使用 <owner> 注解(详见第 1.2.3 节)。

1.3.2 世界数据

全局渲染数据就绪后,再创建各个世界的 LeafNodes 和 CullInstances。每个 CullInstance 包含变换、关联的 CullMesh、LOD 状态、实例材质标志和着色器常量。主视图与阴影贴图可以使用不同的 LOD 切换距离,因此每个实例保存两组 LOD 状态。

GPUIR 需要一次处理数百万实例,因此采用“实体组 → 叶节点”的空间层次提高剔除效率(图 1.9)。一个实体组收集某个加载单元内的同类实例,例如树木或建筑。实例可能由散布规则程序化生成,也可能由“优化实例化”流程遍历该加载单元,选择适合 GPUIR 的实体并将其合并。组内实例再划分为多个叶节点。

原书图 1.9:世界的空间划分。
图 1.9|世界的空间划分。

同一叶节点可以包含不同的 CullMesh,但最多容纳 16,384 个实例,因为实例范围不能跨越数据库表的分配页。系统还尽量将空间位置接近的实例分在一起,缩小叶节点的包围体积,提高剔除效率。

LeafNode 是 CPU 剔除层次的最底层,也是 CPU 跳过空绘制调用的最后机会。因此,每个节点都汇总其 Render Pass Mask,以及所含实例的 CullMesh 提供的批次哈希。借助 SIG 编译器的 <owner> 属性,删除节点时还可以自动释放它在其他表中拥有的实例、着色器常量,以及供 CullMesh 引用计数使用的间接引用。相关数据表的关系见图 1.10。

原书图 1.10:世界相关的数据表及其关系。
图 1.10|描述世界的数据表及其关系。图中的 CullMesh 和 MergedBatchHashes 与图 1.8 中同名的表相同;LeafNode 与 CullMesh 分别拥有不同的 MergedBatchHash 行集合。

1.3.3 Coarse CPU Culling

实体组加载并生成世界数据时,也会插入四叉树剔除结构(图 1.9)。每帧开始时,CPU 用各渲染 Pass 的视锥体测试四叉树单元,收集至少在一个 Pass 中可见的实体组。随后再测试其中的叶节点(图 1.11),并用汇总的 Pass Mask 跳过不可能为当前 Pass 贡献实例的节点。输出是 PassedLeafNodes 及对应的 PassedInstanceRanges;实例范围送往 GPU,继续进行细粒度剔除(图 1.12)。

原书图 1.11:《刺客信条:幻景》中的 LeafNode 调试视图。
图 1.11|《刺客信条:幻景》中的 LeafNode 调试视图。

CPU 还会从 PassedLeafNodes 出发,以批次哈希为键,为每个 Pass 构建渲染批次哈希映射。它告诉 GPU 在哪里写入该 Pass 的间接绘制参数。构建时遍历通过剔除的叶节点,为每个唯一的 MergedBatchHash 分配批次。此时尚不知道有多少活跃的 CullSubMesh 引用该批次,具体数量要等后续 GPU 剔除完成才能确定;CPU 只能先给出批次可容纳的实例和绘制调用上限,以便准备间接绘制。

原书图 1.12:CPU 与 GPU 的 Per-Frame Culling 步骤。
图 1.12|CPU 与 GPU 的 Per-Frame Culling 步骤总览。

1.4 GPU Culling

CPU 已剔除空间层次的上层节点,接下来要逐个处理实例;此时通常仍有数十万个,正适合交给 GPU。CPU 阶段生成的临时表(如 PassedInstanceRanges、BatchMap)会与持久的渲染和世界数据表(如 CullInstances、CullMeshes)一同复制到 GPU。要将这些数据变成绘制调用,还需执行 Per-Frame 和 Per-Pass 实例处理。GPU 剔除通常可以在异步计算队列上与图形队列中的其他工作并行;本方案只有 Cluster Culling 和 Triangle Culling 留在图形队列,并紧挨着几何绘制执行。

下述着色器都把输出写入临时 GPU 数据库表。行分配分两步:先以原子加法统计线程组内各线程申请的行数,再由组内第一个线程以另一次原子加法增加表的总行数。两次操作的返回值分别给出线程在组内的局部偏移,以及整个线程组在表中的写入起点。

1.4.1 Per-Frame GPU Culling

其余 Per-Frame 操作会更新实例的 LOD 状态,生成供 Per-Pass Culling 使用的子网格实例(图 1.12)。

提取实例范围。 CPU 送来的实例列表压缩保存在 PassedInstanceRanges 中。为了让每个 GPU 线程处理一个实例,系统需要从这些区间还原实例索引(图 1.13(a))。它先生成搜索区间表和线程组区间表;随后,实例剔除着色器在组对应的区间边界内二分查找,确定线程负责的实例(图 1.13(b))。

原书图 1.13:从通过筛选的实例范围映射到剔除线程。
图 1.13|(a)目标是为每个剔除线程分配一个通过粗剔除的实例。(b)先由一个线程组对各范围的实例数量求前缀和,确定每组实例的起始索引;然后各剔除线程组在搜索范围内二分查找其负责的实例范围起止位置;最后,线程在该组的范围内再次二分查找,得到自己负责的全局实例索引。

剔除实例并更新 LOD。 取得实例索引后,GPU 线程再次执行 Frustum Culling:用实例测试各渲染 Pass 的视锥体,并把相交结果记入 Pass Mask。只要命中一个视锥体,就将实例的 LOD 状态送入更新阶段。每个实例有两组状态,分别供主视图和阴影使用;清单 1.13 展示了 LOD 状态的 Pass Mask 计算。

更新 LOD 状态时,GPU 判断过渡何时开始并跟踪进度。Anvil 的 LOD 过渡一旦开始就不会中断,而且总是持续固定时间。因此,过渡中的状态会向批次展开阶段输出两个 PassedLOD,其余状态只输出一个。

生成批次。 此时已经得到 PassedLOD 列表,需要把其子网格分配到具体的渲染 Pass。已有的 Pass Mask 只表示“允许在哪些 Pass 渲染”,此处还要决定实际使用哪些 Pass,例如是否进入 Z Pre-Pass。系统依据子网格三角形在屏幕空间中的平均大小作启发式判断:靠近相机、三角形较大的物体更可能进入 Z Pre-Pass。这里还要选定 Shader Permutation;对于无需抖动或 Alpha 测试的对象,可以使用不带 Alpha 裁剪的变体。

清单 1.13|LOD 状态的 Pass Mask 由实例通过剔除的各 Pass 与静态阴影 Pass Mask 合并得到。

uint CullSphere(in const CullingCommon common , in const float4
    centerRadius , in uint rangePassMask)
{
    uint overlappedMask = 0;
    for(uint i = 0; i < common.GetFrustumCount(); i++)
     {
        uint4 frustumDesc = common.GetFrustumDescAt(i);

         // Check if this frustum needs to be culled against
        uint passMaskOverlap = frustumDesc.z & rangePassMask;
         if(passMaskOverlap == 0)
            continue;

         if(!CullPlanesSphere(common , frustumDesc.x, frustumDesc.y,
    centerRadius))
            overlappedMask  |= passMaskOverlap;
     }
    return overlappedMask;
}

void CS_CullInstances(uint3 threadID : SV_DispatchThreadID , uint3
    groupID : SV_GroupID)
{
    // ...
    uint rangePassMask = passedInstanceRanges.GetPassMaskAt(
    instanceRangeRow);
    uint instancePassMask = CullSphere(
        Common, transformedCenterAndRadius , rangePassMask);
    uint mainLODStatePassMask =
        instancePassMask & ~SHADOW_PASS_MASK;
    uint shadowLODStatePassMask =
        instancePassMask & SHADOW_PASS_MASK;
    // ...
}

第 1.3.3 节介绍的 BatchMap 根据通过 CPU 粗剔除的叶节点所提供的 MergedBatchHash,把渲染批次关联到各个 Pass。生成批次时,系统将子网格实例注册到所选 Pass 与 Shader Permutation 对应的批次;完成后,再重排为图 1.14 所示的 Per-Pass 子网格实例缓冲区。

原书图 1.14:按渲染 Pass 和批次压缩组织的子网格实例缓冲区。
图 1.14|这个全局缓冲区按渲染 Pass 和批次存放所有通过剔除的子网格实例。缓冲区经过压缩,使 Per-Pass Culling Shader 可以根据线程 ID 快速找到对应的子网格实例。

1.4.2 Per-Pass GPU Culling

Per-Pass 阶段先做最后一轮实例剔除,再生成各渲染批次的绘制参数。阶段结束时,绘制三角形所需的信息都已备齐,其中 InstanceInfo 让 Vertex Shader 和 Pixel Shader 能够确定每个实例使用的几何与材质。处理步骤见图 1.15。

子网格剔除。 Per-Pass 阶段可以再对子网格实例测试一次。是否启用某项测试,取决于它能排除多少实例以及执行成本。例如,主视图的大多数 Pass(不透明、发光等)以子网格包围盒为采样区域,对主视图 HZB 做遮挡测试。Sun Shadows Pass 则使用 camera depth reprojection,剔除其阴影不会影响屏幕上任何像素的 shadow caster。最后留下的子网格实例才进入绘制调用。

原书图 1.15:Per-Pass Culling 到实际绘制的处理步骤。
图 1.15|Per-Pass Culling 步骤总览。每个 Pass 的剔除管线在异步队列上运行;绘制参数与 InstanceInfo 准备好后,还可在图形队列执行可选的 Cluster Culling 和 Triangle Culling,然后提交绘制调用。

批次哈希。 单个 DrawBuffer 保存各渲染批次的绘制调用数和全部绘制参数。系统对一个实例的子网格 ID 与渲染批次 ID 做哈希,确定它属于哪次绘制。子网格 ID 本已确定几何与材质;但启用 Alpha Clip 时,本方案会切换 PSO,因此还必须按 Shader Permutation 将子网格实例分配到不同的绘制调用(第 1.4.1 节)。

第一遍以绘制调用哈希为键填充 GPU 哈希表,用线性探测处理冲突。首次插入某个键的子网格实例会在临时描述符列表中建立条目;其后的子网格实例则增加该键的实例计数。每个子网格实例还会记录绘制调用所在的哈希槽,以及自身在该调用中的实例偏移量(图 1.16)。

第二遍由一个线程组做前缀和,确定各绘制调用在缓冲区中的位置。每个线程负责若干哈希槽,先统计这些槽需要多少绘制条目和 InstanceInfo;前缀和给出编号更小的线程累计申请的条目数。随后,线程再次遍历自己的槽,结合子网格的几何描述与哈希表中的实例数写入绘制参数,并把该绘制调用的首个 InstanceInfo 偏移存回哈希表,供下一步使用。

原书图 1.16:两遍算法生成绘制参数和 InstanceInfo 的数据流。
图 1.16|两遍算法生成绘制参数和 InstanceInfo 的数据流。哈希表中的每个条目对应一个 HashMapDescriptor,而 HashBatch 的数量与子网格实例数相同。

写入 InstanceInfo。 Per-Pass Culling 的最后一步,是为每个实例写入一条供 Vertex Shader 和 Pixel Shader 读取的 InstanceInfo(清单 1.14)。其中包含世界—视图—投影矩阵、打包属性,以及统一几何缓冲区(第 1.4.3 节)、统一常量缓冲区(第 1.5.3 节)和 Bindless Material Table(第 1.5.2 节)的偏移。InstanceInfo 的索引必须与 Vertex Shader 收到的实例 ID 一致;它由哈希表记录的该绘制调用首条 InstanceInfo 偏移,加上哈希批次中记录的调用内实例偏移得到。

清单 1.14|InstanceInfos 格式:vertexInfo 打包 Vertex Shader 执行 Vertex Fetch 所需的信息;materialOffsets 打包 Pixel Shader 访问 Bindless Material(第 1.5.2 节)与常量表所需的偏移量。

// Array of InstanceInfo
ByteAddressBuffer InstanceInfos;

struct InstanceInfo
{
    uint instanceAndLODFade; // LOD and fade flags
    uint vertexInfo; // Vertex data start, format , and stride
    uint instanceMaterialInfo; // Material flags
    uint materialOffsets; // Texture2DOffset and ConstantOffset
    float4x4 worldViewProj; // Transform
}

1.4.3 Cluster Culling

在 Anvil 中,网格默认划分为 cluster,每个 cluster 包含 64 个顶点(图 1.17)。所有网格的顶点数据存入一个统一缓冲区,索引数据也集中存储;Vertex Shader 依据顶点 ID 手动读取顶点。从图形 API 的角度看,前者不是传统顶点缓冲区,而是共享的字节地址缓冲区。

原书图 1.17:《刺客信条:幻景》中的 Clustered Geometry。
图 1.17|《刺客信条:幻景》中的 Clustered Geometry。

提交多重绘制调用前,系统还可以执行 Cluster Culling 和 Triangle Culling。这两步都是可选的,可根据数据和 Pass 类型,在 Z Pre-Pass、GBuffer 等 Pass 启用。

Cluster Culling 在 Compute Shader 中执行,分为 Frustum Culling 与 Occlusion Culling 两步(图 1.18),每个线程处理一个 cluster。线程从 InstanceInfo 中取得相应的 WorldViewProj 矩阵,并读取 cluster 包围盒的中心与各轴半尺寸,再将包围盒投影到屏幕空间以供剔除测试。

原书图 1.18:per-cluster Frustum Culling 与 Occlusion Culling。
图 1.18|Cluster Culling:先执行 per-cluster Frustum Culling(左),再执行 Occlusion Culling(右)。

Frustum Culling。 首先投影每个 cluster 的包围盒,再在标准化设备坐标(NDC)中判断是否需要剔除(清单 1.15)。

清单 1.15|基础的 per-cluster Frustum Culling 着色器代码。

float3 minAABB = float3(1.0f, 1.0f, 1.0f);
float3 maxAABB = float3(-1.0f, -1.0f, 0.0f);
for(float z = -1; z <= 1; z += 2)
  for(float y = -1; y <= 1; y += 2)
    for(float x = -1; x <= 1; x += 2)
    {
         // Projection -> homogeneous space[-w,w][-w,w][0,w]
        float4 posHS = mul(float4(center + halfExtents *
            float3(x, y, z), 1.0f), worldViewProj);
         // Perspective divide -> NDC[-1,1][-1,1][0,1]
        float3 posSS = posHS.w>0? posHS.xyz/posHS.w:float3(0,0,-1);
         // Handle inverted z axis
        posSS.z = posSS.z *ZScaleBias.x + ZScaleBias.y;

        minAABB = min(posSS, minAABB);
        maxAABB = max(posSS, maxAABB);
    }

// Simple frustum culling(inverted z axis)
passed = ((all(minAABB.xy < 1.0f) && all(maxAABB.xy > -1.0f)   ||
         minAABB.z <= 0));

Occlusion Culling。 这一阶段与 [Haar and Aaltonen 15] 的方法相近,需要 Hierarchical Z-Buffer(HZB)[Hill 10; Greene et al. 93](图 1.19)。系统先选取近处的有效遮挡物,渲染部分 Z Pre-Pass,将结果降采样至 512 × 256,再与上一帧深度的重投影结果合并。随后逐级生成 mip:每一级取前一级相应 2 × 2 纹素的最大值,形成供 GPU 剔除使用的深度层次。

原书图 1.19:《刺客信条:幻景》中的 HZB。
图 1.19|《刺客信条:幻景》中的 HZB。

在遮挡过程中,系统从对应的 mip 级别取 2 × 2 纹素邻域,使 cluster 包围盒的屏幕投影覆盖 4 个纹素,再以这 4 个纹素的最大 Z 值与包围盒的最小深度比较,判断是否被遮挡(清单 1.16)。

清单 1.16|基于 HZB、逐 cluster 执行 Occlusion Culling 的着色器代码。

// Viewport rescale -> [0,1][0,1]
float2 minUV = float2(minAABB.x,maxAABB.y)*float2(0.5f,-0.5f)+0.5f;
float2 maxUV = float2(maxAABB.x,minAABB.y)*float2(0.5f,-0.5f)+0.5f;

// Pixel coords in HZB viewport -> [0,HZBWidth][0,HZBHeight]
float2 minHZBPixel = minUV.xy *GetHZBWidthHeightMips().xy;
float2 maxHZBPixel = maxUV.xy *GetHZBWidthHeightMips().xy;

// Compute HZB mipLevel so that the BB projects on 4 texels
float2 texelSize = maxHZBPixel -minHZBPixel;
float mipValue = ceil(log2(max(texelSize.x, texelSize.y)));
float mipScale = rcp(exp2(mipValue));
float2 minMip = minHZBPixel *mipScale;
float2 maxMip = maxHZBPixel *mipScale;

if(all(floor(minMip) == floor(maxMip)))
{
    mipValue -= 1; minMip *= 2; maxMip *= 2;
}
// If the requested mip exists
if(mipValue < GetHZBWidthHeightMips().z)
{
    uint xOffset = floor(minMip.x) == floor(maxMip.x) ? 0 : 1;
    uint yOffset = floor(minMip.y) == floor(maxMip.y) ? 0 : 1;
    float4 maxDepthMask4 =
        float4(1, xOffset , yOffset , xOffset *yOffset);

    // Fetch the corresponding 2x2 neightborhood pixels
    float4 maxDepth4 = float4(
        GetHZBTexture().SampleLevel(s_PointClamp ,
          minUV.xy, mipValue , float2(0, 0)),
        GetHZBTexture().SampleLevel(s_PointClamp ,
          minUV.xy, mipValue , float2(1, 0)),
        GetHZBTexture().SampleLevel(s_PointClamp ,
          minUV.xy, mipValue , float2(0, 1)),
        GetHZBTexture().SampleLevel(s_PointClamp ,
          minUV.xy, mipValue , float2(1, 1)) );

    maxDepth4 = max(1 -maxDepthMask4 , maxDepth4);

    // Take the max depth value
    float maxDepth = max(
        max(maxDepth4.x,maxDepth4.y),max(maxDepth4.z,maxDepth4.w));

    // Conservative depth test to determine if occluded or not
    passed = minAABB.z < maxDepth;
}

1.4.4 Triangle Culling

Triangle Culling 在 Compute Shader 中分三步运行:Zero-area 与 Backface Culling、Frustum Culling、Small Triangle Culling(图 1.20)。每个线程处理一个三角形,从 InstanceInfos 缓冲区取出相应的 WorldViewProj 矩阵(第 1.4.2 节),先变换顶点,再执行剔除(清单 1.17)。

原书图 1.20:Zero-area、Backface、Frustum 与 Small Triangle Culling。
图 1.20|Triangle Culling:先执行 Zero-area Culling 与 Backface Culling(左),再执行 Frustum Culling(中),最后执行 Small Triangle Culling(右)。

清单 1.17|从统一几何缓冲区读取顶点并变换到齐次空间;此时尚未执行透视除法。

float4 vtx[3];
for(uint i = 0; i < 3; i++)
{
    float3 posOS = FetchVertexPosition(index[i],
      vertexStart , vertexStride);
    vtx[i] = mul(float4(posOS, 1), worldViewProj);
}

Zero-area 与 Backface Culling。 按照 [Wihlidal 17] 和 [Olano and Greer 97] 的方法,检查二维齐次矩阵的行列式(清单 1.18)。det > 0 表示正面三角形;det = 0 表示面积为零,例如三个顶点共线的退化三角形。双面几何另作处理。

清单 1.18|通过二维齐次矩阵的行列式执行 Zero-area 与 Backface Culling;双面几何另作处理。

float det = determinant(float3x3(
    vtx[0].xyw, vtx[1].xyw, vtx[2].xyw) );
passed = (det > 0)   || (twoSided && det != 0);

Frustum Culling。 这一步与 per-cluster Frustum Culling 相近,但在二维屏幕空间进行(清单 1.19)。

清单 1.19|逐三角形执行 Frustum Culling 的着色器代码。

float2 minAABB = 1.0f;
float2 maxAABB = 0.0f;
for(uint i = 0; i < 3; i++)
{
    // Perspective divide and viewport rescale
    float2 posSS = (vtx[i].xy / vtx[i].w) *0.5f + 0.5f;
    minAABB = min(minAABB , posSS);
    maxAABB = max(maxAABB , posSS);
}
// Simple frustum culling
passed = all(minAABB < 1.0f) && all(maxAABB > 0.0f);

Small Triangle Culling。 沿用 [Wihlidal 17] 的思路,将三角形包围盒的最小与最大坐标吸附到最近的像素角点(图 1.21),再判断它是否覆盖任何像素中心(清单 1.20)。

原书图 1.21:通过三角形屏幕空间边界判断小三角形。
图 1.21|Small Triangle Culling:将三角形包围盒的角点吸附到最近的像素角,再比较它们,以判断三角形是否跨过像素中心。

清单 1.20|Small Triangle Culling 着色器代码。

minAABB *= GetScreenWidthHeight();
maxAABB *= GetScreenWidthHeight();
passed = !any(round(minAABB) == round(maxAABB));

Instancing and Index Buffer Compaction。 启用 Cluster Culling 和 Triangle Culling 后,同批次各实例的可见几何可能不同,原始索引缓冲区无法直接复用。系统参照 [Haar and Aaltonen 15],输出只包含逐实例可见三角形索引的压缩缓冲区。在 Triangle Culling 计算任务的最后一步,每个线程确定写入位置;三角形通过剔除时才写入索引。

因此,还要更新向 ExecuteIndirect 提供参数的 DrawBuffer(第 1.6 节),增加绘制条目:同批次实例若有不同的可见几何,就需要拆成不同的 DrawIndexedInstanced 调用。新增条目的索引携带实例 ID,渲染时可用它找回原始 InstanceInfo(第 1.4.2 节)及对应的几何与资源。

1.5 Bindless Material Management

1.5.1 总体设计

Anvil 的材质使用数据驱动的节点式着色器系统(图 1.22)。引擎解析着色器图并生成代码,再把生成结果插入公共材质着色器模板。模板由 Vertex Shader 和 Pixel Shader 组成;两者都有不属于着色器图的固定前后代码,并随材质与网格属性变化,例如延迟或前向路径、TAA 抖动以及顶点格式。

原书图 1.22:Anvil 的着色器图编辑器。
图 1.22|Anvil 的着色器图编辑器。

1.5.2 Bindless Descriptor Table

如第 1.3 节所述,GPU-Driven Pipeline 使用数据库表描述场景(图 1.23)。

Bindless Material Table 汇集当前渲染帧所需的二维、三维和立方体纹理描述符,统一数组共容纳 32K 个条目。三类资源共享同一数组,但三维和立方体纹理只预留较小范围,因为实际需求较少(清单 1.21)。

原书图 1.23:描述场景与材质关系的数据表。
图 1.23|用于描述场景及其关系的数据库表(第 1.2 节)。Texture2DOffset 和 Texture2DCount 用于在 CPU 或 GPU 上访问 Bindless Material Table 中的资源。

清单 1.21|在 SIG 中声明 Bindless Resources 数组。Texture2D、Texture3D 与 TextureCube 条目互为别名。

ShaderInputGroup MaterialBindless
<Bindless; ForceAliasingOfTextures>
{
    Texture2D<float4> textures; <BindlessMaxCount = 32768>
    Texture3D<float4> textures3D; <BindlessMaxCount = 32>
    TextureCube<float4> texturesCube; <BindlessMaxCount = 32>
};

网格加载并加入场景时,系统在 Bindless Material Table 中为其材质分配或更新描述符,并将所需描述符复制进去(图 1.24)。相应的 MaterialInstance 条目以 Texture2DOffset 和 Texture2DCount 指向这段描述符范围,GPU 渲染时即可访问。

原书图 1.24:材质资源表中的描述符范围分配与复制。
图 1.24|在材质资源表中分配并复制新的描述符范围。Texture2DOffset 指向该表中的第一个描述符,Texture2DCount 记录此材质实例条目拥有的描述符数量。

1.5.3 常量管理

常量缓冲区不适合承载大量实例参数。GPU 执行大部分剔除时,视锥体内候选实体的参数都需要先上传,数据量可能很大。因此系统用一个由所有渲染 Pass 共享的字节地址缓冲区保存实例着色器参数(图 1.25);其分配与更新方式类似第 1.5.2 节对 Bindless Material Table 中描述符的管理。

原书图 1.25:MaterialInstance 对全局材质常量表中一段常量的引用。
图 1.25|MaterialInstance 引用全局 MaterialConstantTable 中的一段常量。

渲染前,系统从各可见实例对应的 MaterialInstance 中取得 Texture2DOffset 和 ConstantOffset,将两者打包成 InstanceInfos 中的 uint materialOffsets(第 1.4.2 节)。

1.6 渲染

渲染时,系统为每个通过剔除的批次(第 1.3.1 节)设置 PSO,绑定统一的几何与索引缓冲区(第 1.4.3 节)、Bindless Material Table 和常量表(第 1.5 节),再调用 ExecuteIndirect,通过一系列 DrawIndexedInstanced 命令提交绘制。GPU 剔除阶段已把所需参数写入 DrawBuffer(第 1.4 节);绘制数量与绘制参数保存在同一缓冲区中(图 1.26)。

原书图 1.26:PC DirectX 12 路径上的 ExecuteIndirect 命令与参数。
图 1.26|PC DirectX 12 路径使用 ExecuteIndirect。maxDrawCallCount 是 CPU 剔除后可见批次的最大数量;DrawBuffer 中的 DrawIndirect_Count 是通过 GPU 剔除的批次数量。两者取较小值,决定最终执行多少条 DrawIndexedInstanced 命令。

1.6.1 Vertex Shader

Vertex Shader 用实例 ID 取得对应的 InstanceInfo(见第 1.4.2 节),再解包顶点数据起点、格式和步幅,根据顶点 ID 从统一几何缓冲区手动读取顶点(清单 1.22)。

Vertex Shader 还从 InstanceInfo 取出 Texture2DOffset 和 ConstantOffset,将两者打包为单个不插值的 uint 值传给 Pixel Shader(图 1.27)。Pixel Shader 借此分别访问 Bindless 资源表 MaterialResourceTable 与 MaterialConstantTable。

清单 1.22|使用顶点 ID、起始偏移和步幅从统一几何缓冲区读取顶点。顶点读取宏涵盖常见类型,例如 FETCH1、FETCH2、ToUBYTE4 和 ToSHORT4。

VS_INPUT FetchVertices(in uint vertexStart , in uint vertexFormat , in
    uint vertexStride , in uint vertexID)
{
    uint vertexOffset = vertexStart + vertexStride *vertexID;

    VS_INPUT output = (VS_INPUT)0;
    output.m_Position = ToSHORT4(FETCH2(getClusterVertexDataStatic(),
    vertexOffset));
    output.m_Normal = ToUBYTE4(FETCH1(getClusterVertexDataStatic(),
    vertexOffset));
    ...
    return output;
}
原书图 1.27:GPU 上通过实例信息访问 Bindless Resources。
图 1.27|GPU 上访问 Bindless Resources 的流程。Texture2DOffset 和 ConstantOffset 一起打包进 bindlessMaterialInfo。

1.6.2 Pixel Shader

Pixel Shader 解包 Texture2DOffset 和 ConstantOffset。宏封装了 Bindless Resources 的访问细节:它将纹理槽索引加到 Texture2DOffset 上,定位当前材质的纹理描述符(清单 1.23、1.24)。

清单 1.23|访问 Bindless 纹理与常量的 HLSL 宏。它们依赖 SIG 编译器生成的访问器,例如 Get_MaterialBindless_textures。g_BindlessTex2DOffset 指向当前材质在 Bindless Material Table 中的纹理描述符范围起点(第 1.5.2 节),INDEX 是 Pixel Shader 访问的纹理相对索引;g_BindlessConstantOffset 指向同一材质在材质常量表中的常量范围起点(第 1.5.3 节)。

static uint g_BindlessTex2DOffset = 0;
static uint g_BindlessConstantOffset = 0;

// Global macros used to access textures
#define MATERIAL_TEX2DALIAS(INDEX, NAME, SAMPLER) \
    Tex2DAndSampler Get##NAME() { return \
        GetTexture2DTypeAndSamplerStateType( \
            Get_MaterialBindless_textures( \
                g_BindlessTex2DOffset + INDEX), s_##SAMPLER); }
// ...

// Global macros used to access a single float constant
#define MATERIAL_CONSTLOADSCALAR(OFFSET, CONVERSION) \
    CONVERSION(gpuCullingInstanceParams. \
        GetBindlessMaterialConstants().Load( \
            g_BindlessConstantDOffset + (OFFSET) * 4) )
#define MATERIAL_CONSTALIASFLOAT(TYPE, ALIASNAME, OFFSET) \
    TYPE Get##ALIASNAME() { \
        return (TYPE)(MATERIAL_CONSTLOADSCALAR(OFFSET, asfloat)); }
#define MATERIAL_FLOAT(OFFSET, NAME) \
    MATERIAL_CONSTALIASFLOAT(float, NAME, OFFSET);
// ...

清单原文声明 g_BindlessConstantOffset,后面的宏却使用 g_BindlessConstantDOffset。此处保留原有命名差异,并补齐多行宏的续行符。

清单 1.24|从着色器图生成的 Pixel Shader 代码。纹理和常量索引由着色器图 HLSL 代码生成步骤生成。

MATERIAL_TEX2DALIAS(0, Layer0Diffuse_0, StandardSampler);
MATERIAL_TEX2DALIAS(1, Layer0Normal_1, StandardSampler);

MATERIAL_FLOAT(4, _1Layer0ScaleU_1);
MATERIAL_FLOAT(5, _1Layer0ScaleV_2);

#define Layer0Diffuse_0 GetLayer0Diffuse_0()
#define Layer0Normal_1 GetLayer0Normal_1()

#define _1Layer0ScaleU_1 Get_1Layer0ScaleU_1()
#define _1Layer0ScaleV_2 Get_1Layer0ScaleV_2()

// Some code ...

float2 uvScaled = uvDiffuse *
    float2(_1Layer0ScaleU_1, _1Layer0ScaleV_2);
float4 Layer0Diffuse_0_sample =
    Sample2D(Layer0Diffuse_0, uvScaled);

1.7 结果

BatchRenderer 虽被称为 GPU 管线,大量工作仍在 CPU 上完成(图 1.4):许多合批操作要 Per-Pass 执行,只有最后的实例剔除交给 GPU。

GPUIR 则在加载时完成合批,再执行 Per-Frame 和 Per-Pass 剔除。实例在展开成各个 LOD、子网格与 Pass 之前先接受剔除(图 1.5)。系统还通过 Bindless Material,将 PSO 和几何相同的网格在 GPU 上归入同一绘制调用,以改善顶点着色性能并避免空绘制调用。

考虑到 GPU 上的 LOD 管理和过渡期的时间混合,团队较早就决定将剔除分成 Per-Frame 与 Per-Pass 两级,并都调度到异步队列。这会让部分测试重复执行,但 Per-Pass 阶段使用更紧的子网格包围体,能提高剔除精度。尽管 Per-Frame Culling 并非瓶颈,作者仍希望研究能否进一步缩减这一阶段的工作。

1.7.1 BatchRenderer 与 GPUIR 对比

合成测试场景中,各主要几何 Pass 在 CPU 与 GPU 两侧均有明显改善(表 1.1)。对于大量重复实例化的远景几何,个别场景的 CPU 成本从 6.8 ms 降至 0.14 ms,约有 50 倍加速。

Pass / 处理器 BatchRenderer GPUIR 加速比
GBuffer / CPU 1.43 ms 0.708 ms 2×
GBuffer / GPU 3.72 ms 0.605 ms 6×
Sun Shadows / CPU 1.89 ms 0.834 ms 2.26×
Sun Shadows / GPU 1.42 ms 0.673 ms 2.1×

表 1.1|PlayStation 5、4K 分辨率下主要几何 Pass 的成本;场景见图 1.28。分别以 BatchRenderer 和 GPUIR 渲染整帧,CPU 数值为线程时间。

表 1.2 表明,GPUIR 的剔除作业本身并不总是更快,但可以在帧的早期提交到异步队列。测试场景中,相机视锥体内的 435,133 个实例有 98% 在 GPU Culling 中被剔除,最终仅 8,673 个进入绘制(表 1.3)。

原书图 1.28:用于比较 BatchRenderer 与 GPUIR 的合成测试场景。
图 1.28|用于比较 BatchRenderer 与 GPUIR 的合成测试地图。在 PlayStation 5 上以 4K 分辨率运行时,整帧由两套管线中的其中一套独立渲染。
GPU 剔除任务 BatchRenderer GPUIR
旧管线 GPUCulling 0.525 ms —
Per-Frame Culling — 0.328 ms
Per-Pass Culling(合计) — 0.280 ms
总计 0.525 ms 0.608 ms

表 1.2|PlayStation 5 上合成测试场景的 GPU 剔除成本(图 1.28)。旧 BatchRenderer 必须在图形队列、紧邻渲染执行剔除;GPUIR 可以在异步队列执行。

阶段 DrawIndexedInstanced 调用数 实例数
剔除前 27,197 435,133
剔除后 2,445 8,673
剔除比例 91% 98%

表 1.3|绘制调用数是所有 ExecuteIndirect 中 DrawIndexedInstanced 的总数,实例数是所有绘制调用中的实例总数。这个场景中,位于相机视锥体内的实例仅有 2% 通过全部 GPU 剔除步骤。

1.7.2 《刺客信条:幻景》中的 GPUIR

在《刺客信条:幻景》中,一帧向 GPU 送入超过一百万个待剔除实例并不少见,而最终真正绘制的通常只有数万个。表 1.4、1.5 与图 1.29 展示了游戏中的一个典型帧。

处理器 几何 Pass Per-Frame Culling Per-Pass Culling
CPU 2.19 ms — —
GPU 1.47 ms 0.22 ms 0.43 ms

表 1.4|《刺客信条:幻景》在 PlayStation 5 上的典型帧中,GPUIR 的成本(图 1.29)。数据汇总了整帧的所有几何 Pass,包括不透明物体与阴影等。

原书图 1.29:《刺客信条:幻景》在 PlayStation 5 上的典型场景。
图 1.29|《刺客信条:幻景》在 PlayStation 5 上的典型场景:以动态分辨率 1527p、60 FPS 渲染,再放大至 4K。
阶段 DrawIndexedInstanced 调用数 实例数
剔除前 147,813 1,299,517
剔除后 2,982 17,539
剔除比例 98% 98.5%

表 1.5|渲染图 1.29 所示《刺客信条:幻景》场景时的剔除效率。各项数值的口径见表 1.3。

1.8 结论与后续工作

在《刺客信条》系列中,CPU 长期是重要瓶颈。本章介绍的 GPUIR 主要目标是降低旧 BatchRenderer 的 CPU 成本:加载时合批,借助 Bindless Resources 扩大合批范围,再通过 Database 在 CPU/GPU 间共享复杂场景描述,把更多工作转移到 GPU。虽然设计目标并非专门缩短 GPU 执行时间,更好的合批、剔除和 Async Compute 调度也改善了 GPU 成本,结果见第 1.7 节。

Cluster Culling 和 Triangle Culling 在这一版中保持可选,因为收益随场景和 Pass 而变。这部分较晚加入 GPUIR;作者认为,未来移到 Mesh Shader 后可以省去额外缓冲区、改善实例化处理并简化流程。《幻景》没有采用这一实现,原因是它面向包括 PlayStation 4、Xbox One 在内的跨世代平台,并非所有目标平台都支持 Mesh Shader。

后续方向包括类似 [Karis 21] 虚拟几何方案的连续 LOD cluster 层次,以及评估 work graph 能否进一步简化管线。当时的 work graph API 尚未覆盖作者开发的所有主要平台。

致谢

作者感谢帮助实现 GPUIR 的 Ulrich Haar,以及 Francis Boivin、Michel Gaudreault、Alexandre Blaquière、Daryl Teo、Mykola Naichuck、Lionel Berenguier、Jack Minnetian、Frederic Matz、Sylvain Marleau、Kaori Kato、Luc Poirier、Christian Desautels、Robert Foriel、Danny Oros 和整个《刺客信条:幻景》及 Anvil 团队的贡献与支持。

作者还感谢 Sebastian Aaltonen、Tiago Rodrigues、Graham Wihlidal 和 Brian Karis 分别完成 [Haar and Aaltonen 15]、[Rodrigues 17]、[Wihlidal 17]、[Karis 21] 所引用的工作;感谢 Michel Bouchard 的讨论、Wolfgang Engel 的编辑工作,以及 Manon Gomes、Joel Morange、Marc-Alexis Côté 和 Florence Baccard。

1.9 附录

1.9.1 Database「Hello World」代码生成

清单 1.25|简单的数据库表声明,SIG 编译器的输入。

struct TestStructure
{
    int     IntValue;
     // SSEAlign will force alignment of FloatVectorValue on 16 bytes
    float4  FloatVectorValue; <SSEAlign >
};

databasetable TestStructureTable
{
    int              IntValue;
    TestStructure   Structure;
};

清单 1.26|清单 1.25 生成的 HLSL 代码和访问器示例。

struct TestStructureTableRO
{
    ByteAddressBuffer Table;
    uint                 Size;
    uint                 ReservedSize;

    static TestStructureTableRO
    Create(const ByteAddressBuffer table)
     {
        TestStructureTableRO newTable;
      uint2 header = table.Load2(0);
        newTable.Table = table;
        newTable.Size = header.x;
        newTable.ReservedSize= header.y;
        return newTable;
     }
    bool IsValid(in const uint row)
     {
      if(row >= Size) return false;
         uint offsetInBytes = GetTableOffsetSoA(row , 0, 0, 0, 4, ReservedSize);
        return Table.Load( offsetInBytes ) != uint(-1);
     }
    int GetIntValueAt(in const uint row)
     {
         uint offsetInBytes = GetTableOffsetSoA(row , 0, 0, 0, 4, ReservedSize);
         int value = asint(Table.Load( offsetInBytes + 0));
        return value;
     }

     // IntValue is located at offset 0 in struct TestStructure
     // FloatVectorValueis located at offset 16 in struct TestStructure
     // (because of the use of <SSEAlign > in TestStructure declaration )
     //
     // Note the use of asint and Load for reading the int value ,
     // and Load4 and asfloat for reading the float4 values
    TestStructure GetStructureAt(in const uint row)
     {
         uint offsetInBytes = GetTableOffsetSoA(row , 0, 4, 0, 32, ReservedSize);
        TestStructure value;
        value.IntValue = asint(Table.Load( offsetInBytes + 0));
        value.FloatVectorValue= asfloat(Table.Load4( offsetInBytes + 16));
        return value;
     }
};
struct TestStructureTableRW
{
    RWByteAddressBuffer Table;
    uint                 Size;
    uint                 ReservedSize;

    static TestStructureTableRW Create(const RWByteAddressBuffer table)
     {
        TestStructureTableRW newTable;
      uint2 header = table.Load2(0);
        newTable.Table = table;
        newTable.Size = header.x;
        newTable.ReservedSize= header.y;
        return newTable;
     }
    bool IsValid(in const uint row)
     {
      if(row >= Size) return false;
         uint offsetInBytes = GetTableOffsetSoA(row , 0, 0, 0, 4, ReservedSize);
        return Table.Load( offsetInBytes ) != uint(-1);
     }
    int GetIntValueAt(in const uint row)
     {
         uint offsetInBytes = GetTableOffsetSoA(row , 0, 0, 0, 4, ReservedSize);
         int value = asint(Table.Load( offsetInBytes + 0));
        return value;
     }
    TestStructure GetStructureAt(in const uint row)
     {
         uint offsetInBytes = GetTableOffsetSoA(row , 0, 4, 0, 32, ReservedSize);
        TestStructure value;
        value.IntValue = asint(Table.Load( offsetInBytes + 0));
        value.FloatVectorValue= asfloat(Table.Load4( offsetInBytes + 16));
        return value;
     }
    void SetIntValueAt(in const uint row , in const int value)
     {
         uint offsetInBytes = GetTableOffsetSoA(row , 0, 0, 0, 4, ReservedSize);
        Table.Store( offsetInBytes + 0, asuint(value));
     }

     // Similarly to GetStructureAt , we use the same offsets for storing data
     // Note the use of asuint as we store everything in a ByteAddressBuffer
    void SetStructureAt(in const uint row , in const TestStructure value)
     {
         uint offsetInBytes = GetTableOffsetSoA(row , 0, 4, 0, 32, ReservedSize);
        Table.Store( offsetInBytes + 0, asuint(value.IntValue));
        Table.Store4( offsetInBytes + 16, asuint(value.FloatVectorValue));
     }
    void SetAt(const in uint row , in const int IntValue , in const TestStructure Structure)
     {
        SetIntValueAt(row , IntValue);
        SetStructureAt(row , Structure);
     }
};

清单 1.27|由清单 1.25 生成的 C++ 代码示例。与 HLSL 版本相比,它更依赖 Anvil 引擎的实现,也依赖 SIG 编译器和引擎工具代码。作者将其作为参考,帮助读者在编写自己的数据库编译器时明确目标。

namespace TestStructureTable {
struct Type
{
    static const U32 Hash = 0x1089437D; // Hash of TestStructureTable
    static database::TableTypeDesc GetDesc(database::TableColumnDesc columns[2], database::TableStreamDesc streams[2])
     {
        database::TableColumnAttribute<CBInt >(
             columns[0],"IntValue", "CBInt", 0, 0, 0, 4);
        database::TableColumnAttribute<TestStructure >(
             columns[1],"Structure", "TestStructure", 0, 4, 0, 32);

        TableStreamSoA(streams[0], 0, 4);
        TableStreamSoA(streams[1], 4, 32);

        return {"TestStructureTable", columns , 2, streams , 2, Hash , 0, (U32) -1};
     }
};

typedef database::TableRow <Type > Row;
typedef database::TableRange <Type > Range;
typedef database::TablePartialRange <Type > PartialRange;
typedef database::TableRef <Type > Ref;

struct Table : database::TableBase <Table , database::TableStorageCPU <Table >, database::TableRowAllocatorPersistent <Table >, database::TableWriterSimple <Table >>
{
    typedef Table TableT;
    typedef TestStructureTable::Row RowT;
    typedef TestStructureTable::Range RangeT;
    typedef TestStructureTable::PartialRange PartialRangeT;
    typedef TestStructureTable::Ref RefT;

    Table(U32 maxRows)
          : database::TableBase <Table , database::TableStorageCPU <Table >, database::TableRowAllocatorPersistent <Table >, database::TableWriterSimple <Table >>(maxRows , Type::GetDesc(Columns , Streams))
          , Index{ *this }
          , IntValue{ *this , 0, 0 }
          , Structure{ *this , 0, 1 }
     { }

    void SetReferences() { }

    database::TypedTable <TableT > Index;
    database::TypedTableColumnRW<TableT , CBInt , database::RowAccess <0, 0, 4>> IntValue;
    database::TypedTableColumnRW<TableT , TestStructure , database::RowAccess <0, 4, 32>>
     Structure;

    RowT New(const CBInt& IntValue_ , const TestStructure & Structure_)
     {
         RowT row = RowT::ToDerived(Alloc());
        if(row.IsValid())
          {
            IntValue.Set(row , IntValue_);
            Structure.Set(row , Structure_);
          }
        return row;
     }
    RangeT NewArray(U32 count , const CBInt* IntValue_ , const TestStructure * Structure_)
     {
        RangeT row = RangeT::ToDerived(Alloc(count , 1));
        if(row.IsValid())
          {
            if(IntValue_) IntValue.Set(row , IntValue_);
            if(Structure_) Structure.Set(row , Structure_);
          }
        return row;
     }
    database::TableColumnDesc Columns[2];
    database::TableStreamDesc Streams[2];

    Table::RefT Ref() { return {this , 0x1089437D /* TestStructureTable */}; }
};
} // namespace TestStructureTable

1.9.2 Database 脏页更新代码

清单 1.28|处理脏页更新的 C++ 代码。这是清单 1.12 中的 UpdateTable 在表使用 DirtyPages 更新策略时最终调用的代码。

template <typename TT0 , typename TT1 >
void TypedTableUpdate::UpdateTableDirtyRangeCPUToGPU( GfxComputeDevice & device , const TT0&
      Source , TT1& Dest , const Range& range)
{
    Assert((( range.Row + range.Count  - 1) / C_DIRTY_TABLE_PAGE_SIZE) <=
        (Source.Size() / C_DIRTY_TABLE_PAGE_SIZE));
    Assert((( range.Row + range.Count  - 1) / C_DIRTY_TABLE_PAGE_SIZE) <=
        (Dest.Size() / C_DIRTY_TABLE_PAGE_SIZE));

     // Update dirty page range
    U32 startOffset = range.Row * C_DIRTY_PAGE_SIZE;
    U32 countInBytes = range.Count *  C_DIRTY_PAGE_SIZE;
    U32 destOffset = startOffset + Dest.StreamData(0).OffsetInBytes + TableColumnDesc::C_GPU_TABLE_HEADER;

    ScopedWriteableBufferMap bufferMap(device , *Dest.GetGPUBuffer(),
        destOffset , countInBytes );
    MemCopy(bufferMap.GetDataPtr(), Source.DataPtr() + Source.StreamData(0).OffsetInBytes
      + startOffset , countInBytes );
}

template <typename TT0 , typename TT1 >
void TypedTableUpdate::UpdateRangesCPUToGPUInternal( GfxComputeDevice & device , const TT0&
      Source , TT1& Dest)
{
    U32 maxElements;
    const G4::BitArray <>& dirtyElements = Source.GetDirtyElements(maxElements);
    U32 dirtyElementCount = Source.GetDirtyElementCount();

     // Find dirty Element range for update
    U32 currentElement = 0;
    U32 startElement = C_INVALID_ROW;
    Bool currentDirty = false;
    while(( dirtyElementCount  > 0) &&
             ( currentElement  < maxElements ))
     {
         Bool dirty = dirtyElements.get_element( currentElement ) > 0;
        startElement = ! currentDirty && dirty ? currentElement : startElement;
         if( currentDirty && !dirty)
          {
              // End of dirty range
            UpdateTableDirtyRangeCPUToGPU(device , Source , Dest ,
                   { startElement , currentElement  - startElement });
            dirtyElementCount  -= currentElement  - startElement;
          }
        currentDirty = dirty;
        currentElement ++;
     }

     if( currentDirty )
        UpdateTableDirtyRangeCPUToGPU(device , Source , Dest , { startElement , currentElement
        - startElement });
}

参考文献

[Cignoni et al. 05] P. Cignoni、F. Ganovelli、E. Gobbetti、F. Marton、F. Ponchio、R. Scopigno,“Batched Multi Triangulation”,VIS 2005: IEEE Visualization 2005,pp. 207–214,IEEE Press,2005。

[Greene et al. 93] Ned Greene、Michael Kass、Gavin Miller,“Hierarchical Z-Buffer Visibility”,SIGGRAPH ’93,pp. 231–238,ACM,1993。

[Haar and Aaltonen 15] Ulrich Haar、Sebastian Aaltonen,“GPU Driven Rendering Pipelines”,SIGGRAPH 演讲,2015。

[Hill 10] Stephen Hill,“Rendering with Conviction”,GDC 演讲,2010。

[Karis 21] Brian Karis,“Nanite: A Deep Dive”,SIGGRAPH 演讲,2021。

[Kumar et al. 96] Subodh Kumar、Dinesh Manocha、Bill Garrett、Ming Lin,“Hierarchical Back-Face Culling”,技术报告,1996。

[Olano and Greer 97] Marc Olano、Trey Greer,“Triangle Scan Conversion Using 2D Homogeneous Coordinates”,HWWS ’97,pp. 89–95,ACM,1997。

[Rodrigues 17] Tiago Rodrigues,“Advanced Graphics Tech: Moving to DirectX 12: Lessons Learned”,GDC 演讲,2017。

[Silvennoinen 12] Ari Silvennoinen,“Chasing Shadows”,Game Developer Magazine 19:2(2012),pp. 49–53。

[Wihlidal 17] Graham Wihlidal,“Optimizing the Graphics Pipeline with Compute”,载 Wolfgang Engel 编,GPU Zen: Advanced Rendering Techniques,pp. 277–320,Black Cat Publishing,2017。


AI 协作复盘

初译和图表整理使用了 AI。校对时发现,自动提取会把图内标签混入正文,也会拆散代码标识符;随后对照原文修正了这些问题。读者反馈推动了术语统一和代码呈现方式的调整。

发表评论

了解 AI Native Game Development 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读