3D 高斯泼溅,from first principles
给已经学过 NeRF / SDF、有基本机器学习与线性代数底子的读者,把 3D Gaussian Splatting 的每个构件——从一颗椭球,到一整套训练-渲染流水线,再到 2023 年之后对它本体做出的几次重大改写——一层一层拆开来讲清楚。
3D Gaussian Splatting(3DGS, Kerbl et al., SIGGRAPH 2023)在 NeRF 流派"以 MLP 隐式表示场景"之外,开辟了另一条几乎完全相反的路线:把场景显式地存成几百万颗带颜色和不透明度的 3D 高斯椭球,每帧把它们投影到屏幕、按深度合成。结果是:相比 NeRF,渲染快了约 100 倍,训练快了 50 倍,画质却追平甚至超过了同期最强的 Mip-NeRF 360。
这套技巧的"奇迹",并不来自任何单一的灵感闪光,而来自若干个老早就熟的零件——EWA 投影 (2001)、球谐基 (1980s)、$\alpha$ 合成、可微渲染——被以一种异常严谨的方式拼装在了一起。本文要做的,是把这些零件一颗一颗拆开摆给你看,回答两个问题:
(一)原版 3DGS 为什么能在这套约束下成立?每个看似小聪明的设计选择(协方差的 $RSS^\top R^\top$ 分解、SH 而非 MLP、瓦片光栅化器、致密化的克隆/分裂/裁剪)背后真正在解的是什么问题。(二)2023 年之后,对 3DGS 本体(不是下游应用)有哪几次真正改变范式的改写?——Mip-Splatting 抗锯齿、2D Gaussian Splatting 重写"基元的几何"、GES 把基元从高斯推广到广义指数、3DGS-MCMC 把致密化重新表述为采样、Scaffold-GS 把单层散点改成层级锚点、SuGaR 把高斯云拉回网格。每一项都重新讲一遍其要解决的问题、形式化和直觉。
读完本文,你应当能:用一段 Python 伪码完整复述 3DGS 训练循环;在论文里看到 $\Sigma' = JW\Sigma W^\top J^\top$ 不再发蒙;理解为什么 Mip-Splatting 必须同时引入一个 3D 滤波器和一个 2D 滤波器;分清 2DGS 与 3DGS 在"基元就是表面"这件事上的根本差别。
第一次接触 3DGS:从 §1 顺序读到 §8,做完 Demo 3.1、4.1、6.1。读完你就有了原版 3DGS 的完整骨架。 已经熟悉原版:直接从 §9 Mip-Splatting 开始,Part Ⅱ 每章都从"原版在哪儿崩"讲起。 只想知道发生了什么:跳到 §16 末尾的"骨架映射表",那张表把 Part Ⅱ 每篇改写定位回了 §8 训练循环的具体某一行。
记号约定 · Notation
- $\mu \in \mathbb{R}^3$
- 一颗 3D 高斯的中心位置(世界坐标)
- $\Sigma \in \mathbb{R}^{3\times 3}$
- 3D 协方差矩阵,对称半正定
- $q \in \mathbb{H}$
- 单位四元数;决定旋转矩阵 $R = R(q)$
- $s \in \mathbb{R}^3$
- 沿三主轴的标度;存的是 $\log s$,确保正
- $\alpha \in (0,1)$
- 不透明度;存的是 $\operatorname{logit} \alpha$
- $\mathbf{d} \in S^2$
- 观察方向(单位向量)
- $c(\mathbf{d}) \in \mathbb{R}^3$
- 视角相关 RGB;用球谐基 $\{Y_\ell^m\}$ 展开
- $J = \partial \varphi / \partial x$
- 相机投影 $\varphi : \mathbb{R}^3 \to \mathbb{R}^2$ 在 $\mu$ 处的 $2\times 3$ 雅可比
- $W \in \mathbb{R}^{3\times 3}$
- 世界到相机的旋转部分(去掉透视除法的线性那截)
- $\Sigma' = JW\Sigma W^\top J^\top$
- 屏幕空间 $2\times 2$ 协方差(EWA Splatting)
- $T_i$
- 透射率:累乘到第 $i$ 颗之前的 $\prod(1-\alpha_j)$
Part Ⅰ
一颗高斯,一帧画面,一次梯度
八节连贯地把原版 3DGS 拆开:从两种渲染范式背后共用的同一条物理方程,走到那段你能在 30 行 Python 里写出来的训练循环。
§1体渲染方程:NeRF 与 3DGS 共享的同一条物理
要理解 3DGS 在做什么,必须先承认一件事:它和 NeRF 在形式上是完全相反的两种东西——一个是隐式函数,一个是显式点云——但它们在底层物理上是同一条方程的不同离散化。所以本文的第一节从这条方程讲起。
一束光从相机出发,沿着射线 $\mathbf{r}(t) = \mathbf{o} + t\,\mathbf{d}$ 穿过空气、烟雾、半透明物体,最终在某处被完全吸收或散射回来。中间任何一点 $t$,介质都同时在发光(emission,记作 $c(t)$)和吸光(absorption,密度 $\sigma(t)$)。把这两者沿射线对全程积分,就是辐射传输方程 (radiative transfer) 在"无散射"近似下的体渲染积分:
$T(t)$ 是透射率 (transmittance):从相机走到 $t$ 这一路上"有多少光还没被吸收"。它必然是从 1 单调衰减到 0 的一个量。把发射 $c(t)$ 乘以"它能不能照得回来" $T(t)\cdot\sigma(t)$,再积分,就是这条射线最终送回相机的颜色。
这不是渲染学的人工选择,是真物理。任何会发光又会吸光的连续介质都遵守它。[1]
离散化:把积分换成可计算的求和
计算机不会做积分,只会做加法。把射线沿深度切成 $N$ 段,每段长度 $\Delta_i$,区间内的 $\sigma$、$c$ 视为常数 $\sigma_i$、$c_i$,则段内透射率衰减为
这里 $\alpha_i$ 就是这一段贡献的"不透明度"——你看见的 $\alpha$ 实际是 $\sigma\cdot\Delta$ 的指数变换。把式 (1.1) 离散化、再迭代,得到的就是图形学家家喻户晓的前向 $\alpha$ 合成公式:
式 (1.3) 是同一条体渲染方程的离散形式。NeRF 把它实现为"在射线上挑 $N=128$ 或 $N=256$ 个点,每个点查询 MLP 得到 $(\sigma, c)$,再求和";而 3DGS 把它实现为"在射线上挑出那些被 $N$ 颗高斯椭球覆盖的位置(也就是这些高斯各自的中心附近),每颗给出一对 $(\alpha, c)$,再求和"。两边的求和公式逐字相同。区别只在于:被求和的离散点从哪里来。
因为它告诉你:NeRF 和 3DGS 的画质上限其实是同一条物理决定的;它们没有"哪个更对"的问题。区别只在于离散化的方式给我们留了多少计算预算。NeRF 是均匀-沿-射线采样的,3DGS 是"哪儿有物质就在哪儿放一个样本"的——后者直接跳过了大片空气。这就是 $100\times$ 速度差的源头。
两种采样策略,一图概览
NeRF · 沿射线密集采样
- 射线方向已知,沿
t取 ~128 个等距/分层样本。 - 每个样本送进 $\text{MLP}(\mathbf{x}, \mathbf{d}) \to (\sigma, c)$,~$256\times 8$ FLOPs。
- 大部分样本落在空气里——99% 的算力在算 $\sigma \approx 0$ 的点。
- 显式存储:MLP 权重 ~5 MB;但"场景"在权重里,不可被外部工具直接读。
3DGS · 离散基元覆盖
- 场景由 1–6 M 颗高斯椭球离散表示,每颗带 $(\mu, \Sigma, \alpha, \text{SH})$。
- 渲染:把每颗投到屏幕,按瓦片排好序,逐瓦片合成。
- 对一个像素,平均只触碰 ~30–150 颗高斯。空气里没基元。
- 显式存储:~1 GB 的 PLY/SOG 文件,可在 MeshLab 里直接打开看每一颗的位置。
这一节我们只需要记住两件事:(1)渲染公式两边相同;(2)3DGS 的整套机制本质上是在尽量少的几何位置上"放一份样本",并且把它做成可微的、可被 SGD 调教的基元。后面所有内容——为什么是高斯、$\Sigma$ 怎么参数化、为什么要排序合成——都在回答"如何把第二件事做好"。
§2原子:把场景拆到一颗高斯
3DGS 场景的最小单位是一颗 3D 高斯。先把它的形状、参数、为什么"偏偏挑高斯"想清楚。
它的密度函数就是把一维正态推广到三维:
两个参数定形状和位置:中心 $\mu \in \mathbb{R}^3$ 决定它在哪;$3\times 3$ 协方差 $\Sigma$ 决定它怎么拉、怎么转、有多各向异性。再加两件"衣服":不透明度 $\alpha \in (0,1)$ 与一个视角相关的 RGB 颜色 $c(\mathbf{d})$。后者我们留到 §5。
注意 (2.1) 是一个没有归一化常数的高斯——3DGS 不要求它的全空间积分等于 1,因为它只是个"基元形状",不是概率密度。它在中心 $\mu$ 处取值 1,越远越衰减。
为什么挑高斯
所有可能的"模糊基元"里——梯形、box、椭球指数、Wendland 函数、超椭圆——为什么这套系统挑了高斯?三条原因,依重要性递增:
- 投影闭式可解。一颗 3D 高斯被任意线性变换映射后,仍然是高斯。哪怕相机投影是非线性的(透视除法是比值),我们也可以在 $\mu$ 处线性化,让"3D 高斯 → 屏幕上的 2D 高斯"成为每帧只需算一次 $2\times 2$ 矩阵的事。这就是 EWA Splatting[2],§4 会推。
- 处处光滑。对 $\mu$ 处处可微,对 $\Sigma$ 也是(只要 $\Sigma$ 是 PD 的)。梯度干干净净落下来,没有折点、没有 NaN。这一点对端到端可微渲染是命脉。
- 实际上是"紧支撑"的。理论上高斯永远不到 0,但 $3\sigma$ 之外贡献已经在 0.01 以下。3DGS 在实现上把每颗高斯的有效覆盖区裁到 $3\sigma$ 椭圆——于是渲染一颗高斯只需要触碰一小片像素,把"全场景渲染"变成"每颗高斯写一小块"。这件事是 splatting 之所以快的关键。
如果你来自 SDF 流派,可以这样理解:SDF 把世界存为"到表面的距离",一个标量场;3DGS 把世界存为很多小局部体密度,每一颗占的体积有限,叠加起来覆盖整个场景。SDF 是"一个全局函数",3DGS 是"许多局部基函数的线性组合"——这种"基函数 + 局部支撑"的思想在数值分析里非常古老(径向基函数、有限元、Splatting),3DGS 不过把它推到了亿级规模。
参数表
原版 3DGS 一颗高斯在内存里的样子(注意这些都是每颗独立的张量;整个场景在 PyTorch 里就是几个大张量并排放):
| 字段 | 形状 | 含义 | 实际存的是 |
|---|---|---|---|
mu | 3 | 中心位置 $\mu$ | 直接存浮点 |
q | 4 | 旋转(四元数) | 每次用之前 $q \gets q/\lVert q\rVert$ |
s_log | 3 | 三主轴标度 | 实际尺度 $s = \exp(s_{\text{log}})$ |
a_logit | 1 | 不透明度 | $\alpha = \operatorname{sigmoid}(a_{\text{logit}})$ |
sh | 48 | 球谐颜色系数 | 每通道 16 个 ($L=3$) |
合计每颗 59 个浮点。一个 ~2 M 高斯的场景就是 ~470 MB 的参数。
下一节就来回答:为什么 $\Sigma$ 的 9 个数被拆成了 $q$(4 个)+ $s$(3 个),而不是直接存 6 个独立数?
§3$\Sigma$ 永远不直接优化:$RSS^\top R^\top$ 重参数化的招式与哲学
3DGS 论文里一个极小却极关键的设计选择:永远不把 $\Sigma$ 的 6 个独立数当参数。这一节讲为什么。
$\Sigma$ 是 $3\times 3$ 对称矩阵,独立元素 6 个。表面上看,把这 6 个数往参数向量里一塞,剩下交给 SGD 就好了。千万别这么干。协方差矩阵必须是对称半正定 (SPD)的;梯度下降不知道这件事,只要其中一个特征值往负数滑一小步,你的"椭球"立刻变成虚数,渲染管线整片崩。
3DGS 绕开它的办法,是把 $\Sigma$ 存成两组无约束的参数:旋转和标度。
两个事实让这套写法成立:
(a) 任何 SPD $\Sigma$ 都能写成 (3.1) 形式。这是矩阵的特征分解/极分解:$\Sigma$ 必有 $\Sigma = R\,\Lambda\,R^\top$ 的写法,其中 $R$ 是正交矩阵,$\Lambda$ 是非负对角阵(特征值即各主轴方差)。令 $S = \sqrt{\Lambda}$ 即得 (3.1)。所以这套参数化没有损失任何表达力。
(b) 任何 $(q, s)$ 都给出一个合法的 $\Sigma$。哪怕 $q$ 不是单位四元数(先归一化再用)、$s$ 为零或为负(在用 (3.1) 前先 $s \gets \lvert s \rvert$ 或更常见的 $s \gets \exp(s_{\text{log}})$),结果总是 SPD 的。这把约束"卸"到了参数空间外,让 SGD 在一个完全无约束的空间里跑。
同款思路在别处
"换一组坐标,让约束自动满足"是优化领域的反复使用的招式:
- VAE 的 $\sigma$ 重参数化:不存 $\sigma$ 而存 $\log \sigma$,再 $\sigma = \exp(\log \sigma)$。永远不会采到负方差。
- 四元数代替欧拉角:避开万向锁,且每个 $q \in \mathbb{R}^4$ 归一化后都是合法的 $SO(3)$ 元素。
- SDF 的"任何标量场都是合法 SDF 候选":事后用 Eikonal 损失把它压回到距离场。
- NeRF 的 $\sigma$ 用 ReLU/softplus:保证密度非负。
3DGS 把这套传统武器用在了"椭球的形状"这个新场景上。技术上无新意,用得对就值钱。
顺带的两个小招
同样的"重参数化以满足约束"也用在了 $\alpha$ 和 $s$ 上:
def get_alpha(a_logit):
return torch.sigmoid(a_logit) # (0, 1)
def get_scale(s_log):
return torch.exp(s_log) # > 0
def get_sigma(q, s_log):
R = quaternion_to_R(q / q.norm()) # SO(3)
S = torch.diag(get_scale(s_log))
return R @ S @ S.T @ R.T # SPD by construction
所以训练时优化器看到的是 (mu, q, s_log, a_logit, sh) 这一坨任意浮点;它愿意把 a_logit 推到 $-5$ 就推到 $-5$(对应 $\alpha \approx 0.0067$),愿意把 s_log[0] 推到 1.6 就推到 1.6(对应 5 倍主轴)。约束永远成立,SGD 永远高兴。
如果你把 $q$ 直接当成参数让它自己漂,几千步之后它的模会逐渐偏离 1,对应的 $R(q)$ 会变成"旋转 + 缩放"的混合,跟 $S$ 的标度功能撞车。结果就是 $\Sigma$ 的特征值乱掉,渲染出现亮点跳变。所以工程上必须在每次用 $q$ 之前归一化:q = q / q.norm()。这一句的位置错了,是新手最常踩的坑之一。
§4EWA 投影:一颗 3D 高斯如何变成 2D 椭圆
这一节是整篇文章里数学最稠密的一节,但其实推导只用到链式法则。耐心读一遍,剩下所有 3DGS 论文你看到 $\Sigma' = JW\Sigma W^\top J^\top$ 都不会发蒙。
要把一颗 3D 高斯渲到画面,第一件事是问:它在屏幕上长什么样?中心 $\mu$ 容易——把它通过相机变换 + 透视除法投到屏幕坐标即可。麻烦的是形状——3D 的椭球到 2D 的什么?
第一步:相机的两段
典型的针孔相机由两段组成:
(i) 世界 → 相机坐标系:一个刚体变换,由 $3\times 3$ 旋转 $W$ 与平移 $\mathbf{t}$ 给出
这是线性的——高斯经过线性变换后仍是高斯,且协方差从 $\Sigma$ 变到 $W \Sigma W^\top$。漂亮,干净,没有近似。
(ii) 相机坐标 → 屏幕像素:透视除法
这一段不是线性的——分母里有 $z$。在世界坐标下"一颗椭球"经过它不会变成另一颗椭球,理论上它会变成一个稍微扭曲的形状(一颗"椭球的透视投影"在严格意义上是某种四次曲面的二次截面,并非椭圆)。
第二步:在 $\mu$ 处线性化(这就是 EWA 的灵魂)
但是!如果一颗高斯本身很小(在视场里覆盖几度以内),我们就可以在它的中心 $\mu$ 那一点把 $\pi$ 用它的一阶 Taylor 展开替代:
这里 $J$ 是 $\pi$ 在 $\mu_{\text{cam}}$ 处的 $2\times 3$ 雅可比。算一下 (4.2) 对 $(x, y, z)$ 求偏导:
这是一个常数矩阵(在 $\mu$ 处算定后就不变)。线性近似下,高斯依然是高斯。它的屏幕协方差就是协方差按线性映射的标准变换:
这就是 EWA Splatting 公式(Zwicker, Pfister, van Baar, Gross 2001)。它给了我们一颗 3D 高斯落到屏幕上之后的"足印"——一颗 2D 高斯,由 $2\times 2$ 矩阵 $\Sigma'$ 描述。
第三步:屏幕上的 2D 高斯长什么样
有了 $\Sigma'$,对屏幕上任意像素 $\mathbf{p}$,这颗高斯在它身上的贡献是
其中 $\mathbf{p}_\mu$ 是 $\mu$ 投影后的屏幕坐标。整个内层循环——"这颗高斯在这个像素上贡献多少"——就是算一次 (4.6)。$\Sigma'^{-1}$ 是 $2\times 2$ 的,求逆是闭式两行代码:
def project_one_gaussian(mu, sigma, W, t, J_fn):
# 1) world -> camera
mu_cam = W @ mu + t
sigma_cam = W @ sigma @ W.T
# 2) Jacobian of perspective at mu_cam
J = J_fn(mu_cam) # 2x3, eq (4.4)
# 3) screen-space covariance
sigma_2d = J @ sigma_cam @ J.T # 2x2
# 4) screen-space mean (the actual perspective divide)
p_mu = perspective_divide(mu_cam) # 2
return p_mu, sigma_2d
def eval_2d_gaussian(p, p_mu, sigma_2d):
d = p - p_mu
inv = inv2x2(sigma_2d) # 2x2 closed form
return torch.exp(-0.5 * d @ inv @ d)
这 10 行——稍微展开一点、加上一个低通正则项(§9 会讲)、再批量化——就是 CUDA 内核里最热的一段。
这个近似到底有多准
"线性化"听起来很可疑——是不是只在屏幕中央准,靠近边缘就崩?实际答案是:
- 误差大小 $\propto$ (高斯的角张角)$^2$。
- 实际场景里单颗高斯在屏幕上覆盖 1–30 像素,对应角张角 $\lt 1^{\circ}$。
- 这种尺寸下线性化的几何误差是亚像素级的——屏幕分辨不出来。
所以在工业实践里,EWA 的近似不是"可以接受的近似",它是渲染结果。会出问题的不是它的几何误差,是它的采样问题——这正是 §9 Mip-Splatting 要解决的故事。
EWA = Elliptical Weighted Average,原本是 Greene 与 Heckbert 在 1986 年提出的纹理映射各向异性滤波算法[3]。Zwicker 等 2001 年把它推广到体绘制场景下用作 splatting 的核——所以你今天在 3DGS 论文里看到的 "EWA splatting",是这条二十多年血统的最新一笔。这件事告诉我们:3DGS 的"新",是组合的新;它的零件都很老。
§5球谐颜色:用 48 个浮点把视角依赖塞进去
一颗光面苹果从正前方看起来红、从侧面看起来略带高光白——这种"颜色随视角变"的现象是照片级真实感的关键。NeRF 用 MLP 把它学下来;3DGS 用不起 MLP,于是采用了一组紧凑得不能再紧凑的基底:球谐函数。
球谐是什么
球谐函数 $\{Y_\ell^m\}$ 是定义在单位球面 $S^2$ 上的一组完备正交基。它和傅里叶基对一维周期函数所做的事完全一致,只不过基的"定义域"是球面。任何(足够光滑的)球面函数 $f : S^2 \to \mathbb{R}$ 都可以唯一地展开为
级数前 $(L+1)^2$ 项叫"$L$ 阶截断"。$L=0$ 只有一个常数(直流分量,球面所有方向上取同样的值);$L=1$ 加 3 个,相当于"沿 $x/y/z$ 方向的线性变化";$L=2$ 加 5 个,开始能模出钝的反光瓣;$L=3$ 再加 7 个,能模出还算清晰的高光。
3DGS 默认用 $L = 3$,每通道 16 个系数,3 个通道 48 个浮点——这就是每颗高斯的 sh 张量的大小。
为什么 SH 比 MLP 便宜得多
关键点:式 (5.1) 是 $k_{\ell,m}$ 的线性函数。换言之,给定 $\mathbf{d}$,颜色是 48 个学得来的系数与 48 个可以预先算好的基底值之间的内积。一次 dot product 解决战斗——没有矩阵乘,没有非线性,没有分支。
对 SH 系数的梯度也极其简洁:
SGD 想动哪个系数,"该往哪边动"在前向那次基底求值时就已经全算好了。这是真正意义上的"零额外反向开销"。
SH 不能做什么
SH 是低通工具。$L = 3$ 一共 16 个基底,频谱上能表达的最细方向变化大约对应 $90^{\circ}$ 量级——这意味着:
- 漫反射、轻微光泽:OK。
- 钝的金属高光:勉强够。
- 真正锐利、微面元主导的镜面高光:糊。
- 反射相机本身/天空盒中清晰可见的内容:完全做不到。
这是 3DGS 出来后一年内一串后续工作的开端:GaussianShader 把每颗高斯接一个 Disney BRDF,Relightable 3DGS 引入显式的法向和环境光,Anisotropic-SH 把球谐换成各向异性的方向基。这些都属于"应用层"的扩展,不属于本文 Part Ⅱ 要讲的"本体改进",故只在 §16 提一笔。
§6$\alpha$ 合成与提早终止:内层循环为什么便宜
现在我们手上每颗高斯都有了 $(\mathbf{p}_\mu, \Sigma', \alpha, c(\mathbf{d}))$。剩下的事是:一个像素上躺着好几十颗高斯,要怎么把它们的贡献合到一起?答案:体渲染方程式 (1.3)。但实现时有个关键加速。
对每个像素,我们要做的是:
- 找出所有覆盖到这个像素的高斯(落在它 $3\sigma$ 椭圆内的)。
- 按深度从前到后排序。
- 沿这个有序列表跑式 (1.3)。
第 3 步对像素 $\mathbf{p}$ 展开来写就是:
def shade_one_pixel(p, sorted_gaussians_at_p):
"""sorted_gaussians_at_p: 按 z_cam 升序排好的高斯子集。"""
C = torch.zeros(3)
T = 1.0
for g in sorted_gaussians_at_p:
# 这颗高斯对像素 p 的有效 α
g2d = eval_2d_gaussian(p, g.p_mu, g.sigma_2d) # 标量 ∈ (0, 1]
alpha = g.alpha * g2d
C += T * alpha * g.color_at(view_dir)
T *= (1.0 - alpha)
if T < 1e-4:
break # ← early termination
return C
提早终止:3DGS 之所以快的真原因
那个 if T < 1e-4: break 看起来不起眼,但它带来了量级上的加速。直觉是这样的:
- 每碰到一颗实心高斯($\alpha \approx 0.9$),$T$ 被乘以 0.1。
- 三颗这种的之后,$T \approx 10^{-3} \Rightarrow$ 比阈值还小一个量级。
- 剩下所有的高斯——哪怕这条射线上还排着几百颗——再也不会贡献肉眼可见的颜色。
这意味着一个像素的颜色平均只取决于它前方的几颗高斯。即使整个场景里有 6 M 颗高斯,"对这个像素有视觉意义"的也就那么几颗。于是渲染每一帧的总成本是
而不是 $\mathcal{O}(\text{像素数} \times \text{总高斯数})$。后者是 NeRF 那种"沿射线密集采样"的开销,前者只取决于场景的表面拓扑。
反向传播也享受这个加速
前向能"提早跳出",反向也能:把同一段 sorted list 从后往前遍历,把链式法则项一颗颗收回来;如果某颗的 $T\cdot(\alpha$ 在该像素的贡献$) \lt 10^{-4}$,它的梯度量级早已远小于浮点误差,根本不必算。CUDA 实现里这一段也加了同样的早跳出——这是为什么 3DGS 反向比同等复杂度的 NeRF 反向仍然更快的原因。
§7瓦片光栅化器:把"几百万颗高斯"变成可调度的 GPU 工作
上一节的 shade_one_pixel 写得很清楚,但你如果直接拿它去 GPU 上每像素跑一次,肯定卡。问题不在 $\alpha$ 合成本身,在"怎么找出这个像素的 sorted_gaussians_at_p"——朴素做法是 6 M 颗都得检查一遍,那是灾难。3DGS 的工程突破,是它的瓦片光栅化器。
四步流水线
原版 CUDA 实现拆成四个 kernel,依序在 GPU 上跑:
- 逐高斯:投影 + 计算瓦片覆盖。对每颗高斯,并行地:(i)算 $\Sigma'$ 与 $\mathbf{p}_\mu$;(ii)由 $3\sigma$ 椭圆得到一个屏幕 AABB;(iii)算这个 AABB 覆盖到哪些 $16\times 16$ 像素的瓦片 (tile)。一颗高斯通常落在 1–9 个瓦片上。
- "复制 + 排序"展开。每个 (高斯, 它覆盖的某个瓦片) 配对生成一条 实例 (instance);每条实例携带 (tile_id, depth)。然后全局按 (tile_id, depth) 排序。
- 构建每瓦片的命中区间。排序后实例数组里同一 tile_id 的项是连续的;扫一遍就得到每瓦片的"起止下标"。
- 逐瓦片:着色。对每个 $16\times 16$ 瓦片,启动 256 个线程(每线程负责 1 像素),共同从 shared memory 读出该瓦片的高斯列表,按序合成。
这套流水线的几个关键观察:
- 第 2 步的全局排序用基数排序 (radix sort),在现代 GPU 上对 ~10–30 M 个 64-bit key 是 1–3 ms 的工作。
- 第 4 步对一个 tile 内的所有像素来说,要读的高斯列表是共享的——一次把它搬到 shared memory,256 个像素共享,访存带宽省一大截。
- "按 tile_id 排序"还顺带让 GPU warp 内的线程访问邻近内存,cache locality 极好。
为什么不直接对每像素做 BVH 查询
NeRF 这种"沿射线找邻居"的工作通常会借助八叉树/BVH。3DGS 没用它们,原因极简单:排序就是足够好的空间数据结构。一旦你把"高斯—瓦片"对按 (tile, depth) 全部排好序,每个像素的查询本来就是 O(1) 的——它对应到 tile 的那一段区间。BVH 在这里反而是 overhead。
这是一个非常典型的 GPU 算法设计取舍:用排序换数据结构。在 CPU 上你会觉得"排序很贵",但 GPU 上一次大规模并行排序是它最擅长的事之一。
Forward / Backward 用同一份 sort
反向传播需要一个像素对每颗参与的高斯的梯度。如果再排一次序就是浪费——所以 3DGS 把排序好的 (tile, depth, gid) 大数组缓存住,反向时直接按相反方向遍历同一段,结合上一节"从后向前合成 + 早跳出"的写法把梯度一颗颗收回去。
瓦片光栅化器的伪代码
def rasterize(gaussians, camera, H, W, TILE=16):
# 1. project each Gaussian + compute its tile coverage
proj = project_all(gaussians, camera) # eq (4.5)
inst = [] # (tile_id, depth, gid)
for gid, g in enumerate(proj):
if g.behind_camera or g.too_small: continue
for tile in tiles_covered_by(g.aabb_3sigma, TILE):
inst.append((tile, g.depth, gid))
# 2. global sort by (tile_id, depth)
inst.sort(key=lambda x: (x[0], x[1]))
# 3. range[tile] = (begin, end) into inst
range = build_tile_ranges(inst, H, W, TILE)
# 4. one kernel block per tile, one thread per pixel
img = torch.zeros(H, W, 3)
for tile_id in range:
begin, end = range[tile_id]
ginds_for_tile = [inst[k][2] for k in range(begin, end)]
for pix in pixels_in_tile(tile_id):
img[pix] = shade_one_pixel(pix, [proj[g] for g in ginds_for_tile])
return img
真版本当然是 CUDA 而不是 Python,且第 4 步是 $16\times 16 = 256$ 个线程并发;但骨架就这点东西。
它解释了一件事:3DGS 之所以能落地到实时 (1080p, 100+ FPS),不只是因为高斯/SH 这些数学选择漂亮,更因为这套工程实现把它们对 GPU 体系架构做了精心适配。你换掉任何一颗高斯的参数化都不会让它变慢;但你换掉这个瓦片排序流水线,速度会立刻塌一两个量级。
§8训练:梯度怎么回流、致密化为何要这么写
渲染管线讲完了,剩下问题是怎么训。3DGS 的训练逻辑短得吓人,全部能在 30 行 Python 里讲完;但其中"致密化"那几行是整套系统能不能从 SfM 点云爬到照片级真实感的关键。
损失:L1 + D-SSIM
给定一张训练图 I*(真值)和当前渲染 I:
L1 项强制单像素颜色对齐;D-SSIM 项($\text{D-SSIM} = 1 - \text{SSIM}$[4])强制邻域结构对齐。D-SSIM 项是抗"模糊解"的关键——只用 L1 的话,SGD 会愿意把所有 Gaussians 摊得很大、用平均色去拟合,得到一片糊。结构相似度对邻域协方差敏感,逼着 Gaussians 把高频细节维持住。
梯度怎么回
整个渲染器从头到尾可微:
$\text{img} \to \alpha \text{ 合成} \to$ 每颗高斯 $(\mathbf{p}_\mu, \Sigma', \alpha\cdot G_{2D}, c(\mathbf{d})) \to \Sigma' = JW\Sigma W^\top J^\top \to (q, s_{\text{log}}) \to$ 真参数
PyTorch autograd 配合一份手写 CUDA 反向就能把梯度一路推回到每颗高斯的 $(\mu, q, s_{\text{log}}, a_{\text{logit}}, \text{sh})$。"手写反向"是因为 PyTorch 的 autograd 不会展开"瓦片排序 + 早跳出"那种自定义 kernel——所以 graphdeco-inria/gaussian-splatting 仓库里那个 diff_gaussian_rasterization 是大几千行手敲 CUDA。但它的语义就是把式 (1.3)、(4.5)、(5.1) 链起来求导,没有任何超纲。
致密化:克隆 / 分裂 / 裁剪 / 重置
初始化用 COLMAP 的 SfM 点云:$\sim 10^5$ 个稀疏点,每颗位置取 SfM 点,标度取"到最近邻距离",$\alpha$ 取 0.1。让 SGD 跑 30,000 步。很快两个症状会出现:
欠重建 · under-reconstruction
- 某区域该有细节但根本没几颗高斯。
- 附近的那少数几颗高斯位置梯度 $\lvert \nabla \mu \rvert$ 很大——它们想跑去补,但跑了就毁了别的视角。
- 解:在这颗高斯原地复制一份,让两份各自往不同方向漂。
过重建 · over-reconstruction
- 某颗高斯一个人覆盖了太大一片,把高频细节全模糊掉。
- 位置梯度同样大——但因为它的标度也大,本质上是"用一颗想表达多颗的内容"。
- 解:把它分裂成两颗较小的子高斯,从原椭球内部采样两个新位置作为子中心。
具体规则(来自原论文 Sec. 5):
- 每 100 步检查一次。判据:高斯位置梯度的累积模(averaged over recent steps)超过阈值 $\tau$。
- 同时检查"它有多大":标度的几何平均 $\lt$ 阈值 $\to$ clone;$\gt$ 阈值 $\to$ split(同时把标度乘 $1/1.6$ 缩小,避免重叠)。
- 不透明度 $\alpha \lt 0.005$ 的高斯 $\to$ prune(删掉)。
- 每 3000 步:把全场所有高斯的 $\alpha$整体重置到 0.01。强迫 SGD 重新"证明"每一颗的存在意义。
最后那条opacity reset 是个很狠的招:它定期把所有高斯都先标记为"几乎透明",再让训练自己挑出"必要"的那些重新调上去,从而清掉一波长期低贡献的死点。如果不做,场景里会逐步积累一堆"看似有贡献其实是噪声的"小高斯,最终把 PSNR 拖下去。
为什么"位置梯度大"是"该加密"的信号?因为 SGD 想让这颗高斯往某个方向跑,但跑过去就会毁掉它在别的训练视角的贡献。换句话说,这颗高斯被多个互相冲突的目标拉扯——这正是"一颗高斯不够,需要两颗"的几何信号。这一观察是原论文的关键贡献之一,也是后续 §12 MCMC 改写要重新表述的目标。
30 行 Python 讲完整套训练
for step in range(30_000):
cam, gt = sample_train_view()
# ---- forward (custom CUDA inside) ----
img = rasterize(gaussians, cam) # eq (1.3) implementations
loss = (1-0.2)*(img - gt).abs().mean() \
+ 0.2 *(1 - ssim(img, gt))
# ---- backward ----
loss.backward()
optimizer.step()
optimizer.zero_grad()
# ---- adaptive density control ----
if 500 < step < 15_000 and step % 100 == 0:
grad_pos = gaussians.mu.grad_accum.norm(dim=-1) # accumulated |∇μ|
big_grad = grad_pos > τ_grad
small_g = gaussians.scale.mean(-1) < τ_small
clone_mask = big_grad & small_g # under-reconstruction
split_mask = big_grad & ~small_g # over-reconstruction
prune_mask = gaussians.alpha < 0.005
gaussians.clone(clone_mask)
gaussians.split(split_mask, n_children=2)
gaussians.prune(prune_mask)
if step % 3_000 == 0:
gaussians.a_logit.data.fill_(inv_sigmoid(0.01)) # opacity reset
就这些。整个 3DGS 训练管线在语义层面就是这段。剩下的 graphdeco-inria 仓库里几千行 CUDA 是把上面每一句话编译成对 GPU 友好的形式。
到这里 Part Ⅰ 结束。如果你能用自己的话把上面这 30 行复述一遍——包括为什么是 $RSS^\top R^\top$、为什么 D-SSIM 项不能省、为什么要每 3000 步重置 $\alpha$——那你已经掌握了原版 3DGS 的全部"骨架"。Part Ⅱ 要做的,是把这副骨架上的某几根骨头换掉,看会发生什么。
Part Ⅱ
之后对 3DGS 本体的改写
原版 3DGS 是漂亮、能用、还有不少缺陷的"第一版"。这部分讲 2024-2025 年里若干次真正改写它本体的工作——锯齿、表面不准、致密化启发式、基元形状、层级结构、网格化、压缩——每个故事都从"原版在哪儿崩"讲起。
§9抗锯齿:Mip-Splatting 的两道滤波器
用原版 3DGS 训出来的场景,在训练相机的视角下看美得不行;但只要把相机拉远一点、或者放大一点,画面会突然开始抖、闪、出现锯齿状的鳞片。这一节解释这是为什么,以及 Mip-Splatting[5] 怎么用两道滤波器修。
原版在哪儿崩
问题源于一个采样定理上的不一致:
- 训练时所有视角"分辨率大体一致",每像素覆盖的世界尺度 $\approx s_{\text{train}}$。
- SGD 于是把那些"能跑得过的"高斯标度推到 $\lt s_{\text{train}}$——亚像素大小的高斯反而拟合得最好,因为它们能精确对位某条边缘。
- 但在测试时如果观察距离比训练时远 2 倍,每像素覆盖的世界尺度变成 $2\cdot s_{\text{train}}$。那些亚像素小高斯突然落到了远小于像素的尺度上——它们和像素栅格之间是欠采样的,于是出现 moiré 状闪烁。
- 反过来,如果测试比训练近,原本"刚好填满"的高斯变得太大,相邻高斯重叠加重,色块模糊。
本质:原版 3DGS 没有把"基元的频谱"和"像素的采样率"挂钩。
滤波器 #1:3D smoothing filter
第一道修补,加在世界空间。Mip-Splatting 的关键观察是:
一颗高斯的"有效尺寸"应当不小于它在任何训练视角中对应的"一个像素的世界尺寸"。
形式化:对每颗高斯 $i$,记它在训练视角集 $\{V\}$ 中投影上去时最大的"像素—世界比例"为 $\hat{s}_i$。Mip-Splatting 把这颗高斯的协方差替换为
几何上:在每颗高斯上"卷上"一个最小尺度为 $\hat{s}_i$ 的各向同性高斯——这是一个低通滤波器,截掉了基元上高于训练采样率的频率。一旦做了这一步,不可能再有亚像素高斯出现,因为它们的最低频率已经被锁住了。
这条修改虽小但很微妙:
- 不是"把 $\sigma$ 强制夹一个下限",那种粗暴做法会破坏可微性。它是加一个常数协方差,所有梯度都还能正常回。
- 每颗高斯的 $\hat{s}_i$ 是基于训练视角算出来的,不是每帧重算——所以是个"训练时固定,推理时直接用"的预计算量。
滤波器 #2:2D Mip filter
光有第一道还不够。测试时哪怕一颗高斯本身不再是亚像素的,它落到屏幕上的足印也仍然可能是亚像素的——比如它的法线和视线非常贴近的时候,3D 椭球被压扁成几乎和屏幕共线的薄片,屏幕协方差 $\Sigma'$ 的一根主轴接近 0。这种"屏幕上的细长亚像素 footprint"还是会引锯齿。
第二道修在屏幕空间。把渲染时用的 2D 高斯
替换为它和一个屏幕单像素 box 滤波器卷积之后的形式。具体可以闭式近似为
其中 $\mathbf{d} = \mathbf{p} - \mathbf{p}_\mu$。前面那个比例因子是为了在能量上保持体积守恒——一颗高斯不应该因为被低通滤波而总贡献变小。这一步本质上等价于"对每颗高斯额外做一次像素级的 anti-aliasing 滤波"。
合起来的效果
Mip-Splatting 的两道滤波器加上之后,几乎是"白给"的效果:训练略慢一点(每颗高斯多一个预计算的 $\hat{s}_i$),渲染开销零增加,但在任何测试距离下都消除了锯齿/闪烁。这就是为什么它在出现不到半年内成了几乎所有 3DGS 后续工作的默认基线之一——它是真正意义上的"对本体有改进"的工作。
名字 "Mip" 来自图形学经典的 mipmapping——给纹理预生成多层不同分辨率版本,按观察距离自动选层。Mip-Splatting 在哲学上完全一致:它给"高斯基元"也加了一种"频谱下界"机制,让任何观察尺度下基元的有效频率都不超过采样率。这是把 30 年前纹理领域的智慧搬到 3DGS 上。
§102D Gaussian Splatting:把基元从体压回面
一颗 3D 高斯是一个体,不是一个面。这件事对"出图好不好看"影响不大,但对"我能不能从这堆高斯里抽出一张可用的网格"影响巨大。2DGS[6] 直接把基元换成 2D 的——这个一字之差,引出了整个几何重建路径。
3D 高斯的几何缺陷
在 §1 我们就指出:3DGS 是体渲染,不假设场景由表面构成。一面墙在 3DGS 里通常被一片"薄薄的、贴在墙上"的椭球阵列拟合出来;但这些椭球并不知道自己应该是平的——SGD 只关心"渲出来对不对",不关心"形状对不对"。结果:
- 从训练相机看,墙完美。
- 但你如果想算"墙在哪儿"——比如做 SLAM、做物理碰撞、做编辑——你得到的是一堆方向随机的椭球,深度噪声很大,法向几乎不可信。
- 用任何标准网格提取(Marching Cubes、Poisson)都会得到充满凸起和孔洞的破网格。
2DGS 的设计
2DGS 的解决思路是:把基元从 3D 椭球换成嵌在 3D 空间里的 2D 椭圆盘。一颗 2D Gaussian 由:
- 中心 $\mu \in \mathbb{R}^3$;
- 两个正交的切向量 $(\mathbf{t}_u, \mathbf{t}_v) \in \mathbb{R}^3$,长度即为该方向上的标度 $s_u, s_v$;
- 法向 $\mathbf{n} = \mathbf{t}_u \times \mathbf{t}_v / \lVert \cdot \rVert$;
- $\alpha$, SH 颜色。
盘上的高斯密度(在盘的局部坐标 $(u, v)$ 下)就是普通的 2D 高斯:
(标度已经 baked 进了 $\mathbf{t}_u, \mathbf{t}_v$,所以 (10.1) 是各向同性的形式。)
射线-盘相交:精确而不近似
这是 2DGS 在渲染上比 3DGS 更"好"的关键。3DGS 必须在 $\mu$ 处做 EWA 线性化,得到一个近似的 2D 椭圆。2DGS 的盘是 3D 中精确的几何对象,对每条射线 $\mathbf{r}(t)$ 与盘的相交可以闭式求解:把射线代入"盘所在的平面方程",得到 $t^*$,再把交点投回 $(u, v)$,代进 (10.1)。
整套渲染管线变成:
- 对每条射线(每个像素的中心),求出所有相交盘的 $(t^*, u^*, v^*)$;
- 按 $t^*$ 排序;
- 对每个交点算 $(10.1)\cdot\alpha$,按 (1.3) 合成。
注意:没有 EWA、没有 $\Sigma' = JW\Sigma W^\top J^\top$。§4 的整套近似在 2DGS 里被绕过了。
几何正则:让 disks 真的贴上表面
光把基元换成 2D 还不够——SGD 仍然可能给你一堆"方向乱七八糟"的盘。2DGS 加了两个关键损失:
(a) 深度畸变损失 (depth distortion):对每条射线,要求"对该像素贡献最大的几个盘"在深度上集中在一点。形式上是
其中 $w_i = \alpha_i T_i$ 是第 $i$ 个盘对该像素的"权重"。这个损失最小时所有盘几乎重合在同一深度——也就是"表面"。
(b) 法向一致性损失 (normal consistency):把渲染时累积出的"软法向" $\hat{\mathbf{n}}(p) = \sum_i w_i\,\mathbf{n}_i$ 和"从深度图直接微分得到的几何法向" $\hat{\mathbf{n}}_{\text{geom}}(p)$ 对齐:
这两条加起来,2DGS 的 disks 会自动把法向调到"表面的真法向"上、并把中心位置调到表面真正所在的位置上。
从 2DGS 抽网格
正因为 2DGS 的盘已经躺在表面上,从一堆训练相机的角度对场景做多视角深度图,把这些深度做 TSDF (Truncated Signed Distance Function) 融合,再 Marching Cubes,就能得到非常干净的网格。这是它最大的工程价值。
不完全是。一颗 3D 高斯的标度比方说 (1, 1, 0.01)——它在数学上是个非常扁的椭球,但是 SGD 不知道"该让它有多扁"。2DGS 的盘是结构上就只有 2 维的——没有第三根标度可以学,它必须保持平的。这种"维度上的硬约束"是关键差别。再加上 (10.2)(10.3) 的几何正则,几何信息从软的"渲对就行"变成硬的"位置和方向都要对"。
§11GES:当高斯不够尖锐时,把它推广到广义指数
高斯衰减太慢——它在 $3\sigma$ 以外才接近 0。对于场景里的"硬边界"(比如一根细绳、一条树枝、一块字体的笔画边缘),原版 3DGS 必须用多颗高斯叠起来勉强模出锐利的边缘。GES[7] 提出一个简单而有效的改写:让每颗基元自己选择"衰减得有多锐"。
广义指数函数
把高斯的衰减项推广为广义指数 (Generalized Exponential):
多了一个形状参数 $\beta \gt 0$:
- $\beta = 2$:经典高斯,衰减为 $\exp(-r^2/2)$,平缓。
- $\beta = 1$:拉普拉斯 (Laplace) 分布,$\exp(-r/\sqrt{2})$,更陡。
- $\beta \to \infty$:close to box function(在椭球内取 1,外取 0),最锐利。
- $\beta \lt 1$:比高斯更平缓(基本没用,但 SGD 自由选择)。
为什么这件事有用
核心观察:场景里不同区域需要的频率不同。
- 大块的漫反射区域(墙、地板、植被远景):用平缓的高斯非常合适——少数几颗就铺满了,参数效率高。
- 硬边界、细线条、文字:用平缓高斯则必须叠 5–10 颗才能模出来;用 $\beta=8$ 这种"近 box"的基元一颗就够。
GES 让每颗基元的 $\beta$ 是可学的参数。一颗自由选择 $\beta$ 的基元相当于"自动选择自己的频谱"。在同等画质下,GES 用的基元数比原版 3DGS 少 20%–40%;在同等数量下,PSNR 高 0.3–0.6 dB。
训练上的小工程
GES 实操上有两个细节:(1)$\beta$ 用 softplus 重参数化为 $1 + \operatorname{softplus}(\beta_{\text{raw}})$,确保 $\beta \gt 1$;(2)作者引入一个频率调制损失(frequency-modulated loss),在训练早期让 $\beta$ 偏向小值(容易学)、后期偏向 SGD 自由选择。这是一种课程学习:先用平滑基元打底,再让需要"硬边"的位置自动把 $\beta$ 推高。
是的——但它有依据。在统计学里"广义指数族"是众所周知的一族包含高斯/拉普拉斯/box 的分布;$\beta$ 就是这一族里的形状参数。GES 不过把它带进了 splatting。这种"把存在已久的数学工具用对地方"是 3DGS 后续工作的常见模式,参见 §14 把 Poisson 重建挪来做网格抽取,§12 把 SGLD 挪来重写致密化。
§123DGS-MCMC:把致密化重新表述为采样
§8 的致密化是一堆启发式:阈值、克隆、分裂、重置 $\alpha$。这些规则能 work,但每个阈值都要调,对场景不够鲁棒。3DGS-MCMC[8] 提出一个让人耳目一新的视角:把这些启发式整体替换成一个原理化的过程——把高斯集看作一个分布的样本,用 SGLD(随机梯度 Langevin 动力学)更新它们。
Reframe
原版 3DGS 把训练看成"参数优化":损失 $\mathcal{L}$ 关于参数 $\theta = (\mu, q, s, \alpha, \text{sh})$ 的梯度推 $\theta$ 往低损失方向走。
MCMC 视角则是这样表述:
把场景看作"一个未知后验分布 p(场景 | 训练图像) 上的样本集"。每颗高斯都是这分布的一份样本。训练就是从该分布上做 MCMC 采样。
这样一来,"高斯位置更新"不再是普通的 SGD 步,而是一次 Langevin 步:
多出来的 $\sqrt{2\eta\tau}\,\varepsilon$ 项是噪声。$\tau$ 是"温度"。这个改写说出来很轻飘,但它带来三个直接的工程后果:
后果 1:克隆 / 分裂消失了,被"重定位 (relocate)"取代
既然每颗高斯都在做 Langevin 采样,那本来用来"补足空白"的克隆,可以直接被"采到那里去"代替。具体:作者维护一个固定的高斯总数 $N$(用户指定预算),每隔 $K$ 步检查"贡献低的"高斯(其 $\alpha\cdot$ 渲染权重接近 0),把它们整体搬运到"贡献被严重需要的"位置(比如位置梯度大的区域附近,按概率采)。
"Relocate" 在数学上是一次 reversible-jump MCMC:从低概率位置搬到高概率位置,相当于改了链的状态而不改变 stationary distribution。这一招的工程好处是预算可控——你能在 train 前就钉死最终高斯数。
后果 2:opacity reset 不需要了
回忆 §8:原版每 3000 步把所有 $\alpha$ 重置到 0.01,强迫 SGD 重新挑出有用的高斯。这种"硬冲洗"在 MCMC 视角下不必要——relocate 已经在自动做"把没用的搬到有用的地方"了。所以 3DGS-MCMC 把这个反直觉但必要的工程招式拿掉了,整套循环更干净。
后果 3:超参数减少
原版有:clone 阈值、split 阈值、scale 阈值、prune $\alpha$ 阈值、reset 周期、densification 频率。一共 6 个。
MCMC 版本只剩:温度 $\tau$、relocate 频率 $K$、总预算 $N$。3 个,且每一个都有清晰的"它在控制什么"的解读。对调参鲁棒性极有意义。
本质
在 SGD 视角下,"一颗高斯"是被损失推到某个最优位置的参数。在 MCMC 视角下,"一颗高斯"是从一个目标分布中采出来的样本。两套视角都对——但 MCMC 视角让"为什么需要噪声、为什么需要 relocate"成为一阶问题,而不是诉诸启发式。
这是 3DGS 后续工作里少见的"哲学换皮"——它没改任何渲染、没改任何参数化,但它重写了训练的整个语义。后续好几个工作(Taming-3DGS、Speedy-Splat 等)都已经把"按贡献 budget-aware 地维护高斯集"作为默认范式,根源都能追到这个 MCMC 改写。
§13Scaffold-GS:散点不够,搭一层锚点骨架
3DGS 是个"散点法":场景就是一长串独立的高斯,互相之间不知道彼此的存在。这种平坦结构在小到中等场景上没问题,但拓展到大场景时会冗余得厉害——同一面墙上的相邻高斯几乎在重复表达同一份信息。Scaffold-GS[9] 给这种"局部相关性"引入一层显式的层级结构。
核心结构
Scaffold-GS 把高斯分成两层:
- 锚点 (anchors)。稀疏的、有空间位置的"骨架"——通常 $\sim 10^5$ 个,分布在一个由 SfM 点云粗化得到的 voxel grid 上。每个锚点存 $(\mu_a,\ \mathbf{f}_a \in \mathbb{R}^{32},\ \hat{\mathbf{v}}_a \in \mathbb{R}^3,\ \hat{s}_a)$:位置、一个特征向量、一个朝向、一个尺度。
- 由锚点解出来的"神经高斯" (neural Gaussians)。对每个锚点 $a$,在它周围生成 $K$ 颗(典型 $K = 10$)实际用于渲染的高斯。每颗实际高斯的属性(局部位置偏移 $\Delta\mu$、旋转 $q$、$\alpha$、SH 颜色)不是独立存的,而是由一个小 MLP 实时解码出来:
这是每帧每个锚点都要做的一次小推理。MLP 很小(两三层 64 维),开销不大;但它带来一个 critical 性质:相邻锚点上的高斯不再独立——它们由相邻的 $\mathbf{f}_a$ 解出来,自动维持局部一致。
这件事为什么 work
三个直接好处:
- 参数效率。一个锚点 $\sim 30$ 个浮点 + $K=10$ 颗高斯共享一份 MLP 解码——比直接存 10 颗独立高斯($59\times 10 = 590$ 浮点)省一个数量级。
- 视角依赖压缩进解码。注意 (13.1) 的输入里有 $\mathbf{d}_{\text{view}}$——这意味着锚点的"实际高斯参数"也随观察方向变化。原本要靠 SH 表达的视角依赖,现在可以由 MLP 部分承担,对反光/各向异性表面更友好。
- LOD 自然涌现。由于锚点分布在 voxel grid 上,远观时可以只取粗 grid 的锚点(每个解 $K=10$ 高斯),近观时取细 grid(每个仍解 $K$ 高斯,但分布更密)。Octree-GS 把这点做到了极致——锚点形成一棵八叉树,渲染时根据视距选层。
代价
Scaffold-GS 不是完全免费的。代价:(1)渲染时多了一次锚点 MLP 推理,推理 FPS 比原版 3DGS 慢 $1.5$–$2\times$;(2)训练时致密化变复杂——你要决定"该加锚点还是加它的子高斯"——原论文用了一套"锚点 growing"启发式(看预测残差大的位置)。
所以 Scaffold-GS 的定位不是"任何场景都用",而是"大场景、希望表达紧凑"的时候它优于平坦的 3DGS。对小型物体或室内房间,原版 3DGS 仍然更简单更快。但它打开了一条重要的设计走廊:3DGS 的高斯不必都是独立的参数,可以是某个潜变量集的解码。这条思路也启发了后来的 4DGS、压缩工作等。
§14SuGaR:让高斯云退化出一张网格
3DGS 的高斯云可看不可摸——你不能直接拿它做物理仿真、不能用它做精确编辑、不能塞进游戏引擎。SuGaR[10] 给出一条把高斯云拉回三角网格的路径。它和 2DGS 走的是同一个目标,但方法不同。
问题转述
从高斯云抽网格的核心难点:场景的"表面"在哪儿没有明确定义。你能问一颗高斯"中心在哪?",但你不能问一个 1 M 颗高斯的云"表面在哪?"——它压根不是表面表示。
朴素做法(直接对每颗高斯取中心做 Poisson 重建)输出的网格充满凸起、毛刺、孔洞,没法用。问题在于:高斯没有被逼着呆在某个一致的表面上。
SuGaR 的三步
SuGaR 的解法本质上是"一次再训练,把高斯整队到表面"。
第一步:SDF 一致性正则。核心观察是这样:
如果一组高斯恰好对应一个干净的表面,那么这组高斯定义的"密度场"应当只在表面附近非零。即,把所有高斯的密度场叠加起来,它的极大值应当形成一张二维流形。
形式化:把场景密度定义为
用一个辅助损失逼这个 $\rho$ 满足"在最大值附近近似为高斯山脊(ridge)"——具体是 $D$ 维 SDF 的二阶矩条件。这件事让所有高斯倾向于"贴到同一张曲面上"。
第二步:Poisson 重建。等高斯都贴在表面附近之后,对它们的中心 + 法向(由每颗高斯最短主轴方向估计)做Screened Poisson Surface Reconstruction(2006 经典算法),得到一个干净的三角网格。
第三步(可选):把 3DGS 绑到这张网格上。SuGaR 提供一个"refined" 模式:把每颗高斯挂到网格的三角形上(坐标用重心坐标表示),然后再训练。这样得到的是同时拥有几何网格和高斯渲染的混合表示——网格用于物理/编辑,高斯用于渲染。
和 2DGS 的对比
SuGaR · 后处理路径
- 先有训好的 3DGS,再加正则把它"压到面上",再 Poisson 重建。
- 基元仍是 3D 椭球——可以直接复用一切 3DGS 工具链。
- 得到的网格相对粗糙,但兼容性最高。
- 训练总时长 ~原版 3DGS + 一次 refine 阶段。
2DGS · 重写基元路径
- 基元从一开始就是 2D 盘。
- 盘上的法向就是几何法向,深度图原生就准。
- TSDF fusion 出来的网格更精细,但你的工具链需要重新适配 2D 基元。
- 训练时长和原版 3DGS 相当。
实践中两条路并存。如果你的下游需要"用 3DGS 渲染 + 偶尔抽个网格"——SuGaR 友好;如果你的下游是"必须有精确网格做 SLAM 或物理"——2DGS 更好。两者代表 2024 年对同一个核心缺陷的两种治法。
§15压缩:从 1 GB 的 PLY 到 30 MB 的 SOG
一个 ~2 M 高斯的场景在原版 PLY 里是 ~470 MB;如果加上 LightGS、Compact3D 这类压缩,可以降到 30–50 MB。三个量级里少了一个量级。这一节简短地讲压缩工作在做什么。
从信息论上看 3DGS 哪里"胖"
每颗高斯的 59 个浮点里:
- $\mu \in \mathbb{R}^3$ (3 个):信息量最高,几乎没有冗余。
- $q, s_{\text{log}}, \alpha$ (8 个):低维属性,邻近高斯之间相关性强但每颗自己的取值有效熵不低。
- $\text{sh} \in \mathbb{R}^{48}$ (48 个):这一块占总字节数的 80%。但相邻高斯的 SH 几乎一样——这里有巨量冗余。
所以所有 3DGS 压缩方法的共同核心:压 SH。剩下 11 个浮点的压缩相对边际。
三类常见招
(a) 矢量量化 (VQ)。把全场所有高斯的 SH 系数视为 4 M 个 48 维向量,跑一次 k-means 得到一个 $K=4096$ 的码本,然后每颗高斯只存它属于哪个 cluster 的 ID(12 bit)。代价:码本一份($K\times 48\times 4 = 786$ KB)+ 每颗 1.5 字节,整个 SH 从 192 字节降到 1.5 字节。质量损失:PSNR 通常掉 0.1–0.3 dB。LightGaussian、Compact3D 都是这条思路。
(b) 自组织排序 + 图像编码 (SOG)。Self-Organizing Gaussians[11] 用一次 self-organizing map 把高斯排成一个 2D 网格——空间相近的高斯被放到 2D 网格上相近的位置;然后这个 2D 网格的每个通道当成一张图像,用JPEG/PNG/WebP 标准编码。整个场景变成几张图像 + 一份元数据,浏览器原生支持加载。压缩比通常达到 20–30×。
(c) 裁剪 + 量化。(a)(b) 之前先做的事:把"贡献低的"高斯全删掉(按 $\alpha\cdot$ 渲染权重排序,砍掉末尾 30–50%;通常 PSNR 几乎不掉),把 $q$、$s_{\text{log}}$、$\alpha$ 从 float32 量化到 int8 或 int16。仅这两招就能把场景文件砍到 $\sim 150$ MB。
结果
| 方法 | 场景大小(MIP-NeRF 360 garden) | PSNR 变化 | 渲染开销 |
|---|---|---|---|
| 原版 PLY | ~470 MB | — | — |
| 裁剪 + int8 量化 | ~150 MB | -0.05 dB | 0 |
| LightGaussian (VQ SH) | ~45 MB | -0.2 dB | +一次 LUT 查表 |
| SOG (图像编码) | ~28 MB | -0.3 dB | 解码后等价 |
压缩对"3DGS 能不能上 web/移动端"是必须的。Mip-NeRF 上一个场景 ~5 MB 的 MLP 文件本身就够小;3DGS 的 PLY 在浏览器里下 1 GB 没人会等。SOG/LightGaussian 的 ~30 MB 量级把 3DGS 真正带进了"可以下发的"场景。
§16合页:还没解决的问题,与值得读的下一篇
Part Ⅰ + Part Ⅱ 走完,原版 3DGS 的骨架 + 它本体上的几次主要改写都讲过了。这一节列出还没解决的问题,以及下一步该读什么。
原版 + 这些改进之后,还差什么
-
真正锐利的镜面反光。SH(即使 L=3)+ Mip-Splatting 的低通仍然不足以表达"镜面反射出环境里的清晰物体"。需要把材质 (BRDF) 和环境光从高斯参数里解耦出来——GaussianShader、Relightable 3DGS 走的是这条路。本系列另一篇
3dgs-relighting专门讲。 -
动态场景。3DGS 是为静态场景写的。给每颗高斯加时间维 $(\mu(t), \Sigma(t))$ 后是 4DGS / Dynamic 3DGS。本系列
3dgs-dynamic。 -
从单/少视角合成 3DGS。原版需要几十到几百张校准过的训练图。要从一张图直接出 3DGS:DreamGaussian、LGM、TripoSR 等用的是 SDS 蒸馏或前馈 UNet 路径。本系列
3dgs-generation。 -
SLAM 与重建。"高斯是显式的"这件事让在线重建可以"边来帧边加高斯"——MonoGS、Splat-SLAM。本系列
3dgs-slam。 -
大场景。1 平方公里街景,30 M 高斯如何分块训练 / 渲染?本系列
3dgs-large-scale。 -
编辑与语义。怎么"选中一只猫的高斯"做删除/染色?GaussianEditor、SAM-GS 用 2D 语义提升到 3D。本系列
3dgs-editing。
30 行训练循环到底变成了什么
Part Ⅱ 之后,§8 那段 Python 伪码骨架上对应改的位置是:
| 骨架位置 | Part Ⅱ 的改写 |
|---|---|
| rasterize() 内的 $G_{2D}$ | Mip-Splatting (§9) → 加 3D + 2D 双滤波器 |
| 基元定义 | 2DGS (§10) → 换 disk;或 GES (§11) → 换广义指数 |
| 致密化分支 | 3DGS-MCMC (§12) → 换成 Langevin + relocate |
| "高斯参数"本身 | Scaffold-GS (§13) → 由锚点 + MLP 解码 |
| 训练完后 | SuGaR (§14) / 2DGS 自带 mesh fusion |
| 导出 | LightGS / SOG 压缩 (§15) |
每一行都不是凭空发明的,每一行都在回答"原版 30 行训练里某一步具体在做什么 / 假设了什么 / 漏掉了什么"。这是为什么本文叫"foundations"——能把这张映射表填出来的人,已经能看懂目前 3DGS 99% 的论文。
三条值得在 PyTorch 里跑一遍的练习
读到这里你大概已经手痒。三条对建立"实操直觉"最有效的练习,从易到难:
-
用 splatfacto 跑一个场景。下载 nerfstudio,
ns-train splatfacto --data $your_scene。20 分钟看到第一张图,看 viewer 里的高斯云,按住鼠标转一圈。 - 用 gsplat 库手写一次 rasterizer 调用。gsplat[12] 是 nerfstudio 团队写的纯 Python + CUDA 库,把 §7 的所有 kernel 暴露成 Python API。手写一个 ~100 行的脚本:给定一组随机高斯 + 一个相机,把图像渲染出来——能跑通你就掌握了。
- 实现一次"opacity reset"。找一个 splatfacto 的 commit,在 trainer 里把 opacity reset 那段注释掉,重新训一个场景。对比 PSNR 曲线——你会亲眼看到 §8 那一招的意义。
祝你接下来读论文顺利。3DGS 这个领域在 2023–2026 几乎按周更新;但只要骨架对了,新论文都只是骨架上的某一根换骨头——这是为什么我们花这么大力气把骨架本身讲清楚。
参考与延伸阅读
- [1] Mildenhall, B., Srinivasan, P. P., Tancik, M., Barron, J. T., Ramamoorthi, R., & Ng, R. (2020). NeRF: Representing Scenes as Neural Radiance Fields for View Synthesis. ECCV.
- [2] Zwicker, M., Pfister, H., van Baar, J., & Gross, M. (2001). EWA Volume Splatting. IEEE Visualization.
- [3] Greene, N., & Heckbert, P. S. (1986). Creating raster Omnimax images from multiple perspective views using the elliptical weighted average filter. IEEE CG&A.
- [4] Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. (2004). Image quality assessment: from error visibility to structural similarity. IEEE TIP.
- ★ Kerbl, B., Kopanas, G., Leimkühler, T., & Drettakis, G. (2023). 3D Gaussian Splatting for Real-Time Radiance Field Rendering. SIGGRAPH. project page
- [5] Yu, Z., Chen, A., Huang, B., Sattler, T., & Geiger, A. (2024). Mip-Splatting: Alias-free 3D Gaussian Splatting. CVPR.
- [6] Huang, B., Yu, Z., Chen, A., Geiger, A., & Gao, S. (2024). 2D Gaussian Splatting for Geometrically Accurate Radiance Fields. SIGGRAPH.
- [7] Hamdi, A., Melas-Kyriazi, L., Mai, J., Qian, G., Liu, R., Vondrick, C., Ghanem, B., & Vedaldi, A. (2024). GES: Generalized Exponential Splatting for Efficient Radiance Field Rendering. CVPR.
- [8] Kheradmand, S., Rebain, D., Sharma, G., Sun, W., Tseng, J., Isack, H., Kar, A., Tagliasacchi, A., & Yi, K. M. (2024). 3D Gaussian Splatting as Markov Chain Monte Carlo. NeurIPS.
- [9] Lu, T., Yu, M., Xu, L., Xiangli, Y., Wang, L., Lin, D., & Dai, B. (2024). Scaffold-GS: Structured 3D Gaussians for View-Adaptive Rendering. CVPR.
- [10] Guédon, A., & Lepetit, V. (2024). SuGaR: Surface-Aligned Gaussian Splatting for Efficient 3D Mesh Reconstruction and High-Quality Mesh Rendering. CVPR.
- [11] Morgenstern, W., Barthel, F., Hilsmann, A., & Eisert, P. (2024). Compact 3D Scene Representation via Self-Organizing Gaussian Grids. ECCV.
- [12] Ye, V., Li, R., Kerr, J., Turkulainen, M., Yi, B., Pan, Z., Seiskari, O., Ye, J., Hu, J., Tancik, M., & Kanazawa, A. (2024). gsplat: An Open-Source Library for Gaussian Splatting. arXiv:2409.06765.
- ★ graphdeco-inria/gaussian-splatting github.com(官方训练代码,~3000 行,值得通读)
- ★ nerfstudio-project/gsplat github.com(更模块化的开源实现)
- ★ MrNeRF/awesome-3D-gaussian-splatting github.com(社区维护的论文索引,按主题分类)