GPU-Driven Rendering inAssassin’s Creed Mirage

发布于:2025-09-01 ⋅ 阅读:(20) ⋅ 点赞:(0)

1.1Introduction

1.1.1 Anvil Engine

Anvil 游戏引擎 以其在《刺客信条》系列中的应用而闻名,同时也被其他大型游戏所选择,如《彩虹六号:围攻》、《幽灵行动:断点》和《荣耀战魂》。该引擎主要针对大规模系统化世界设计,强调远距离渲染、海量实例数量以及系统化的游戏机制(Figure 1.1)。

1.1.2 先前工作

Anvil引擎在游戏中率先引入 GPU驱动渲染管线 ,始于2014年的《刺客信条:大革命》[Haar and Aaltonen 15] 和2015年的《彩虹六号:围攻》。自那时起,GPU驱动渲染管线已成为所有基于Anvil引擎游戏的核心技术,并不断得到优化。

在本迭代中,所有游戏中的网格均经过簇化处理(Cluster-based Processing)(Figure 1.2),并基于材质进行动态批处理,在GPU上进行剔除 (per instance, cluster, triangle)(针对实例、簇化和三角形),并通过 MultiDrawIndexedInstancedIndirect 间接绘制调用。尽管网格簇化概念在视频游戏引擎中相对较新,但在学术研究中已有深厚基础,如 [Kumar et al. 96] 和 [Cignoni et al. 05] 所述。此外,[Haar and Aaltonen 15] 基于[Silvennoinen 12] 引入了摄像机深度重投影技术,用于去除无法产生可见阴影接收器的阴影投射体,从而加速级联阴影贴图渲染(Figure 1.3)。

随后,[Wihlidal 17] 在 [Haar and Aaltonen 15] 的基础上,通过 完全基于计算着色器的实现 改进了三角形剔除旨在CPU端 ,而[Haar and Aaltonen 15] 则预计算了基于固定角度的静态每三角形簇的可见性掩码。最后,[Karis 21] 引入了虚拟几何体 管线,延续 [Cignoni et al. 05] 的想法,采用三角形簇的层次细节(LOD)结构,并基于三角形大小分类,然后用 网格着色器 和 软光栅化 来处理小三角形。

1.1.3 设计理念

第一代GPU驱动渲染管线名为BatchRenderer,并针对DirectX 11 API设计(Figure 1.4)。BatchRenderer的主要目标是通过利用 MultiDrawIndexedInstancedIndirect 减少DirectX 11中昂贵的单个绘制调用开销。尽管如此,它存在几个缺点:

• 无异步执行(No Async):剔除操作在渲染前立即执行,无法并行调度。

• 无绑定资源(No Bindless):仅支持按材质批处理,限制了灵活性。

• 对于不支持或不需要批处理的图形对象,要么无用,要么实现过于复杂。

• 对于实例化次数极高的批处理对象,效率低下。

针对《刺客信条:英灵殿》和《刺客信条:幻景》,我们对该系统进行了完整重写,基于DirectX 12 API 设计。我们称之为GPU实例渲染器(GPU Instance Renderer GPUIR)。通过GPUIR,我们解决了BatchRenderer的缺点:

• 减少CPU时间:加载时进行批处理,将更多步骤迁移到GPU,并使用无绑定资源实现更高效的按着色器批处理。
• 减少GPU时间:异步执行每帧和每通道实例剔除,并在渲染时进行 网格簇 和 三角形剔除。
 限制于不透明的静态或蒙皮网格实例及其LOD转换:与网格LOD选择和纹理流紧密耦合。

GPU实例渲染器 是一种高吞吐量渲染器,基于GPU驱动剔除管线的核心理念,强调在加载/创建时预先批处理。通过无绑定材质全局几何缓冲区,它允许按着色器而不是 按材质或 按几何体进行CPU批处理。与BatchRenderer不同,它在GPU上处理有状态的LOD转换,并仅接受实例组(LeafNodes)列表作为输入,然后在 GPU 上将其扩展为逐通道的单独绘制调用(例如 ZPrepass、GBufferPass)(Figure 1.5)

1.2 数据库

GPUIR 高度依赖数据库系统,作为CPU和GPU之间高效共享完整场景描述的手段。这是新GPU驱动管线的核心之一,使更多CPU工作能够迁移到GPU执行。

1.2.1 设计理念

数据库并非 MySQL 的GPU移植版本;它本质上是一个新型数据结构容器,支持CPU和GPU之间共享数据。

我们已在多部作品中部署了复杂系统,例如操作复杂数据结构的GPU驱动管线,或具有持久稀疏体积的体素烘焙全局光照(GI)。在GPU上分配和维护此类复杂数据结构可能很繁琐,因为涉及大量样板代码和更新操作。

通过数据库,我们构建了一个可重用的CPU/GPU数据容器接口,能够以模块化方式结合不同的分配、存储和复制行为,并再现C++面向对象编程(OOP)的调试便利性。

1.2.2 数据布局

着色器输入组编译器(Shader Input Groups Compiler SIG)[Rodrigues 17] 是 基于根布局和着色器输入的内部的编译器。它为 C++和 HLSL 生成绑定代码。我们选择依赖它来为数据库生成所有 C++和 HLSL 代码,包括所有必需的访问器和优化的数据布局(Listing 1.1 ) .

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

//Listing 1.1. Example of a database table declaration, input for the SIG compiler.

