高斯泼溅第3部分:总有更大的场景

Hacker News Top 新闻

摘要

Magnopus 工程师详细介绍了他们基于 Unreal 的高斯泼溅渲染器如何触发了 Unreal GPU Scene 中隐藏的每资源 1600 万泼溅上限,以及如何通过自定义 DrawIndexedInstancedIndirect 管线和顶点工厂绕过该限制,彻底消除了瓶颈。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/10/28 16:27

# 高斯泼溅 第三部分:永远有更大的场景 — Magnopus 原文:https://www.magnopus.com/blog/gaussiansplats-part-3-theres-always-a-bigger-scene **前情回顾,OKO 编……** 时间来到了 2025 年,正如我上次在文中所写(https://www.magnopus.com/blog/how-i-ended-up-rendering-unlimited-gaussian-splats-in-unreal),整个行业已经对 Gaussian splats 爱得不可自拔,Magnopus 自然也不例外。我花了好一阵子,把我们基于 Unreal 的 splat 渲染器从 Niagara 中拖了出来,围绕引擎的实例化渲染进行了重构:每个 splat 都注册为一个实例,每帧在 GPU 端执行一次排序以正确合成透明度,再由一个材质将每个 splat 着色为一个柔和的椭圆。 它确实能工作,而且效果好到我为此写了一整篇文章。我当时颇为得意。 然而事实证明,我宣称"可以渲染无限数量 splat"这个结论,庆祝得*太早了*。实际上有好几堵墙让这个说法根本不成立,而第一堵墙比我预想的来得还要快。 *终究不是无限的……* ### **第一堵墙** 有个让我很打脸的事,是我*在上篇文章发布之后*才知道的:我一直在炫耀的那个渲染器,每个资产实际上有大约**1600 万 splat 的硬性上限**。我之前完全不知道。我测试过的每个资产恰好都在这个上限之下,但这个限制始终存在。我的盲目自信让我对此一无所知。 这个上限的根源在于每个 splat 的数据实际存放在哪里。在 Unreal 中将每个 splat 注册为现代网格实例,意味着将它交给引擎的 **GPU Scene**——可以理解为一个通常记录 GPU 上世界中一切事物的中央账本。 GPU Scene 在其职责范围内表现优异,但它是为支持数千个网格实例而设计的——而不是数千万或数亿个。在底层,它对同一网格的实例数量最多限制为 1600 万。一旦越过这个限制,系统就会崩溃:任何时候只有资产的一部分可见,图形损坏层出不穷,视口上还会打印一条警告,提醒你的失败。 最直接的解决方案——也是对我有效的方案——是彻底不再使用 GPU Scene 来追踪 splat。相反,我回到了老派做法,依赖一种更传统的实例化渲染管线,基本上等同于直接调用通常所说的 DrawIndexedInstancedIndirect。 这需要几个关键步骤: 1. 跳过向 GPU Scene 注册实例,改用经典的间接实例化绘制。 2. 将排序 keygen shader 生成的绘制计数写入间接绘制缓冲区,而该 shader 本来就在每帧遍历所有 splat 以生成排序键。 3. 通过纹理将所有 splat 实例数据存储在 GPU 上(这些纹理也是我们在上一篇文章中免费获得的:https://www.magnopus.com/blog/how-i-ended-up-rendering-unlimited-gaussian-splats-in-unreal)。 4. 编写一个小的 vertex factory(Unreal 对顶点着色器及其材质绑定的抽象层),让顶点着色器采样已排序的 splat 缓冲区(见上一篇文章),获取对应的 splat 属性,然后传递给材质。 渲染出的图像与之前逐像素完全一致,但 1600 万的天花板消失了,因为施加限制的源头已经不在管线中了。我们约 3000 万 splat 的测试资产渲染得很好。更棒了! *摆脱了 GPU Scene 实例数量上限的枷锁。* 自定义 vertex factory 的方案还带来了一些额外的好处。由于 splat 网格始终是简单的四边形,很容易在着色器中程序化地推导出来,这意味着不再需要指定一个实际的 Unreal 网格——系统直接在顶点着色器中推导顶点属性,使整个流程更加开箱即用。 另一个好处是,间接实例化绘制实际上让我们转向了更多的无绑定(bindless)渲染方式。 既然我们每帧已经在生成排序键的同时遍历了所有 splat,我们还可以在其中做更高级的逐 splat 剔除,然后将所有可见 splat 的总数写入间接绘制缓冲区。现在我们只绘制相机视锥体内可见的 splat 数量,而剔除逻辑完全在 GPU 上运行。 *之前——GPU Scene 实现* *之后——间接实例化绘制与逐 splat 剔除* 这固然很好,因为它带来了不错的性能提升,但还有一点值得指出:每帧实际绘制的 splat 数量现在是真正**可变的**,完全由 GPU 决定。管线已经发展成了 LOD 系统所需的正确形态:每帧绘制不同的子集,在最后一刻才做决定。 我原本并不是要构建 LOD 的入口。但结果确实如此。 ### **傲慢:续集** 解除实例数量上限的感觉就像到达了终点线。然而不到一周,我就撞上了下一堵墙。 因为将数据从 GPU Scene 中撤离,改变的是我能*绘制*多少 splat,而不是我能*容纳*多少:场景中的每个 splat 仍然始终驻留在 GPU 内存中。当整个场景能装下时,这完全没问题。但显然一个 splat 在内存中是有成本的。位置、缩放、旋转、透明度、球谐系数——在我尽最大努力紧密打包之后,每个 splat 大约需要 32 字节。 这意味着你可以往 VRAM 里塞很多 splat,直到你遇到一个真实遗址的真实摄影测量采集数据,然后你会发现现实的分辨率高得烦人,它根本不在乎你的 VRAM 预算。 我们真正想展示给观众的采集数据比机器容量更大,而且已经没有更多巧办法可以进一步打包了。所以整个场景无法全部驻留在内存中。这意味着只有*一部分*可以驻留。这意味着渲染器必须每帧决定要渲染哪一部分,去获取它,然后将其绘制到屏幕上。如果你觉得这个剧情似曾相识,可以随时叫停我。 ### **LOD 对 splat 意味着什么** 如果你做过任何实时图形相关的工作,你就知道 LOD:远处的东西用更便宜的替代品。一棵远处的树的网格变成一棵低多边形的树,然后变成一个公告板,再变成一个模糊的色块。随距离增加,成本递减。 Splat 没有多边形可以丢弃,但它有更优解:数量实在太多了,而很多密度浪费在一个距离四十米、屏幕上只有六个像素宽的区域上。因此 splat 的 LOD 就是该区域的**抽稀副本**:同样的点云,去掉了大部分点,留下的点被放大以填补空隙。在 LOD 级别 0 时,我们显示每个 splat;在更高级别时,我们显示的越来越少。 我们最终选定的格式是 PlayCanvas 的**Streamed SOG**(https://developer.playcanvas.com/user-manual/gaussian-splatting/building/lod-streaming/),它已经为你烘焙好了这一切。它本质上将场景切分为一棵空间树("叶子"节点),每个叶子描述了多个抽稀的 LOD 级别。根目录下有一个小小的 `lod-meta.json`,描述了整体的树结构以及每个数据块的存放位置。 *LOD 级别 0——密集* *LOD 级别 3——粗糙* ### **决定绘制什么** 好的。我们有了 LOD 数据。现在,每帧、对每个可见区域,选择一个 LOD 级别。很好,但具体怎么做? 我的第一直觉是找一些花哨的方法。我第二直觉——也是更好的直觉——让我想起唯一真正重要的是*在屏幕上看起来有多差*,而"多差"是以像素为单位衡量的。 为此,有一个几十年前就有的概念叫做**屏幕空间误差(SSE)**:计算某个级别会引入多少视觉误差,将该误差投影到当前相机距离下的像素尺寸,然后选择误差仍低于某个阈值的最粗级别。把这个阈值叫做 **τ**(tau),因为在图形领域,一切最终都会获得一个希腊字母。 对于 splat,我们没有网格来测量误差,但我们可以在密度上取巧:从一个区域在其体积中容纳了多少 splat 可以得到它们之间的大致间距,将*这个间距*投影为像素大小,再与 τ 比较。在屏幕上看起来大且密集?你就配得上级别 0。又小又远?用粗一点的吧,没人会注意到。 *渲染器基于 SSE 指标在不同 LOD 级别间切换的夸张视图。* 最终,τ 成为一个暴露给用户的旋钮,在项目设置中以人类友好的方式呈现为低/中/高画质选项。高画质是默认选项,意味着"大方地使用细节";而低画质意味着"远处就省着点用"。 ### **不那么性感的底层机制** 没那么性感的一面是仅仅将 LOD 数据搬到 GPU 上这一过程中所涉及的底层机制。 因为当你第一次查看这样一个场景时,什么数据都还没有加载。渲染器启动了,代理对象存在了,纹理池里却空空如也。LOD 选择运行了,算出"我想要这些数据块",但它们还不在那里。它们必须先被加载(甚至下载)。所以它只能绘制手头*已有的*东西:一开始什么都没有,然后随着粗级别逐步到达,再然后是实际注视区域的细粒度细节。 核心思路是场景在你面前由粗到细地自我组装,持续一段时间。 *Splat 通过 HTTP 传输,从最粗到最细。* 另一部分则涉及到利用我们所采用的 LOD 格式之所以被称为*流式* SOG 的真正原因。它专为通过网络逐叶子、逐 LOD 级别交付而设计。核心理念是,你不必在本地拥有完整的资产才能查看内容——你只需要获取你周围叶子的 LOD 级别即可。 概念上确实很简单,但实现它是我发现最难搞定的部分。大量繁琐的底层机制需要理清;以下是我发现最难处理的几个关键点: - **一切异步:**加载一个数据块可能意味着读取本地文件,但也可能意味着向另一个大陆上的服务器发起 HTTP 请求。显然,对于后者(甚至对于前者)我们不能阻塞游戏线程,因此整个流程必须是一个异步请求/交付模型。你请求一个数据块,然后在完成工作的工作线程上通过回调通知你。于是就有了大量精细的线程同步机制。 - **粗 LOD 优先:**我们先请求一个区域的最粗级别,这样你能尽快在屏幕上看到一个稳定的低细节版本,然后随着更细的数据块到达,画面逐渐变得清晰。 - **数据块上传的围栏机制:**说到线程同步机制,数据块上传值得特别提一下。将数据上传到 GPU 内存和在 CPU 侧标记为"可绘制"是两个不同时间线上的不同事件,如果顺序搞错了,你就会享受一帧垃圾三角形的视觉盛宴。一个数据块只有在其上传被证明已经落地之后的下一帧才可绘制。 - **多视图支持:**我们经常在虚拟制片场景中使用 splat,其中不可避免地会在一个 Unreal 实例中同时有多个相机进行渲染。由于 splat 数据是相同的——只需要存在一份——但所有 LOD 选择决策、这些决策的记录以及向 GPU 传输正确相机的决策都需要被考虑进去。 ### **提升 splat 数量上限** 正如我们所确立的,墙是一个接一个来的。就在流式传输端到端跑通的前后,我喂给它一个真正巨大的采集数据,然后它崩溃了:`Texture2D cannot be created, exceeds this rhi's maximum dimension (16384)` 啊。原来所有这些 splat 都存储在一个纹理中,而我一直以每行 4096 个 splat 的方式排列。一个纹理的行数最多有限(在这种情况下是 16384 行),这意味着单个纹理最多能容纳大约**6700 万**个 splat。一旦超出这个数量,驱动/RHI 层拒绝创建纹理,整个系统就崩溃了。 最终,有两件事需要修复。 第一件事是个让人尴尬的轶事。早期,我做了一个决定:使用固定的每行 4096 个 splat 的行大小,这意味着每行还有(16384 - 4096)个纹理元素的空间被浪费了。纹理中有大量横向空间我没有利用。 所以……我把纹理加宽了。将行宽从 4096 向硬件最大值扩展,同样的纹理现在最多可以容纳约 **2.68 亿**个 splat(大约占用 8.6GB 显存),此时你才真正受到了单个纹理最大潜在存储量的限制。 这是一个非常小的改变:行宽变成了按资产计算的数值,而不是硬编码在各处的常量,但我喜欢把它当作一个寓言。我花了很长时间将某样东西视为根本性限制,而它实际上只是我几个月前随手输入的一个任意常量。看来关于假设的那些老话确实是真理。 *最终突破 6700 万的上限。渲染 1 亿个 splat(未启用 LOD)。图片来源:Poland Jastrzebia Gora(https://superspl.at/scene/221ee167),Andrii Shramko* 另一件需要解决的事情是当 splat *确实*太大而无法放入单个纹理时该怎么办。当我们第一次遇到一个 3 亿 splat 的资产时会发生什么(按照这个趋势,大概周二就会出现)?答案与所有内存受限场景一样:管理一个**预算**:你不试图将整个场景驻留在内存中,而是维护一个*工作集*。 你将纹理池设定在一个内存上限内,当区域进入视野时按需流式加载数据块,当空间耗尽时,使用最远优先的启发式算法**驱逐**那些没人再看的区域。 这样一来,磁盘上的场景可以是任意大小的,因为内存中永远只有一个有界的切片。终于,我认为渲染器已经配得上"无限"这个词了。也许吧。我的信心还是有点受打击。 ### **全面总结** 以下是整个系统的概貌,从服务器上的文件到屏幕上的色块: 1. **加载与解码:**在工作线程上,从磁盘或 HTTP 拉取数据块,解压缩并解包为 GPU 就绪的格式,完成后通知游戏线程和渲染线程。 2. **驻留与流式传输:**将数据块上传到固定的纹理池,按需将行分配给需要的区域,按需流式加载(粗优先),并在内存预算下驱逐数据块,使任意大小的场景都能装下。 3. **LOD 选择:**每个视角、每帧,基于 SSE 指标为每个区域选择一个级别,在理想数据还在下载时先显示当前最好的。 4. **排序与剔除:**一个 compute shader 按相机距离为每个可见 splat 生成键值,并丢弃屏幕外的那些;Unreal 的基数排序将它们从后到前排列,使透明度正确合成。 5. **绘制:**一个 vertex factory 在内存中没有几何体的情况下,为每个存活的 splat 变出一个面向相机的四边形;材质获取每个 splat 的数据并进行相应着色。 最漂亮的结构性成果是,一个普通的单文件 splat 场景(没有 LOD,没有流式传输)自然而然地成为了一个*退化情况*:一个数据块,永远驻留在内存中,所以 LOD 选择直接短路。一切都遵循相同的代码路径——从维护和复杂度的角度来看,这太棒了。 *一个流式 SOG 资产通过 http 动态获取 LOD 级别。图片来源:Church of Saints Peter and Paul,PlayCanvas(https://developer.playcanvas.com/tutorials/gaussian-splat-streaming-lod/)。* ### **下次见?** 它真的是无限的吗?差不多吧,我觉得。 至少它对远超以往的 splat 数量有了更强的适应能力。磁盘上的场景可以比 GPU 更大,比机器更大,甚至在通过 HTTP 拉取数据时也是如此,它依然能够渲染……

相似文章

高斯点溅射

Hacker News Top

研究人员提出了高斯点溅射(Gaussian Point Splatting),这是一种随机渲染方法,利用像素大小的不透明点和64位GPU原子操作,能够实时渲染数亿个高斯点。该方法已被SIGGRAPH 2026接收,采用层次化剔除与并行编程原语,实现均匀的工作负载分配,与原始高斯溅射相比仅存在轻微的噪声差异。

在Julia中实现更好的高斯溅射

Hacker News Top

GaussianSplatting.jl 2.0版本通过KernelAbstractions.jl带来了多GPU后端支持、多线程UI、MCMC致密化策略,以及用于更好3D重建的深度/几何监督。

GlobalSplat: 通过全局场景标记实现高效的前馈式三维高斯散射

Hugging Face Daily Papers

GlobalSplat 引入了一种高效的前馈框架,用于三维高斯散射,通过全局场景标记实现紧凑且一致的场景重建,将计算开销和推理时间降低至78毫秒以下。该方法采用从粗到细的训练策略,防止表示膨胀,同时以显著更少的高斯原语(16K)达到有竞争力的新视角合成性能,与密集基线相比更为高效。