使用 AMD DGF 制作几何动画

首次发布时间:
Sander Reitter's avatar
Sander Reitter
Quirin Meyer's avatar
Quirin Meyer
Josh Barczak's avatar
Josh Barczak

通过 Dense Geometry Format (DGF),AMD 推出了一种对缓存友好的压缩三角形表示形式。由于未来的 GPU 架构将支持 DGF,因此有机会重新审视图形编程堆栈中可能利用并受益于 DGF 的各个部分。

我们的 AMD DGF SDK 最近已更新了错误修复、改进和新功能。我们最令人兴奋的新功能之一是增加了动画感知编码流水线。有关更改的完整列表,您可以参考 发行说明

在这篇博文中,我们将探讨动画及其与 DGF 的工作方式。通过改变多帧的顶点位置来实现网格的动画化,从而产生运动的错觉。我们的核心思想是在每个帧中用动画位置覆盖 DGF 块内的位置。

我们解释了如何使用 DGF 创建动画,以及如何在光线追踪应用程序中使用这些动画。

但在深入了解 动画 细节之前,让我们先回顾一下 DGF线性顶点混合动画

DGF 简介

DGF 由 AMD 在 2024 年高性能图形会议 [1] 上推出,是一种硬件友好的压缩三角形网格存储格式。作为预处理过程的一部分,AMD DGF Baker 将三角形网格分解为小的网格,或短的 *meshlets*。然后 DGF Baker 会单独压缩这些 meshlet。DGF-meshlet 最多包含 64 个位置和 64 个三角形。DGF-meshlet 与元信息一起存储在 128 字节的 DGF 块中。一组 DGF 块表示一个网格。

DGF 块

DGF 块包含三个部分:头、几何体部分和拓扑部分。

The layout of a DGF block

对于我们博文的目标,我们需要了解头和几何体部分的详细信息。您只需要对拓扑部分有基本了解即可,让我们从它开始。

拓扑部分

拓扑部分保存三角形的连接性:在传统的表示方法中,您会使用索引缓冲区,其中三个索引定义一个三角形。

在内存方面,每个三角形存储索引会占用大量空间。给定每个 DGF 块有 64 个顶点,我们需要 6 位来寻址单个顶点。一个具有三个顶点的三角形将需要 36=183\cdot 6=18 位。取而代之的是,DGF 将拓扑存储为一种 *带有回溯的通用三角形条带*。如图所示,这只需要大约 5 位/三角形,存储在 *重用缓冲区* 中,通过 *首个索引位* 和 *控制位* 来实现。

最初,我们在 High Performance Graphics 2024 上提出了一个顺序 Θ(N)\Theta(N) 算法 [1]。但在 Eurographics [2] 上,我们展示了多种数据并行 Θ(logN)\Theta(\log N) 算法。我们甚至推导出了一个随机访问 Θ(1)\Theta(1) 版本。

几何体部分

上图显示几何体部分由 *顶点偏移量* 和可选的 *GeomID* 部分组成。对于我们动画的目标,您只需要了解顶点偏移量的含义,而忽略 *GeomID* 部分。

每个 DGF 块存储最多 6464 个顶点偏移量 Oi\vec{O}_i, 0i0\leq i < 6464,从中我们计算出顶点位置 Pi\vec{P}_i

  • 首先,我们存储相对于 3D *锚点* A\vec{A} 的顶点偏移量。我们使用 A\vec{A} 来 *偏移* 顶点偏移量(因此得名)。每个 DGF 块在其头中存储自己的锚点 A\vec{A} 使用三个有符号的 24 位整数。

  • 其次,每个顶点偏移量 Oi\vec{O}_i 包含表示顶点偏移量 x、y 和 z 的三个无符号整数值。对于每个通道,我们在每个 DGF 块中使用固定数量的位。我们可以选择每通道 1 到 16 位。每通道的位数也存储在头中。

  • 最后,我们需要一个比例因子 2E1272^{E-127}。我们将它的偏差指数 EE 作为 8 位无符号整数存储在 DGF 块头中。我们使用该因子将位置缩放到浮点数,这是光线追踪和光栅化所必需的。

总之,我们从无符号整数偏移量 Oi\vec{O}_i 中,计算出 3D 顶点浮点位置 Pi\vec{P}_i,与

Pi=(A+Oi)2E127.\vec{P}_i = (\vec{A} + \vec{O}_i)\cdot 2^{E - 127}.

AMD DGF Baker 会仔细量化原始位置以计算 Oi\vec{O}_iA\vec{A},以及 P\vec{P},以避免出现不希望的裂缝。更多细节,请参阅我们原始论文的第4节[1]。