为了强调数据并行性质,我们采用数据库类比(Figure 1.6)进行数据访问。一个表是数据容器的实例,包含 固定类型列 和可变行数。

借助SIG生成的代码,我们可以轻松在C++和HLSL中声明表并访问其数据(Listing 1.2)。注意,HLSL在操作符重载方面非常有限,因此接口必须类似于 [Rodrigues 17] 中SIG常量缓冲区接口。

// 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);

//Listing 1.2. Example of how the data declared in Listing 1.1 can be accessed in C++ and HLSL.

结构化数组
通过注解,我们可以轻松将表数据布局从数组结构体(AoS)切换到 结构体数组(SoA)(Listing1.3),而不改变数据访问接口。根据数据访问模式,还可以混合使用AoS和SoA。访问代码没有差异,因此可以迭代优化表数据布局,以获得最佳缓存行为,而无需更新使用该表的代码。

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

//Listing 1.3. Example of SoA table declaration, reusing Listing 1.1.

分页结构化数组

我们支持另一种称为分页数组结构(Paged SoA)(Listing1.4)的数据布局方式。由于GPU资源与着色器的绑定方式有限,若仅为表的每列使用任意内存范围将会效率低下。相反,每个表使用单一GPU资源更为理想,但若不使用虚拟内存则会非常受限,因为必须始终为每个表预定义峰值内存使用量。

在SoA布局中,由于表的每列在内存中是连续的,对大型表使用虚拟内存意味着当表增长时,每列至少需要增长一个页面。这对于具有大量列的表将非常沉重。为此,我们支持一种数据布局方式,其中每列只有部分数据块是连续的(Figure 1.7)。这样,单个物理内存页面可以同时增长所有列。

1.2.3  关系模型

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

1 to 1: 简单的 1 对 1 关系是可以认为是 指针 的数据库版本(Listing 1.5) . 在底层实现上。仅仅是一个uint索引 ,但在 C++中至少具有类型安全的好处。目标表(destination table)的指针存储在表实例(table instance)中,行索引(row index)存储在 Row 关系中。通过 指针 + 行索引 × 行大小 ,计算出目标表中某一行数据的内存地址。最终转换为一个 具体的内存地址,可以直接访问该地址处的数据。

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

//Listing 1.5. A row relation is a simple 1-to-1 relation that is simulating a pointer. Row access is illustrated in Listing 1.2.

1 to n:我们还支持 1 对 n 关系,这是前面 一对一 概念的扩展。我们称之为 Range, 通过模拟带有大小的指针(Listing 1.6)实现 一个指针访问多个对象的功能。(可以理解成 N 个对象组合成一个新的对象,指针指向这个对象,然后通过子对象去访问,来实现一对多)

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

//Listing 1.6. A Range relation is a 1-to-n relation that simulates a pointer with a size.

 PartialRange 类似于 Range,但将 Range 的大小拆分为“已用大小”和“最大大小”,以模拟std::vector的增长和收缩行为(Listing 1.7)。

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

//Listing 1.7. A PartialRange relation is a 1-to-n relation that is only partially used.

所有权 Ownership
我们已经看到,Row和Range只是指向其他表中数据的指针。我们可以通过 ownership属性增强这些关系(Listing 1.8 and 1.9)。它确保当所有者表中的条目被删除时,被Row或Range引用的拥有的行也会从相应表中正确删除。

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

//Listing 1.8. TestRange owns the rows in the table TestPOD referenced by the Range POD.
// Simple table range
TestPOD :: Table tablePOD( G4 KB (1));
TestPOD :: Range ranges [16];

S32 intValue [16];
F32 floatValue [16];

for (U32 i = 0; i < 16; i++)
{
    intValue[i] = −S32(i);
    floatValue [i] = i ∗ 0.125f;
}

for (U32 i = 0; i < 16; i++)
    ranges[i] = tablePOD. NewArray(i + 1, intValue , floatValue );
    
// Adding ranges
TestRange :: Table tableRange ( G4 KB (1) , tablePOD .Ref ());
    
for (U32 i = 0; i < 16; i++)
    tableRange .New(ranges [15 − i]);
    
// Do things ...
// delete owned ranges

for (U32 i = 0; i < 16; i += 2)
    tableRange .Delete(i);

//Listing 1.9. Example of C++ code manipulating the tables declared in Listing 1.8. When an entry in tableRange is deleted, all the rows of tablePOD referenced by tableRange::POD are also deleted, thanks to the <owner> attribute.

索引 Index:最复杂的关系是索引(Listing 1.10 and 1.11)。通过注解[index()]内容, SIG 将会自动在键列(key column)上生成特定类型的索引。该索引允许高效访问反向关系。它的工作原理类似于 SQL 中的二级键索引。

// 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 ;
};

//Listing 1.10. Index relation: In this case not only can one retrieve the owner of a Component n but also efficiently iterate over all the components with the same owner m.
// 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 ...
    }
}

//Listing 1.11. This example iterates over all the components owned by an Entity i,on the CPU, using tables declared in Listing 1.10. One can see how powerful and practical this can be to describe a whole scene on the GPU with complex relations between objects.

我们编写了一个数据库来操作这些关系,就像操作数组一样。一些标准函数也在C++和HLSL中代码生成(see Appendix 1.9.1),以便轻松在CPU或GPU上操作表。
 

1.2.4 复制机制

不同的表实例分别处理 CPU 存储 和 GPU 存储。在某些情况下,对于非常大的表,不需要完全在CPU上分配表,我们支持仅在 CPU 上分配和存储脏行,直到它们被刷新到 GPU。

