An illustrated essay · 长文 · May 2026

3D 高斯泼溅,from first principles

给已经学过 NeRF / SDF、有基本机器学习与线性代数底子的读者,把 3D Gaussian Splatting 的每个构件——从一颗椭球,到一整套训练-渲染流水线,再到 2023 年之后对它本体做出的几次重大改写——一层一层拆开来讲清楚。

约 45 分钟阅读 · ~14,000 字 · 含 9 个交互图例 · 无需 CUDA 背景

摘要

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) 在"无散射"近似下的体渲染积分:

$$ C(\mathbf{r}) \;=\; \int_{t_n}^{t_f} T(t)\,\sigma(t)\,c(t)\,\mathrm{d}t, \qquad T(t) \;=\; \exp\!\Big(-\!\!\int_{t_n}^{t} \sigma(s)\,\mathrm{d}s\Big). $$ (1.1)

$T(t)$ 是透射率 (transmittance):从相机走到 $t$ 这一路上"有多少光还没被吸收"。它必然是从 1 单调衰减到 0 的一个量。把发射 $c(t)$ 乘以"它能不能照得回来" $T(t)\cdot\sigma(t)$,再积分,就是这条射线最终送回相机的颜色。

这不是渲染学的人工选择,是真物理。任何会发光又会吸光的连续介质都遵守它。[1]

离散化:把积分换成可计算的求和

计算机不会做积分,只会做加法。把射线沿深度切成 $N$ 段,每段长度 $\Delta_i$,区间内的 $\sigma$、$c$ 视为常数 $\sigma_i$、$c_i$,则段内透射率衰减为

$$ T_i^{\text{end}} \;=\; T_i^{\text{begin}}\cdot\exp(-\sigma_i\Delta_i) \;=\; T_i^{\text{begin}}\cdot(1-\alpha_i), \quad \alpha_i \;\stackrel{\text{def}}{=}\; 1 - e^{-\sigma_i \Delta_i}. $$ (1.2)

这里 $\alpha_i$ 就是这一段贡献的"不透明度"——你看见的 $\alpha$ 实际是 $\sigma\cdot\Delta$ 的指数变换。把式 (1.1) 离散化、再迭代,得到的就是图形学家家喻户晓的前向 $\alpha$ 合成公式:

$$ C \;=\; \sum_{i=1}^{N} c_i\,\alpha_i\,T_i, \qquad T_i \;=\; \prod_{j=1}^{i-1}(1-\alpha_j). $$ (1.3)

