磁刻技术实录 · 第 12 篇 · 工程实践 · 技术基线 2026-10-31
我们正在做的「磁刻(Cike)」,是一款鸿蒙原生的生活记录与个性化创作应用。用户可以把照片做成数字冰箱贴,在洞洞板上自由摆放、组合自己的生活片段,也可以把喜欢的内容做成桌面卡片,用来回看、互动和分享。
在空间记录上,我们引入了 3D Gaussian Splatting(3DGS,三维高斯泼溅),把拍摄的视频重建为可以转动、靠近观察的三维场景。为此,我们重构了视频重建链路,实现自己的 C++ 训练核心,并结合鸿蒙原生的媒体处理、模型查看和分享能力,希望让用户保存一段经历时,也能留下当时的空间关系。
自研训练器要判断模型是否接近拍摄内容,首先得把当前高斯画成一幅图像。这篇分享前向光栅化,把空间椭球、屏幕投影、Tile 和透明合成连起来,让磁刻的训练过程能够从具体计算而非一个抽象接口开始理解。
前向光栅化回答一个具体问题:给定相机和高斯集合,观察图像应该是什么样。磁刻的 CPU 训练器按高斯、Tile 和像素三个层次组织工作,使昂贵的协方差计算与大量像素合成分离。这样既便于验证,也为后续替换执行后端提供结构。
以下代码按当前实现整理,保留关键运算与控制顺序;省略导入、日志和完整类型,部分辅助函数采用说明性命名,片段需结合上下文阅读。
一个直接实现可以遍历每个像素,再检查所有高斯。它很容易理解,却会访问大量不影响当前像素的分布。Tile 方法先划分屏幕,把高斯只分配给覆盖到的区域,从而缩小每个像素需要考虑的候选集合。
图 1:几何准备按高斯进行,颜色合成按像素进行
投影的不只是中心位置
中心按照相机外参和透视投影得到二维位置。形状则需要把三维协方差变换到相机空间,再使用投影的局部雅可比近似得到二维协方差。二维椭圆因此会随着相机角度和深度改变,而不是永远保持一个固定圆点。
二维协方差的逆决定像素相对高斯中心的距离度量。对于偏移 d,可以用 q=dᵀΣ₂D⁻¹d 计算指数衰减。像素 Alpha 则由基础不透明度与 exp(-q/2) 相乘得到。这里的形状计算必须与反向传播采用同一约定。
覆盖范围决定工作量
高斯理论上具有无限支撑,但工程实现采用有限范围和贡献阈值。根据屏幕椭圆计算包围范围后,把它加入相交 Tile 的列表。巨大高斯可能覆盖很多 Tile,因此高斯数量并不是工作量的唯一指标,屏幕覆盖面积同样重要。
候选列表建立后,每个 Tile 按深度排序。这里使用的是实现定义的深度近似,并非对复杂交叠形状进行精确逐像素几何排序。理解这种近似,有助于区分算法结构与可能出现的透明层次误差。
透射率把多个贡献连接起来
由前向后合成时,令 T 为前面所有高斯留下的透射率。当前高斯贡献为 T×alpha×color,之后更新 T=T×(1-alpha)。当前实现的小贡献过滤与透射率提前结束,减少了几乎不可见的计算。
color = 0
transmittance = 1
for gaussian in depthOrder:
alpha = opacity * gaussianWeight(pixel)
color += transmittance * alpha * baseColor
transmittance *= 1 - alpha
三维形状先变到相机,再投影到屏幕
Vec3 p = camera.rotation * g.mean + camera.translation;
if (p.z <= near_depth) return;
Mat3 sigma_camera = camera.rotation * Covariance3D(g)
* Transpose(camera.rotation);
Mat23 j = {{fx / p.z, 0, -fx * p.x / (p.z * p.z)},
{0, fy / p.z, -fy * p.y / (p.z * p.z)}};
Mat2 sigma_screen = j * sigma_camera * Transpose(j);
sigma_screen += minimum_variance * Identity2();
Vec2 center = {fx * p.x / p.z + cx, fy * p.y / p.z + cy};
j 是透视投影对相机坐标的雅可比。它随深度变化,所以相同的三维椭球远离相机以后,投影通常变小。代码同时变换位置与协方差:只更新中心、仍使用世界坐标的形状,会让高斯在相机旋转时表现异常。
对角线加入最小屏幕方差用于避免投影退化并提供像素尺度的平滑。这个参数不是世界空间半径,不能随场景尺度随意放大。投影计算应保持一致的相机约定;训练器的正向深度与 GLES 眼空间符号不同,不能逐字复制公式却忽略坐标变换。
图 2:把重复数学与像素工作分开组织
缓存要服务后续求导
训练不仅需要最后颜色,还需要解释颜色怎样形成。前向准备的相机空间位置、投影形状、颜色和不透明度可以被反向复用。设计缓存时要区分与视角有关的数据和不变参数,避免换相机后误用旧投影。
并行计算也应遵循数据所有权。不同 Tile 可以处理独立像素区域,减少前向写冲突。反向则会让不同 Tile 为同一个高斯累加梯度,需要另行设计归约。前向并行容易,并不意味着反向可以直接照搬相同写法。
用参考实现建立可信起点
项目测试保留逐像素参考路径,以小场景比较 Tile 结果。参考实现不必追求速度,它的价值是结构简单,便于检查。比较时必须使用相同相机、阈值和背景约定,否则差异可能来自配置,而不是 Tile 算法。
一个像素按深度顺序累积颜色
Vec3 color = {0, 0, 0};
float transmittance = 1.0f;
for (int id : tile.depth_sorted_ids) {
Vec2 delta = pixel - projected[id].center;
float q = Dot(delta, projected[id].inverse_covariance * delta);
if (q > radius_sigma * radius_sigma) continue;
float alpha = opacity[id] * std::exp(-0.5f * q);
if (alpha < alpha_cutoff) continue;
color += transmittance * alpha * rgb[id];
transmittance *= 1.0f - alpha;
if (transmittance < transmittance_cutoff) break;
}
q 表示当前像素相对椭圆中心的归一化距离。沿着长轴移动相同像素距离,与沿短轴移动的衰减并不相同,因此不能替换为普通圆形距离。transmittance 表示前方高斯之后还剩多少透光比例,越靠前且越不透明的元素,越会削弱后方贡献。
Tile 限制候选集合,支撑范围跳过离得太远的像素,透射率阈值提前结束几乎已经遮满的像素。它们分别减少不同层次的工作。调阈值需要同时检查画质和梯度,因为裁剪条件改变的不只是速度,还改变了实际参与计算的集合。
还应覆盖椭球旋转、画面边缘、近裁面、极小透明度和大范围覆盖。只有普通正面场景通过,无法证明各种几何条件都正确。前向是整套训练的基准:若渲染函数本身错误,梯度即使忠实求导,也只会更快优化一个错误目标。