我们支持多种数据复制模式,以确保数据从一个实例传播到另一个实例(通常是从 CPU 到 GPU)。

复制(Copy)
复制是最简单的复制模式。我们支持所有CPU和GPU表复制的组合,包括分页或非分页。如果源和目标都在GPU上,则复制直接在GPU上发生;否则,复制的工作方式与 从/向ByteAddressBuffer复制数据时类似(例如将缓冲区映射到 CPU 等),并涉及与我们的数据库系统相关的一些额外管理。

脏行更新(DirtyRows Update)
使用此策略的表会在更新行时更新行脏掩码。连续的脏行范围会在更新时复制到另一个表。行复制会导致许多小规模独立复制,会产生最小复制带宽。

脏页面更新(DirtyPages Update)
此策略与DirtyRows Update类似,但脏标记发生在更低的粒度级别(每页面而非每行)。跟踪脏度的存储空间较小,但所需的复制带宽更高。

脏页面复制(DirtyPage Copy)
DirtyRows和DirtyPages都需要CPU存储。当行或页面中的值发生变化时,会将整个行或页面上传到GPU。在DirtyPage Copy的情况下,脏页面在CPU系统内存中的单独数据结构中分配,然后在更新时复制到上传堆,并使用计算着色器写入最终GPU存储(Listing 1.12)。此复制策略允许表实例不使用额外的任何 CPU 存储来 记录未修改的行,并使它们仅支持写入。

// 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 ();

//Listing 1.12. Example of a database table copy and update from the CPU to the GPU.Note the use of the annotation DirtyPages.

1.3 CPU数据管理

GPUIR 的所有输入都转换为数据库表,以实现快速获取和轻松复制到GPU。三个最重要的输入表是CullMeshes、CullInstances和LeafNodes。本节将讨论这些表的管理方式、它们包含的数据类型,以及它们如何启动GPU上的剔除过程。

1.3.1 渲染数据

在BatchRenderer中,LOD选择器表示一组五个几何细节层次。当前 活动的 LOD是基于与摄像机的距离选择,LOD 之间的过渡是根据时间确定。每个LOD由一个或多个子网格组成,每个子网格恰好有一个材质。我们决定在GPUIR中保留LOD选择器的概念,但仅作为数据创作和几何序列化到磁盘的方式。

在GPUIR中创建实例集合时,首先需要设置CullMeshes及其渲染批次。CullMesh是LOD选择器的GPU表示形式。除了LODs之外,它还包含有关可渲染通道的信息。每个LOD节点(CullLODNode)指向可变数量的CullSubMeshes,其中存储材质和几何描述。有关用于实现CullMesh的数据库表的更完整描述,请参阅 (Figure1.8)。

为了在每个绘制调用中批处理尽可能多的实例,我们为每个管线状态对象(PSO)创建一个批处理槽。这些批处理槽包含在绘制调用期间不能更改的所有内容:着色器模板、特定着色器排列以及各种管线标志。由于我们依赖无绑定描述符在渲染时 addressing 纹理,因此无需按材质使用的纹理集拆分批处理。相反,几何形状与PSO的关联在CullSubMesh级别进行。用于创建批处理槽的所有输入被一起哈希以创建BatchHash。从现在起,我们将始终通过其哈希引用这些批处理槽,因为它们将存储在哈希映射中。CullSubMeshes为每个可渲染的管线配置存储一个 批处理哈希(例如,G-buffer、仅深度渲染、透明渲染)。

(Render Pass masks)渲染通道掩码由材质决定;然而,一些剔除操作发生在CullMesh级别,因此需要在该级别聚合信息。MergedBatchHash存储了CullMesh中所有CullSubMeshes的组合Pass Mask。此外,它包含CullMesh中实例的最大数量和三角形簇。该信息稍后将用于计算给定世界的间接绘制调用总数。

1.3.2世界数据

一旦全局渲染数据准备就绪,我们就可以创建特定于世界的数据,即LeafNodes及其CullInstancesCullInstance由transform、CullMesh、LOD states、实例材质标志(per-instance material flags)以及一些 实例着色器常量 组成。由于我们可以为主视图和阴影贴图定义不同的LOD 切换距离,因此每个实例存储两组 LOD 状态。

GPUIR 被设计为能够一次性剔除和渲染数百万个实例。为了实现良好的剔除性能,它依赖于由实体组(Entity Group)和 LeafNodes 组成的层次结构(Figure 1.9)。实体组表示给定加载单元中某一类型的实例(例如,树木或建筑物)的集合。这些实例通常基于Scattering rules 程序化生成,或是optimized instancing 生产的,然后通过遍历加载单元中的所有实体,并有选择地合并与GPUIR兼容的实体, 构成实体组。

在集合内部,实例被拆分为LeafNodes。不同CullMeshes的实例可以放入同一个LeafNodes,但选择要分组的实例有某些约束。首先,每个LeafNodes不能包含超过16,384个实例,因为实例范围不允许跨越数据库表的分配页面。其次,我们尽量将空间上相邻的实例保持在同一个LeafNodes中,以最小化其边界体积并提高剔除效率。

LeafNodes是CPU上剔除的层次结构的最低级别,因此也是跳过空绘制调用的最后一个机会。这就是为什么我们为每个LeafNodes计算PassMask,并收集其实例的CullMesh提供的批处理哈希。LeafNodes还利用SIG编译器的ownership属性,在删除一个LeafNodes时自动释放其他数据库表中的拥有的行。因此,一个LeafNodes拥有其实例、它们的着色器常量以及一些用于引用计数的CullMeshes间接引用。世界特定数据库表之间的关系如 Figure 1.10 所示。