式 (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 高斯。先把它的形状、参数、为什么"偏偏挑高斯"想清楚。

它的密度函数就是把一维正态推广到三维:

$$ G(\mathbf{x}) \;=\; \exp\!\Big(-\tfrac{1}{2}\,(\mathbf{x}-\boldsymbol{\mu})^{\!\top}\,\boldsymbol{\Sigma}^{-1}\,(\mathbf{x}-\boldsymbol{\mu})\Big). $$ (2.1)

两个参数定形状和位置:中心 $\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 函数、超椭圆——为什么这套系统挑了高斯?三条原因,依重要性递增:

  1. 投影闭式可解。一颗 3D 高斯被任意线性变换映射后,仍然是高斯。哪怕相机投影是非线性的(透视除法是比值),我们也可以在 $\mu$ 处线性化,让"3D 高斯 → 屏幕上的 2D 高斯"成为每帧只需算一次 $2\times 2$ 矩阵的事。这就是 EWA Splatting[2]§4 会推。
  2. 处处光滑。对 $\mu$ 处处可微,对 $\Sigma$ 也是(只要 $\Sigma$ 是 PD 的)。梯度干干净净落下来,没有折点、没有 NaN。这一点对端到端可微渲染是命脉。
  3. 实际上是"紧支撑"的。理论上高斯永远不到 0,但 $3\sigma$ 之外贡献已经在 0.01 以下。3DGS 在实现上把每颗高斯的有效覆盖区裁到 $3\sigma$ 椭圆——于是渲染一颗高斯只需要触碰一小片像素,把"全场景渲染"变成"每颗高斯写一小块"。这件事是 splatting 之所以快的关键
对比 SDF 的直觉

如果你来自 SDF 流派,可以这样理解:SDF 把世界存为"到表面的距离",一个标量场;3DGS 把世界存为很多小局部体密度,每一颗占的体积有限,叠加起来覆盖整个场景。SDF 是"一个全局函数",3DGS 是"许多局部基函数的线性组合"——这种"基函数 + 局部支撑"的思想在数值分析里非常古老(径向基函数、有限元、Splatting),3DGS 不过把它推到了亿级规模。

参数表

原版 3DGS 一颗高斯在内存里的样子(注意这些都是每颗独立的张量;整个场景在 PyTorch 里就是几个大张量并排放):

字段形状含义实际存的是
mu3中心位置 $\mu$直接存浮点
q4旋转(四元数)每次用之前 $q \gets q/\lVert q\rVert$
s_log3三主轴标度实际尺度 $s = \exp(s_{\text{log}})$
a_logit1不透明度$\alpha = \operatorname{sigmoid}(a_{\text{logit}})$
sh48球谐颜色系数每通道 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$ 存成两组无约束的参数:旋转和标度。

$$ \boldsymbol{\Sigma} \;=\; R\,S\,S^{\!\top}\,R^{\!\top}, \qquad S = \mathrm{diag}(s_1, s_2, s_3),\quad R = R(\mathbf{q}). $$ (3.1)

两个事实让这套写法成立:

(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 不归一化的话

如果你把 $q$ 直接当成参数让它自己漂,几千步之后它的模会逐渐偏离 1,对应的 $R(q)$ 会变成"旋转 + 缩放"的混合,跟 $S$ 的标度功能撞车。结果就是 $\Sigma$ 的特征值乱掉,渲染出现亮点跳变。所以工程上必须在每次用 $q$ 之前归一化:q = q / q.norm()。这一句的位置错了,是新手最常踩的坑之一。

Demo 3.1 · 拖动手柄,看 $\Sigma$ 实时更新
Σ = …
图 3.1 · 二维下的 $\Sigma = RSS^\top R^\top$。中央圆点是 $\mu$;两侧手柄设定 $s_x, s_y$;弧形手柄给出旋转角 $\theta \to R(\theta)$。底部数值是当前的 $2\times 2$ $\Sigma$ 矩阵(由 $R, S$ 实时合成,永远 SPD)。3D 情形把"角度 $\theta$"换成"四元数 $q$"、把 $S$ 加一维,思想完全一致。

§4EWA 投影:一颗 3D 高斯如何变成 2D 椭圆

这一节是整篇文章里数学最稠密的一节,但其实推导只用到链式法则。耐心读一遍,剩下所有 3DGS 论文你看到 $\Sigma' = JW\Sigma W^\top J^\top$ 都不会发蒙。

要把一颗 3D 高斯渲到画面,第一件事是问:它在屏幕上长什么样?中心 $\mu$ 容易——把它通过相机变换 + 透视除法投到屏幕坐标即可。麻烦的是形状——3D 的椭球到 2D 的什么

第一步:相机的两段

典型的针孔相机由两段组成:

(i) 世界 → 相机坐标系:一个刚体变换,由 $3\times 3$ 旋转 $W$ 与平移 $\mathbf{t}$ 给出

$$ \mathbf{x}_{\text{cam}} \;=\; W\,\mathbf{x}_{\text{world}} + \mathbf{t}. $$ (4.1)

这是线性的——高斯经过线性变换后仍是高斯,且协方差从 $\Sigma$ 变到 $W \Sigma W^\top$。漂亮,干净,没有近似。

(ii) 相机坐标 → 屏幕像素:透视除法

$$ \mathbf{p} \;=\; \pi(\mathbf{x}_{\text{cam}}) \;=\; \Big(\frac{f_x\,x_{\text{cam}}}{z_{\text{cam}}}\,, \;\frac{f_y\,y_{\text{cam}}}{z_{\text{cam}}}\Big). $$ (4.2)

这一段是线性的——分母里有 $z$。在世界坐标下"一颗椭球"经过它不会变成另一颗椭球,理论上它会变成一个稍微扭曲的形状(一颗"椭球的透视投影"在严格意义上是某种四次曲面的二次截面,并非椭圆)。

第二步:在 $\mu$ 处线性化(这就是 EWA 的灵魂)

但是!如果一颗高斯本身很小(在视场里覆盖几度以内),我们就可以在它的中心 $\mu$ 那一点把 $\pi$ 用它的一阶 Taylor 展开替代:

$$ \pi(\mathbf{x}) \;\approx\; \pi(\boldsymbol{\mu}_{\text{cam}}) + J\,(\mathbf{x} - \boldsymbol{\mu}_{\text{cam}}), \qquad J = \frac{\partial \pi}{\partial \mathbf{x}}\bigg|_{\boldsymbol{\mu}_{\text{cam}}}. $$ (4.3)

这里 $J$ 是 $\pi$ 在 $\mu_{\text{cam}}$ 处的 $2\times 3$ 雅可比。算一下 (4.2) 对 $(x, y, z)$ 求偏导:

$$ J \;=\; \begin{pmatrix} f_x/z & 0 & -f_x x / z^2 \\ 0 & f_y/z & -f_y y / z^2 \end{pmatrix}. $$ (4.4)

这是一个常数矩阵(在 $\mu$ 处算定后就不变)。线性近似下,高斯依然是高斯。它的屏幕协方差就是协方差按线性映射的标准变换:

$$ \boldsymbol{\Sigma}' \;=\; J\,W\,\boldsymbol{\Sigma}\,W^{\!\top}\,J^{\!\top} \;\in\; \mathbb{R}^{2\times 2}. $$ (4.5)

这就是 EWA Splatting 公式(Zwicker, Pfister, van Baar, Gross 2001)。它给了我们一颗 3D 高斯落到屏幕上之后的"足印"——一颗 2D 高斯,由 $2\times 2$ 矩阵 $\Sigma'$ 描述。

第三步:屏幕上的 2D 高斯长什么样

有了 $\Sigma'$,对屏幕上任意像素 $\mathbf{p}$,这颗高斯在它身上的贡献是

$$ G_{2D}(\mathbf{p}) \;=\; \exp\!\Big(-\tfrac{1}{2}\,(\mathbf{p}-\mathbf{p}_{\boldsymbol{\mu}})^{\!\top}\,\boldsymbol{\Sigma}'^{-1}\,(\mathbf{p}-\mathbf{p}_{\boldsymbol{\mu}})\Big). $$ (4.6)

其中 $\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 要解决的故事。

Demo 4.1 · 3D 椭球 → 屏幕椭圆
图 4.1 · 左:一颗 3D 椭球,画出三主轴。右:当前相机看到的屏幕椭圆(即 $\Sigma'$ 的等值线),由 $\Sigma' = JW\Sigma W^\top J^\top$ 实时算出。拖动 yaw / pitch 可以看到屏幕椭圆的纵横比在变化——这是真投影几何,不是错觉。
名字的由来

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}$ 都可以唯一地展开为

$$ f(\mathbf{d}) \;=\; \sum_{\ell=0}^{\infty}\sum_{m=-\ell}^{\ell} k_{\ell,m}\, Y_{\ell}^{m}(\mathbf{d}). $$ (5.1)

级数前 $(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 系数的梯度也极其简洁:

$$ \frac{\partial c(\mathbf{d})}{\partial k_{\ell,m}} \;=\; Y_{\ell}^{m}(\mathbf{d}). $$ (5.2)

SGD 想动哪个系数,"该往哪边动"在前向那次基底求值时就已经全算好了。这是真正意义上的"零额外反向开销"。

SH 不能做什么

SH 是低通工具。$L = 3$ 一共 16 个基底,频谱上能表达的最细方向变化大约对应 $90^{\circ}$ 量级——这意味着:

  • 漫反射、轻微光泽:OK。
  • 钝的金属高光:勉强够。
  • 真正锐利、微面元主导的镜面高光:糊。
  • 反射相机本身/天空盒中清晰可见的内容:完全做不到。

这是 3DGS 出来后一年内一串后续工作的开端:GaussianShader 把每颗高斯接一个 Disney BRDF,Relightable 3DGS 引入显式的法向和环境光,Anisotropic-SH 把球谐换成各向异性的方向基。这些都属于"应用层"的扩展,不属于本文 Part Ⅱ 要讲的"本体改进",故只在 §16 提一笔。

Demo 5.1 · 视角方向 → 颜色
图 5.1 · 一颗球的表面颜色随视角变化。改变低阶 SH 系数能给球加上"左右明暗"或者"上下明暗";把 $Y_2^{0}$ 推大能加一个明显的反光瓣。Reset 之后退化成一颗 Lambert 漫反射球(只有 $Y_0^{0}$ 是非零)。

§6$\alpha$ 合成与提早终止:内层循环为什么便宜

现在我们手上每颗高斯都有了 $(\mathbf{p}_\mu, \Sigma', \alpha, c(\mathbf{d}))$。剩下的事是:一个像素上躺着好几十颗高斯,要怎么把它们的贡献合到一起?答案:体渲染方程式 (1.3)。但实现时有个关键加速。

对每个像素,我们要做的是:

  1. 找出所有覆盖到这个像素的高斯(落在它 $3\sigma$ 椭圆内的)。
  2. 按深度从前到后排序。
  3. 沿这个有序列表跑式 (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 颗高斯,"对这个像素有视觉意义"的也就那么几颗。于是渲染每一帧的总成本是

$$ T_{\text{render}} \;=\; \mathcal{O}\big(\text{像素数} \times \text{平均每像素深入的高斯数}\big), $$ (6.1)

而不是 $\mathcal{O}(\text{像素数} \times \text{总高斯数})$。后者是 NeRF 那种"沿射线密集采样"的开销,前者只取决于场景的表面拓扑

Demo 6.1 · 给一个像素从前往后逐颗合成
step 0 / 20
图 6.1 · 合成一条射线上 20 颗高斯。拖动滑块逐颗推进。两条折线分别是 $T_i$(透射率)的衰减、累计颜色的爬升。你会看到一旦 $T$ 跌过 $\sim 10^{-3}$,后面几颗即使存在也已经"看不见"了——这就是 (6.1) 的几何含义。

反向传播也享受这个加速

前向能"提早跳出",反向能:把同一段 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 上跑:

  1. 逐高斯:投影 + 计算瓦片覆盖。对每颗高斯,并行地:(i)算 $\Sigma'$ 与 $\mathbf{p}_\mu$;(ii)由 $3\sigma$ 椭圆得到一个屏幕 AABB;(iii)算这个 AABB 覆盖到哪些 $16\times 16$ 像素的瓦片 (tile)。一颗高斯通常落在 1–9 个瓦片上。
  2. "复制 + 排序"展开。每个 (高斯, 它覆盖的某个瓦片) 配对生成一条 实例 (instance);每条实例携带 (tile_id, depth)。然后全局按 (tile_id, depth) 排序。
  3. 构建每瓦片的命中区间。排序后实例数组里同一 tile_id 的项是连续的;扫一遍就得到每瓦片的"起止下标"。
  4. 逐瓦片:着色。对每个 $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:

$$ \mathcal{L} \;=\; (1-\lambda)\,\|I - I^*\|_1 \;+\; \lambda\,\mathcal{L}_{\text{D-SSIM}}(I, I^*), \qquad \lambda = 0.2. $$ (8.1)

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 改写要重新表述的目标。

Demo 8.1 · 致密化 / 裁剪小型模拟
iter 0 · N = 30
图 8.1 · 一个 2D 玩具场景:30 颗随机初始化高斯试图拟合一张目标图像。点击 "Step ×100" 连续走 100 步:你会看到细节区域逐渐被克隆/分裂填满,低不透明度的废点被裁掉。真版本是 CUDA 上的一个 kernel;这里是纯 JavaScript。

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 把这颗高斯的协方差替换为

$$ \boldsymbol{\Sigma}_i^{\text{Mip}} \;=\; \boldsymbol{\Sigma}_i + s_i^{\star\,2}\,\mathbf{I}_3, $$ (9.1)

几何上:在每颗高斯上"卷上"一个最小尺度为 $\hat{s}_i$ 的各向同性高斯——这是一个低通滤波器,截掉了基元上高于训练采样率的频率。一旦做了这一步,不可能再有亚像素高斯出现,因为它们的最低频率已经被锁住了。

这条修改虽小但很微妙:

  • 不是"把 $\sigma$ 强制夹一个下限",那种粗暴做法会破坏可微性。它是一个常数协方差,所有梯度都还能正常回。
  • 每颗高斯的 $\hat{s}_i$ 是基于训练视角算出来的,不是每帧重算——所以是个"训练时固定,推理时直接用"的预计算量。

滤波器 #2:2D Mip filter

光有第一道还不够。测试时哪怕一颗高斯本身不再是亚像素的,它落到屏幕上的足印也仍然可能是亚像素的——比如它的法线和视线非常贴近的时候,3D 椭球被压扁成几乎和屏幕共线的薄片,屏幕协方差 $\Sigma'$ 的一根主轴接近 0。这种"屏幕上的细长亚像素 footprint"还是会引锯齿。

第二道修在屏幕空间。把渲染时用的 2D 高斯

$$ G_{2D}(\mathbf{p}; \boldsymbol{\Sigma}') $$ (9.2)

替换为它和一个屏幕单像素 box 滤波器卷积之后的形式。具体可以闭式近似为

$$ \tilde G_{2D}(\mathbf{p}) \;=\; \frac{|\boldsymbol{\Sigma}'|^{1/2}}{|\boldsymbol{\Sigma}' + \frac{1}{4}\mathbf{I}_2|^{1/2}}\,\exp\!\Big(-\tfrac{1}{2}\mathbf{d}^{\!\top}(\boldsymbol{\Sigma}' + \tfrac{1}{4}\mathbf{I}_2)^{-1}\mathbf{d}\Big), $$ (9.3)

其中 $\mathbf{d} = \mathbf{p} - \mathbf{p}_\mu$。前面那个比例因子是为了在能量上保持体积守恒——一颗高斯不应该因为被低通滤波而总贡献变小。这一步本质上等价于"对每颗高斯额外做一次像素级的 anti-aliasing 滤波"。

合起来的效果

Demo 9.1 · 亚像素高斯的 moiré → 滤波后干净
图 9.1 · 一片由若干小高斯组成的"砖墙"。"缩放"控制虚拟相机离表面的远近——拉远时每像素覆盖的世界距离变大。关掉 Mip 时,远观会出现莫尔纹(亚像素 footprint 与像素栅格欠采样的产物);勾上 Mip 之后,3D 低通把 $\sigma$ 下界锁住、2D 低通把 $\Sigma'$ 下界锁住,画面平稳过渡。

Mip-Splatting 的两道滤波器加上之后,几乎是"白给"的效果:训练略慢一点(每颗高斯多一个预计算的 $\hat{s}_i$),渲染开销零增加,但在任何测试距离下都消除了锯齿/闪烁。这就是为什么它在出现不到半年内成了几乎所有 3DGS 后续工作的默认基线之一——它是真正意义上的"对本体有改进"的工作。

类比 Mipmap

名字 "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 高斯:

$$ G_{2D}^{\text{disk}}(u, v) \;=\; \exp\!\Big(-\tfrac{u^2 + v^2}{2}\Big). $$ (10.1)

(标度已经 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)。

整套渲染管线变成:

  1. 对每条射线(每个像素的中心),求出所有相交盘的 $(t^*, u^*, v^*)$;
  2. 按 $t^*$ 排序;
  3. 对每个交点算 $(10.1)\cdot\alpha$,按 (1.3) 合成。

注意:没有 EWA、没有 $\Sigma' = JW\Sigma W^\top J^\top$。§4 的整套近似在 2DGS 里被绕过了。

几何正则:让 disks 真的贴上表面

光把基元换成 2D 还不够——SGD 仍然可能给你一堆"方向乱七八糟"的盘。2DGS 加了两个关键损失:

(a) 深度畸变损失 (depth distortion):对每条射线,要求"对该像素贡献最大的几个盘"在深度上集中在一点。形式上是

$$ \mathcal{L}_{\text{dist}} \;=\; \sum_{i,j} w_i w_j \,|t_i - t_j|, $$ (10.2)

其中 $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)$ 对齐:

$$ \mathcal{L}_{\text{norm}} \;=\; \sum_p \big\| \hat{\mathbf n}(p) - \hat{\mathbf n}_{\text{geom}}(p) \big\|. $$ (10.3)

这两条加起来,2DGS 的 disks 会自动把法向调到"表面的真法向"上、并把中心位置调到表面真正所在的位置上。

Demo 10.1 · 同一面墙,3D 椭球 vs 2D 盘
表面深度偏差: σ = 0.18
图 10.1 · 一面像素级噪声的平面,分别由 3D 椭球与 2D 盘拟合。切换两种基元,右侧"表面深度偏差"显示从训练视角看不出区别,但深度集中度差几个量级。2DGS 的盘在 (10.2)(10.3) 正则下深度收敛到一点,可以直接用 TSDF fusion 出网格;3D 椭球做不到。

从 2DGS 抽网格

正因为 2DGS 的盘已经躺在表面上,从一堆训练相机的角度对场景做多视角深度图,把这些深度做 TSDF (Truncated Signed Distance Function) 融合,再 Marching Cubes,就能得到非常干净的网格。这是它最大的工程价值。

关于"压扁的 3D 高斯就是 2D 盘吗?"

不完全是。一颗 3D 高斯的标度比方说 (1, 1, 0.01)——它在数学上是个非常扁的椭球,但是 SGD 不知道"该让它有多扁"。2DGS 的盘是结构上就只有 2 维的——没有第三根标度可以学,它必须保持平的。这种"维度上的硬约束"是关键差别。再加上 (10.2)(10.3) 的几何正则,几何信息从软的"渲对就行"变成硬的"位置和方向都要对"。

§11GES:当高斯不够尖锐时,把它推广到广义指数

高斯衰减太慢——它在 $3\sigma$ 以外才接近 0。对于场景里的"硬边界"(比如一根细绳、一条树枝、一块字体的笔画边缘),原版 3DGS 必须用多颗高斯叠起来勉强模出锐利的边缘。GES[7] 提出一个简单而有效的改写:让每颗基元自己选择"衰减得有多锐"。

广义指数函数

把高斯的衰减项推广为广义指数 (Generalized Exponential)

$$ G_\beta(\mathbf{x}) \;=\; \exp\!\Big(-\tfrac{1}{2}\big[(\mathbf{x}-\boldsymbol{\mu})^{\!\top}\boldsymbol{\Sigma}^{-1}(\mathbf{x}-\boldsymbol{\mu})\big]^{\beta/2}\Big). $$ (11.1)

多了一个形状参数 $\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 自由选择)。
Demo 11.1 · 不同 $\beta$ 下的 1D 截面
β = 2.00 (Gaussian)
图 11.1 · 沿基元中心一条直线截面,高斯($\beta=2$)与广义指数($\beta=1, 4, \infty$)的对比。$\beta$ 越大边缘越锐——表达同样硬度的边界需要的基元数越少。

为什么这件事有用

核心观察:场景里不同区域需要的频率不同

  • 大块的漫反射区域(墙、地板、植被远景):用平缓的高斯非常合适——少数几颗就铺满了,参数效率高。
  • 硬边界、细线条、文字:用平缓高斯则必须叠 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 步:

$$ \boldsymbol{\mu}_i \;\leftarrow\; \boldsymbol{\mu}_i \;-\; \eta\,\nabla_{\boldsymbol{\mu}_i}\mathcal{L} \;+\; \sqrt{2\eta\,\tau}\,\boldsymbol{\varepsilon}_i, \qquad \boldsymbol{\varepsilon}_i \sim \mathcal{N}(0, \mathbf{I}_3). $$ (12.1)

多出来的 $\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 个,且每一个都有清晰的"它在控制什么"的解读。对调参鲁棒性极有意义。

Demo 12.1 · Langevin 噪声让样本"探索"
iter 0 · 覆盖率 0%
图 12.1 · 一组样本(每个对应一颗高斯的位置)试图覆盖一个"双月"目标区域。$\tau=0$ 时退化为纯 SGD,样本会陷在初始团簇内;$\tau \gt 0$ 时引入噪声,样本能逃离局部洞、覆盖到整个目标区域。这就是 (12.1) 的本质——SGD 找极小值,MCMC 找分布

本质

在 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 实时解码出来
$$ (\Delta\boldsymbol{\mu}_k, \mathbf{q}_k, \alpha_k, \mathbf{sh}_k) \;=\; \mathrm{MLP}_{\theta}\big(\mathbf{f}_a,\; \hat{\mathbf{v}}_a,\; \mathbf{d}_\text{view},\; k\big), \quad k = 1, \dots, K. $$ (13.1)

这是每帧每个锚点都要做的一次小推理。MLP 很小(两三层 64 维),开销不大;但它带来一个 critical 性质:相邻锚点上的高斯不再独立——它们由相邻的 $\mathbf{f}_a$ 解出来,自动维持局部一致。

这件事为什么 work

三个直接好处:

  1. 参数效率。一个锚点 $\sim 30$ 个浮点 + $K=10$ 颗高斯共享一份 MLP 解码——比直接存 10 颗独立高斯($59\times 10 = 590$ 浮点)省一个数量级。
  2. 视角依赖压缩进解码。注意 (13.1) 的输入里有 $\mathbf{d}_{\text{view}}$——这意味着锚点的"实际高斯参数"也随观察方向变化。原本要靠 SH 表达的视角依赖,现在可以由 MLP 部分承担,对反光/各向异性表面更友好。
  3. 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(\mathbf{x}) \;=\; \sum_i \alpha_i\, G_i(\mathbf{x}), $$ (14.1)

用一个辅助损失逼这个 $\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 dB0
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 里跑一遍的练习

读到这里你大概已经手痒。三条对建立"实操直觉"最有效的练习,从易到难:

  1. 用 splatfacto 跑一个场景。下载 nerfstudio,ns-train splatfacto --data $your_scene。20 分钟看到第一张图,看 viewer 里的高斯云,按住鼠标转一圈。
  2. 用 gsplat 库手写一次 rasterizer 调用。gsplat[12] 是 nerfstudio 团队写的纯 Python + CUDA 库,把 §7 的所有 kernel 暴露成 Python API。手写一个 ~100 行的脚本:给定一组随机高斯 + 一个相机,把图像渲染出来——能跑通你就掌握了。
  3. 实现一次"opacity reset"。找一个 splatfacto 的 commit,在 trainer 里把 opacity reset 那段注释掉,重新训一个场景。对比 PSNR 曲线——你会亲眼看到 §8 那一招的意义。

祝你接下来读论文顺利。3DGS 这个领域在 2023–2026 几乎按周更新;但只要骨架对了,新论文都只是骨架上的某一根换骨头——这是为什么我们花这么大力气把骨架本身讲清楚。

参考与延伸阅读

  1. [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. [2] Zwicker, M., Pfister, H., van Baar, J., & Gross, M. (2001). EWA Volume Splatting. IEEE Visualization.
  3. [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. [4] Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. (2004). Image quality assessment: from error visibility to structural similarity. IEEE TIP.
  5. Kerbl, B., Kopanas, G., Leimkühler, T., & Drettakis, G. (2023). 3D Gaussian Splatting for Real-Time Radiance Field Rendering. SIGGRAPH. project page
  6. [5] Yu, Z., Chen, A., Huang, B., Sattler, T., & Geiger, A. (2024). Mip-Splatting: Alias-free 3D Gaussian Splatting. CVPR.
  7. [6] Huang, B., Yu, Z., Chen, A., Geiger, A., & Gao, S. (2024). 2D Gaussian Splatting for Geometrically Accurate Radiance Fields. SIGGRAPH.
  8. [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.
  9. [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.
  10. [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.
  11. [10] Guédon, A., & Lepetit, V. (2024). SuGaR: Surface-Aligned Gaussian Splatting for Efficient 3D Mesh Reconstruction and High-Quality Mesh Rendering. CVPR.
  12. [11] Morgenstern, W., Barthel, F., Hilsmann, A., & Eisert, P. (2024). Compact 3D Scene Representation via Self-Organizing Gaussian Grids. ECCV.
  13. [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.
  14. graphdeco-inria/gaussian-splatting github.com(官方训练代码,~3000 行,值得通读)
  15. nerfstudio-project/gsplat github.com(更模块化的开源实现)
  16. MrNeRF/awesome-3D-gaussian-splatting github.com(社区维护的论文索引,按主题分类)