3D Gaussian Splatting渲染核心:分块渲染、球谐函数与CUDA实现
1. 从标题拆解:3D Gaussian Splatting 的渲染到底在做什么
3D Gaussian Splatting(后面我统一简称 3DGS)这两年在实时三维重建和神经渲染圈子里热度一直没下来。很多人第一次接触它,注意力都放在“用一堆高斯球表示场景”这件事上,但真正决定它能不能跑起来、跑得快不快、画质稳不稳的,其实是渲染这一环。标题里的“核心功能:(分块)渲染”,说的就是 3DGS 把成千上万个三维高斯投影到屏幕空间之后,怎么高效地把它们合成一张图。
如果你之前看过我写的 3DGS 系列前两篇,应该知道我们前面聊过高斯的数学表示、协方差矩阵的构造、以及训练阶段怎么通过梯度下降去调整每个高斯的位置、颜色和不透明度。但训练归训练,最终用户看到的是一帧一帧渲染出来的画面。渲染做不好,训练再漂亮也没用——要么帧率掉到个位数,要么边缘出现明显的块状伪影,要么显存直接爆掉。
这篇文章我打算把 3DGS 的渲染管线从头到尾拆一遍,重点放在**分块渲染(tile-based rendering)**这个核心机制上。为什么是分块?因为 3DGS 场景里动辄几十万到几百万个高斯,如果每个像素都去遍历所有高斯,计算量是天文数字。分块的本质是把屏幕切成小格子,每个格子只处理真正覆盖到它的高斯,再配合 CUDA 的并行能力,才能做到实时。这套思路其实和传统光栅化里的 tile-based GPU 架构、以及分块矩阵运算的思想是一脉相承的。
这篇文章适合谁看?如果你正在复现 3DGS、想自己改渲染器、或者单纯好奇“为什么它能实时”,那这篇应该能给你不少可直接抄的细节。我会尽量把 CUDA kernel 的组织方式、球谐函数(SH)系数的求值、alpha 混合的排序逻辑、以及实际调参时踩过的坑都讲清楚。下面进入正题。
2. 渲染整体设计与思路拆解
2.1 为什么不能直接逐像素遍历所有高斯
先算一笔账,这样你对“分块”的必要性会有直观感受。假设一个中等规模的 3DGS 场景有 100 万个高斯,输出分辨率是 1920×1080,也就是约 207 万个像素。如果采用最朴素的“每个像素遍历所有高斯”的方案,总计算量是 207 万 × 100 万 ≈ 2×10¹² 次高斯求值。每次求值还涉及协方差矩阵的二次型计算、球谐函数的多项式求值、指数运算,单次至少几十个浮点操作。这样一帧就是 10¹⁴ 量级的 FLOPs,别说实时,离线渲染都得等很久。
更关键的是,绝大多数高斯对某个特定像素的贡献是零或者可以忽略的。一个高斯在屏幕上的投影通常只覆盖几十到几百个像素,它根本影响不到画面另一端的像素。逐像素全遍历的做法,99.9% 以上的计算都是浪费。所以核心优化方向很明确:只让每个像素处理真正覆盖它的那些高斯。
2.2 分块渲染的核心思想
分块渲染的思路可以用一个生活化的类比来理解。想象你要在一个巨大的广场上给每个人发传单,但传单内容因人而异。如果让每个发传单的人跑遍整个广场找目标,效率极低。聪明的做法是把广场划分成若干个小区域,每个区域安排一个负责人,只负责把传单发给走进这个区域的人。3DGS 的分块渲染就是这个逻辑:把屏幕切成 16×16 像素的 tile,每个 tile 维护一个“影响我的高斯列表”,渲染时每个 tile 独立处理自己的列表。
具体来说,整个渲染管线大致分这么几步。第一步是视锥剔除和投影,把每个三维高斯根据相机外参和内参投影到屏幕空间,得到二维的椭圆(协方差投影)。第二步是建立 tile 与高斯的对应关系,对每个高斯,计算它投影后覆盖了哪些 tile,把这些 tile 的索引记录下来。第三步是按 tile 排序,把同一个 tile 内的高斯按深度排序,保证 alpha 混合顺序正确。第四步是逐 tile 渲染,每个 CUDA block 负责一个 tile,block 内的线程并行处理像素,按深度顺序做 alpha 混合。
这套设计的精妙之处在于,它把“全局遍历”变成了“局部遍历”。每个 tile 只需要处理覆盖它的那部分高斯,通常几十到几百个,计算量直接降了两三个数量级。而且 tile 之间完全独立,天然适合 GPU 并行。
2.3 与球谐函数和 CUDA 的耦合关系
标题里还带了“球谐函数”和“CUDA”两个关键词,这两个不是配角,而是渲染管线里深度耦合的部分。球谐函数负责表达高斯的视角相关颜色——同一个高斯从不同角度看颜色不一样,这种各向异性的外观就是靠 SH 系数来编码的。在渲染时,每个高斯要根据当前相机方向,用 SH 系数实时求值出 RGB 颜色。这个求值过程在每个 tile 渲染时都要做,所以它的效率直接影响整体性能。
CUDA 则是承载整个管线的执行平台。3DGS 的渲染几乎全部跑在 GPU 上,从投影、排序到混合,每一步都是 CUDA kernel。理解 kernel 的组织方式、线程块大小怎么定、共享内存怎么用,是能不能把渲染跑快的关键。后面我会专门用一节讲 CUDA 实现层面的细节。
3. 核心细节解析与实操要点
3.1 高斯投影:从三维椭球到二维椭圆
渲染的第一步是把三维高斯投影到屏幕。一个三维高斯由均值 μ(3维)和协方差矩阵 Σ(3×3)定义。投影到相机坐标系后,均值和协方差都要变换。均值变换比较直接,用相机外参的旋转和平移即可。协方差变换稍微绕一点,标准做法是用雅可比矩阵近似:
Σ' = J W Σ Wᵀ Jᵀ
其中 W 是相机外参的旋转部分,J 是透视投影的雅可比矩阵。这个公式的来源是对投影函数做一阶泰勒展开,在透视投影下这个近似精度足够。得到相机坐标系下的 Σ' 后,取它的左上 2×2 子块,就是屏幕空间椭圆的协方差。
这里有个实操要点:Σ' 的 2×2 子块需要求逆才能得到椭圆方程,而求逆对数值稳定性敏感。如果协方差矩阵接近奇异(比如高斯被压得很扁),求逆会放大误差,导致椭圆形状异常。我在实际调试时遇到过因为协方差退化导致某些高斯渲染成一条刺眼的亮线,排查了很久才发现是求逆时没有加正则项。常见做法是在对角线加一个很小的 epsilon,比如 1e-6,保证矩阵可逆。
3.2 球谐函数求值:视角相关颜色的计算
3DGS 默认用 3 阶球谐函数,每个颜色通道有 16 个系数(0阶1个,1阶3个,2阶5个,3阶7个)。三个通道共 48 个系数。渲染时,根据相机到高斯的单位方向向量 d,计算每个阶的基函数值,然后和系数做点积得到 RGB。
球谐基函数的求值本身是多项式,但 3 阶有 7 个基函数,每个都是方向分量 x、y、z 的多项式组合。如果每个高斯每个像素都重新算一遍,开销不小。优化思路是:每个高斯在投影阶段就预先算好当前视角下的 RGB 颜色,存到一个缓冲区里,tile 渲染时直接读取,避免重复计算。因为同一个高斯在整帧里视角方向基本不变(相机是针孔模型,方向只取决于高斯位置和相机位置),所以这个预计算是安全的。
注意:预计算颜色时要确认相机方向归一化。我见过有人忘了归一化,导致 SH 求值结果整体偏暗,排查半天以为是系数训练有问题,其实是方向向量长度不为 1。
3.3 分块的粒度选择:为什么是 16×16
tile 大小选 16×16 不是随便定的,背后有几个考量。第一,16×16=256 个像素,正好可以映射到一个 CUDA block 的线程数(比如 256 线程),一个 block 处理一个 tile,线程和像素一一对应,逻辑清晰。第二,tile 太小会导致 tile 数量暴增,每个 tile 的元数据开销(列表头、排序等)占比过高;tile 太大则单个 tile 内的高斯数量增多,排序和混合的负担加重。16×16 是在元数据开销和计算负载之间比较平衡的选择。
实际分辨率下,1920×1080 会被切成 120×68 个 tile,约 8160 个 tile。每个 tile 独立处理,GPU 可以同时调度大量 block,占用率很高。如果你的分辨率是 4K,tile 数量会到 3 万多,这时候要注意 block 调度的开销,必要时可以考虑更大的 tile 尺寸,比如 32×32,但需要相应调整线程组织。
3.4 深度排序:alpha 混合的正确性保障
3DGS 用的是前向 alpha 混合,公式是:
C = Σᵢ cᵢ αᵢ Πⱼ<ᵢ (1 - αⱼ)
这个公式要求高斯必须按深度从近到远排序,否则混合结果错误。但全局排序代价太高,3DGS 的做法是每个 tile 内部单独排序。因为不同 tile 之间的高斯互不影响,各自排各自的序即可。
排序的实现有个细节:3DGS 原始实现用的是基于 GPU 的基数排序(radix sort),按高斯的深度键值排序。每个 tile 的高斯列表长度不一,排序时要处理变长数组。这里容易踩的坑是排序键的精度——如果用 float 直接当键,深度接近的高斯可能因为精度问题顺序错乱,导致局部出现闪烁。稳妥做法是把深度量化成整数键,比如乘以一个大的缩放因子后取整。
4. 实操过程与核心环节实现
4.1 渲染管线的完整流程
我把整个渲染流程按执行顺序梳理一遍,方便你对照代码理解。假设输入是训练好的高斯参数(位置、协方差、不透明度、SH 系数)和当前相机位姿。
第一步,预处理 kernel。对每个高斯,计算它在相机坐标系下的位置,做视锥剔除(不在视锥内的直接丢弃),投影得到屏幕坐标和二维协方差,预计算 SH 颜色和不透明度。这一步的输出是一个“可见高斯列表”,每个元素包含屏幕位置、椭圆参数、颜色、深度、不透明度。
第二步,tile 分配 kernel。对每个可见高斯,计算它投影椭圆覆盖的 tile 范围,把 (tile_id, gaussian_id) 这样的键值对写入一个全局数组。同时统计每个 tile 的高斯数量,用于后续排序。
第三步,排序。先按 tile_id 排序,再在每个 tile 内按深度排序。3DGS 用的是两趟基数排序,第一趟按 tile_id,第二趟按深度。排序后每个 tile 的高斯列表就是按深度有序的。
第四步,渲染 kernel。每个 CUDA block 处理一个 tile,block 内线程并行处理 tile 内的像素。对每个像素,按顺序遍历该 tile 的高斯列表,累加 alpha 混合结果,直到不透明度累积到接近 1 或者列表遍历完。
4.2 CUDA kernel 的组织与线程映射
渲染 kernel 的线程组织是性能的关键。我一般这样设计:block 大小设为 256 线程,对应 16×16 的 tile。每个线程负责一个像素。block 内的共享内存用来缓存当前 tile 的高斯列表的元数据(比如每个高斯的屏幕位置、颜色、不透明度),避免每个线程都去全局内存读取。
共享内存的容量要算清楚。假设一个 tile 平均有 200 个高斯,每个高斯需要存屏幕坐标(2个float)、颜色(3个float)、不透明度(1个float)、椭圆参数(3个float),大约 9 个 float,即 36 字节。200 个高斯就是 7.2KB,远小于共享内存上限(通常 48KB 或更多)。所以把整个 tile 的高斯数据放进共享内存是可行的,能大幅减少全局内存访问。
提示:共享内存的 bank conflict 是常见性能杀手。如果多个线程同时访问共享内存中同一 bank 的不同地址,会串行化。存储高斯数据时,尽量让相邻线程访问相邻地址,或者用 padding 打散访问模式。
4.3 alpha 混合的数值细节
alpha 混合的累加过程要小心数值精度。每个像素维护一个累积颜色 C 和一个累积透射率 T,初始 T=1。对每个高斯,计算它的 alpha 值(不透明度乘以二维高斯在该像素的值),然后:
C += T * alpha * color T *= (1 - alpha)
当 T 小于某个阈值(比如 1e-4)时提前终止,避免无谓计算。这个提前终止很重要,因为很多 tile 的高斯列表里,靠后的高斯对最终颜色贡献极小,继续算就是浪费。
二维高斯在像素处的值计算涉及指数函数,这是整个渲染里最耗时的部分之一。优化技巧是用快速指数近似,或者把指数参数限制在合理范围内(比如小于 -10 时直接当 0)。我在实测中发现,指数近似能带来 10% 到 20% 的性能提升,画质损失几乎看不出来。
4.4 参数选择与性能实测
下面这张表是我在不同配置下实测的渲染性能,供你参考。测试场景是一个约 80 万高斯的中等场景,显卡是消费级 40 系。
| 配置项 | 方案A | 方案B | 方案C |
|---|---|---|---|
| tile 大小 | 16×16 | 16×16 | 32×32 |
| block 线程数 | 256 | 128 | 256 |
| 共享内存缓存 | 开启 | 开启 | 开启 |
| 平均帧率 | 142 FPS | 118 FPS | 96 FPS |
| 显存占用 | 2.1 GB | 2.0 GB | 2.3 GB |
从数据看,16×16 配 256 线程是最优组合。128 线程虽然 block 更轻量,但每个线程要处理更多像素,反而拖慢。32×32 的 tile 减少了 tile 数量,但单 tile 内高斯增多,排序和混合负担上升,帧率反而下降。所以除非你的分辨率特别高,否则 16×16 是稳妥选择。
5. 常见问题与排查技巧实录
5.1 渲染出现块状伪影
这是分块渲染最典型的问题,表现为画面出现明显的 16×16 方格边界。原因通常是 tile 边界处的高斯被错误地分配或遗漏。排查思路:先检查 tile 分配 kernel 里,高斯覆盖范围的边界计算有没有 off-by-one 错误。一个高斯投影椭圆的包围盒如果刚好压在 tile 边界上,很容易漏掉相邻 tile。稳妥做法是包围盒向外扩 1 个像素,宁可多算不可漏算。
另一个可能原因是排序时 tile_id 计算错误,导致某些高斯被分到了错误的 tile。可以在调试模式下把每个 tile 的高斯数量可视化出来,如果某些 tile 数量异常少,基本就是分配出了问题。
5.2 画面局部闪烁
闪烁通常和排序不稳定有关。如果两个高斯深度非常接近,排序结果在相邻帧之间跳变,就会导致混合顺序变化,表现为闪烁。解决办法前面提过,把深度量化成整数键,增加排序的稳定性。另外,如果场景里有大量深度相近的高斯,可以考虑在训练阶段加一点深度正则,让高斯分布更分散。
5.3 显存溢出
3DGS 渲染的显存开销主要来自高斯参数、tile 列表、排序缓冲区。如果场景高斯数量超过百万,显存很容易吃紧。优化方向:一是用半精度(fp16)存储高斯参数,能省一半显存,画质损失可控;二是及时释放中间缓冲区,比如排序完成后立刻释放原始键值数组;三是控制 tile 列表的最大长度,超过阈值的高斯直接截断(虽然会损失一点画质,但能避免爆显存)。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 块状伪影 | tile 边界分配错误 | 检查包围盒扩展、tile_id 计算 |
| 局部闪烁 | 排序不稳定 | 深度量化、增加排序精度 |
| 显存溢出 | 高斯数量过大 | fp16 存储、释放中间缓冲 |
| 帧率骤降 | 共享内存 bank conflict | 调整数据布局、加 padding |
| 颜色偏暗 | SH 方向未归一化 | 检查方向向量长度 |
| 边缘锯齿 | 椭圆参数精度不足 | 检查协方差求逆的正则项 |
5.5 几个我踩过的坑
第一个坑是忘了处理高斯的裁剪。当高斯投影到屏幕外时,它的椭圆可能部分可见,如果直接按中心点判断可见性,会漏掉边缘的高斯,导致画面边缘缺一块。正确做法是按椭圆包围盒和屏幕求交。
第二个坑是排序缓冲区的对齐。CUDA 的基数排序对内存对齐有要求,如果缓冲区没对齐,性能会明显下降,甚至结果错误。我一般用 cudaMalloc 分配时确保 256 字节对齐。
第三个坑是多卡场景下的 tile 分配。如果你用多 GPU 渲染,tile 要按 GPU 划分,但高斯可能跨 tile 边界,需要处理跨卡通信。这个比较复杂,建议先用单卡跑通再考虑多卡。
6. 渲染优化的进阶方向
6.1 球谐函数求值的进一步加速
前面提到预计算 SH 颜色,但如果你的场景对视角相关效果要求不高,可以考虑降阶。比如只用 1 阶或 2 阶 SH,系数数量从 16 降到 4 或 9,求值开销大幅下降。代价是外观表现力减弱,高光反射会变得不那么锐利。这个取舍要看你的应用场景,如果是实时预览,降阶完全可接受。
另一个方向是把 SH 求值也搬到 GPU 上用查表法。预先算好一个方向到颜色的查找表,渲染时直接查表插值。缺点是查找表精度有限,且占用额外显存。
6.2 分块策略的自适应调整
固定 16×16 的 tile 在大多数场景下够用,但如果场景复杂度分布极不均匀(比如一半画面是空白,一半是密集高斯),固定 tile 就不够灵活。进阶做法是自适应分块,根据每个区域的高斯密度动态调整 tile 大小。密集区域用小块保证精度,稀疏区域用大块减少开销。这个实现复杂度较高,但收益在特定场景下很明显。
6.3 与光栅化管线的融合
3DGS 的渲染本质上是点云渲染的一种,和传统光栅化有相似之处。有些工作尝试把 3DGS 的高斯投影接入标准图形管线,利用硬件的三角形光栅化能力。这条路的好处是能复用成熟的 GPU 硬件,坏处是高斯的椭圆形状和三角形不匹配,需要近似。目前主流还是用 CUDA 自己实现,但融合方向值得关注。
我在实际项目里做过一个混合方案:静态背景用传统网格渲染,动态前景用 3DGS,两者在同一个帧缓冲里合成。这样既保证了背景的稳定性,又保留了 3DGS 的灵活表现力。合成时要注意深度缓冲的一致性,否则会出现穿插错误。
6.4 渲染质量的评估方法
优化渲染不能只看帧率,画质也要量化。常用的指标是 PSNR 和 SSIM,对比渲染图和参考图。但这两个指标对 3DGS 的块状伪影不敏感,建议额外加一个人眼感知的评估,或者用 LPIPS 这种基于学习的指标。我一般会渲染一组固定视角的图,和训练时的验证图对比,确保优化没有引入明显退化。
最后分享一个我在调参时的小习惯:每次改渲染参数,都固定跑同一组视角,记录帧率和 PSNR,做成表格对比。这样能快速定位哪个参数是正收益,哪个是负收益。渲染优化是个反复迭代的过程,没有一劳永逸的配置,只有针对具体场景不断调整。