1.3.3 粗粒度CPU剔除

当实体组加载到内存中并创建其世界数据时,它们也会插入到四叉树剔除结构中,如 Figure 1.9 所示。在每帧开始时,通过将四叉树的每个单元与渲染通道的视锥体进行检查,来收集那些至少在一个渲染通道中可见的实体组。这些实体组的LeafNodes(Figure 1.11)再次针对每个视锥体进行测试,但此时,我们可以利用合并通道掩码来跳过那些不会为渲染通道产生任何可见实例的LeafNodes。这样,我们就得到了一个 PassedLeafNodes 列表及其对应的 PassedInstanceRanges(存储一个 Pass 中可见的 Instance Range)。实例范围被发送到 GPU 以进行下一阶段的细粒度剔除。(Figure 1.12)

PassedLeafNodes开始,我们为每个通道构建一个渲染批处理哈希映射,使用批处理槽哈希作为键。这些哈希映射将让GPU知道在哪里写入给定通道的渲染批处理的间接绘制调用参数。为了生成这些映射,我们只需迭代PassedLeafNodes并为每个唯一MergedBatchHash分配一个渲染批处理。分配的渲染批处理尚不能完全初始化,因为我们不知道有多少活动CullSubMeshes引用这些渲染批处理。这将在GPU上完成剔除通道后确定(see section 1.4.2)。目前,我们只能确定渲染批处理可以包含的最大实例和绘制调用数量,这是CPU上准备间接绘制调用所必需的。

1.4 GPU剔除

现在最高层次的层次结构已经被剔除,我们可以开始单独处理实例。这是GPU真正发挥作用的地方,因为我们通常需要处理数十万个实例。CPU剔除阶段生成了一系列的临时数据库表(例如,PassedInstanceRanges、BatchMap),这些表准备好 与 持久性渲染数据世界数据表(例如,CullInstances、CullMeshes)一起复制到GPU。为了将所有这些信息转换为实际绘制调用,我们仍然需要在实例上执行一些逐帧和逐通道操作。通常可以在图形队列更新其他系统的同时,在异步队列上执行GPU剔除遍历操作。在我们的案例中,仅在几何体绘制前,通过图形队列完成三角形和簇剔除操作。

下文将要描述的每个着色器都会将其输出存储在临时GPU数据库表中。这些表的分配过程分为两个步骤:首先通过原子加法计算线程组请求的行数,然后让该组的第一个线程用另一个原子加法操作来增加表的大小。上述操作的返回值分别给出了每个线程的局部偏移量和线程组的写入偏移量。

1.4.1 逐帧GPU剔除

接下来每帧操作的目的(Figure 1.12)是更新实例的 LOD状态 并生成sub-mesh 实例,将其传递给逐通道剔除。

提取实例范围 Extract Instance Ranges:从CPU剔除阶段发送到GPU的实例列表被压缩为PassedInstanceRanges。为了将每个GPU线程分配一个实例,我们需要从这些范围中提取单个实例索引(Figure 1.13(a))。这通过构建生成 搜索范围表 和 组范围表 来完成,以便实例剔除着色器可以通过在组的实例范围边界内进行二分搜索查找来找到其 实例索引(Figure 1.13(b))。

实例和LOD剔除:有了实例索引,GPU线程可以执行进一步的视锥体剔除步骤。每个实例针对每个通道视锥体进行测试,并记录相交视锥体到通道掩码中。如果实例与至少一个视锥体相交,其LOD状态将被输出到LOD更新阶段。如前所述,我们为每个实例跟踪两个LOD状态,一个用于主视图,另一个用于阴影。Listing 1.13显示了如何计算LOD状态通道掩码。

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 ;
    // ...
}

//Listing 1.13. The LOD state pass masks are computed from the union of all the passes an instance was not culled from, plus the static shadow pass mask.

更新LOD状态 包括检测LOD过渡开始并跟踪正在进行的过渡进度。在我们的实现中,我们决定使LOD过渡不可中断 并始终持续固定时间。更新后,每个LOD状态可以根据过渡是否正在进行导出一到两个PassedLODs到批处理扩展阶段。

生成批处理:此时,我们有一个PassedLODs列表,我们希望将其sub mesh分配到不同通道。到目前为止计算的通道掩码指定了实例可以在哪个通道中渲染,但我们需要精确知道实例LOD 的 sub-Mesh将在哪个通道中渲染。例如,我们需要决定在Z-pre Pass中渲染哪些sub-Mesh。为此,我们使用基于Sub-Mesh 在屏幕空间投影三角形的平均尺寸来启发式的评估。距离摄像机较近且由较大三角形组成的物体更可能的被发送到Z pre-pass。此外,我们还需要选择使用哪种着色器变体来 sub-Mesh。例如,如果Object不需要任何alpha剪切操作(即,没有抖动或没有 alpha测试),我们可以选择使用无裁剪优化渲染 sub mesh。

1.3.3节描述了如何初始化批处理映射,将不同渲染批处理与基于通过CPU粗粒度剔除的LeafNodes的MergedBatchHashes关联到不同渲染通道。生成渲染批处理涉及将子网格实例注册到给定选定通道和着色器变体的正确渲染批次中。一旦所有子网格实例注册完成,我们会重新排序它们,以获得最终的每通道子网格实例缓冲区,如Figure 1.14所示。

