3D Gaussian Splatting(3DGS)凭借高质量的新视角合成效果和实时渲染速度,已经成为三维重建领域的热门方案。但它有一个明显短板:训练过程中的致密化机制会持续克隆和分裂高斯点,一个中等规模场景训练完往往产生两三百万甚至更多的点,每个点存储位置、协方差、颜色球谐系数等属性,模型文件轻松突破GB级别。想要在移动端部署或者做大规模场景,点云修剪是必须面对的环节。本文围绕高斯点密度的自适应控制和常见修剪策略展开,帮你理解剪枝背后的原理和工程实现。

为什么3DGS会产生大量冗余点
要理解修剪,得先看冗余是怎么来的。原始3DGS的训练流程中每隔1000次迭代执行一次致密化(densification),对梯度累积较大的高斯点进行克隆或分裂。克隆用于欠重建区域,分裂用于过重建区域,再加上随机初始化的基础点云,这套机制确实能快速补全细节,但也带来两个副作用。
第一个副作用是分裂缺少上界约束。分裂操作把一个点变成两个,各自协方差缩小,如果场景中存在高频纹理或视角不一致的观测噪声,梯度阈值判断会反复触发,点位数量呈指数式膨胀。第二个副作用是透明度没有及时回收。一个高斯点对最终像素的贡献由它的不透明度乘以高斯权重决定,训练早期为了拟合某些视角临时长出来的点,后期可能只以极低透明度存在,理论上删掉不影响画面,但原版实现只在致密化阶段做了一次低于0.005的透明度过滤,之后不再清理。
此外,很多点在几何上是重复覆盖的:多个高斯在几乎相同的位置用相近的协方差渲染同一片区域,属于典型的过参数化。这些点单独看都有一点贡献,合在一起看就是浪费显存和渲染带宽。修剪的目标就是把这些低贡献、可替代的点识别出来并删除,同时把画质损失控制在肉眼难以察觉的范围内。
常见的修剪策略与实现方式
修剪的核心是给每个高斯点打一个分数,衡量它对渲染结果的贡献,然后按阈值删除。工业界和学术界目前有几类主流做法,各有适用场景。
基于透明度和尺寸的硬阈值修剪
最简单直接的方式是按不透明度alpha和最大尺度(协方差特征值的平方根)过滤。训练结束后遍历所有点,删除alpha低于某阈值(如0.01)或尺度异常大的点。这种方式实现成本几乎为零,一般能砍掉20%到40%的点,对PSNR影响在0.05dB以内。缺点是只能清掉明显的噪声点,对前面提到的重复覆盖问题无能为力。
import torch
def prune_by_threshold(gaussians, alpha_thresh=0.01, scale_thresh=None):
"""基于透明度和尺度的简单修剪"""
alpha = gaussians.get_opacity.squeeze() # 每个点的不透明度
mask = alpha > alpha_thresh # 保留透明度足够的点
if scale_thresh is not None:
scales = gaussians.get_scaling # (N, 3) 各轴尺度
max_scale = scales.max(dim=1).values
mask &= max_scale < scale_thresh # 过滤异常大的点
keep_idx = mask.nonzero(as_tuple=True)[0]
gaussians.prune_points(keep_idx)
print(f"修剪后剩余点数: {len(keep_idx)} / {len(alpha)}")
return gaussians基于评分函数的重要性修剪
更精细的做法是借鉴剪枝网络权重的思路,计算每个点在多个视角下的全局重要性。EAGLES、LightGaussian等工作给出了不同的评分公式,基本思想是:重要性等于透明度乘以该点覆盖的像素权重,再乘以体积因子。体积因子通常取协方差行列式的立方根,用来惩罚大而稀疏的高斯。计算时取若干训练视角渲染一遍,累积每个高斯对最终颜色的贡献作为分数。
def compute_importance_score(gaussians, viewpoint_stack, n_views=50):
"""在采样视角上累积每个高斯点的贡献分数"""
scores = torch.zeros(gaussians.get_xyz.shape[0], device="cuda")
alpha = gaussians.get_opacity.squeeze()
# 体积因子:三个轴尺度的乘积开立方
vol = gaussians.get_scaling.prod(dim=1).clamp(min=1e-8).pow(1/3)
for i, view in enumerate(viewpoint_stack[:n_views]):
# 渲染时记录每个高斯影响的像素数量与累计透射率
pixel_contrib = render_contrib(view, gaussians) # (N,) 该视角的贡献
scores += pixel_contrib
scores = scores / n_views * alpha * vol
return scores
def prune_by_score(gaussians, scores, keep_ratio=0.6):
"""按分数排序,保留前keep_ratio比例的点"""
n_keep = int(len(scores) * keep_ratio)
keep_idx = scores.topk(n_keep).indices
gaussians.prune_points(keep_idx)这种方法的压缩率可以做到50%到80%,代价是需要额外的评分计算和一轮微调。需要注意的是纯按分数剪枝有偏差:远处的小高斯分数天然偏低,但它们对远景细节可能不可替代,所以实践中常按深度或空间分桶,在桶内部分别排序,避免远处细节被一刀切。
修剪后微调:恢复画质的关键一步
无论用哪种策略,剪掉点之后直接评估画质通常会掉0.2到0.5dB的PSNR。恢复画质的做法是冻结已保留点的位置和协方差,只微调透明度和球谐系数,跑几千次迭代。经验上3000到7000次迭代基本能把损失追回来,因为修剪删除的是冗余,剩下的点有能力重新分配贡献。如果把位置也放开微调,恢复更快,但可能引起点位漂移,需要谨慎设置学习率。
# 修剪后微调:只更新不透明度和球谐系数
for param_group in optimizer.param_groups:
name = param_group["name"]
if name in ["xyz", "scaling", "rotation"]:
param_group["lr"] = 0.0 # 冻结几何属性
else:
param_group["lr"] = 1e-3 # 颜色属性用较小学习率
for iteration in range(fine_tune_iters):
view = random.choice(train_views)
loss = render_and_compute_loss(view, gaussians)
loss.backward()
optimizer.step()
optimizer.zero_grad()自适应密度控制:训练过程中动态修剪
事后修剪治标,更彻底的方案是把密度控制融入训练过程,让点数在训练中就保持合理规模。Radical Splatting、Taming 3DGS等工作的思路是:分裂的同时必须伴随回收,每个点维护一个年龄计数器或生存概率,长期低贡献的点自动淘汰;同时限制全局点数上限,达到上限后启用固定预算模式,新点的加入以旧点的退出为代价。
这种动态平衡机制的另一个好处是可以设定期望的点数预算。比如目标是100万个点,训练全程点数会在预算附近波动,最终文件大小可控,不再需要训练后再做激进的修剪。对大规模场景(如城市级重建)来说,这一点尤其重要,因为事后修剪对千万级点云的评分计算本身就是不小的开销。
工程上还有几个细节值得注意。一是修剪和致密化不要在同一迭代步执行,先致密化再过若干步修剪,给新点一个被观测的机会,否则刚分裂出来的点会因为初始分数低被立刻误删。二是评分统计要用指数移动平均而不是单次快照,避免个别视角的偶然性影响判断。三是配合量化(把属性从float32压到fp16甚至更低)一起做,修剪减少点数量,量化减少单点体积,两者叠加才能把模型压到适合Web端加载的量级。
各方案对比与选型建议
下表汇总了几类主流方法的典型表现,数据为社区在Mip-NeRF 360等常用数据集上的大致水平,具体数值因场景而异,仅作选型参考。
| 方案 | 典型压缩率 | 画质损失 | 额外训练成本 | 适用场景 |
|---|---|---|---|---|
| 透明度阈值修剪 | 20%至40% | 几乎无 | 无 | 快速瘦身、预处理 |
| 评分函数加微调 | 50%至80% | 0.1至0.3dB | 数千次迭代 | 移动端部署 |
| 矢量量化加修剪 | 80%至95% | 0.2至0.5dB | 较低 | Web端、流式加载 |
| 自适应密度控制训练 | 50%至90% | 0.1dB左右 | 训练期开销 | 大规模场景 |
| 蒸馏到小模型 | 90%以上 | 视模型而定 | 较高 | 极端体积限制 |
选型时建议按需求分层:只是想减小文件,先用透明度阈值修剪看效果,成本最低;要部署到手机或浏览器,评分修剪加量化的组合是目前性价比最高的路线;做城市级大场景或需要频繁重训的业务,直接在训练阶段启用自适应密度控制,避免后期处理的海量点云。无论选哪条路,剪枝后一定要做一轮微调并客观对比PSNR、SSIM指标,很多看似激进的压缩在微调后画质损失远比预期小,而看似温和的策略如果跳过微调,反而可能出现可见的空洞和噪点。修剪不是单纯删点,而是重新分配渲染预算的过程,理解这一点才能真正做好3DGS模型的压缩。