3D Gaussian Splatting分块渲染:让百万级高斯实时跑起来的核心工程
提到3D Gaussian Splatting,很多人第一反应是“那个效果惊艳的NeRF接班人”,但真正让它在消费级显卡上实时跑起来的,其实是背后那套非常务实的渲染工程。这个系列叫3D Gaussian,之前聊过数据表示和场景初始化,这次进入第三篇,专门拆一个最容易被忽略、却决定了帧率的核心功能:分块渲染。
这里说的分块,不是算法比赛里那种为了并行而做的简单切分,而是一套完整的“屏幕Tile化数据流”:从3D高斯投影、建立Tile索引、块内排序,到最终逐像素合成,每一步都在为“局部性”服务。本文不打算只讲口号,会把每个环节的原理、工程取舍、以及我踩过的坑都摆出来,希望能给正在读源码或准备复现的朋友一点参考。
1. 为什么3D Gaussian绘制非要“分块”不可
1.1 直接逐像素算,为什么算不动
先看最朴素的渲染思路:场景里有N个3D高斯,屏幕上有M个像素。对每个像素,遍历所有N个高斯,计算投影后的贡献并合成颜色。这个复杂度是O(N×M)。一个中等规模的场景,高斯数量大概在50万到200万之间,1080p屏幕有约200万个像素,两者相乘就是10的12次方量级的操作。即便每个高斯只做一次简单的指数计算,这个数量也足够让显卡瞬间卡成PPT。
有人会说,每个高斯投影后只覆盖一小块区域,可以提前裁剪。确实,3D高斯是带协方差矩阵的椭球,经过透视投影后在图像空间也是一个椭球,而且几乎所有实现都会用3σ截断来定义有效覆盖区域。这就是局部性的来源。但如果不在架构层面把这个局部性利用起来,单纯靠逐像素循环里加if判断,性能依然不行。因为GPU的并行方式是SIMT,不同像素执行不同的分支,warp divergence会严重拖低吞吐,而每个高斯的数据也要反复从内存搬进搬出。
1.2 16x16的Tile:把渲染从“全局”变成“本地”
于是就有了分块渲染(Tile-based Rendering)。做法很直接:把屏幕划分成一组不重叠的小方块(tile),每个tile大小通常是16×16像素。对每个3D高斯,计算它投影后覆盖的屏幕范围,找到它与哪些tile相交,然后把它的索引塞进这些tile维护的列表中。渲染某个像素时,只遍历这个像素所在tile的高斯列表,而不是全场高斯。
这个思路在日常配送里很容易理解:你不是让每个快递员把全市所有包裹都送到每个小区,而是先按小区分拣,再由对应几个快递员派送。3D Gaussian渲染也一样,每个tile相当于一个小区,高斯列表就是这个小区的包裹清单。于是像素和像素之间不再因为遍历不同数量、不同身份的高斯而互相拖累,每个像素只关心自己所在tile内的那几十个高斯。
1.3 分而治之的思路,和分块矩阵求逆很像
如果你学过线性代数里的分块矩阵,应该会觉得这很眼熟。分块矩阵求逆不是直接对整个大矩阵做一次求逆,而是把矩阵切成子块,先在子块内部做分解,再处理子块之间的耦合;好处是子块运算可以独立并行,且能利用“大部分子块对最终结果影响有限”这一局部性。
3D Gaussian里的分块渲染在精神上是同一类思路:屏幕空间被切成tile,每个tile内部只处理与它重叠的高斯,块与块之间的耦合通过“高斯会跨tile”这一事实来兜底——一个大高斯可以同时出现在若干tile的列表中,但这不影响每个tile独立执行排序和合成。这种局部性抽象,让我们可以把原本全局的O(N×M)问题,拆解成一组规模很小的独立问题。
2. 分块渲染的完整数据流水线
2.1 投影:把3D高斯变成屏幕上的椭圆
分块渲染的第一步,是把世界空间里的3D高斯投影到屏幕空间。每个3D高斯通常用均值μ和协方差矩阵Σ描述。要把它渲染成图像,需要经过视图变换和透视投影。协方差矩阵的变换不是简单的“乘以旋转矩阵”就行,因为透视投影是非线性变换,需要一个雅可比矩阵来近似局部线性化。
具体来说,屏幕空间的协方差矩阵可以写成:
Σ' = J W Σ W^T J^T
其中W是视图变换矩阵,J是投影变换的雅可比矩阵。这一步通常在一个CUDA kernel里完成,每个线程处理一个高斯,并行度非常高。投影完成后,每个高斯就有了屏幕空间的位置、尺度、旋转角度,以及一个“可见性”标志——如果它在视锥体外面或者深度异常,就标记为不可见。
2.2 Binning:决定每个高斯属于哪些块
投影之后要做的事情叫Binning:为每个可见高斯计算它在屏幕上的包围盒,根据包围盒找到它覆盖的tile集合,再把高斯ID写入这些tile对应的列表。注意,一个高斯通常会覆盖多个tile,尤其当高斯尺寸很大、覆盖半屏的时候。所以同一个高斯ID会出现在很多tile的列表里,这是设计层面的冗余,也是局部性和索引开销之间的交换。
实现时,这一步要非常小心并发写入。多个线程可能同时往同一个tile的列表里写数据,所以一般会先为每个tile统计收到的高斯数量,再用前缀和算好写入偏移,最后按偏移写入,避免用锁或者原子操作反复竞争。有的实现甚至会用两轮循环:第一轮只统计,第二轮再把数据写入扁平数组。这种“分桶”过程其实是整个渲染管线里最容易被低估的一步,因为它的内存访问模式是散乱的,直接决定后续所有阶段的缓存命中率。
2.3 块内排序与合成:真正的渲染发生在这里
有了每个tile的高斯列表,接下来要做的是按深度排序。绝大多数实现会为每个tile内的高斯按深度从小到大排序,也就是从近到远。为什么排序重要?因为最终合成是一个顺序相关的alpha混合过程。一个像素的最终颜色是所有覆盖它的高斯在这个像素上按深度从近到远的累加:
C = Σ_i c_i α_i Π_{j=1}^{i-1} (1 - α_j)
其中c_i是第i个高斯的颜色,α_i是由2D高斯覆盖率和该点不透明度共同决定的alpha值。因为每个tile都有自己的独立列表,排序可以在tile级别并行做,而不需要对全场景的高斯做一次全局排序。全局排序的长度动辄百万,而tile内的列表长度通常在几到几百之间,排序成本小了好几个数量级。
这里有个容易被忽略的细节:排序的key并不只是深度值本身。深度值由世界坐标经透视除法得到,浮点精度有限,如果两个高斯的深度值非常接近,排序结果就不稳定。后面我会单独讲这个坑。总之,渲染kernel会在每个像素上遍历自己所在tile的高斯列表,取出预排序好的高斯属性,计算2D高斯在该像素位置的覆盖率和颜色,然后不断累积合成。
3. 分块方案背后的工程取舍与并行优化
3.1 为什么Tile尺寸默认是16×16而不是8×8或32×32
分块渲染里最核心的超参数就是tile尺寸,目前论文和主流开源实现几乎清一色选16×16。这不是随便定的。tile越小,局部性越强,每个像素需要遍历的高斯越少,但tile总数会变多,每个高斯的重复写入次数也会相应增加,Binning阶段的索引开销会变大。tile越大,一个block能覆盖的像素更多,但tile内的高斯列表会更长,排序和alpha合成的串行部分会变重。
16×16恰好落在了一个非常平衡的位置。它对应CUDA线程块中256个线程,正好是8个warp,和GPU调度单元天然对齐;16也是个比较“整”的数,让屏幕坐标到tile坐标的除法变成移位操作,省一点指令开销。我实际测过8×8和32×32,8×8时Binning开销肉眼可见地变多,32×32时排序阶段的时间占比明显上升。所以除非你明确知道自己场景的Gaussian分布特征,否则先别改这个参数。
3.2 渲染kernel和CUDA线程块的映射关系
分块渲染在CUDA里的映射很有讲究。第一阶段的投影kernel每个线程处理一个高斯,第二阶段Binning通常每个block处理若干个tile,第三阶段渲染kernel可以是每个像素一个线程,也可以是每个tile的block先做块内排序,再按像素取数据。
其中一个常见做法是:每个线程块对应一个tile,线程块内有256个线程,每个线程负责16×16 tile里的一个像素。渲染线程遍历的列表,正是前面Binning阶段给这个tile生成的。一个block里的所有线程共享同一个高斯列表,这天然拿到了极高的“列表复用率”。如果换成逐像素全局遍历,缓存里装的全是别的像素需要的高斯,带宽浪费会非常严重。
这里我特别想提一点:不要试图在一个block里一次性装载tile内所有高斯的所有属性,因为一个tile内的可见高斯数量是不固定的,有时甚至能超过1000个。更稳的做法是循环分批读取,或者把属性按“预排序后的索引”紧凑存放在全局内存,让block内的线程以协同方式去读取。
3.3 内存带宽与中间缓冲区的平衡
分块渲染的中间产物很多:每个高斯的投影参数、每个tile的高斯ID列表、排序后的key-value数组、每个像素的累计颜色和透明度。这些数据如果不控制好,带宽压力比计算压力还大。实际工程里常用这么几个手段:一是把不那么关键的属性(位置、颜色、旋转)压成half或低比特类型;二是不要在每个tile里重复存储完整的高斯副本,而是只存高斯ID,属性统一查全局表;三是利用共享内存做块内的索引缓存。
还有一点,Binning出来的列表如果直接用全局内存,散落的写入很容易造成缓存颠簸。很多实现会先用共享内存累积当前block负责的tile列表,块处理完后再刷回全局内存。这样虽然增加了一点代码复杂度,但能显著降低原子操作的数量。复现的时候可以留意一下profiler里local memory和atomic操作的比例,往往能看出实现是不是健康。
4. 我在复现过程中踩过的几个坑
4.1 排序不稳定导致的重影问题
先说最折磨人的一个问题:画面闪烁。我在刚开始实现分块渲染时,渲染出来的每一帧边缘都会出现类似毛发一样的重影,画面稍微转动就疯狂闪烁。第一反应是投影算错了,查了半天才发现是排序不稳定。
原因其实很简单:两个高斯在同一个像素上的投影覆盖区域可能有很大重叠,深度差异又只有小数点后几位。如果排序的key只用了float深度,它们的相对顺序会随着相机小幅移动而翻转,而alpha混合又对顺序极度敏感,顺序一翻,结果自然跳变。解决方法是让排序key带上高斯的唯一ID作为次级比较键:深度相同或接近时,按ID排序,保证一次渲染内的顺序稳定。这也解释了为什么源码里排序key经常是64位的——高32位存深度,低32位存ID。
4.2 半精度精度不足导致远近穿插
第二个坑和精度有关。为了省显存,我把一部分属性压成了half精度,结果在远景和近景交界的地方经常出现“穿帮”,远处的山体偶尔会突然跑到近处建筑前面。调试时发现是深度精度不足:把一个很大的深度值存成half后,相邻级别之间的间隔可能达到好几米,排序时几个远处的高斯深度完全分不出先后。
排查时我把半精度改成float重新跑,问题消失了。后来换了一种更聪明的量化方式:不是对原始深度做half,而是对逆深度(1/z)做half。逆深度在透视投影后是线性分布的,相邻级别对应的实际深度变化也更接近视觉感知。这样既能保精度,又能继续享受半精度带来的带宽收益。如果你不想大动数据结构,至少也应该在排序阶段把深度值还原成float,而不是直接拿half值排序。
4.3 如何在真机上定位“少了哪块”的问题
还有一种很隐蔽的Bug:画面里某个区域突然出现空洞,或者整块内容消失。这种问题通常不是算法错,而是数据被“丢”了。最典型的原因是tile列表容量不足。有的早期实现会给每个tile预设一个最大高斯数量,超出就直接截断,超出的高斯不参与渲染。当场景中有很大的高斯或者多视角合并导致局部密度很高时,列表很容易打满,于是出现空洞。
定位这种问题,我会写一个调试kernel:对每个tile统计实际高斯数量,输出成一张热力图。看到某个区域的热力值顶到了上限,就能立刻判断是不是被截断了。还有一招是把tile边界可视化——把tile的边界线画成亮色,如果空洞恰好出现在某个tile内部,说明是这个tile的列表有问题;如果空洞沿tile边界扩散,说明可能是Binning阶段边界判定出错。
5. 分块渲染的调参经验与现实边界
5.1 什么时候改tile size有收益
虽然默认16×16,但并不意味着任何场景都不该动它。如果你的场景是无人机扫描出来的建筑工地,点云很密、高斯碎片很多,8×8的tile往往能让排序更轻,因为每个tile里的高斯数会明显减少,代价是Binning阶段的原子操作变多。反过来,如果你的场景大部分是大尺度的天空、背景板这类“大型高斯”,32×32反而能减少列表写入次数,缓解Binning瓶颈。
判断依据不是感觉,而是profiler。把投影、Binning、排序、渲染四个阶段的时间单独拆出来,看哪一段占比最高。如果排序占20%以上,试试缩小tile;如果Binning占20%以上,试试增大tile。我遇到的大部分项目,瓶颈最终都落在Binning和合成阶段,而不是投影。
5.2 和密度控制配合
分块渲染的效率和场景里高斯的“块大小分布”关系很大。如果所有高斯都是小碎块,那么每个tile的列表都不会太长,局部性极佳;如果存在一批特别大的高斯,它们会污染大量tile的列表,导致很多tile必须遍历一个很长的列表,但其中大部分贡献几乎为0。这也是为什么很多训练流程里会做“密度控制”:把覆盖范围超过一定阈值的大高斯分裂成若干小高斯。
在接入分块渲染管线之前,我建议先做一遍高斯压缩/剪枝,把那些贡献小、方差大的高斯删掉或者分裂掉。实测下来,一次合理的密度控制能让最终渲染帧率提升30%以上,而且画质几乎不受影响。这不是分块渲染本身的问题,而是分块渲染会把“高斯分布不健康”这个问题放大给你看。
5.3 分块不是银弹:小场景里的逆向选择
最后说点可能反直觉的结论:场景如果只有几千个高斯,分块渲染反而可能是负优化。因为Binning和排序都是有固定开销的,哪怕每个tile里只有两三个高斯,也要走一遍统计、写索引、排序的过程。这种情况下,传统的前向渲染(直接遍历所有高斯画椭球)更快,代码也更简单。
我自己做过一个自动开关:当场景可见高斯数低于某个阈值时,退回逐高斯光栅化;高于阈值时才启用分块渲染。这个阈值在不同硬件上不太一样,一般在2万到5万之间。所以如果你只是在一个小场景里做实验,没必要强行套分块框架;真正让它发挥价值的是那种几十万、上百万高斯的大场景,那一刻你才会发现,分块不只是一个优化技巧,而是让实时渲染成为可能的支柱。
回头再看整个分块渲染链路,最值得记住的其实就一句话:它没有用什么复杂数学,而是老老实实把“局部性”写到了每一步流程里。项目写到这里,我自己复现时最大的体会是,这种工程里的取舍通常比算法本身更影响最终效果。希望这篇能把分块渲染的门道讲清楚,如果后面有时间,我打算再写写排序那部分的极致优化。