线性顶点混合的动画

在本博客中,我们专注于在动画序列过程中保持拓扑稳定的动画技术,只随时间改变顶点位置。这适用于实践中流行的动画方法:例如,所有属于混合形状(也称为变形目标、形状键)或蒙皮(也称为顶点混合、骨骼动画、骨架绑定)类别中的技术[3],第4章

在此,我们使用一种基本的线性顶点混合形式作为演示。但我们相信我们在此演示的基本思想可以轻松扩展到更复杂 的动画方法。

DGF 动画管线概述

接下来,我们解释我们动画管线的关键思想。我们使用粗体来指代下图所示的实体。

The DGF Animation Pipeline

启动阶段,我们加载所有动画关键帧(加载关键帧)。如前所述,我们这里使用的动画类型仅转换顶点数据,并保持所有关键帧的拓扑稳定。我们利用这一事实,并为单个关键帧烘焙 DGF,仅使用关键帧拓扑 0关键帧位置 0

在烘焙(DGF 烘焙)期间,我们在 DGF 块内预留顶点位置的空间。这些空间稍后将填充动画后的位置。DGF 块的拓扑部分包含不可变的三角形连接信息。烘焙后,我们上传生成的DGF 块

启动阶段,我们将所有关键帧的关键帧顶点数据(例如,在我们的示例中是位置和法线)上传到 GPU。

启动后,我们在每一帧执行动画更新渲染等步骤。

动画阶段,我们使用计算着色器转换顶点。计算着色器读取来自两个相邻关键帧的关键帧顶点数据并在它们之间进行插值。其背后的数学原理就是您在上面关于线性顶点混合动画的章节中看到的。我们将输出存储在动画顶点数据中。

更新阶段,我们量化动画顶点数据,并覆盖相应DGF 块中的位置。我们将在后面的量化部分详细介绍。

请注意,DGF 烘焙后,顶点位置可能会出现在多个 DGF 块中。为了将 DGF 块内的顶点索引映射到动画顶点数据/关键帧顶点数据中的顶点,DGF Baker 会输出一个我们保留在 GPU 上的顶点表。我们使用该表将量化和动画后的位置散布到其关联的 DGF 块中。您可以在下一节调整 DGF Baker中找到这些详细信息。

有了这些,就可以对每个顶点进行一次动画处理,并将更新后的位置拉入新的 DGF 块。这允许自由操纵顶点数据,使用不同的关键帧、插值顶点等。

接下来,我们将详细介绍调整 DGF Baker 以及我们的量化方法。

调整 DGF Baker

AMD 的 DGF Baker 允许您从任意三角形网格创建压缩的 DGF 网格。DGF Baker 是一个 C++ 库。有关如何在 C++ 程序中使用 baker 的更多详细信息,请参阅 DGF SDK 文档,此处

要烘焙 DGF 网格,您需要在一个 DGFBaker::Baker 对象上调用 BakeDGF 方法。您可以通过将配置结构 DGFBaker::BakerConfig config = {} 传递给构造函数来创建 DGFBaker::Baker 对象。

DGF 顶点偏移量 Oi\vec{O}_i 限制为 16 位大小。如果顶点离其锚点太远,偏移量会溢出,导致严重失真。对于静态几何体,DGFBaker 通过根据每个三角形簇的尺寸选择量化指数 EE 来防止此溢出。此过程在我们的 HPG 论文中得到了详细描述。

对于动画几何体,由于在烘焙时我们不知道动画顶点,因此 EE 的值必须更保守地选择。

为动画量化

使用动画时,请确保将 config.quantizeForAnimations 设置为 true。这样,量化因子将根据簇 AABB 的对角线长度来选择。设置为 false 时,则使用 x、y 和 z 范围。使用对角线长度可防止在簇在动画期间旋转时发生偏移溢出。

簇变形填充

动画的另一个字段是 config.clusterDeformationPadding 字段。它是一个浮点乘数,用于在选择量化指数时缩放簇 AABB。此字段的目的是添加一些填充以考虑动画中的局部拉伸。同样,这是防止偏移溢出的必要措施。

固定偏移宽度

我们使用 config.blockForcedOffsetWidth 选项为 X、Y 和 Z 坐标的每个坐标分配一个固定、预定义的精度。

通常,DGF Baker 会计算每个通道的灵活位宽,从而实现进一步的压缩潜力。然而,我们使用单个关键帧创建 DGF 块,并忽略其他关键帧。如果我们仅使用一个关键帧的位宽,则其他关键帧的位置(以及插值后的位置)将不适合位预算。但是,我们需要一个适合所有关键帧以及它们之间所有插值帧的位宽。

