3D Gaussian Splatting(3DGS)凭借高斯椭球的显式表示和实时渲染能力,已经成为神经渲染领域的主流方案之一。不过它有一个先天短板:高斯是体积元而非表面元,本身并不携带法线信息。当你想基于3DGS做网格提取、表面对齐、几何质量评估时,法线就成了绕不开的问题。本文介绍几种从3DGS中提取法线的实用方法,重点讲解通过计算渲染来获得法线的思路与实现。

为什么高斯没有天然法线
3DGS用一组带协方差的三维高斯来表示场景,每个高斯由中心位置、尺度向量和旋转四元数定义。从数学上看,一个各向异性的高斯在空间中是一个椭球状的密度分布,它描述的是体积概率密度,而不是一个有明确朝向的表面。渲染时通过alpha blending把所有高斯沿视线方向混合,最终像素的颜色是多个高斯加权和的结果,并不存在一个传统意义上的三角形面片或SDF零等值面,因此没有可以直接读取的法线属性。
最直观的想法是用每个高斯的最短轴方向当作法线。因为高斯在尺度最小的方向上最薄,可以近似认为该方向垂直于它所表示的局部表面。这个方向可以通过对协方差矩阵做特征值分解得到:最小特征值对应的特征向量就是候选法线。但在真实场景中,一个像素的颜色往往由几十个高斯混合而成,简单取最近高斯的短轴会产生严重的噪声和不连续,法线图上会出现明显的斑点状伪影。因此实践中更常用的做法是不依赖单个高斯,而是从渲染结果中重新计算法线,也就是所谓的计算渲染法线(computed rendered normals)。
基于渲染深度图求导的法线计算
这类方法的核心思想是:先渲染一张视角下的深度图,再把深度看作图像平面上的函数,对其求空间导数即可重建表面朝向。深度图本身可以是所有高斯深度的加权混合结果,与颜色渲染共用同一套alpha blending权重,这保证了法线与渲染视图的严格一致性,也是它相对直接读取高斯短轴最大的优势。
屏幕空间求导法的公式很简洁。设像素坐标为(u,v),混合深度为D(u,v),相机内参为f_x、f_y,则在相机坐标系下,表面点的偏导为:
import torch
def depth_to_normal(depth, fx, fy):
# depth: [H, W] 混合深度图(相机坐标系下)
# 返回法线图 [H, W, 3]
dD_dv, dD_du = torch.gradient(depth) # 深度对像素坐标求导
dz_dx = dD_du * fx / depth
dz_dy = dD_dv * fy / depth
# 表面在相机系下的切向量
tx = torch.stack([torch.ones_like(depth), torch.zeros_like(depth), dz_dx], dim=-1)
ty = torch.stack([torch.zeros_like(depth), torch.ones_like(depth), dz_dy], dim=-1)
normal = torch.cross(tx, ty, dim=-1)
normal = normal / (torch.norm(normal, dim=-1, keepdim=True) + 1e-8)
# 统一朝向相机
facing = (normal[..., 2] < 0).float().unsqueeze(-1) * 2 - 1
return normal * facing
这个方法的缺点是对深度噪声极其敏感。因为求导本质上是高通滤波,深度图上哪怕一个像素的微小扰动都会被放大成法线的剧烈跳变。缓解办法有两个:一是在渲染深度时就启用低不透明度过滤,剔除权重很小的高斯,让深度混合更干净;二是在求导前对深度图做一次小半径的联合双边滤波,在保边的同时平滑噪声。另外要注意,在物体边缘和深度不连续处,屏幕空间求导会跨越前后两个表面,产生一条错误法线带,这部分区域需要在后续使用时用深度不连续检测掩掉。
最小二乘平面拟合法
为了进一步抑制噪声,可以把屏幕空间一阶求导升级为局部平面拟合。做法是以目标像素为中心取一个K×K的邻域窗口,把窗口内的深度反投影回相机坐标系得到一组三维点,然后拟合一个平面,平面法线就作为该像素的法线。由于窗口内点数多于三个,问题变成超定方程,用最小二乘求解,等价于对局部点云做PCA取最小特征向量。
import torch
def fit_local_normals(depth, K=7, fx=500.0, fy=500.0, cx=None, cy=None):
H, W = depth.shape
if cx is None:
cx, cy = W / 2, H / 2
# 生成像素网格并反投影到相机系
v, u = torch.meshgrid(torch.arange(H), torch.arange(W), indexing='ij')
x = (u - cx) * depth / fx
y = (v - cy) * depth / fy
pts = torch.stack([x, y, depth], dim=-1) # [H, W, 3]
pad = K // 2
# 用unfold提取局部窗口 [H, W, K*K, 3]
win = pts.unfold(0, K, 1).unfold(1, K, 1).reshape(H - pad * 2, W - pad * 2, 3, -1)
win = win.permute(0, 1, 3, 2) # [H', W', N, 3]
center = win.mean(dim=2, keepdim=True)
diff = win - center
cov = torch.matmul(diff.transpose(-1, -2), diff) # 局部协方差
eigvals, eigvecs = torch.linalg.eigh(cov)
normal = eigvecs[..., :, 0] # 最小特征值对应的特征向量
normal = normal / (torch.norm(normal, dim=-1, keepdim=True) + 1e-8)
facing = torch.where(normal[..., 2:3] < 0, -normal, normal)
return facing
拟合窗口的大小是一个关键的超参数。窗口太小,噪声抑制不足,效果退化成一阶求导;窗口太大,则会跨越曲率变化区域和深度边缘,把拐角磨圆,损失几何细节。经验上K取5到9比较合适,也可以采用先小窗口后大窗口的两级拟合,取两者结果差异小的区域作为可信掩码。相比屏幕空间求导,平面拟合对随机噪声的鲁棒性明显更好,代价是计算量上升,不过在GPU上批量做eigh分解,一张1080P法线图的耗时通常也在可接受范围内。
把法线渲染纳入训练流程
上面两种方法都是后处理性质,即训练完成后离线计算法线。更进一步的做法是把法线作为可微渲染的一部分放进训练循环:在alpha blending时,除了混合颜色,还同时混合每个高斯的短轴方向,得到一张平滑的法线渲染图,并添加法线一致性损失,约束高斯的短轴与从深度图计算出的伪法线对齐。这样训练出来的3DGS在高斯形状上更贴近真实表面,后续无论做网格提取还是重新光照,质量都有明显提升。
需要注意的是,直接混合方向向量会出现正负抵消问题,两个方向相反的法线混合可能得到接近零的向量。解决思路是对参与混合的高斯法线先做符号对齐:如果某高斯法线与当前视角下已累积的加权平均方向点积为负,就先翻转再累加。另外还可以引入先验法线监督,比如用单目法线估计网络对渲染法线图做监督,这在弱纹理区域尤其有效,因为这类区域深度往往不可靠,而法线先验能提供额外的几何约束。
从下游应用角度看,渲染法线的价值主要体现在三个场景:一是作为网格提取的辅助信息,在TSDF融合或泊松重建时用法线加权,可以显著改善平面区域的平整度;二是用于表面对齐评估,把法线图与真实扫描的法线做余弦相似度统计,得到比PSNR更反映几何质量的指标;三是光照一致性检查,法线错误的位置在重新渲染时往往出现光照异常,可以作为定位重建缺陷的线索。
方法对比与选择建议
综合来看,三种路线各有定位。直接读取高斯短轴实现最简单,一个特征值分解就完成,但噪声大,只适合快速预览或作为训练早期的粗监督信号;屏幕空间深度求导速度快、与渲染严格一致,适合需要实时法线的应用,但对深度噪声和边缘效应敏感;最小二乘平面拟合精度和稳定性最好,是离线几何分析的首选,代价是参数调节和计算开销。把法线融进训练流程则是追求最终几何质量的方案,训练时间通常会增加两到三成。
实际工程中一个常见的组合策略是:训练时用轻量的短轴混合法线加一致性损失做正则,推理和离线分析时再用平面拟合法线生成高质量的最终结果。这样既控制了训练成本,又能拿到可用于几何分析的法线数据。如果你的目标是提取网格,建议在拟合法线后再做一次基于深度不连续的区域分割,把边缘处的错误法线区域剔除,能进一步提升重建网格的边界质量。法线提取虽是3DGS管线中的一个小环节,却直接决定了几何类下游任务的上限,值得在实现时多花一些心思。