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

当时的实现将所有网格划分为 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)。

随后,[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,只能按材质合批。
- 对不适合合批、或本来不需要合批的图形对象,收益很小或实现过于复杂。
- 对重复实例化成千上万次的对象,处理效率仍不足。

团队为《刺客信条:英灵殿》和《刺客信条:幻景》围绕 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.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.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.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.3.2 世界数据
全局渲染数据就绪后,再创建各个世界的 LeafNodes 和 CullInstances。每个 CullInstance 包含变换、关联的 CullMesh、LOD 状态、实例材质标志和着色器常量。主视图与阴影贴图可以使用不同的 LOD 切换距离,因此每个实例保存两组 LOD 状态。
GPUIR 需要一次处理数百万实例,因此采用“实体组 → 叶节点”的空间层次提高剔除效率(图 1.9)。一个实体组收集某个加载单元内的同类实例,例如树木或建筑。实例可能由散布规则程序化生成,也可能由“优化实例化”流程遍历该加载单元,选择适合 GPUIR 的实体并将其合并。组内实例再划分为多个叶节点。

同一叶节点可以包含不同的 CullMesh,但最多容纳 16,384 个实例,因为实例范围不能跨越数据库表的分配页。系统还尽量将空间位置接近的实例分在一起,缩小叶节点的包围体积,提高剔除效率。
LeafNode 是 CPU 剔除层次的最底层,也是 CPU 跳过空绘制调用的最后机会。因此,每个节点都汇总其 Render Pass Mask,以及所含实例的 CullMesh 提供的批次哈希。借助 SIG 编译器的 <owner> 属性,删除节点时还可以自动释放它在其他表中拥有的实例、着色器常量,以及供 CullMesh 引用计数使用的间接引用。相关数据表的关系见图 1.10。

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

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

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))。

剔除实例并更新 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.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。最后留下的子网格实例才进入绘制调用。

批次哈希。 单个 DrawBuffer 保存各渲染批次的绘制调用数和全部绘制参数。系统对一个实例的子网格 ID 与渲染批次 ID 做哈希,确定它属于哪次绘制。子网格 ID 本已确定几何与材质;但启用 Alpha Clip 时,本方案会切换 PSO,因此还必须按 Shader Permutation 将子网格实例分配到不同的绘制调用(第 1.4.1 节)。
第一遍以绘制调用哈希为键填充 GPU 哈希表,用线性探测处理冲突。首次插入某个键的子网格实例会在临时描述符列表中建立条目;其后的子网格实例则增加该键的实例计数。每个子网格实例还会记录绘制调用所在的哈希槽,以及自身在该调用中的实例偏移量(图 1.16)。
第二遍由一个线程组做前缀和,确定各绘制调用在缓冲区中的位置。每个线程负责若干哈希槽,先统计这些槽需要多少绘制条目和 InstanceInfo;前缀和给出编号更小的线程累计申请的条目数。随后,线程再次遍历自己的槽,结合子网格的几何描述与哈希表中的实例数写入绘制参数,并把该绘制调用的首个 InstanceInfo 偏移存回哈希表,供下一步使用。

写入 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 的角度看,前者不是传统顶点缓冲区,而是共享的字节地址缓冲区。

提交多重绘制调用前,系统还可以执行 Cluster Culling 和 Triangle Culling。这两步都是可选的,可根据数据和 Pass 类型,在 Z Pre-Pass、GBuffer 等 Pass 启用。
Cluster Culling 在 Compute Shader 中执行,分为 Frustum Culling 与 Occlusion Culling 两步(图 1.18),每个线程处理一个 cluster。线程从 InstanceInfo 中取得相应的 WorldViewProj 矩阵,并读取 cluster 包围盒的中心与各轴半尺寸,再将包围盒投影到屏幕空间以供剔除测试。

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 剔除使用的深度层次。

在遮挡过程中,系统从对应的 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.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.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.5.2 Bindless Descriptor Table
如第 1.3 节所述,GPU-Driven Pipeline 使用数据库表描述场景(图 1.23)。
Bindless Material Table 汇集当前渲染帧所需的二维、三维和立方体纹理描述符,统一数组共容纳 32K 个条目。三类资源共享同一数组,但三维和立方体纹理只预留较小范围,因为实际需求较少(清单 1.21)。

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

渲染前,系统从各可见实例对应的 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.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.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)。

| 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,包括不透明物体与阴影等。

| 阶段 | 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。校对时发现,自动提取会把图内标签混入正文,也会拆散代码标识符;随后对照原文修正了这些问题。读者反馈推动了术语统一和代码呈现方式的调整。