在iOS平台上做AR或者全景预览时,开发者经常需要把虚拟物体放置到一张球面全景图中。如果虚拟物体的受光方向和全景图本身的光照来源不一致,用户一眼就能看出破绽。Core Image虽然是一个二维图像框架,但我们可以通过它完成全景图光照信息的提取,再结合球面谐波理论把环境光照转化为少数几个系数,驱动三维引擎中的灯光,从而实现光照一致性。阴影投射则是让虚拟物体看起来真正落地的关键,这部分同样可以从全景图的亮度分布里找到参考。

从全景图提取光照基色与亮度分布
等距柱状投影(equirectangular)的全景图把球面展开成一张宽高比为二的图片,水平方向对应经度,垂直方向对应纬度。Core Image中可以用CIColorAverage滤镜快速求出整图平均色,但这只能代表全局环境光,无法表达方向性。为了获得方向光,我们需要把全景图按经纬度网格切分,分别求每个区域的平均亮度,再将它们映射到球面方向上的采样点。
具体做法是先用CIcrop把图片切成若干横条,再对每个横条用CIColorAverage取色。注意Core Image的坐标系原点在左下角,而全景图的北极通常在顶部,因此裁剪矩形要计算好垂直翻转。得到的颜色数组就是球面若干个方向的入射光强,后续球面谐波拟合要用到这些采样值。下面代码展示如何用Swift把一张全景图按纬度切成八份并提取平均色:
import CoreImage
import CoreImage.CIFilterBuiltins
func extractPanoramaBands(url: URL, bands: Int) -> [CIColor] {
let context = CIContext()
let image = CIImage(contentsOf: url)!
let height = image.extent.height
let bandHeight = height / CGFloat(bands)
var colors: [CIColor] = []
for i in 0..<bands {
let rect = CGRect(x: image.extent.origin.x,
y: image.extent.origin.y + CGFloat(i) * bandHeight,
width: image.extent.width,
height: bandHeight)
let cropped = image.cropped(to: rect)
let filter = CIFilter.areaAverage()
filter.inputImage = cropped
if let out = filter.outputImage,
let color = context.createCGImage(out, from: out.extent) {
let data = color.dataProvider?.data
let ptr = CFDataGetBytePtr(data)
let c = CIColor(red: CGFloat(ptr![0]) / 255.0,
green: CGFloat(ptr![1]) / 255.0,
blue: CGFloat(ptr![2]) / 255.0)
colors.append(c)
}
}
return colors
}
这种分带提取的方式比整图平均更贴近真实光照,因为赤道附近往往更亮(天空或地面反射),两极偏暗。但它仍然丢失了水平方向信息。若需要更高精度,可以把每张横条再按经度切成若干块,形成球面网格采样。采样点越多,球面谐波重构的误差越小,但计算量也随之上升,在iPhone上一般取经纬各十六份已足够。
球面谐波光照系数的计算与传递
球面谐波(Spherical Harmonics)用一组正交基函数表示球面上的任意函数。对于光照来说,前九个基函数(三阶)就能很好地近似低频环境光。每一个基函数对应一个系数,计算方式是把全景图采样点的亮度值乘以对应基函数在采样方向的值再求和。Core Image并不直接算这个,我们在CPU或GPU并行算好后,把九个数传给渲染引擎。
在SceneKit里可以创建一个SCNLight并把类型设为ambient或者自定义着色器读取系数。更常见的做法是使用基于图像的照明,但如果我们只有提取出的九系数,就相当于一个低频环境光探针。下面Swift代码演示如何由分带颜色粗略估算前四阶里的零阶与一阶系数,零阶代表整体亮度,一阶三个系数代表光从x、y、z方向的偏移:
struct SHCoef {
var l0: Float
var l1x: Float
var l1y: Float
var l1z: Float
}
func roughSH(from bands: [CIColor]) -> SHCoef {
var total: Float = 0
var y: Float = 0
var z: Float = 0
let n = Float(bands.count)
for (i, c) in bands.enumerated() {
let lum = Float(c.red + c.green + c.blue) / 3.0
total += lum
let lat = (Float(i) + 0.5) / n * Float.pi // 0到pi
y += lum * cos(lat)
z += lum * sin(lat)
}
return SHCoef(l0: total / n, l1x: 0, l1y: y / n, l1z: z / n)
}
得到系数后,虚拟物体的漫反射项可用公式diffuse = albedo * (c0 + c1·normal)近似。这样物体顶部如果全景图亮,它也被照亮,底部暗则变暗。相比写死一个正向光,这种方式让物体和背景融合了。若使用RealityKit,可以通过自定义Material把系数写进常量缓冲区,在片元着色器里完成同样计算。要注意的是,球面谐波只处理低频,硬边缘阴影和高光仍需要额外光源补充。
阴影投射与落地阴影的实现方案
光照一致解决的是物体表面的明暗,阴影投射解决的是物体和地面的接触关系。在全景图场景里,真实地面可能是不平的,但多数预览应用假设一个虚拟水平面。我们可以根据全景图底部亮度判断地面颜色,然后在该平面上画一块模糊深色椭圆作为落影。更严谨的做法是用正交相机从光源方向渲染物体深度,得到阴影贴图,再投影到平面。
Core Image可以参与后处理:把三维引擎渲染出的阴影蒙版和全景图用CIBlendWithMask混合。假设我们已经用Metal渲染出一张黑色半透明阴影图,就可以用以下滤镜链把它压到全景图上,使阴影只出现在亮度较高的地面区域,避免阴影浮在天空上。下面代码展示混合思路:
import CoreImage.CIFilterBuiltins
func compositeShadow(pano: CIImage, shadow: CIImage, groundMask: CIImage) -> CIImage {
let blend = CIFilter.blendWithMask()
blend.inputImage = shadow
blend.backgroundImage = pano
blend.maskImage = groundMask
return blend.outputImage!
}
地面蒙版本身也可用Core Image从全景图生成:用CIHeightFieldFromMask不合适,更简单的是取亮度阈值,把偏暗且位于下半幅的区域当作地面。阴影的软硬程度由CIGaussianBlur控制,半径大则阴影柔和,符合阴天;半径小则边缘锐利,像烈日。最后把合成结果放回AR视图,用户拖动虚拟物体时实时更新阴影位置,就能获得稳定的空间锚定感。整套流程里Core Image负责二维提取与合成,三维引擎负责几何与光照解算,两者通过系数和贴图衔接,便能在iOS端实现可信的全景融合效果。
Core_Image球面谐波光照阴影投射修改时间:2026-08-17 15:48:37