1.4.2 逐通道GPU剔除

我们几乎准备好生成单个渲染批次的绘制调用参数,但在执行此操作之前,还有最后一个机会剔除实例。在本阶段结束时,我们将拥有渲染三角形所需的一切,包括InstanceInfo,它允许我们为每个实例从顶点和像素着色器中获取正确的几何形状和材质。Figure 1.15显示了剩余的剔除和绘制调用准备步骤。

剔除通道子网格 (Cull Pass Sub-meshes) :在这里,我们可以选择对子网格实例执行额外的剔除测试。执行的测试取决于通道和执行测试的成本效益比。例如,我们决定针对大多数主视图通道 使用主视图的层次Z缓冲区(HZB)进行遮挡剔除测试,使用子网格的边界框作为采样区域(see section 1.4.3)。对于太阳阴影,我们选择应用摄像机深度重投影方法来剔除不会影响屏幕上任何像素的阴影投射体。通过最后一轮测试的所有子网格实例都会最终在drawcall中。

哈希批处理(Hash Batches):我们使用单个绘制缓冲区来存储每个渲染批处理的绘制调用计数和所有绘制调用参数。为了确定一个实例属于哪个绘制调用,我们对 子网格ID和渲染批次ID 进行哈希。从技术上讲,子网格ID就足以创建绘制调用哈希,因为它代表几何形状和材质的组合,但在我们的情况下,当启用alpha剪切时,我们在不同PSO之间切换,因此需要根据使用的着色器排列将子网格实例注册到不同绘制调用(see Section 1.4.1)。

哈希批次构建(第一遍处理)

  • 哈希键生成:将子网格ID(sub-mesh ID)与渲染批次ID(render batch ID)组合生成绘制调用哈键。
  • 哈希映射填充:
    • 冲突解决:采用 闭合哈希(Closed Hashing)与线性探测(Linear Probing) 处理哈希冲突。
    • 首次插入:若实例为当前键的首个插入者,则在临时列表中创建哈希槽描述符条目。
    • 计数更新:若非首次,则递增哈希映射中该键的实例计数
  • 哈希批次记录:无论是否首次插入,均生成哈希批次(Hashed Batch),记录绘制调用在哈希映射中的位置及实例在绘制调用内的偏移量(Figure 1.16)。

前缀和计算与参数写入(第二遍处理)

  • 前缀和分配:
    • 线程组分配:每个线程处理多个哈希映射槽位,统计需分配的绘制调用及InstanceInfos数量。
    • 前缀和计算:通过单线程组前缀和(Prefix Sum)确定各绘制调用的全局起始位置。
  • 参数写入:
    • 循环遍历:线程再次遍历其负责的哈希槽位,利用前缀和结果确定内存位置。
    • 参数生成:根据子网格的几何描述符及哈希映射中的实例计数,写入绘制调用参数。
    • 偏移记录:在哈希映射中保存每个绘制调用的首个InstanceInfo偏移,供后续步骤使用。

Write InstanceInfos:每通道剔除阶段的最后一步是为每个实例在缓冲区中写入一个InstanceInfo,该缓冲区将在渲染时传递到顶点和像素着色器(Listing 1.14)。InstanceInfo包含世界-视图-投影矩阵、一些打包属性以及到统一几何缓冲区(Section 1.4.3)、统一常量缓冲区(Section 1.5.3)和无绑定材质表的偏移量。一个实例的InstanceInfo索引必须与传递给顶点着色器的实例ID匹配。我们通过从哈希映射中读取绘制调用的第一个实例信息偏移量,并将其与记录在哈希批次中的绘制调用内的实例偏移量相加,来计算此索引。

// 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
}

//Listing 1.14. InstanceInfos format: vertexInfo packs the data necessary to fetch vertices in the vertex shader, while materialOffsets packs offsets to fetch the bindless material (Section 1.5.2) and constant tables in the pixel shader.

1.4.3 簇剔除

在Anvil Engine中,所有网格默认经过簇划分,每个簇由64个顶点组成(Figure 1.17)。我们将几何顶点和索引存储在统一缓冲区中。所有顶点数据驻留在同一个缓冲区中,我们在顶点着色器中基于顶点ID手动获取顶点。它不再是API意义上的顶点缓冲区,而是一个共享字节缓冲区。

在发出实际多绘制调用之前,我们可以执行簇和三角形剔除。这些步骤是可选的,并可以按渲染通道启用(例如,ZPrepass、GBufferPass),前提是它们有益(主要取决于数据和渲染通道类型)。

每簇剔除由两个步骤组成 视锥体剔除和遮挡剔除 并在计算着色器中运行(Figure 1.18)。每个线程处理一个簇。对于每个簇,我们从InstanceInfos缓冲区(Section 1.4.2)获取相应的WorldViewProj矩阵以及其边界框的中心和半径范围。用于计算投影边界框以进行剔除。

视锥体剔除:对于每个簇,我们首先投影其边界框,然后在归一化设备坐标(NDC)中剔除它(Listing 1.15)。

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

//Listing 1.15. Simple per-cluster frustum culling shader code.

遮挡剔除:此步骤类似于[Haar and Aaltonen 15],需要层次Z缓冲区[Hill 10, Greene et al. 93](Figure 1.19)。我们首先使用最佳最近遮挡体渲染部分深度预通道,将其下采样到512 × 256,并将其与上一帧深度的重投影结果组合。然后,将结果进行mipmap处理,取4个纹素的最大值以生成下一级别,从而获得用于GPU剔除的深度层次结构。

