把一个虚拟茶杯放进一张360度全景照片里,让它看起来真的“站在”那个环境中,是AR渲染里非常考验功力的需求。模型本身好不好看是其次,关键是光照必须骗过人眼:高光的位置要对、颜色要贴近环境、阴影的方向要和全景图里的亮度分布一致。这篇文章完整讲解在iOS端用Core Image完成全景图光照估计、球面谐波系数计算,再到Metal着色器里实现高光反射与阴影投射的整套流程。

为什么用球面谐波来表示全景光照
球面全景图通常以等距柱状投影(Equirectangular)格式存储,宽是高的两倍,经度方向360度、纬度方向180度。要做光照估计,最朴素的想法是直接把全景图当成环境贴图采样,这在计算漫反射时还行,但一遇到高光反射就要在着色器里做多次采样甚至预滤波,性能开销不小。
球面谐波(Spherical Harmonics,简称SH)提供了一种压缩表示。任何一个定义在球面上的函数都可以展开成一组正交基函数的线性组合,光照函数也不例外。实际工程中用到三阶就够了:前3阶一共9个系数,每个系数是一个RGB颜色,总共27个浮点数就能近似表达整个全景图的环境光分布。渲染时法线朝向和这9个系数做一次点积,就能得到该方向的入射光颜色,速度非常快。
三阶SH只能还原低频光照,也就是大方向的明暗和色调。这恰好是漫反射需要的,但高光需要更高频的信息,所以高光部分我们后面会用另一种策略:从全景图裁剪出反射向量对应的小区域再做模糊采样,兼顾效果和性能。
用Core Image计算全景图的球面谐波系数
计算SH系数的思路是对全景图所有像素做加权积分。等距柱状图上每个像素对应球面上一个立体角,纬度越高单个像素覆盖的立体角越小,所以积分时要乘以cos(纬度)的权重。用Core Image可以先把图缩小到64x32这种小尺寸再计算,误差完全够用,还能省下大量计算时间。
具体实现分两步:第一步用CIImage加载全景图,通过CILanczosScaleTransform降采样;第二步拿到像素数据后逐像素累加。下面是核心代码:
- (void)computeSHCoefficientsFromImage:(CIImage *)image
{
// 降采样到 64x32,减少计算量
CIFilter *scale = [CIFilter filterWithName:@"CILanczosScaleTransform"];
[scale setValue:image forKey:kCIInputImageKey];
[scale setValue:@(64.0 / image.extent.size.width) forKey:kCIInputScaleKey];
CIContext *context = [CIContext contextWithOptions:nil];
CGColorSpace *colorSpace = CGColorSpaceCreateDeviceRGB();
uint8_t *pixels = (uint8_t *)malloc(64 * 32 * 4);
[context render:[scale outputImage]
toBitmap:pixels
bitmapWidth:64
bitmapHeight:32
bitsPerComponent:8
bytesPerRow:64 * 4
space:colorSpace
premultiplied:NO];
// 9个系数,每个是 RGB 三通道
simd_float3 coeffs[9] = {0};
static const float c0 = 0.282095f;
static const float c1 = 0.488603f;
for (int y = 0; y < 32; y++) {
float theta = (y + 0.5f) / 32.0f * M_PI; // 纬度
float weight = sinf(theta); // 立体角权重
for (int x = 0; x < 64; x++) {
float phi = (x + 0.5f) / 64.0f * 2.0f * M_PI; // 经度
int idx = (y * 64 + x) * 4;
simd_float3 color = simd_make_float3(
pixels[idx] / 255.0f,
pixels[idx + 1] / 255.0f,
pixels[idx + 2] / 255.0f);
// 只计算前两阶(4个系数),三阶类似扩展
coeffs[0] += color * c0 * weight;
coeffs[1] += color * (c1 * sinf(theta) * cosf(phi)) * weight;
coeffs[2] += color * (c1 * sinf(theta) * sinf(phi)) * weight;
coeffs[3] += color * (c1 * cosf(theta)) * weight;
}
}
float norm = 64.0f * 32.0f / (4.0f * (float)M_PI);
for (int i = 0; i < 9; i++) {
coeffs[i] *= norm; // 归一化后传给着色器
}
free(pixels);
}这段代码只展开了前两阶,剩下的高阶项按公式补齐即可。系数算好后建议缓存到磁盘,同一张全景图不必重复计算。另外要注意Core Image渲染位图时颜色空间的问题,如果全景图是HDR格式,先把CIContext换成支持浮点输出的配置,否则亮部会被截断,导致估计出的整体光照偏暗。
Metal着色器中的漫反射与高光实现
拿到9个系数后,把它们作为常量缓冲传进Metal vertex shader的参数结构里。片元着色器中对每个像素,用世界空间法线和SH基函数求值,再与系数加权求和,得到该点的入射辐照度。这部分计算量极小,即使全屏渲染也毫无压力。
高光的处理分两层:低频部分直接混入SH光照的结果作为基础色调;高频部分则用反射向量去采样预处理过的全景 mip 图。预处理可以在Core Image里用CIGaussianBlur配合多次降采样生成一个模糊金字塔,运行时根据材质粗糙度选择对应的模糊级别,这就是简化版的预滤波环境贴图。着色器核心逻辑如下:
fragment float4 shadeFragment(VertexOut in [[stage_in]],
constant SHCoeffs &sh [[buffer(1)]],
texture2d<float> panorama [[texture(0)]],
sampler bilinear [[sampler(0)]])
{
float3 n = normalize(in.worldNormal);
float3 v = normalize(in.cameraPos - in.worldPos);
float3 r = reflect(-v, n);
// 漫反射:SH 三阶求值
float3 diffuse = sh.c0
+ sh.c1 * n.y + sh.c2 * n.z + sh.c3 * n.x
+ sh.c4 * (n.x * n.y) + sh.c5 * (n.y * n.z)
+ sh.c6 * (n.z * n.z - 1.0 / 3.0) + sh.c7 * (n.x * n.z)
+ sh.c8 * (n.x * n.x - n.y * n.y);
diffuse = max(diffuse, 0.0) / 3.14159265;
// 高光:反射向量转成等距柱状图的 uv 坐标采样
float phi = atan2(r.z, r.x);
float theta = acos(clamp(r.y, -1.0, 1.0));
float2 uv = float2(phi / (2.0 * 3.14159265) + 0.5, theta / 3.14159265);
float3 specular = panorama.sample(bilinear, uv, level(in.roughness * 6.0)).rgb;
// Fresnel 项简化处理
float f = pow(1.0 - max(dot(n, v), 0.0), 5.0);
specular *= 0.04 + 0.96 * f;
float3 color = diffuse * in.albedo + specular * in.metallic;
return float4(color, 1.0);
}这里有个细节值得注意:反射向量转uv时,经度要加0.5做偏移,否则高光位置会和全景图对不上,排查这种错位最好的办法是先渲染一个纯镜面球放在场景里,肉眼比对球面上的倒影和背景是否吻合。
阴影投射与接收的完整方案
光照对了但阴影不对,融合感会立刻崩塌。全景图场景里没有真实几何体,无法直接用传统shadow map,所以采用的方案是“虚拟物体互投 + 环境光遮蔽近似”。虚拟物体之间用标准shadow map:以SH系数反推的主光照方向为光源位置,渲染一张深度贴图,再在主pass里做常规的阴影测试。
主光照方向可以直接从一阶SH系数提取:coeffs[1]到coeffs[3]三个向量合成的方向就是环境亮度的重心方向,把它的归一化结果乘以一个足够大的距离当作平行光位置即可。至于虚拟物体在全景地面上“落地”的阴影,可以额外画一个半透明渐变的椭圆贴片,颜色取SH系数中较暗分量的反色,透明度随离物体的距离衰减,这种假阴影在全景融合里的观感出乎意料地好。
接收全景图自身投影的阴影(比如虚拟物体应该被全景里的一棵树的影子盖住)属于更高级的需求,需要从全景图反推深度,通常依赖深度估计模型,超出Core Image的能力范围。实践中更务实的做法是交互式地摆放物体,避开明显矛盾的区域,或者由设计师手动指定遮挡关系。
性能优化与工程落地建议
整套链路里最耗时的部分是一次性的SH系数计算和模糊金字塔生成,这两步都应该放到后台线程,在全景图加载完成后异步执行,主线程只负责把结果提交给渲染循环。Core Image的CIContext建议复用一个实例,反复创建的开销在高频操作下非常可观。
渲染端如果用SceneKit,可以把SH系数喂给MDLSphericalHarmonicsCoefficients配合SCNLight的probe类型使用;如果是自绘Metal管线,就按上文方式手动传常量。两种路线我都在实际项目里验证过,自绘管线的可控性更强,尤其是高光采样那一层,SceneKit封装后很难精细控制mip级别选择。
最后提醒一点色彩管理:全景图经过Core Image处理时默认工作在线性空间,而Metal输出到屏幕是sRGB,中间的 gamma 转换如果遗漏,会出现物体比背景“发灰”或“过艳”的问题。确保在着色器输出前统一做一次pow(color, 1.0 / 2.2)或者配置好MTLPixelFormatBGRA8Unorm_sRGB,让虚拟物体和全景背景走完全一致的颜色路径,融合效果才能真正经得起细看。
Core Image球面谐波光照估计修改时间:2026-09-08 03:54:39