做AR融合或者全景图查看器的同学大概都遇到过同一个问题:虚拟物体的光照和真实环境对不上。一个金属质感的茶壶放在客厅全景图中央,如果高光位置不随环境亮度分布变化,看上去就像贴图贴上去的,完全没有真实感。解决这个问题的经典思路是环境光照估计,而从全景图提取光照最常用的数学工具就是球面谐波。本文围绕这套方案在iOS端的完整落地展开,覆盖Core Image的图像预处理、球面谐波系数的GPU投影计算,以及基于预过滤环境贴图的高光反射实现。

一、为什么是球面谐波而不是直接采样
环境光照本质上是一个定义在球面上的函数,全景图就是这个函数的离散采样。最朴素的做法是渲染时对全景图做多次采样求平均,得到漫反射颜色。但这个做法在实时渲染里代价太高:每个片元、每个方向都要采样,而且光照是低频信息,用几百万像素去表达几十个自由度的信号是一种浪费。
球面谐波(Spherical Harmonics,简称SH)是一组定义在球面上的正交基函数,类似于一维的傅里叶基在球面上的推广。任何球面函数都可以投影到这组基上,得到一组系数。关键结论是:环境漫反射是极其低频的信号,只用前3阶(共9个系数)就能还原到90%以上的精度;高光部分频率稍高,一般5阶25个系数也够用了。这样一来,运行时每个片元只需要做9次点乘就能拿到漫反射光照,成本几乎可以忽略。
另一个好处是旋转不变性相关的性质:物体旋转等价于对SH系数做旋转,意味着当虚拟物体或相机姿态变化时,不需要重新采样全景图,只需要变换系数,这对AR场景里频繁的姿态更新非常友好。
二、Core Image预处理:把全景图变成可采样的HDR数据
iOS端拿到手的全景图通常是equirectangular(等距圆柱投影)格式的JPEG或PNG,是LDR数据。要做光照估计,第一步是恢复或者近似HDR信息。Core Image提供了CIToneMapFilter和CIHighlightShadowAdjust等滤镜,但更实用的做法是自己写Core Image Kernel做简单的逆Gamma和亮度缩放,把图像转成线性空间的浮点纹理。
下面的代码展示了用Core Image处理全景图并输出线性HDR纹理的流程。注意输出纹理要使用MTLTextureUsageShaderRead,后面球面谐波的GPU计算和渲染着色器都要读它:
// Objective-C: 将全景图转为线性HDR纹理
CIContext *context = [[CIContext alloc] initWithOptions:@{
kCIContextWorkingColorSpace: (id)CGColorSpaceCreateLinearSRGB()
}];
CIImage *pano = [CIImage imageWithContentsOfURL:panoURL];
// 逆Gamma,恢复线性亮度
CIFilter *gamma = [CIFilter filterWithName:@"CIGammaAdjust"];
[gamma setValue:pano forKey:kCIInputImageKey];
[gamma setValue:@0.4545 forKey:@"inputPower"]; // 1/2.2 的倒数
CIImage *linear = gamma.outputImage;
id<MTLTexture> envTexture = nil;
// 渲染到浮点纹理,供后续GPU采样
[context startTaskToRender:linear
toTexture:mtlTexture
commandBuffer:nil
bounds:linear.extent
colorSpace:NULL];
处理完的纹理宽高比是2:1,水平方向覆盖360度,垂直方向覆盖180度。采样时需要注意纬度越高一行像素代表的立体角越小,采样权重必须乘以sin(theta),否则极地区域会被过度加权,导致估计出的环境光偏亮。这是实践中最常见的错误之一。
三、球面谐波系数的GPU投影计算
投影的数学形式很简单:系数等于环境亮度函数与基函数在球面上的积分,离散化后就是对全景图所有像素求加权和。基函数前3阶共9个,每个像素贡献一次,用Metal compute shader来做非常合适。下面是核心的kernel代码,只展示计算逻辑:
// Metal compute shader: 从全景纹理计算前3阶SH系数
kernel void shProjection(texture2d<float> pano [[texture(0)]],
device float3 *shCoeffs [[buffer(0)]],
uint2 gid [[thread_position_in_grid]])
{
uint w = pano.get_width();
uint h = pano.get_height();
if (gid.x >= w || gid.y >= h) return;
// 转成球面方向
float u = (float)gid.x / w;
float v = (float)gid.y / h;
float theta = v * M_PI_F; // 极角
float phi = u * 2.0 * M_PI_F; // 方位角
float3 dir;
dir.x = sin(theta) * cos(phi);
dir.y = cos(theta);
dir.z = sin(theta) * sin(phi);
float3 rgb = pano.read(gid).rgb;
float weight = sin(theta) * (2.0 * M_PI_F / w) * (M_PI_F / h);
// 前9个基函数 Y00, Y1-1..Y11, Y2-2..Y22
float Y[9];
Y[0] = 0.282095;
Y[1] = 0.488603 * dir.y;
Y[2] = 0.488603 * dir.z;
Y[3] = 0.488603 * dir.x;
Y[4] = 1.092548 * dir.x * dir.y;
Y[5] = 1.092548 * dir.y * dir.z;
Y[6] = 0.315392 * (3.0 * dir.z * dir.z - 1.0);
Y[7] = 1.092548 * dir.x * dir.z;
Y[8] = 0.546274 * (dir.x * dir.x - dir.y * dir.y);
for (int i = 0; i < 9; i++) {
shCoeffs[i] += rgb * Y[i] * weight;
}
}
得到9个系数后,渲染时重建漫反射光照只需9次点乘。对于基于PBR的渲染,还有一个广为人知的技巧:来自Ramamoorthi和Hanrahan的论文结论表明,把余弦项(兰伯特项)预先和SH基函数卷积,系数分别是A0=3.141593、A1=2.094395、A2=0.785398,直接乘到对应阶的系数上即可,不需要任何积分运算。
漫反射部分在片元着色器里的重建代码大致如下,其中normal是表面法线:
// 片元着色器:SH重建漫反射环境光
float3 shDiffuse(float3 n, constant float3 *c)
{
float3 result =
c[0] * 0.886227 +
c[1] * 1.023328 * n.y +
c[2] * 1.023328 * n.z +
c[3] * 1.023328 * n.x +
c[4] * 0.858086 * n.x * n.y +
c[5] * 0.858086 * n.y * n.z +
c[6] * 0.247708 * (3.0 * n.z * n.z - 1.0) +
c[7] * 0.858086 * n.x * n.z +
c[8] * 0.429043 * (n.x * n.x - n.y * n.y);
return max(result, 0.0);
}
四、高光反射:预过滤环境贴图与粗糙度
球面谐波只适合低频的漫反射,高光对频率敏感,尤其是光滑表面,反射的是环境的清晰影像,9个系数远远不够。主流做法来自Epic Games在UE4中提出的split-sum近似:把镜面反射拆成两部分,一部分是预过滤的环境贴图,另一部分是BRDF积分查找表。
预过滤贴图的生成就是对原始全景图按不同粗糙度做多次模糊卷积,卷积核依据GGX分布采样半程向量方向。在iOS端可以用Metal Performance Shaders的卷积配合自己写的采样kernel,生成一组从光滑到粗糙的mipmap链。渲染时根据材质的roughness值选择对应层级,再用法线反射向量采样,就得到高光的环境贡献。
这里有一个性能上的取舍值得说明:预过滤贴图链的生成是一次性成本,可以在加载全景图后异步完成;而如果每次姿态变化都重算SH系数,2K分辨率的全景图在A系列芯片上用compute shader大约只需要几毫秒,完全可以每帧更新。反过来,如果把预过滤也做成每帧更新,开销会明显上升,一般不建议,除非环境光源动态变化非常剧烈。
高光和漫反射合成时,别忘了菲涅尔项。用Schlick近似即可,F0取材质的基础反射率,金属的F0是有颜色的,非金属一般是0.04的灰。合成公式为color = albedo * shDiffuse + specular * prefilteredSample * fresnel,再乘上环境光遮蔽系数,虚拟物体就能和全景图在明暗分布上对齐了。
五、工程细节与常见坑
第一,色彩空间必须全程线性。Core Image默认工作在sRGB空间,如果忘记设置kCIContextWorkingColorSpace为线性空间,估计出的SH系数会系统性偏暗,而且这种偏差很难在渲染端补偿回来。
第二,equirectangular采样时的接缝问题。全景图水平方向是循环的,采样时phi方向要取模,否则在phi接近2PI的位置会出现一条肉眼可见的缝隙,预过滤贴图上尤其明显。
第三,iOS上Metal的buffer大小限制。SH系数虽小,但预过滤贴图是mipmap链,注意MTLTextureDescriptor的mipmapLevelCount要和生成时的层级匹配,层级不足时高粗糙度采样会越界读到黑色。
第四,实时AR场景下可以用空间换时间:把SH系数计算放到后台队列,渲染端使用双缓冲的系数,更新完成后再原子交换,避免渲染线程和计算线程竞争同一块buffer。
整体而言,这套方案的渲染端成本非常低,漫反射是常数时间的9次点乘,高光是一次纹理采样,在iPhone上以60帧渲染几十个虚拟物体毫无压力。瓶颈主要在预处理阶段,而预处理又是可缓存的一次性成本,架构上把这两部分分离清楚,工程就能稳定落地了。
球面谐波Core Image高光反射修改时间:2026-09-11 17:26:59