优化meshoptimizer在几分钟内处理数十亿三角形(2025)
摘要
这篇博客文章详细介绍了为meshoptimizer工具所做的优化,用于处理数十亿三角形,灵感来源于NVIDIA的RTX Mega Geometry和hierarchical clustered level-of-detail技术。
暂无内容
查看缓存全文
缓存时间: 2026/08/22 07:29
# 几分钟处理数十亿三角形
来源:https://zeux.io/2025/09/30/billions-of-triangles-in-minutes/
2025年9月30日
今年早些时候,NVIDIA发布了其新的光线追踪技术RTX Mega Geometry(https://developer.nvidia.com/blog/nvidia-rtx-mega-geometry-now-available-with-new-vulkan-samples/),并配以令人印象深刻的Zorah演示场景(https://developer.nvidia.com/rtx-kit)。该演示以约100 GB的Unreal Engine场景文件形式分发——只能在Unreal Engine的特殊分支NvRTX中打开。演示展示了新驱动公开的光线追踪功能(特别是聚簇式光线追踪)与Nanite聚簇LOD流水线的结合应用——允许在不使用Unreal Engine当前为光线追踪生成的Nanite代理网格的情况下,流式传输和渲染细节度极高的完整光线追踪场景。
由于这对我来说过于依赖Unreal Engine,我无法进行太多实验——但到了九月初,NVIDIA更新了其vk_lod_clusters开源示例(https://github.com/nvpro-samples/vk_lod_clusters),其中(除其他功能外)提供了以glTF文件格式的Zorah场景。这自然引起了我的兴趣——并促使我投入大量时间在meshoptimizer(https://github.com/zeux/meshoptimizer)中改进对层级聚簇LOD的支持。
[](https://zeux.io/images/zorah_1.jpg)
## 技术原理
如果你对Nanite有相当的了解,下文会更容易理解。如果没有,我强烈推荐阅读Brian Karis等人撰写的《Nanite: A Deep Dive》(https://advances.realtimerendering.com/s2021/Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf),其中详细介绍了相关内容;我将在此简要概述基本流程。
给定一个可能包含大量三角形的三角形网格,我们的任务是:1)生成一个能以任意细节层次表示该网格的层级结构;2)以适当的细节层次流式传输该结构的部分内容;3)以适当的细节层次渲染网格的可见部分。关键在于,我们所使用的结构必须能够以多个不同细节层次表示网格的不同区域——这使其能够扩展到大型模型,同时合理分配细节;同时,它也必须高效渲染。这里选择的结构是聚簇图(DAG);每个聚簇是一小组三角形(例如最多128个),代表给定细节层次下网格的一小块区域。该结构包含不同细节层次的聚簇,运行时代码负责流式传输和渲染它们以最小化视觉误差——仅当结果视觉误差低于1个像素(并且切换被TAA或其他时间滤波隐藏)时,一个聚簇才会被更粗糙的聚簇替换。
该技术有三个难点:从高细节网格生成结构;压缩结果以实现高效流式传输;以及实时渲染结果。我们今天只讨论第一部分 :)
要构建该结构,首先将网格分割为一组聚簇;将相邻聚簇合并为稍大的分组;每个分组独立简化,同时保留分组的边界边;然后将结果分组再分割为更多聚簇,此过程递归进行,直到没有可处理的聚簇为止。如何组合这些算法以确保不同细节层次的聚簇之间不产生裂缝,其中存在很多细微之处,且每个算法都有诸多权衡——这完全足以成为一篇论文的主题(实际上也确实有多篇相关论文)。
自Nanite于2021年发布以来,多种不同的引擎开始采用这种处理范式。我目前参与开发的一个开源几何处理库meshoptimizer(https://github.com/zeux/meshoptimizer)自2024年起提供了如何组合meshoptimizer提供的多种不同算法来构建最终结构的示例1(https://zeux.io/2025/09/30/billions-of-triangles-in-minutes/#fn:8)。有了这个端到端示例,改进算法和尝试更高层次技术的变体变得容易得多——我是否可以使用该示例代码处理Zorah场景呢?
## 规模之感
上面的截图看起来确实很漂亮;要达到这种保真度,除了Nanite之外还需要大量的纹理、着色和光照工作。幸运的是,我们只关注几何部分;这应该会让我们的工作变得轻松,对吧?
如前所述,虽然原始Zorah场景是Unreal Engine资产,但NVIDIA在其开源Vulkan示例中发布了Zorah的glTF场景。让我们看看吧?
> zorah_main_public.gltf.7z (http://developer.download.nvidia.com/ProGraphics/nvpro-samples/zorah_main_public.gltf.7z) - 16.4亿三角形,实例化后为189亿三角形 - 磁盘占用36.1 GB - 渲染缓存62 GB磁盘占用,可下载或生成
...哦。一个仅包含几何数据的36 GB glTF文件——而且,它不包含大多数网格的顶点属性数据,只有位置!(示例代码在着色器代码中从位置法线推导法线用于着色)
尝试在Blender中导入此glTF文件需要约10分钟,直到内存不足2(https://zeux.io/2025/09/30/billions-of-triangles-in-minutes/#fn:1)。当然,Unreal Engine更快——也就是说,UE导入此文件时内存不足并在不到5分钟内崩溃3。显然,处理过程非常耗内存,而192 GB RAM对某些人来说确实不够。
幸运的是,我们不需要导入此文件:我们只需要运行NVIDIA处理此文件的示例代码。然而,九月初尝试这样做时,使用16个线程也会导致内存不足。实验发现,只要系统中没有其他程序运行,使用8个线程(`--processingthreadpct 0.25`)可以可靠地运行处理代码,因为进程会占用约180+ GB RAM。使用7个线程则可以在处理期间(大约需要30分钟)勉强使用计算机。
既然我已经充分让你了解了这个场景有多大,现在该谈谈一系列让这一切更实用的优化了:)
## 基准线
要构建上述层级结构,我们需要聚簇化(https://meshoptimizer.org/#clusterization)(将网格分割为聚簇)、分区(https://meshoptimizer.org/#cluster-partitioning)(将聚簇分组)和简化(https://meshoptimizer.org/#simplification)(将聚簇组简化为更少的三角形)。幸运的是,meshoptimizer(https://github.com/zeux/meshoptimizer/)提供了这三种算法。
截至版本0.25,meshoptimizer包含两种主要的聚簇化算法:一种为光栅化和网格着色器构建,试图通过将几何紧密打包到网格块中来最小化生成的网格块数量;另一种为光线追踪和新的聚簇式光线追踪扩展构建。前者在过去8年中通过增量改进和修复不断演进;后者相对新颖,是在NVIDIA发布其RTX Mega Geometry工作之后专门开发的4(https://zeux.io/2025/09/30/billions-of-triangles-in-minutes/#fn:3)。需要两种算法的原因是,聚簇化用于光线追踪时,对聚簇边界的确切位置非常敏感——最优的光线追踪聚簇化使得可以为每个聚簇构建微BVH树,为所有结果聚簇构建一个BVH树,并通过结果结构追踪光线。`vk_lod_clusters`示例只需要光线追踪优化的聚簇,但我原始的演示使用了光栅化优化的聚簇,所以我们从这里开始。
原始演示代码编写时,其结构旨在便于研究底层算法,而非可重用。将其重构为易于理解且提供简洁清晰接口的代码花费了一些时间;代码接受网格和许多配置参数作为输入,并通过回调生成聚簇组。这种向可重用代码的转换本身也带来了一些性能优势——除了消除一些冗余的STL复制(与meshoptimizer主库不同,此代码目前为方便使用STL),切换到调用者单独传递顶点属性的接口也很有帮助。Zorah场景主要使用仅包含位置的网格,因此我们不应花费时间处理法线或其他属性。新接口还集成了简化功能的一些近期添加,如宽容模式,这超出了本文的范围。一个最小示例现在非常小巧简单:
```
clodConfig config = clodDefaultConfigRT(128);
clodMesh cmesh = {};
cmesh.indices = &indices[0];
cmesh.index_count = indices.size();
cmesh.vertex_count = vertex_count;
cmesh.vertex_positions = positions.data();
cmesh.vertex_positions_stride = sizeof(float) * 3;
clodBuild(config, cmesh,
[&](clodGroup group, const clodCluster* clusters, size_t cluster_count) -> int {
...
});
```
那么,剩下的就是加载glTF场景并在每个独立网格上运行代码。我的测试代码*不*将结果数据保存到磁盘——因此这不是完全公平的测试,因为保存数据可能产生额外开销和序列化成本。我们将在最后讨论这个问题。当然,我们将使用多线程处理数据——并使用cgltf(https://github.com/jkuhlmann/cgltf)将文件加载到内存中。
文件大小为36 GB;为了避免在进程开始前将整个文件同步加载到内存中,以及避免额外的36 GB内存开销,我们将使用内存映射;我为`cgltf`贡献了一个小PR(https://github.com/jkuhlmann/cgltf/pull/278)使内存映射缓冲区更易使用。
最后,另一个关键操作是网格重索引(https://meshoptimizer.org/#indexing);源glTF文件有一些非常大的网格,其索引效率极低(例如3000万三角形对应9000万顶点)——除了使高质量简化更困难外,这也损害了我们的处理时间,因为正如我们即将发现的,网格中的顶点数量有时很重要。
经过这些调整,一个小型程序在Linux上使用16个线程运行,可在约9分20秒内完成文件处理,使用约54.6 GB RAM。这是使用光栅化优化设置;如果我们切换到新的光线追踪优化聚簇器(https://meshoptimizer.org/#clustered-raytracing),则需要约7分10秒和约57.6 GB RAM。
一方面,这还不算太差!另一方面,7-9分钟仍然相当长;泡杯咖啡都不需要这么长时间。是时候看看我们能否改进这一点了。
## 稀疏性问题
使用出色的Superluminal(https://superluminal.eu/)分析器,我们可以尝试理解时间花在哪里以及如何改进。首先,让我们运行光栅化和光线追踪两个构建,看看是否能找到明显的热点...
光栅化:
光线追踪:
嗯,memset花费这么多时间真是糟糕!(我们稍后会讨论其他问题)
这里发生的是,两种聚簇器都使用一个按顶点索引编排的数组来跟踪顶点是否已分配给当前网格块。这免去了我们尝试将三角形添加到网格块时检查64-128个顶点以查看是否会增加顶点数量的麻烦。不幸的是,这段代码:
```
memset(used, -1, vertex_count * sizeof(short));
```
...仅当顶点数量相当小时才快——当我们反复对3000万三角形网格的子集进行聚簇化时就不适用了!奇怪的是,简化器中也存在类似但不那么严重的问题——作为2024年使meshoptimizer更友好地支持聚簇LOD用例的工作的一部分,我添加了`meshopt_SimplifySparse`标志,该标志假设输入索引缓冲区是网格的小子集,并尝试避免O(vertex_count)的操作...但其中也有一个小问题,即初始化用于类似性过滤的位数组:
```
memset(filter, 0, (vertex_count + 7) / 8);
```
当然,每个顶点1位比16位填充成本低得多...但在处理接近1亿三角形的网格时,这仍然会累积。此前,我测试此代码的最大单个网格是600万三角形、300万顶点——比此场景中的单个网格小一个数量级。
有一些方法可以让此代码更独立于顶点数量——例如动态切换到完整哈希映射——但这会带来额外成本和复杂性,因此目前让我们看看如果仅初始化索引缓冲区使用的数组条目(当检测到稀疏访问(`index_count < vertex_count`)时)会发生什么。使用这些修复5(https://zeux.io/2025/09/30/billions-of-triangles-in-minutes/#fn:4)重新运行代码,我们得到光栅化版本3分31秒,光线追踪版本3分57秒。取得进展!
你会注意到这里的收益程度与分析器报告的信息不一致。有几个因素影响,例如分析器在这种情况下开销显著,可能扭曲结果;但更重要的是,分析器报告的时间分布是*所有*线程上*所有*工作的分布,而整个处理的挂钟时间取决于最慢的线程。这就引出了...
## 线程平衡
与其查看耗时函数的分布,不如关注我们是否充分利用了线程。从终端运行可执行文件时,可以使用`/usr/bin/time -v`获取命令使用的CPU%;对我们来说,这些值在1240-1260%之间(取决于运行模式)——换句话说,我们使用了相当于12个多一点线程的总计算能力。
让我们用Superluminal更仔细地查看结果:
[](https://zeux.io/images/zorah_5.png)
...啊,这可不妙。如果我们查看此场景中每个网格的三角形数量分布,会发现显著不平衡:少数网格有数千万三角形,但大多数网格没有那么多。如果我们运气不好,大型网格可能在进程后期才开始处理,如果它们不是线程池队列中的第一个;这里我们可以在“悬垂部分”看到,确实有一个大型网格仅构建第一层DAG的聚簇就花费约48秒。我们需要优先处理这类网格。
[](https://zeux.io/images/zorah_6.png)
虽然此类调度问题的通用解决方案非常复杂,且效果可能不稳定,但幸运的是我们不需要通用解决方案。处理一个网格所需的时间是三角形数量的函数,因此我们可以简单地按三角形数量降序排序网格。这确保我们将首先处理最昂贵的网格。
此时也该谈谈内存限制了。实验表明,现在我们知道在16线程上处理此场景需要约60 GB RAM——我们处理速度大幅提升的部分原因是内存使用减少,允许我们扩展到更多线程。但是,如果我们需要运行的系统只有40 GB RAM怎么办?6(https://zeux.io/2025/09/30/billions-of-triangles-in-minutes/#fn:5)
相似文章
zeux/meshoptimizer
meshoptimizer 是一个库,提供优化三角形网格以用于GPU渲染的算法,提升顶点和索引数据的效率,同时降低存储开销。
SkewAdam:一种分层优化器,将MoE状态内存减少97%(可在40GB GPU上容纳6.7B MoE模型)[R]
SkewAdam是一种分层优化器,可将MoE状态内存使用量减少97%,从而使得6.7B MoE模型能够适配单个40GB GPU。
使用四面体笼对大量动画几何体进行光线追踪
本文介绍了一种通过使用四面体笼将动画与三角形数量解耦,从而对大量动画三角形进行光线追踪的技术,使得在消费级GPU上以60 FPS处理包含数亿个动画三角形的场景成为可能。
TideGS:通过外存优化实现超过十亿3D高斯泼溅原语的可扩展训练
TideGS提出了一种外存训练框架,通过块虚拟化、异步流水线和差分流式传输技术,在SSD-CPU-GPU层级管理参数,使得在单个GPU上能够以超过十亿原语进行3D高斯泼溅训练。
@leloykun:[进行中] 关于 Lean4-to-TileLang 张量程序超级优化器的博文:
一篇技术博文介绍了一种 Lean4-to-TileLang 张量程序超级优化器,能自动生成优化的 GPU/TPU 内核与超参数缩放规律,展示了相较 torch.compile 的性能提升。