想让一张普通照片中出现一颗通透的水晶球,系统内置的扭曲滤镜并不够用。球面折射在每个像素上的采样偏移量不是固定的,它取决于光线从球面进入玻璃时的入射角与折射率,因此需要一个逐像素计算的法线。Core Image允许使用自定义CIKernel将这种计算放到GPU上,同时保持与实时相机流或照片编辑链路的兼容。下面从几何模型开始,逐步补齐环境映射和实时性能优化。

一、球面折射的几何模型与采样偏移
在Core Image内核中,destCoord()会返回当前输出像素在工作空间中的坐标。为了在图像上画出玻璃球,需要先确定球心center和半径radius,这两个参数都要落在图像坐标空间内。对任意输出坐标uv,计算它与球心的二维距离dist,如果距离大于半径,说明该像素不在球体覆盖范围内,直接返回原图即可。对于球内像素,球面的三维法线可以通过normal = normalize(vec3(delta, z))得到,其中z来自球面方程z = sqrt(radius * radius - dist * dist)。这一做法把图像平面放在xy平面,球面朝观察者方向凸起,z轴正方向指向观察者。
光线从观察者眼睛向图像深处传播,方向可以约定为vec3(0.0, 0.0, -1.0)。当这条光线进入玻璃球时,入射方向与球面法线方向相反,因此可以直接交给GLSL内置的refract函数计算折射方向。下面的内核实现了一次折射近似:只计算光线从空气进入玻璃球后方向的变化,再用这个方向的xy分量去偏移采样坐标。虽然忽略后表面二次折射,但它已经能表现出中心放大、边缘压缩和扭曲的水晶球特征。
kernel vec4 glassBallRefraction(sampler image, vec2 center, float radius, float refractionIndex)
{
vec2 uv = destCoord();
vec2 delta = uv - center;
float dist = length(delta);
if (dist > radius) {
return sample(image, samplerCoord(image));
}
float z = sqrt(radius * radius - dist * dist);
vec3 normal = normalize(vec3(delta, z));
vec3 incident = vec3(0.0, 0.0, -1.0);
float eta = 1.0 / refractionIndex;
vec3 refracted = refract(incident, normal, eta);
float offsetScale = 0.12;
vec2 sampleCoord = uv + refracted.xy * offsetScale;
return sample(image, sampleCoord);
}偏移缩放系数offsetScale是一个经验参数,它控制折射后采样点向外偏移的程度。这个值过大会把采样坐标推到球体外部,导致球内出现明显撕裂或黑色区域;过小则折射感太弱,球体看起来只是轻微扭曲。通常把半径设到图像短边的0.3到0.4之间,折射率取玻璃常见值1.5左右,缩放系数取0.08到0.15可以得到比较自然的视觉效果。球心附近的dist很小,法线接近正z方向,折射偏移量弱;越靠近球体边缘,法线与入射方向的夹角越大,偏移越明显,这正是水晶球四周变形强烈的原因。
如果只保留这一次折射近似,球体中心区域的放大倍率会偏低。真实玻璃球的光线会在进入球体后到达后表面,再经历一次从玻璃到空气的折射。双重折射在球体较厚或折射率较高时能明显增强中心凸起感,但计算量也会增加。对于照片编辑和实时相机滤镜场景,一次折射已经可以在观感上达到合格水平,后续再通过环境映射和菲涅尔反射弥补细节。
二、环境映射与菲涅尔反射叠加
仅有折射采样并不能让玻璃球显得通透,因为真实玻璃球边缘会反射周围环境,这种反射随观察角度变化。直白地说,从正面看球心时反射很弱,视线偏到边缘时反射增强。Core Image中可以使用第二个采样器传入环境图,通过球面法线把环境颜色映射到球体表面。对于二维图像滤镜,取法线的xy分量映射到0到1区间已经足够制造环境反射的错觉。
kernel vec4 glassBallWithFresnel(sampler image, sampler envMap, vec2 center, float radius, float refractionIndex, float envStrength)
{
vec2 uv = destCoord();
vec2 delta = uv - center;
float dist = length(delta);
if (dist > radius) {
return sample(image, samplerCoord(image));
}
float z = sqrt(radius * radius - dist * dist);
vec3 normal = normalize(vec3(delta, z));
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 incident = vec3(0.0, 0.0, -1.0);
float eta = 1.0 / refractionIndex;
vec3 refracted = refract(incident, normal, eta);
vec2 sampleCoord = uv + refracted.xy * 0.12;
vec4 refractedColor = sample(image, sampleCoord);
float fresnel = pow(1.0 - dot(normal, viewDir), 3.0);
vec2 envCoord = normal.xy * 0.5 + 0.5;
vec4 envColor = sample(envMap, envCoord);
return mix(refractedColor, envColor, fresnel * envStrength);
}这里的菲涅尔项使用pow(1.0 - dot(normal, viewDir), 3.0)近似。球心处法线与观察方向几乎平行,点积接近1,菲涅尔值接近0,折射颜色占主导;球体边缘法线与观察方向夹角增大,菲涅尔值快速上升,环境颜色覆盖上来,形成边缘反射。指数3是一个常用近似,也可以改成5来让反射区域更贴边,或者改小让反射更扩散。
如果没有专门的环境贴图,可以用固定颜色模拟天空反射,例如vec4(0.75, 0.85, 1.0, 1.0)代替envColor。这种方式成本极低,而且在小尺寸球体上很难看出与环境图的差别。若想增加更集中的高光点,可以在菲涅尔基础上继续计算pow(fresnel, 8.0),只保留非常靠近边缘的亮带,让球体轮廓更清晰。
三、Swift封装与实时性能优化
在iOS工程中,可以把内核逻辑封装成CIFilter子类,方便接入相机管线或照片编辑流程。下面的Swift代码使用CIColorKernel加载内核字符串并输出处理后的CIImage。球心、半径、折射率和环境强度都通过属性暴露,外部只需要修改属性并重新读取outputImage即可,不会触发内核重编译。
import CoreImage
class GlassBallRefractionFilter: CIFilter {
@objc dynamic var inputImage: CIImage?
@objc dynamic var inputCenter: CIVector?
@objc dynamic var inputRadius: CGFloat = 120.0
@objc dynamic var inputRefractionIndex: CGFloat = 1.52
@objc dynamic var inputEnvironmentImage: CIImage?
static var kernel: CIColorKernel? = {
let source = """
kernel vec4 glassBallWithFresnel(sampler image, sampler envMap, vec2 center, float radius, float refractionIndex, float envStrength)
{
vec2 uv = destCoord();
vec2 delta = uv - center;
float dist = length(delta);
if (dist > radius) {
return sample(image, samplerCoord(image));
}
float z = sqrt(radius * radius - dist * dist);
vec3 normal = normalize(vec3(delta, z));
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 incident = vec3(0.0, 0.0, -1.0);
float eta = 1.0 / refractionIndex;
vec3 refracted = refract(incident, normal, eta);
vec2 sampleCoord = uv + refracted.xy * 0.12;
vec4 refractedColor = sample(image, sampleCoord);
float fresnel = pow(1.0 - dot(normal, viewDir), 3.0);
vec2 envCoord = normal.xy * 0.5 + 0.5;
vec4 envColor = sample(envMap, envCoord);
return mix(refractedColor, envColor, fresnel * envStrength);
}
"""
return CIColorKernel(source: source)
}()
override var outputImage: CIImage? {
guard let inputImage = inputImage else { return nil }
let center = inputCenter ?? CIVector(x: inputImage.extent.midX, y: inputImage.extent.midY)
let environment = inputEnvironmentImage ?? inputImage
let envStrength: CGFloat = 0.65
return Self.kernel?.apply(
extent: inputImage.extent,
arguments: [inputImage, environment, center, inputRadius, inputRefractionIndex, envStrength]
)
}
}实时预览时,这个内核在每个输出像素上执行一次,球外像素直接返回原图,耗时主要集中在球体覆盖区域。如果图像尺寸很大,球体占比又小,最直接的优化方式是先把输入图像裁剪到球体包围盒,再交给内核处理,最后与原始图像合成。但由于Core Image的延迟渲染机制,多一步裁剪和合成也会增加纹理往返开销,所以只有在明显卡顿时才值得这样做。对于相机视频流,通常保持单次全图内核更简单稳定。
如果后续要升级到双重折射,可以在第一次折射得到方向后,求该方向与球体后表面的交点,再对交点处法线执行第二次折射,最后用新的出射方向与背景平面求交来确定采样坐标。这个计算在数学上并不复杂,但对每个球内像素都要多一次平方根和折射函数调用。对于静态照片处理完全可接受,对于实时视频流则需要根据机型表现决定是否开启。无论采用哪种方案,把复杂计算保留在GPU上用CIKernel完成,是Core Image实现这类像素级效果的正确思路。
Core Image球面折射环境映射修改时间:2026-10-03 02:03:26