磁刻技术实录 · 第 13 篇 · 工程实践 · 技术基线 2026-10-31
我们正在做的「磁刻(Cike)」,是一款鸿蒙原生的生活记录与个性化创作应用。用户可以把照片做成数字冰箱贴,在洞洞板上自由摆放、组合自己的生活片段,也可以把喜欢的内容做成桌面卡片,用来回看、互动和分享。
在空间记录上,我们引入了 3D Gaussian Splatting(3DGS,三维高斯泼溅),把拍摄的视频重建为可以转动、靠近观察的三维场景。为此,我们重构了视频重建链路,实现自己的 C++ 训练核心,并结合鸿蒙原生的媒体处理、模型查看和分享能力,希望让用户保存一段经历时,也能留下当时的空间关系。
为了在鸿蒙端控制训练依赖和计算过程,我们实现了 C++ 解析反向传播。我们愿意展开这部分,是因为画出图像只是起点,误差怎样正确传回位置、尺度和旋转才决定模型能否学好。这篇同时分享推导与验证思路。
可微渲染的关键不是画出图像,而是计算图像误差对场景参数的影响。磁刻的训练器采用手写解析反向传播,从像素损失出发,逐层得到高斯颜色、不透明度、位置、尺度和旋转的梯度。这样减少了对大型自动微分运行时的依赖,也让每一步数学都需要自己验证。
以下代码按当前实现整理,保留关键运算与控制顺序;省略导入、日志和完整类型,部分辅助函数采用说明性命名,片段需结合上下文阅读。
反向过程必须对实际前向函数求导。前向使用什么参数化、截断与归一化,反向就需要在可微区域内匹配这些操作。仅根据论文公式写出一个相似函数,而忽略工程实现中的变换,会产生难以察觉的系统性误差。
图 1:按链式法则逐层传递,并保留屏幕位置梯度
从合成颜色开始反推
一个高斯影响当前像素有两条路径:它直接贡献自己的颜色,也通过不透明度影响后方内容的可见性。因此,Alpha 的导数不能只看当前颜色,还要考虑被遮挡的后景。当前实现从后向前递推后景颜色,并使用前向记录的透射率计算当前贡献。
对基础颜色的梯度相对直接,是像素颜色梯度乘以当前可见权重。对 opacity logit 的梯度还要经过 sigmoid 导数。把优化参数与可见参数区分开,才能保证学习率作用在正确的变量空间。
屏幕位置与形状分别求导
高斯指数中的二次型依赖像素偏移和二维协方差逆。对二维中心求导得到屏幕位置梯度,对椭圆参数求导则描述形状变化。实现单独保存 mean2d 梯度供致密化使用,因为它与包含深度、协方差链路的三维位置梯度含义不同。
这一区分非常重要。若用三维位置梯度直接替代屏幕误差统计,致密化会混入相机投影和尺度因素,导致“哪里需要增加表示能力”的判断发生偏移。
把梯度送回三维参数
二维协方差来自相机变换和投影雅可比,因此其梯度需要继续传播到三维协方差与中心。三维协方差又由旋转和指数尺度构成。对 log scale 的导数需要乘以指数尺度,对四元数则必须包含归一化链路。
同一个高斯可能出现在多个 Tile,并影响很多像素。反向并行时,每个工作单元产生局部贡献,再汇总成最终梯度。直接让多个线程无保护写同一数组,会把正确数学变成不稳定执行结果。线程一致性测试因此属于算法正确性的一部分。
从颜色误差推回不透明度参数
Vec3 tail = {0, 0, 0};
for (int k = contributions.size() - 1; k >= 0; --k) {
const auto& hit = contributions[k];
float da = hit.transmittance * Dot(dLoss_dColor, hit.color - tail);
dColor[hit.id] += hit.alpha * hit.transmittance * dLoss_dColor;
float p = opacity[hit.id];
dOpacityLogit[hit.id] += da * hit.gaussian_weight * p * (1 - p);
tail = hit.alpha * hit.color + (1 - hit.alpha) * tail;
}
tail 保存当前高斯后方的合成颜色,初始黑色对应这里的背景约定。增加当前 Alpha,一方面增强当前颜色,另一方面遮住后方,因此导数中出现 hit.color - tail。只保留第一项,会把遮挡关系漏掉,导致重叠区域的训练方向错误。
最后的 p*(1-p) 来自 sigmoid 导数,说明优化变量仍然是 logit。gaussian_weight 则是空间衰减项,两者不能合成一个不明来源的系数。代码为了讲解省略屏幕位置、协方差与线程局部累积;实际反向还要把这些分支继续传回三维参数。
图 2:数值、结构与训练行为共同发现问题
中心差分是第一道检查
对参数 θ,数值导数可近似为 [L(θ+ε)-L(θ-ε)]/(2ε)。把它与解析导数比较,可以快速找到符号、转置、遗漏链式因子等问题。ε 太小会受到浮点误差影响,太大则不再局部,因此需要针对参数尺度选择并观察稳定区间。
渲染包含覆盖截断、排序和提前结束等非光滑操作。梯度检查应避开分支切换边界,或在专门配置中放宽支撑和阈值。否则,数值扰动改变了可见集合,有限差分与当前分支的解析导数不一致,不一定意味着导数公式错误。
验证不能止于一个高斯
单个球形高斯很容易掩盖旋转问题,因为对称形状对某些旋转不敏感。测试需要各向异性椭球、多相机、重叠遮挡和接近饱和 Alpha。再结合世界坐标旋转一致性与小场景收敛,检查局部导数是否真正支持整体优化。
用有限差分直接审问某一个梯度
float original = parameter;
parameter = original + epsilon;
float plus = EvaluateLoss(scene);
parameter = original - epsilon;
float minus = EvaluateLoss(scene);
parameter = original;
float numeric = (plus - minus) / (2 * epsilon);
float scale = std::max(1e-6f, std::abs(numeric) + std::abs(analytic));
float relative_error = std::abs(numeric - analytic) / scale;
每次只扰动一个参数,其余输入保持一致。parameter = original 必须执行,否则测试本身会逐渐改变场景。相对误差有助于比较不同量级的导数,但导数接近零时还应看绝对误差,避免把舍入噪声放大成很大的百分比。
epsilon 需要扫描一个合理区间,而不是固定成越小越好。透射率提前结束、支撑裁剪和深度排序存在分支边界,扰动跨过边界会改变函数局部形态。应先在稳定可微配置验证数学,再单独检查边界行为;这样才能分清公式错误与离散分支造成的不一致。
手写反向的价值在于可控和可解释,成本则是必须把验证当作实现的一部分。只有当每条梯度路径都有对应测试,后续性能优化才有稳定参照。