点云着色不自然的根本原因,往往不是颜色本身错了,而是光照结构丢失。真实场景中,墙角、家具缝隙、叠放物体接触面会因为相互遮挡而变暗,这种被称为环境光遮蔽的现象在人眼判断空间形态时起着关键作用。直接把照片颜色贴到点上,相当于假设每个点都被环境均匀照亮,结果就是整片点云像浮在半空,缺乏体积感。要修复这一问题,需要两条技术路线并行:一是通过烘焙环境光遮蔽贴图把遮挡关系固化成灰度信息,再传递到点云;二是利用光照估计补全低频入射光方向,让漫反射响应符合现场光线。

环境光遮蔽贴图烘焙原理与网格预处理
环境光遮蔽(Ambient Occlusion,简称AO)衡量的是某个表面点能被环境光照射到的比例。其数学本质是半球空间内未被遮挡的方向占比,值越接近0表示越被包裹在缝隙中,值越接近1表示开阔受光。实际烘焙时,我们通常不直接对稀疏点云计算,而是先由点云重建出水面网格,因为射线投射在连续表面上更稳定,也能避免点间距带来的噪声。
网格预处理包括去除孤立碎片、补洞和统一法线方向。若法线朝内,烘焙工具会误判遮挡,输出反向的亮暗。接着在建模软件或离线渲染器中设置天光(sky light),关闭直接光源,仅让光线从半球各处入射。烘焙分辨率要根据网格面积权衡,过高会放大几何瑕疵,过低则丢失角落细节。下面是一段用Python调用简易射线法估算AO的示例,帮助理解底层逻辑而非生产代码。
import numpy as np
def estimate_ao(point, normal, mesh_triangles, samples=64):
# point: 表面点坐标 (3,)
# normal: 单位法线 (3,)
# mesh_triangles: 三角形列表,每个为三个顶点
hit = 0
for i in range(samples):
# 在法线半球内随机方向
d = random_hemisphere_direction(normal)
if ray_intersect(mesh_triangles, point + normal*0.001, d):
hit += 1
return 1.0 - hit / samples
def random_hemisphere_direction(normal):
z = np.random.rand()
phi = 2 * np.pi * np.random.rand()
r = np.sqrt(1 - z*z)
local = np.array([r*np.cos(phi), r*np.sin(phi), z])
# 省略将local转到以normal为z轴的变换
return local
def ray_intersect(tris, origin, direction):
# 简化:遍历三角形做相交测试
for t in tris:
if triangle_hit(t, origin, direction):
return True
return False
上述代码展示了AO的核心思想:从表面点沿法线半球发射若干射线,被网格挡住的比例就是遮蔽量。生产环境会使用层次包围盒加速,但原理一致。烘焙出的AO贴图是一张灰度图,白色代表完全受光,黑色代表深缝。需要注意的是,AO只描述几何遮挡,不包含任何颜色光源信息,因此它应与反照率(albedo)分开存储。
将AO贴图映射回点云的具体步骤
有了网格上的AO贴图,下一步是把每个点的UV坐标作为桥梁,采样贴图得到该点的遮蔽值。如果原始点云带有UV,可直接使用;若没有,需要在网格参数化阶段一并生成,或采用顶点色烘焙方式把AO写入网格顶点,再根据点云与网格的最近顶点拷贝属性。实践中推荐先让点云与网格做最近邻配准,确保空间对齐误差小于一个扫描分辨率。
映射完成后,点云每个点应新增一个标量属性,例如叫ao_value,范围0到1。着色时,最终显示颜色等于基础照片色乘以该值,必要时可加一个对比度系数。如下伪代码说明如何合并:
# points: Nx3 坐标
# colors: Nx3 照片色 0-255
# ao_values: N 遮蔽值 0-1
contrast = 1.3
adjusted_ao = np.clip((ao_values - 0.5) * contrast + 0.5, 0, 1)
display_colors = colors * adjusted_ao[:, None]
# 写入点云属性供查看器读取
save_ply('colored_with_ao.ply', points, display_colors, ao_values)
这一步能立刻改善缝隙发亮的问题,但仅用AO仍假设环境光来自均匀半球。如果现场有强窗光或单一主灯,点云会缺少方向性明暗,看起来仍不够自然。因此我们还需引入光照估计,对低频入射光做建模,使点云在保留AO接触阴影的同时,整体受光符合实际场景。
基于球面谐波的光照估计与点云重光照
光照估计的目标是恢复场景中被物体接收的入射辐射度。由于高频光源细节对点云这种稀疏表达意义不大,学界常用二阶球面谐波(SH)表示低频光照。SH系数可从网格渲染图反向拟合:用已知几何和 albedo 渲染不同光照,比较与真实照片的差异,优化出九个系数(RGB各三组)。得到系数后,对任意点法线,可用SH基函数算出该方向的环境光强。
将SH光照与AO结合时,正确做法是先用SH算出无遮挡环境光亮度,再乘以AO值作为最终漫反射。这样既保留了窗边亮、室内暗的大关系,又叠加了墙角变暗的细节。下面示例展示用三阶SH近似计算一个法线方向的光强:
#include <cmath>
#include <vector>
// 简化:仅用0阶和1阶SH
float sh_light(const std::vector<float>& coeff, float nx, float ny, float nz) {
// coeff[0]: 直流项, coeff[1..3]: 线性项
float y0 = 0.282095f;
float y1 = 0.488603f * nx;
float y2 = 0.488603f * ny;
float y3 = 0.488603f * nz;
return coeff[0]*y0 + coeff[1]*y1 + coeff[2]*y2 + coeff[3]*y3;
}
// 结合AO
float final_light = sh_light(coeff, nx, ny, nz) * ao_value;
在工程落地中,如果无法拿到精确 albedo,可用灰度均值代替,先估光照再反推颜色,形成交替优化。对于点云查看器,只要把final_light乘到基础色上即可。经过AO贴图烘焙与光照估计双管齐下,原本扁平的点云会呈现出明确的凹暗、受光面偏转和空间围合感,在文物、建筑和城市扫描展示中尤为关键。最后提醒,烘焙和估计都依赖几何质量,若网格破洞多,应先修复再走流程,否则错误遮挡会延续到点云难以剔除。