3DGS烘焙到网格UV的动机
3D高斯泼溅(3DGS)通过数十万各向异性的高斯核表示场景,配合基于光栅化的渲染管线,能够以接近实时的速度生成高质量新视角图像。但这种表示不适合直接用于传统渲染管线:大多数游戏引擎和移动端框架处理的是三角形网格和纹理贴图,而不是一堆松散的高斯。如果能把高斯的颜色信息转移到网格表面,就能用很低的顶点数和一张纹理贴图复现出相似的视觉效果。

纹理烘焙正是这个转移过程。它需要解决两个核心问题:如何确定每个高斯对应网格上的哪个位置,以及如何将多个高斯的颜色融合到同一个纹理像素中。UV坐标提供了从网格表面到二维纹理空间的映射,因此高斯颜色最终会写入UV贴图的对应区域。烘焙质量取决于投影方式的准确性以及融合权重的设计。
颜色映射与混合策略
要把一个高斯核的颜色烘焙到网格上,最直接的方式是把高斯的中心投影到最近的网格表面。这里可以用两种投影:一种是将高斯沿其法线方向投影到网格表面;另一种是将高斯沿视线方向投影到相机平面后,再反投影到网格上。前者适合静态对象,后者更接近实际渲染时的外观。无论哪种方式,投影点落在哪个三角形上,就决定了该高斯影响哪个UV区域。
对于某个纹理像素,可能有多个高斯投影点落在同一个三角形内或者附近。此时需要一种权重函数来衡量每个高斯的贡献。常见做法是根据高斯中心到像素对应的三维表面点的距离计算高斯权重,并结合优化后的不透明度进行alpha混合。公式可以表示为颜色累积 c_accum = sum(alpha_i * color_i * w_i) / sum(alpha_i * w_i),其中 w_i 是距离相关的高斯衰减因子。这种加权平均能有效减少边缘噪声。
需要注意的是,高斯的尺度参数(协方差矩阵)决定了它在空间中的影响范围。如果投影距离超过高斯半径的2-3倍,可以忽略该高斯对当前像素的贡献。提前做范围裁剪能大幅减少计算量,尤其是在处理百万级高斯时。
实现步骤与代码示例
下面给出一个简化的Python实现流程。假设已经有一个三角形网格(包含顶点、三角形索引和UV坐标)以及一组高斯(中心位置、颜色、不透明度、尺度)。烘焙时遍历每个三角形,将它光栅化到UV空间,对每个纹理像素找到对应三维表面点,然后遍历所有高斯计算贡献。为了提高效率,可以使用空间划分结构如KD树来快速查询邻近高斯。
import numpy as np
from scipy.spatial import cKDTree
# 假设数据已经加载
# vertices: (N, 3) 网格顶点
# triangles: (M, 3) 三角形顶点索引
# uv_coords: (N, 2) 网格顶点UV
# gaussian_centers: (G, 3)
# gaussian_colors: (G, 3)
# gaussian_opacities: (G,)
# gaussian_scales: (G, 3)
def bake_gs_to_uv(tex_res=1024, max_radius=0.1):
# 初始化纹理
tex_color = np.zeros((tex_res, tex_res, 3), dtype=np.float32)
tex_weight = np.zeros((tex_res, tex_res), dtype=np.float32)
# 构建高斯的KD树用于快速查询
kdtree = cKDTree(gaussian_centers)
for tri in triangles:
# 获取三角形三个顶点的三维位置和UV
v0, v1, v2 = vertices[tri[0]], vertices[tri[1]], vertices[tri[2]]
uv0, uv1, uv2 = uv_coords[tri[0]], uv_coords[tri[1]], uv_coords[tri[2]]
# 计算UV空间的包围盒
uv_min = np.minimum(np.minimum(uv0, uv1), uv2)
uv_max = np.maximum(np.maximum(uv0, uv1), uv2)
u_min_i = int(np.floor(uv_min[0] * tex_res))
u_max_i = int(np.ceil(uv_max[0] * tex_res))
v_min_i = int(np.floor(uv_min[1] * tex_res))
v_max_i = int(np.ceil(uv_max[1] * tex_res))
# 裁剪到纹理范围
u_min_i = max(0, u_min_i)
u_max_i = min(tex_res - 1, u_max_i)
v_min_i = max(0, v_min_i)
v_max_i = min(tex_res - 1, v_max_i)
for u in range(u_min_i, u_max_i + 1):
for v in range(v_min_i, v_max_i + 1):
# 像素中心的UV坐标
uv_p = np.array([(u + 0.5) / tex_res, (v + 0.5) / tex_res], dtype=np.float32)
# 使用重心坐标判断是否在三角形内
denom = (uv1[1] - uv2[1]) * (uv0[0] - uv2[0]) + (uv2[0] - uv1[0]) * (uv0[1] - uv2[1])
if abs(denom) < 1e-8:
continue
w0 = ((uv1[1] - uv2[1]) * (uv_p[0] - uv2[0]) + (uv2[0] - uv1[0]) * (uv_p[1] - uv2[1])) / denom
w1 = ((uv2[1] - uv0[1]) * (uv_p[0] - uv2[0]) + (uv0[0] - uv2[0]) * (uv_p[1] - uv2[1])) / denom
w2 = 1.0 - w0 - w1
if w0 < -1e-6 or w1 < -1e-6 or w2 < -1e-6:
continue
# 计算对应的三维表面点
surface_pt = w0 * v0 + w1 * v1 + w2 * v2
# 查询邻近的高斯
indices = kdtree.query_ball_point(surface_pt, r=max_radius)
if not indices:
continue
local_color = np.zeros(3, dtype=np.float32)
local_weight_sum = 0.0
for idx in indices:
center = gaussian_centers[idx]
dist = np.linalg.norm(surface_pt - center)
# 简化:使用欧氏距离计算权重
sigma = np.mean(gaussian_scales[idx])
w = np.exp(-dist * dist / (2.0 * sigma * sigma))
if dist < max_radius * 0.5:
alpha = gaussian_opacities[idx]
local_color += alpha * w * gaussian_colors[idx]
local_weight_sum += alpha * w
if local_weight_sum > 0:
tex_color[v, u] = local_color / local_weight_sum
tex_weight[v, u] = 1.0
# 对未填充的像素进行简单扩散填充
# 这里仅返回结果,实际需要做padding处理
return tex_color, tex_weight
上述代码没有处理接缝和空洞,实际使用时需要做后处理。此外,KD树查询半径 max_radius 需要根据高斯尺度动态调整,或者为每个三角形预先收集相关高斯。更高效的做法是在烘焙前对每个三角形建立一个高斯列表。
在真实项目中,烘焙过程通常结合CUDA或PyTorch加速,把三角形光栅化和高斯卷积放在GPU上并行执行。如果使用PyTorch,可以利用自定义算子,但逻辑和上述循环一致。
接缝处理与颜色空间注意事项
UV接缝处容易出现颜色断裂,原因是同一个三维点在不同三角形中被分配到了不同的UV坐标。解决这个问题的一个常用方法是在烘焙时进行“扩展填充”:对于每个纹理像素,不仅考虑其所在三角形,还考虑相邻三角形在UV空间中重叠或相邻的区域。也可以在烘焙完成后对纹理做形态学膨胀,将有效像素向周围扩散几个像素。这样在接缝两侧就有足够的像素过渡,减少渲染时的明显裂缝。
另一个容易被忽略的问题是颜色空间。3DGS优化通常在RGB线性空间中进行,而常见纹理贴图使用sRGB。如果直接将线性颜色写入sRGB纹理,画面会偏暗或偏灰。正确做法是在写入纹理前将线性值转换为sRGB,或者存储为线性纹理并在shader中处理。烘焙时建议使用线性格式(如EXR),并在导入引擎后再做转换。
最后,烘焙分辨率需要根据网格UV布局和表面复杂程度选择。过低的纹理分辨率会导致颜色模糊,过高会浪费显存并放大接缝问题。一般从1024x1024开始,检查视觉质量后逐步调整。对于具有复杂颜色变化的区域,可以手动增加UV密度。
通过上述流程,3DGS场景就能转换为带纹理的网格,用于更广泛的实时应用。结合网格简化与UV重展开,可以进一步优化存储和渲染性能。

