将虚拟物体合成到真实场景时,光照方向、颜色和阴影的差异会立刻暴露合成痕迹。球面全景图记录了场景中各个方向的入射光,Core Image则可以在iOS端将这些信息转化为可供渲染使用的光照数据。本文从等距柱状全景图出发,介绍球面谐波系数计算、高光反射估值、阴影接收以及全局光照合成流程,并给出基于Core Image的实现代码。

一、全景图预处理与光照方向映射
球面全景图最常见的存储格式是等距柱状投影,横向分辨率通常是纵向的两倍。图像中的每个像素都对应一个球面方向,水平坐标u从0到1映射到方位角φ,纵向坐标v从0到1映射到极角θ。方向向量可以按以下方式计算:d = (sinθ·cosφ, sinθ·sinφ, cosθ),其中θ = π(1 - v),φ = 2πu。这个映射关系是后续所有光照估计的基础,因为无论是球面谐波投影还是高光反射采样,都要先把像素坐标转换为三维方向。
直接使用原始分辨率全景图做光照计算会带来两个问题:噪声过大和计算量过高。全景图中往往包含太阳、窗户等高亮度小区域,这些区域对镜面高光贡献很大,但对漫反射光照容易造成闪烁。Core Image可以先用 CILanczosScaleTransform 将全景图降采样到较低分辨率,例如 64×32 或 128×64,生成漫反射光照探针。降采样后的图像相当于对高频光照做了一次低通滤波,更适合球面谐波系数的估计。
下面这段Swift代码演示了如何加载全景图,并利用Core Image生成不同分辨率的光照探针。这里先生成漫反射探针,后续的高光预过滤也会复用同一个流程。
import CoreImage
import UIKit
func loadPanoramaProbe(_ image: CIImage, targetWidth: Int, targetHeight: Int) -> CIImage? {
let scaleX = CGFloat(targetWidth) / image.extent.width
let scaleY = CGFloat(targetHeight) / image.extent.height
let scaleFilter = CIFilter(name: "CILanczosScaleTransform")!
scaleFilter.setValue(image, forKey: kCIInputImageKey)
scaleFilter.setValue(scaleX, forKey: kCIInputScaleKey)
scaleFilter.setValue(scaleY, forKey: "inputScaleY")
scaleFilter.setValue(1.0, forKey: kCIInputAspectRatioKey)
return scaleFilter.outputImage
}
// 使用示例
let panoramaURL = URL(fileURLWithPath: "panorama.jpg")
let sourceImage = CIImage(contentsOf: panoramaURL)
let diffuseProbe = loadPanoramaProbe(sourceImage!, targetWidth: 64, targetHeight: 32)
二、球面谐波系数的估计与Core Image内核实现
球面谐波可以把环境光照近似为一组低频基函数的加权和。对于真实感合成,前三阶共9个系数通常已经足够表达漫反射环境光。球面谐波基函数的前几项包括常数项 0.282095,一阶项 0.488603·x、0.488603·y、0.488603·z,以及二阶项中的 1.092548·xz、1.092548·yz、1.092548·xy、0.315392·(3z² - 1) 和 0.546274·(x² - y²)。每个系数 c_lm 定义为环境函数 L(d) 与对应基函数 Y_lm(d) 在球面上的积分:c_lm = ∫ L(d)·Y_lm(d) dΩ。
Core Image本身没有全局求和或归约能力,但可以用一个两步技巧完成近似积分。第一步针对某个基函数生成加权图,即让全景图中每个像素的颜色乘以该像素方向对应的球面谐波权重;第二步用 CIAreaAverage 计算整张加权图的平均颜色。由于全景图已经是等距柱状投影,像素面积并不完全相等,但经过降采样后这种误差可以接受。最终系数约等于平均颜色乘以 4π,因为球面积分总面积为 4π。
为了生成加权图,需要编写Core Image自定义内核。以下Metal内核接收采样器和球面方向,根据传入的基函数类型计算权重,并输出加权后的颜色。该内核可以处理单通道或三通道图像。
#include <CoreImage/CoreImage.h>
using namespace metal;
extern "C" {
namespace coreimage {
float4 shWeightKernel(sampler image, float2 uv, float3 direction, int basisIndex) {
float3 color = image.sample(uv).rgb;
float weight = 0.282095;
float x = direction.x;
float y = direction.y;
float z = direction.z;
if (basisIndex == 0) {
weight = 0.282095;
} else if (basisIndex == 1) {
weight = 0.488603 * x;
} else if (basisIndex == 2) {
weight = 0.488603 * y;
} else if (basisIndex == 3) {
weight = 0.488603 * z;
} else if (basisIndex == 4) {
weight = 1.092548 * x * z;
} else if (basisIndex == 5) {
weight = 1.092548 * y * z;
} else if (basisIndex == 6) {
weight = 1.092548 * x * y;
} else if (basisIndex == 7) {
weight = 0.315392 * (3.0 * z * z - 1.0);
} else {
weight = 0.546274 * (x * x - y * y);
}
return float4(color * weight, 1.0);
}
}
}
在Swift端循环9个基函数,生成加权图并调用 CIAreaAverage,即可得到球面谐波系数数组。实际项目中建议把系数缓存在内存中,因为全景图变化不频繁时无需每帧重算。
func estimateSHCoefficients(panorama: CIImage, context: CIContext) -> [SIMD3<Float>] {
guard let kernel = CIKernel(source: shKernelSource) else { return [] }
let probes = [panorama] // 已经简化为均匀探针
var coefficients: [SIMD3<Float>] = []
for basisIndex in 0..<9 {
var rgbSum = SIMD3<Float>(0, 0, 0)
for probe in probes {
let probeExtent = probe.extent
let direction = SIMD3<Float>(0, 0, 1)
let output = kernel.apply(extent: probeExtent, roiCallback: { _, rect in rect },
arguments: [probe, direction, basisIndex])
guard let weighted = output else { continue }
let averageFilter = CIFilter(name: "CIAreaAverage")!
averageFilter.setValue(weighted, forKey: kCIInputImageKey)
averageFilter.setValue(CIVector(cgRect: probeExtent), forKey: kCIInputExtentKey)
guard let avgImage = averageFilter.outputImage else { continue }
var bitmap = [UInt8](repeating: 0, count: 4)
context.render(avgImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
rgbSum += SIMD3<Float>(Float(bitmap[0]) / 255.0,
Float(bitmap[1]) / 255.0,
Float(bitmap[2]) / 255.0)
}
let scale = 4.0 * Float.pi / Float(probes.count)
coefficients.append(rgbSum * scale)
}
return coefficients
}
三、高光反射估值与阴影接收合成
高光反射对环境光照的细节更敏感,因此不能直接使用低分辨率漫反射探针。标准做法是为不同粗糙度生成预过滤环境贴图:粗糙度越高,使用的高斯模糊半径越大。Core Image的 CIGaussianBlur 可以快速生成多级模糊版本。采样时根据虚拟物体表面的粗糙度选择对应级别的探针,再将反射向量转换到等距柱状坐标进行采样。反射向量可以通过入射方向与法线计算,即 r = reflect(-v, n)。
阴影接收在合成阶段表现为背景需要出现虚拟物体投下的暗部。首先需要从全景图中估计主光源方向。一个简单方式是用 CIAreaMaximum 找到全景图最亮区域,再把该区域中心坐标转换为方向向量。这个方向不一定准确,但在户外太阳场景中足够使用。得到主方向后,可以把虚拟物体的轮廓沿地面投影,生成阴影蒙版。
以下Swift代码展示了阴影蒙版的核心生成过程。阴影蒙版先经过透视变换,再模糊,最后用混合滤镜叠加到背景上。
func generateShadowMask(objectContour: CIImage, lightDirection: CGPoint, extent: CGRect) -> CIImage? {
let transform = CGAffineTransform(translationX: lightDirection.x * 30.0,
y: lightDirection.y * 20.0)
let shadow = objectContour.transformed(by: transform)
let blurFilter = CIFilter(name: "CIGaussianBlur")!
blurFilter.setValue(shadow, forKey: kCIInputImageKey)
blurFilter.setValue(12.0, forKey: kCIInputRadiusKey)
return blurFilter.outputImage?.cropped(to: extent)
}
func blendShadow(background: CIImage, shadowMask: CIImage) -> CIImage? {
let blendFilter = CIFilter(name: "CIBlendWithMask")!
blendFilter.setValue(background, forKey: kCIInputBackgroundImageKey)
blendFilter.setValue(CIImage(color: CIColor(red: 0.15, green: 0.14, blue: 0.12)),
forKey: kCIInputImageKey)
blendFilter.setValue(shadowMask, forKey: kCIInputMaskImageKey)
return blendFilter.outputImage
}
四、全局光照与间接光融合
全局光照不仅包含直接光源,还包含环境中的间接反射光和颜色溢出。球面谐波系数很适合计算逐像素漫反射辐照度。对于法线方向 n,漫反射辐照度可以通过前9个系数和基函数快速求和得到:E(n) = c0·Y0 + c1·Y1(n) + ... + c8·Y8(n)。这样虚拟物体的每个表面点都能获得来自四面八方的环境光,而不是被一个统一的亮度代替。
间接光的另一个重要表现是颜色溢出。比如在红色墙壁附近放置白色虚拟物体,物体表面应该出现淡淡的红色。为实现这一效果,可以用低分辨率漫反射探针的平均颜色对虚拟物体基础色进行调制。Core Image里的 CIMultiplyCompositing 非常适合把两张图逐像素相乘,生成被环境染色后的合成结果。高光部分则使用 CIAdditionCompositing 或 CIScreenBlendMode 叠加,以保持镜面高光的明亮感。
下面提供全局光照合成的关键片段。虚拟物体渲染结果先乘间接光颜色,再叠加高光项,最后合成到背景上。
func composeGlobalIllumination(objectRender: CIImage,
indirectColor: CIColor,
highlight: CIImage,
background: CIImage) -> CIImage? {
let indirectImage = CIImage(color: indirectColor)
let multiplyFilter = CIFilter(name: "CIMultiplyCompositing")!
multiplyFilter.setValue(objectRender, forKey: kCIInputImageKey)
multiplyFilter.setValue(indirectImage, forKey: kCIInputBackgroundImageKey)
guard let tintedObject = multiplyFilter.outputImage else { return nil }
let addFilter = CIFilter(name: "CIAdditionCompositing")!
addFilter.setValue(tintedObject, forKey: kCIInputImageKey)
addFilter.setValue(highlight, forKey: kCIInputBackgroundImageKey)
guard let litObject = addFilter.outputImage else { return nil }
let overFilter = CIFilter(name: "CISourceOverCompositing")!
overFilter.setValue(litObject, forKey: kCIInputImageKey)
overFilter.setValue(background, forKey: kCIInputBackgroundImageKey)
return overFilter.outputImage
}
五、性能优化与场景集成建议
在实际iOS应用中,球面谐波系数和预过滤光照探针不需要每一帧都重新计算。如果全景图来自固定的拍摄或环境纹理,可以在加载阶段完成所有预处理,之后只缓存结果。Core Image的 CIContext 可以复用,避免反复创建GPU上下文。对于高光预过滤,尽量离线预生成多级模糊图,运行时只做采样和合成。
Core Image强在图像处理和合成,但三维物体的投影、深度测试和遮挡关系仍建议交给SceneKit或RealityKit处理。一个可行架构是:用Core Image生成光照探针和球面谐波系数,然后把系数传入3D渲染器完成虚拟物体的光照;渲染出的物体图再回到Core Image做阴影蒙版和全局光照合成。这样各自发挥长处,也能保证帧率。
需要特别注意,Core Image对球面谐波系数的计算本质上是对全景图离散像素的近似积分,精度受分辨率和投影格式影响。如果场景中存在非常小的强光源,低频球面谐波可能丢失方向性,此时需要额外提取主光源方向,并用一束定向光补足高光和阴影。这个混合方案能够显著提升虚拟物体的真实感。
Core Image球面谐波全局光照修改时间:2026-08-26 02:38:15