动画示例目前支持 config.blockForcedOffsetWidth,每个组件 16 位,每个组件 12 位,每个组件 8 位。此外,我们的示例支持混合精度位宽,其中 x 和 y 分量使用 11 位,z 分量使用 10 位。

生成顶点表

DGF baker 有 config.generateVertexTable 选项,该选项会输出一个顶点表

此表将 DGF 块内的顶点索引映射回其在输入顶点数组中的原始索引。请注意,DGF baker 会复制输入的顶点位置并将它们分散到多个 DGF 块中。这种映射使我们能够操纵顶点数据,而无需提取 DGF 块内的顶点。相反,我们可以直接从关键帧顶点数据读取并写入动画顶点数据。通过这种方式,我们只需要在动画阶段处理每个顶点一次。最终,我们使用顶点表量化期间更新 DGF 块内的位置。

启用用户数据

用户数据字段是 DGF 块的可选部分,位于 DGF 标头之后。其大小为 32 位。我们需要此字段来存储块的顶点偏移量。利用偏移量,我们可以为给定 DGF 块中的每个局部顶点检索全局顶点索引。此选项称为 config.enableUserData,需要将其设置为 true

示例配置

总而言之,要实现动画所需的最低配置是

DGFBaker::Config bakerConfig = {
.blockForcedOffsetWidth = {16, 16, 16}, // {12, 12, 12}, {8, 8, 8}, or {11, 11, 10}
.generateVertexTable = true,
.enableUserData = true,
.quantizeForAnimation = true,
.clusterDeformationPadding = 1.03f
};

设置动画范围

DGFBaker 根据顶点坐标的边界框选择其量化指数 EE。较大的坐标需要较大的量化因子,以防止 24 位锚点溢出。动画顶点很容易超出初始边界框,因此需要更保守的边界。

对于动画,我们提供了一个 BakerMesh::SetAnimationExtents 方法来定义自定义 3D 边界框。您可以使用它来告诉 DGFBaker 您的动画范围是多少。通过这种方式,您可以确保在范围太大而无法容纳在 24 位内的情况下,DGF 块锚点不会溢出。

以下代码显示了如何设置动画范围

DGFBaker::BakerMesh mesh(vertices, indices, numVerts, numTris);
DGFBaker::AnimationExtents extents = {};
float extentMin[3] = ...; // compute animation bounding box
float extentMax[3] = ...; // (NOT SHOWN)
extents.Set(extentMin[0],extentMin[1],extentMin[2],
extentMax[0],extentMax[1],extentMax[2]);
// add the animation extents to the baker mesh
mesh.SetAnimationExtents(extents);

如果在烘焙时未指定动画范围,则会改用顶点边界框,这可能不够。

在我们的示例应用程序中,我们计算了顶点动画的精确边界框。如果动画无法提前得知,简单的启发式方法,例如将静止姿态边界框乘以任意因子,也可以取得良好的效果。

量化

动画后,每个浮点位置在插入回 DGF 块之前都需要重新量化。我们使用单独的量化计算着色器来高效地完成此操作。在本节中,我们参考了此博客文章附带的源代码

我们为每个 DGF 块启动一个线程组,并将波形大小设置为 32。我们使用 32 而不是 64,因为固定位宽将顶点计数限制为少于 32。虽然这可能不是最优选择,但在结果部分,我们将展示此计算内核的运行时可以忽略不计。每个线程(其索引为 gtid (组线程 ID))会操纵一个顶点。

接下来,我们将更详细地描述量化过程。我们利用LDS(本地数据共享)内存来实现快速读写访问:首先,我们将整个 DGF 块加载到 LDS 中。其中,组线程 ID 确定块索引。

我们需要一个 32 个 DWORD 的数组来容纳一个 128 字节的 DGF 块。

groupshared uint blockData[32]; // sizeof(uint) * 32 = 128 Bytes, i.e., size of a DGF block.
...
{
blockData[gtid] = DgfBlockBuffer.Load(blockInfo.blockStartOffset + 4 * gtid);
...
}

如前所述,我们使用固定顶点通道宽度,大小各不相同。我们紧密打包顶点数据,因此我们将一个顶点打包到多个 DWORD 中,其中一个 DWORD 由最多三个顶点共享。我们使用 GetIndices 来获取顶点覆盖的两个 DWORD 索引。然而,操纵由两个或多个顶点共享的 DWORD 需要额外的同步。我们使用原子位操作(即 InterlockedAndInterlockedOr)来安全地写入 DWORD 的相应部分。根据其字节地址,我们使用 GetMasks 为每个线程生成三个位掩码。我们计算掩码,以便将我们要覆盖的顶点清零。