在遮挡通道期间,我们获取相应mipmap级别中2 × 2纹素邻域,使簇包围盒的屏幕区域投影到4个纹素,并将这些4个纹素的最大Z值与该边界框的最小深度值进行比较,以确定是否被遮挡(Listing 1.16)。

// 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;
}

//Listing 1.16. Per-cluster occlusion culling shader code using a HZB.

1.4.4 三角形剔除

每三角形剔除由三个步骤组成——零面积和背面剔除、视锥体剔除以及小三角形剔除——并在计算着色器中运行(Figure 1.20)。每个线程处理一个三角形。对于每个三角形,我们再次从InstanceInfos缓冲区(section 1.4.2)获取相应的WorldViewProj矩阵,并在进行任何剔除之前转换顶点(Listing 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 );
}
//Listing 1.17. Fetch vertices in the unified geometry buffer and transform them to the homogeneous space. Note that we don’t do the perspective divide yet.

零面积和背面剔除:我们检查2D齐次矩阵的行列式,如[Wihlidal 17]和[Olano and Greer 97]所述(Listing 1.18)。(det > 0)表示正面三角形,而(det = 0)表示零面积三角形。具有共线顶点的三角形是退化的,具有零面积。

float det = determinant (float3x3(vtx [0]. xyw , vtx [1]. xyw , vtx [2]. xyw) );
passed = (det > 0) | | (twoSided && det != 0);
//Listing 1.18. Zero area and backface triangle culling shader code testing the determinant of the 2DH Matrix. Note the special case for double-sided geometry

视锥体剔除:此步骤(Listing 1.19)与集群级视锥体剔除(Listing 1.15)类似,但仅处理二维屏幕空间。

小三角形剔除:为了剔除小三角形,我们使用[Wihlidal 17]的相同方法,将三角形边界框的最小和最大值捕捉到最近的像素角落(Figure 1.21)。然后,我们通过测试对齐后的坐标是否覆盖像素中心来判断是否剔除(Listing 1.20)。

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

//Listing 1.19. Per-triangle frustum culling shader code.

实例化和索引缓冲区压缩:当启用簇和三角形剔除时,它们会影响实例级别可渲染的几何形状。我们无法再使用原始索引缓冲区,因为这些剔除步骤破坏了实例化机制。取而代之,我们输出一个新的压缩索引缓冲区,包含每个实例的可见三角形索引,遵循 [Haar and Aaltonen 15] 中描述的类似方法。这是三角形剔除计算任务的最后一步,其中每个线程计算输出写入到新索引缓冲区的位置,并在通过剔除时写入其三角形索引。

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

//Listing 1.20. Small triangle culling shader code

出于相同原因,还需更新DrawBuffer(见第1.6节),为ExecuteIndirect提供额外的绘制条目参数。这是因为同一批次的实例可能因可见几何体不同,需拆分为多个DrawIndexedInstanced调用。这些新绘制条目的索引会嵌入实例ID,以便从InstanceInfo参数(第1.4.2节)中获取原始数据,确保渲染时正确访问几何体和资源。

1.5 无绑定材质管理

1.5.1总体设计


我们的材质依赖于一个基于数据驱动的节点着色器系统(Figure1.22)。为了输出最终着色器代码,着色器图(Shader Graph)被解析处理,然后图代码被插入到公共材质着色器模板中。该模板由顶点着色器和像素着色器组成。它们都包括必要的头部和尾部代码两者均包含必须的头部和尾部代码,这些代码不属于着色器图本身,但依赖于材质和网格属性(例如,延迟渲染、正向渲染、TAA抖动、顶点格式)。

1.5.2 无绑定描述符表

如 Section 1.3 所述,我们在GPU驱动管线中使用数据库表表示场景(Figure 1.23)。

材质无绑定表表示渲染一帧所需的所有纹理(2D、3D或立方体贴图)。该表是一个统一资源描述符数组,包含32K项。所有三种资源类型通过别名机制并存储在同一数组中,但实际 3D 纹理 和 立方体贴图 保留了较小的范围(因为实际使用较少),具体声明如下(Listing 1.21):

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

//Listing 1.21. Declaration of bindless ressource arrays in SIG. Note that Texture2D, Texture3D, and TextureCube entries are aliased.

当网格加载并添加到场景中时,它会触发在其无绑定材质表中分配或更新其材质描述符(Figure 1.24)。然后,所有必要的描述符被复制到表中。该描述符范围通过Texture2DOffsetTexture2DCount在相应的MaterialInstance表条目中引用,以便在GPU上渲染时正确访问。

1.5.3 常量管理

常量缓冲区 Constant buffers 不适合保存大量实例参数数据。因为我们的大部分剔除步骤在GPU上执行,我们需要上传视锥体内的所有实例参数数据(数据量可能非常庞大)。为此,我们使用一个 单字缓冲区 single-byte buffer(Figure 1.25),供所有渲染通道共享,存储所有实例的着色器参数。其分配与更新机制与 Section 1.5.2 描述的无绑材质描述符表几乎相同。

在渲染前,从对应的MaterialInstance中获取所有可见实例的Texture2DOffsetConstantOffset,并将其打包为uint materialOffsets字段存入InstanceInfos(see Section 1.4.2)。

1.6 渲染


