要在iOS端对图像叠加基于球面谐波的环境光照,Core Image的自定义内核提供了一条绕过传统3D渲染管线的可行路径。球面谐波能把任意环境贴图压缩成9个低频系数,在像素着色阶段重建漫反射辐照度,配合法线贴图和深度信息,还能近似模拟全局光照与柔和阴影。下面从球面谐波的数学基础开始,逐步给出能在Core Image渲染链路中直接运行的Metal内核代码,并分析移动端性能优化的关键点。

一、球面谐波如何压缩环境光照
球面谐波函数是一组定义在球面上的正交基函数,类似于傅里叶变换在圆上的作用。任意球面函数都可以投影到这些基函数上,用一组系数近似表示。对于环境光照来说,低频部分已经能够很好地描述天空光、墙面反射等柔和光分布,而高频部分主要对应锐利的高光或直接光源。因此,移动端实时渲染通常只保留前9个系数,也就是带宽等于2的近似。
实值球面谐波基函数中,前两个阶对应的表达式并不复杂。0阶只有一个常数项,1阶包含法线方向的三个分量,2阶包含五个二次项组合。把环境贴图与余弦卷积之后,漫反射辐照度可以写成这9个基函数的线性组合。卷积会改变系数幅值,工程中常常把卷积因子预先乘入系数,这样着色器只需要计算法线对应的基函数值并与系数点积,就能得到该法线方向上的漫反射环境光。常见的卷积因子分别是π、2π/3和π/4,对应0阶、1阶和2阶。
从HDR环境贴图生成这9个RGB系数可以在CPU端或GPU端预先完成。iOS项目中,可以把系数存储到一张3x3的浮点纹理里,每个像素的RGB对应一组系数。这样做的原因是Core Image自定义内核的标量参数传递能力有限,而sampler可以稳定地读取纹理数据。后续计算时,内核从这张小纹理中依次取出系数,避免了传递数组带来的兼容性问题。
二、用Core Image自定义内核计算SH漫反射
Core Image提供了CIColorKernel和CIWarpKernel两类自定义内核,环境光照属于逐像素颜色计算,使用CIColorKernel最合适。开发者可以用Metal Shading Language编写内核函数,通过字符串加载并创建CIColorKernel对象。函数需要接收法线贴图、反照率贴图和SH系数纹理作为输入,输出叠加光照后的像素颜色。
下面的Metal内核展示了完整的SH漫反射计算过程。法线贴图通常以0到1编码法线方向,需要先映射到-1到1区间并归一化。随后根据法线分量计算9个实值基函数值,再从3x3系数纹理中依次采样9个RGB系数,两者点积得到该像素的辐照度。最后乘上反照率贴图的颜色,输出最终结果。
#include <metal_stdlib>
#include <CoreImage/CoreImage.h>
using namespace metal;
using namespace coreimage;
extern "C" float4 shDiffuseLight(sampler normalMap, sampler albedoMap, sampler shCoeffTex, destination dest)
{
float2 uv = dest.coord();
float3 n = normalMap.sample(normalMap.transform(uv)).xyz * 2.0 - 1.0;
n = normalize(n);
float3 coeffs[9];
for (int i = 0; i < 9; i++) {
float2 coeffUV = float2((i % 3 + 0.5) / 3.0, (i / 3 + 0.5) / 3.0);
coeffs[i] = shCoeffTex.sample(shCoeffTex.transform(coeffUV)).rgb;
}
float sh[9];
sh[0] = 0.282095f;
sh[1] = 0.488603f * n.y;
sh[2] = 0.488603f * n.z;
sh[3] = 0.488603f * n.x;
sh[4] = 1.092548f * n.x * n.y;
sh[5] = 1.092548f * n.y * n.z;
sh[6] = 0.315392f * (3.0f * n.z * n.z - 1.0f);
sh[7] = 1.092548f * n.x * n.z;
sh[8] = 0.546274f * (n.x * n.x - n.y * n.y);
float3 irradiance = float3(0.0f);
for (int i = 0; i < 9; i++) {
irradiance += coeffs[i] * sh[i];
}
float3 albedo = albedoMap.sample(albedoMap.transform(uv)).rgb;
float3 color = albedo * irradiance;
return float4(color, 1.0);
}
在Swift中,创建CIColorKernel后,可以使用apply方法传入多个CIImage。需要注意的是,法线贴图和系数纹理的采样坐标依赖Core Image的坐标映射,默认情况下sampler的transform会处理目标到源图的坐标转换。如果发现采样错位,检查图像的extent和原点设置,必要时通过imageByApplyingTransform调整。此外,输出图像的颜色空间应与反照率贴图保持一致,避免颜色偏移。
三、全局光照与柔和阴影的近似方案
仅靠SH漫反射得到的是低频环境光,物体之间相互遮挡产生的接触阴影和环境光遮蔽还需要额外处理。在Core Image的图像处理管线中,可以传入一张深度图,用屏幕空间采样近似计算遮挡。对于当前像素,沿着法线周围的几个方向采样深度和法线,如果采样点的深度明显小于当前深度,说明该方向可能存在遮挡,将遮挡因子累加。多次采样后取平均,就能得到柔和的阴影过渡。
下面的内核在SH漫反射基础上增加了深度遮挡采样。每个像素采样6个方向,方向角由随机偏移扰动,避免出现规则条纹。对于每个采样点,比较深度差并用smoothstep软化边缘,同时用法线相似度加权,减少平面上的伪影。最终shadow因子再乘到辐照度上,实现类似全局光照的柔和阴影效果。
extern "C" float4 shWithSoftShadow(sampler normalMap, sampler albedoMap, sampler depthMap, sampler shCoeffTex, float2 randomOffset, destination dest)
{
float2 uv = dest.coord();
float3 n = normalMap.sample(normalMap.transform(uv)).xyz * 2.0 - 1.0;
n = normalize(n);
float depth = depthMap.sample(depthMap.transform(uv)).x;
float occlusion = 0.0f;
const int sampleCount = 6;
for (int i = 0; i < sampleCount; i++) {
float angle = float(i) * 2.399963f + length(randomOffset) * 0.5f;
float2 dir = float2(cos(angle), sin(angle));
float2 sampleUV = uv + dir * 0.008f;
float sampleDepth = depthMap.sample(depthMap.transform(sampleUV)).x;
float3 sampleNormal = normalMap.sample(normalMap.transform(sampleUV)).xyz * 2.0 - 1.0;
sampleNormal = normalize(sampleNormal);
float depthDiff = depth - sampleDepth;
float normalSimilarity = max(dot(n, sampleNormal), 0.0f);
occlusion += smoothstep(0.0f, 0.02f, depthDiff) * normalSimilarity;
}
float shadow = saturate(1.0f - occlusion / float(sampleCount));
float3 coeffs[9];
for (int i = 0; i < 9; i++) {
float2 coeffUV = float2((i % 3 + 0.5) / 3.0, (i / 3 + 0.5) / 3.0);
coeffs[i] = shCoeffTex.sample(shCoeffTex.transform(coeffUV)).rgb;
}
float sh[9];
sh[0] = 0.282095f;
sh[1] = 0.488603f * n.y;
sh[2] = 0.488603f * n.z;
sh[3] = 0.488603f * n.x;
sh[4] = 1.092548f * n.x * n.y;
sh[5] = 1.092548f * n.y * n.z;
sh[6] = 0.315392f * (3.0f * n.z * n.z - 1.0f);
sh[7] = 1.092548f * n.x * n.z;
sh[8] = 0.546274f * (n.x * n.x - n.y * n.y);
float3 irradiance = float3(0.0f);
for (int i = 0; i < 9; i++) {
irradiance += coeffs[i] * sh[i];
}
float3 albedo = albedoMap.sample(albedoMap.transform(uv)).rgb;
float3 color = albedo * irradiance * shadow;
return float4(color, 1.0);
}
这种方案的计算量集中在深度图和法线贴图的多次采样上。移动端可以把深度图和法线贴图降低到半分辨率,再用线性插值采样,视觉上几乎看不出差异。采样数量也可以根据设备性能动态调整,6到8次已经能获得不错的效果。如果场景中有动态物体,需要每帧更新深度图;如果环境光和遮挡完全静态,则可以把阴影因子缓存起来,避免重复计算。
四、iOS实时渲染的性能与优化
SH漫反射本身的开销很小,9次系数采样和乘加运算对GPU来说非常轻量。真正的性能瓶颈通常来自法线、反照率和深度的多次纹理采样,以及半分辨率纹理的上采样。要维持60帧,可以先对法线和深度图做降采样,并在Core Image链路的早期阶段完成光照计算,再与原始分辨率反照率合成。这样大多数像素的采样成本都发生在较低分辨率下。
带宽选择也需要权衡。漫反射用3阶9系数已经足够,因为漫反射卷积的核非常平滑,高阶系数对结果影响很小。如果还需要近似镜面反射,可以额外使用预滤波环境图或提高SH带宽,但这会显著增加存储和计算成本。对于纯环境光照场景,保持9系数并用一张小纹理存储是最经济的方案。
另外,SH系数纹理可以跨帧复用。如果环境光不发生变化,只需在启动时从HDR环境贴图生成一次系数,后续所有图像处理都直接读取。Core Image的切片渲染和自动缓存机制也能帮助减少重复计算,但要注意避免在同一帧内多次请求同一输出图像。合理组织输入图像的缓存策略,可以让整个链路在移动端达到实时要求。
在iOS端用Core Image实现球面谐波环境光照,核心在于把环境信息压缩为SH系数,并用自定义Metal内核逐像素重建辐照度。加上屏幕空间遮挡采样,可以在图像处理链路中获得接近全局光照的柔和阴影效果。相比引入完整3D渲染引擎,这种方式更轻量,适合实时滤镜、图像增强和AR场景。实际项目中,可根据帧率要求调节采样数量与纹理精度。
Core Image球面谐波环境光照修改时间:2026-09-18 16:32:22