const uint vertexIndex = gtid; // use the group-thread id as vertex index within the DGF block.
const uint vertexByteAddress = (vertexIndex * blockInfo.bitsPerVertex);
const uint3 indices = GetIndices(vertexBitAddress);
const uint3 masks = GetMasks(vertexBitAddress, blockInfo.header.bitsPerComponent);
// Start location in 4 Byte increment
const uint bitsPerByte = 8;
const uint vertexStartIndex = blockInfo.header.bitSize / (bitsPerByte * 4);
if (gtid < blockInfo.header.numVerts)
{
InterlockedAnd(blockData[vertexStartIndex + indices.x], ~masks.x);
InterlockedAnd(blockData[vertexStartIndex + indices.y], ~masks.y);
InterlockedAnd(blockData[vertexStartIndex + indices.z], ~masks.z);
}

同步后,我们可以开始量化顶点位置。我们通过顶点表和存储在 DGF 块用户数据中的顶点偏移量来获取线程指定的顶点。之后,我们使用无偏指数 E127E - 127 进行量化。标头在第二个 DWORD 的较低部分存储有偏指数 EE,我们提取并移除偏差。我们使用波形内在函数 WaveActiveMin 来查找每个通道的最小值。此最小值将用作块的新锚点。

int3 quantizedPositions = int3(0x7fffffff, 0x7fffffff, 0x7fffffff);
if (gtid < blockInfo.header.numVerts)
{
const uint globalVertexIndex = VertexTable.Load(blockInfo.header.userData + gtid);
const float3 vertexPosition = Positions.Load(globalVertexIndex);
const uint headerDWORD1 = blockData[1];
const int unbiasedExponent = -((headerDWORD1 & 0xff) - 127);
quantizedPositions = Quantize(vertexPosition, pow(2, unbiasedExponent));
}
const int3 anchor = WaveActiveMin(quantizedPositions);

我们通过从每个通道减去锚点来调整量化的顶点位置。然后,我们将三个通道合并成一个 64 位宽的码字。由于码字需要最多三个 DWORD 才能写入,因此我们需要将其分离成多个值并相应地进行位移。同样,这取决于字节地址。使用之前的位掩码,我们可以使用原子或操作将值写入块中的位置。

if (gtid < blockInfo.header.numVerts)
{
const int3 offsetVert = quantizedPositions - anchor;
const uint64_t xbits = blockInfo.header.bitsPerComponent.x;
const uint64_t xybits = blockInfo.header.bitsPerComponent.x + blockInfo.header.bitsPerComponent.y;
const uint64_t codedVert = offsetVert.x | (offsetVert.y << xbits) | ((uint64_t) offsetVert.z << xybits);
const uint3 values = GetValues(vertexBitAddress, blockInfo.header.bitsPerComponent, codedVert);
InterlockedOr(blockData[vertexStartIndex + indices.x], values.x & masks.x);
InterlockedOr(blockData[vertexStartIndex + indices.y], values.y & masks.y);
InterlockedOr(blockData[vertexStartIndex + indices.z], values.z & masks.z);
}

最后,我们需要更新存储在块标头中的块锚点。每个锚点通道存储在标头第二个、第三个和第四个 DWORD 的上半部分三个字节中。我们再次使用位掩码和位操作,以保持 DWORD 的其他部分不变。使用 WaveIsFirstLane,我们确保只有一个线程写入锚点。

if (WaveIsFirstLane())
{
blockData[1] = (blockData[1] & 0x000000FF) | ((anchor.x << 8) & 0xFFFFFF00);
blockData[2] = (blockData[2] & 0x000000FF) | ((anchor.y << 8) & 0xFFFFFF00);
blockData[3] = (blockData[3] & 0x000000FF) | ((anchor.z << 8) & 0xFFFFFF00);
}

同步后,我们将新块数据写入内存,量化步骤完成。

结果

我们创建了一个利用此方法的示例。下表展示了不同步骤对帧时间的影响:我们展示了来自The Utah 3D Animation Repository的各种模型的(左列)结果,以及不同的三角形(第二列)和顶点计数(第三列)。其余列显示了我们管线不同阶段(参见DGF 动画管线概述)以毫秒为单位的时间。所有时间均在 AMD Radeon™ RX 7900 XT 显卡上测量。

三角形计数顶点计数动画 (ms)量化 (ms)BVH 构建 (ms)光线追踪 (ms)
wooddoll537838990.00180.00160.19530.4787
marbles880046200.00180.00160.20200.5254
toasters1114156280.00180.00170.24400.5675
hand1585586360.00190.00180.21430.5280
ben78029414740.00250.00430.41080.7776
24-cell122880637440.00300.00600.45161.0506
fairyforest174117965660.00360.02400.76461.0422
explodingDragon2525721928590.00630.01350.76761.4074