在渲染时,对于每个通过剔除的渲染批处理(Section 1.3.1),我们设置正确的PSO;绑定统一几何和索引缓冲区(Section 1.4.3)、无绑定和常量表(Section 1.5)等;并发出ExecuteIndirect以渲染一批绘制调用。所有必要的绘制参数都位于DrawBuffer中,在GPU剔除步骤期间填充(see Sction 1.4)。同一缓冲区存储绘制计数和绘制参数(Figure 1.26)。

1.6.1顶点着色器

在渲染时,我们在顶点着色器中使用 instance ID 来获取匹配的 InstanceInfos 条目(more details in Section 1.4.2)。然后,我们解包所需的属性(顶点数据起始位置、格式和步长),以基于其顶点ID从统一几何缓冲区中手动获取相应顶点数据(Listing 1.22)。

Texture2DOffset(纹理偏移)和 ConstantOffset(常量偏移)同样在顶点着色器中从InstanceInfos获取,并以uint值存储,禁用插值传递至像素着色器(Figure 1.27)。这两个偏移量在像素着色器中分别用于访问无绑定材质资源表 MaterialResourceTable 和材质常量表 MaterialConstantTable。

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;
}

//Listing 1.22. Vertex fetch from the unified geometry buffer using vertex ID, start, and stride. Vertex fetch macros are available for all common types (e.g., FETCH1, FETCH2, ToUBYTE4, ToSHORT4).

1.6.2像素着色器

在像素着色器中,Texture2DOffset和ConstantOffset被解包。我们使用宏来隐藏与无绑定资源管理相关的所有复杂性。在底层,这些宏接受纹理槽索引作为输入,并将其与Texture2DOffset相加,以正确访问bindlessMaterialInfo中的相应纹理描述符(Listings 1.23 and 1.24)。

// 代码清单1.23:用于访问无绑定纹理和常量的HLSL宏
// g_BindlessTex2DOffset 表示当前材质在无绑定材质表中的纹理描述符范围起始偏移(第1.5.2节),
// INDEX 为像素着色器访问的纹理相对索引。
// g_BindlessConstantOffset 指向同一材质在材质常量表中的常量范围起始位置(第1.5.3节)。
static uint g_BindlessTex2DOffset = 0;  
static uint g_BindlessConstantOffset = 0;  

// 访问纹理的全局宏  
#define MATERIAL_TEX2DALIAS(INDEX, NAME, SAMPLER) \  
Tex2DAndSampler Get##NAME() { \  
    return GetTexture2DTypeAndSamplerStateType( \  
        Get_MaterialBindless_textures(g_BindlessTex2DOffset + INDEX), \  
        s##SAMPLER); \  
}  

// 访问单个浮点常量的全局宏  
#define MATERIAL_CONSTLOADSCALAR(OFFSET, CONVERSION) \  
    CONVERSION(gpuCullingInstanceParams.GetBindlessMaterialConstants().Load( \  
        g_BindlessConstantOffset + (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)

------------------------------------------------------------------------------------------------------------



// 代码清单1.24:从着色器图生成的像素着色器代码 
// - 纹理和常量索引由着色器图的HLSL代码生成步骤生成。 
MATERIAL_TEX2DALIAS(0, Layer0Diffuse0, StandardSampler);  
MATERIAL_TEX2DALIAS(1, Layer0Normal1, StandardSampler);  
MATERIAL_FLOAT(4, Layer0ScaleU1);  
MATERIAL_FLOAT(5, Layer0ScaleV2);  

#define Layer0Diffuse0 GetLayer0Diffuse0()  
#define Layer0Normal1 GetLayer0Normal1()  
#define Layer0ScaleU1 GetLayer0ScaleU1()  
#define Layer0ScaleV2 GetLayer0ScaleV2()  

// some code ...  
float2 uvScaled = uvDiffuse * float2(Layer0ScaleU1, Layer0ScaleV2);  
float4 Layer0Diffuse0_sample = Sample2D(Layer0Diffuse0, uvScaled);

1.7 结果

尽管我们称其为GPU管线,但BatchRenderer的大部分仍在CPU上(Figure1.4)。大量批处理发生在CPU上的每通道级别,仅有最终实例剔除发生在GPU上。

另一方面,GPU实例渲染器 GPU Instance Renderer 则在加载时进行所有批处理,并结合每帧和每通道的剔除。剔除也发生在每个实例上,之后才将实例拆分为不同的LOD、子网格和渲染通道(Figure 1.5)。我们还使用了无绑定材质,并且在GPU上将所有使用相同PSO和几何体的网格映射到同一个绘制调用中,以提高顶点着色性能并消除空的绘制调用

早期决定将剔除拆分为每帧和每通道,两者都在异步队列上调度,主要因为GPU LOD管理和时域混合逻辑 temporal blending logic。这也意味着有些剔除会重复执行。一方面,这很好,因为我们在每通道剔除中操作更紧凑的边界体积(子网格),但另一方面,我们希望探索将每帧剔除步骤减少到严格的最小值的可能性,尽管它并不是当前一个瓶颈。

1.7.1BatchRenderer与GPU实例渲染器对比

在性能方面,我们为所有几何通道实现了显著加速,既在GPU上也在CPU上(Table 1.1)。对于实例化密集的远距离几何体,某些场景下CPU端的性能提升甚至达到50倍 —— 例如,特定场景的CPU耗时从6.8毫秒降至0.14毫秒。

Table 1.2 显示,使用GPU实例渲染器(GPUIR)时,剔除任务并非总是更快,但现在可以及早地在异步队列上调度。在我们的测试场景中,相机视锥体内的435K实例中有98%在GPU上被成功剔除(Table 1.3)。

1.7.2 GPU实例渲染器在《刺客信条:幻景》中的应用

在《刺客信条:幻景》中,不少帧会将超过100万实例分派到GPU进行剔除,而实际渲染的只有数万个实例(Table 1.4 and 1.5)。在本节中,将展示GPU实例渲染器在游戏典型帧中的性能结果(Figure 1.29)。

1.8 结论和未来工作

CPU一直是《刺客信条》系列游戏的主要瓶颈。本文描述了我们通过实现新的GPU驱动管线——GPU实例渲染器(GPU Instance Renderer)——来减少这一瓶颈的努力。

我们的主要目标是减少先前管线 BatchRenderer 的CPU成本。我们通过在加载时批处理实例、引入无绑定资源(Section 1.5)以实现更好的批处理,以及借助新实用库Database(Section 1.2)将更多工作迁移到GPU来实现这一点,该库允许复杂场景描述和CPU与GPU之间的数据复制。尽管我们的主要目标不是专门针对GPU执行时间,但我们通过更好的批处理、剔除优化和利用异步计算实现了显著改进。如Section 1.7所示,我们的发现突出了新管线为CPU和GPU带来的实质益处。

对于簇和三角形剔除,我们在本迭代中使其可选,因为益处可能因不同场景和渲染通道而异。此功能是在GPUIR开发后期引入的,我们认为可以通过将其迁移到网格着色器来大大改进。这样做,我们可以消除额外缓冲区的需求(Section 1.4.4)、更好地处理实例化并简化整个过程。我们没有为《刺客信条:幻景》实现这一点的主要原因是它是跨代游戏,而网格着色器在所有目标平台(包括PlayStation 4和Xbox One)上不受支持。

未来,我们计划扩展GPU实例渲染器以支持由簇层次结构组成的连续细节级别,类似于[Karis 21]中提出的虚拟几何。我们还计划探索工作图,以评估它们是否能帮助我们改进和简化管线。然而,需要注意的是,此API尚未在所有主要开发平台上可用。

1.9 附录

1.9.1 数据表 “Hello World” 代码生成


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

//Listing 1.25. Simple database table declaration, input for the SIG Compiler.
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
	// FloatVectorValue is 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 everyting 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 ) ;
	}
};


//Listing 1.26. Example of generated HLSL code and accessors for Listing 1.25.
namespace TestStructureTable {
	struct Type
	{
		static const U32 Hash = 0 x1089437D ; // 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 , 0 x1089437D /* TestStructureTable */ }; }
	};
} // namespace TestStructureTable

//Listing 1.27. Example of generated C++ code and for Listing 1.25. It is more enginespecific than its HLSL counterpart and relies heavily on the SIG Compiler and some engine utility code. This is shared as a reference so the reader can start writing their own database compiler with a clear target in mind.

1.9.2 Database Dirty Page Update Code

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 ;
	}
	
	if ( currentDirty )
		UpdateTableDirtyRangeCPUToGPU ( device , Source , Dest , { startElement , currentElement - startElement }) ;
}
//Listing 1.28. C++ code that handles a dirty page update. This is the code that UpdateTable in Listing 1.12 ends up calling when a table uses the DirtyPages update policy

参考文献

[Cignoni et al. 05] P. Cignoni, F. Ganovelli, E. Gobbetti, F. Marton, F. Ponchio, and R. Scopigno. “Batched Multi Triangulation.” In VIS 05: IEEE Visualization 2005, pp. 207–214. IEEE Press, 2005. https://vcg.isti.cnr.it/ Publications/2005/CGGMPS05/BatchedMT Vis05.pdf.

[Greene et al. 93] Ned Greene, Michael Kass, and Gavin Miller. “Hierarchical Z-Buffer Visibility.” In Proceedings of the 20th Annual Conference on Computer Graphics and Interactive Techniques, SIGGRAPH ’93, p. 231–238. Association for Computing Machinery, 1993. https://doi.org/10.1145/166117. 166147.

[Haar and Aaltonen 15] Ulrich Haar and Sebastian Aaltonen. “GPU Driven Rendering Pipelines.” Presented at SIGGRAPH, 2015. https://advances.realtimerendering.com/s2015/aaltonenhaar siggraph2015 combined final footer 220dpi.pdf.

[Hill 10] Stephen Hill. “Rendering with Conviction.” Presented at Game Developers Conference, 2010. https://www.selfshadow.com/talks/rwc gdc2010 v1.pdf.

[Karis 21] Brian Karis. “Nanite: A Deep Dive.” Presented at SIGGRAPH, 2021. https://advances.realtimerendering.com/s2021/KarisNanite SIGGRAPH Advances 2021 final.pdf.

[Kumar et al. 96] Subodh Kumar, Dinesh Manocha, Bill Garrett, and Ming Lin.“Hierarchical Back-Face Culling.” Technical report, 1996. https://wwwx.cs.unc.edu/geom/papers/documents/technicalreports/tr96014.pdf.

[Olano and Greer 97] Marc Olano and Trey Greer. “Triangle Scan Conversion Using 2D Homogeneous Coordinates.” In Proceedings of the ACM SIGGRAPH/EUROGRAPHICS Workshop on Graphics Hardware, HWWS ’97, p. 89–95. Association for Computing Machinery, 1997. https://doi.org/10.1145/258694.258723.