量化花费了不到总帧时间的 1%,这意味着此过程不会对动画管线中的渲染时间产生重大影响。同样,动画位置数据(动画)对帧时间的影响几乎可以忽略不计。BVH 构建光线追踪主导了图像计算。

接下来,我们分析固定位通道宽度如何影响 DGF 压缩能力。我们测量三角形密度,即每个三角形的平均字节数。在静态 DGF列中,您可以看到单个关键帧数据的密度,目标位宽为 14。这是为了作为参考,以评估单个关键帧使用 DGF 进行压缩的效果。未压缩列显示了传统索引面集(浮点数作为位置,整数作为索引)所需的密度。其余列是我们使用动画示例时测量的三角形密度。各列显示了我们为 config.blockForcedOffsetWidth 使用的不同选项。括号中的数字是相对于静态 DGF 的比率。

静态 DGF未压缩16 16 1612 12 1211 11 108 8 8
wooddoll6.920.7 (3.0)13.4 (1.9)9.6 (1.4)8.8 (1.3)6.4 (0.9)
hand6.218.5 (3.0)12.1 (2.0)8.6 (1.4)7.6 (1.2)5.7 (0.9)
toasters6.618.1 (2.7)13.6 (2.1)9.9(1.5)8.8 (1.3)6.4 (1.0)
marbles4.818.3 (3.8)10.5 (2.2)7.4 (1.5)6.9 (1.4)4.8 (1.0)
ben5.218.4 (3.5)14.2 (2.7)9.9 (1.9)8.8 (1.7)6.6 (1.3)
fairyforest4.318.7 (4.3)14.0 (3.3)10.1 (2.3)8.9 (2.1)6.7 (1.6)
24-cell6.418.2 (2.8)14.1 (2.2)9.5 (1.5)8.5 (1.3)6.8 (1.1)
explodingDragon5.621.2 (3.8)15.0 (2.7)10.8 (1.9)9.5 (1.7)7.0 (1.3)

正如预期的那样,当使用较高的 config.blockForcedOffsetWidth 时,动画的三角形密度会增加。这是因为较大的顶点尺寸限制了适合块的三角形数量,导致块的数量增加。然而,DGF 仍然能够极大地减少未压缩几何体上三角形网格的内存占用。

结论

我们已经证明 DGF 能够高效地处理动画。特别是,我们能够在大约 20 微秒内为 252K 三角形场景动画和更新 DGF 块。这是因为当在位置更新过程中使用位操作时,我们可以以随机访问的方式访问 DGF 块的位置。这使得动画成本低廉,并且在运行时性能方面可以忽略不计。

同时,我们可以利用 DGF 压缩比,因为即使带有动画,使用 DGF 进行编码时,三角形网格也会显著变小。

动画示例可以在 DGF SDK 存储库的更新版本中找到。

查看参考资料

[1] J. Barczak, C. Benthin, and D. McAllister, “DGF: A Dense, Hardware-Friendly Geometry Format for Lossily Compressing Meshlets with Arbitrary Topologies”, Proc. ACM Comput. Graph. Interact. Tech., 2024.

[2] Q. Meyer, J. Barczak, S. Reitter, and C. Benthin , “Parallel Dense-Geometry-Format Topology Decompression” in Eurographics 2025 - Short Papers, 2024.

[3] T. Akenine-Möller, E. Haines, N. Hoffmann, A. Pesce, M. Iwanicki, S. Hillaire, “Real-Time Rendering 4th Edition”, A K Peters/CRC Press, 2018.

Sander Reitter's avatar

Sander Reitter

Sander Reitter 是 AMD 的高级软件开发工程师,在获得科堡大学计算机科学硕士学位后,他开始在 AMD 的光线追踪团队工作。他的兴趣在于光线追踪、几何压缩和 GPU 架构。
Quirin Meyer's avatar

Quirin Meyer

在成为科堡大学计算机图形学教授之前,Quirin Meyer 获得了图形学博士学位,并在业界担任过软件工程师。他的研究主要集中在 GPU 上的实时几何处理。
Josh Barczak's avatar

Josh Barczak

Josh Barczak 是 AMD 的一位杰出技术专家,专注于 GPU 光线追踪架构和 3D API 演进。他从事 GPU 硬件和软件架构相关工作约 6 年。在此之前,他曾在游戏行业担任渲染主管。
© . This site is unofficial and not affiliated with AMD.