图像的波浪扭曲本质上是按照某种周期函数对像素坐标做位移映射,Core Image提供了GPU加速的渲染管线,非常适合实现这类逐像素计算的效果。无论是做照片编辑App的创意滤镜,还是给游戏背景加一层水波荡漾,理解正弦波、余弦波扭曲的数学模型和CIFilter的用法都十分关键。本文将从原理、内置滤镜、自定义Kernel以及动态动画四个层面,完整实现一套水波纹扭曲方案。

一、波浪扭曲的数学原理:位移映射是怎么回事
波浪扭曲属于几何变换(Warp)的一种。普通滤镜计算的是某个像素输出什么颜色,而几何扭曲计算的是某个位置的像素应该从原图哪个坐标取样。Core Image的Warp类滤镜(如CIBumpDistortion、CITwirlDistortion)都是这个思路,波浪扭曲也一样。
以正弦波为例,对于输出图像上的一点(x, y),我们希望它取样自原图中沿某个方向发生周期性偏移的坐标。水平方向的波浪映射可以写成:
// 正弦波扭曲的坐标映射公式 newX = x + amplitude * sin(2 * PI * y / wavelength + phase) newY = y
其中amplitude(振幅)决定波峰波谷的幅度,wavelength(波长)决定波纹的密度,phase(相位)决定波的起始位置。当相位随时间递增时,波纹就会看起来在移动,这就是动态水波的核心。如果把sin换成cos,波形整体错开四分之一个周期,视觉效果上波的起点不同,常用于与正弦波叠加制造更自然的随机感。
真实的水波纹通常不是单一方向的波,而是从某个中心向外扩散的同心圆。此时映射公式改为基于半径计算:对每个像素求它到波心的距离r,再用sin(r / wavelength - phase)作为径向偏移量,沿半径方向推拉采样点。振幅还应随距离衰减,否则整个画面都在抖动,只有波心附近的扰动才像真实水面。
二、使用内置滤镜快速实现波纹过渡
Core Image自带的CIRippleTransition是最省事的方案,它模拟一粒石子落入水面产生的涟漪,并用在两张图片的过渡动画中。它暴露了inputCenter(波心坐标)、inputWidth(波纹宽度)、inputScale(缩放强度)和inputTime(时间参数)等属性,把inputTime从0递增到1即可看到涟漪从中心扩散。
import CoreImage
let context = CIContext()
guard let inputImage = CIImage(image: UIImage(named: "lake")!) else { return }
if let filter = CIFilter(name: "CIRippleTransition") {
filter.setValue(inputImage, forKey: kCIInputImageKey)
// 波心归一化到0~1再乘以尺寸
filter.setValue(CIVector(x: inputImage.extent.width * 0.5,
y: inputImage.extent.height * 0.5),
forKey: kCIInputCenterKey)
filter.setValue(10.0, forKey: "inputWidth")
filter.setValue(30.0, forKey: "inputScale")
if let output = filter.outputImage,
let cgImage = context.createCGImage(output, from: inputImage.extent) {
imageView.image = UIImage(cgImage: cgImage)
}
}
需要注意的是,CIRippleTransition本质是过渡滤镜,它会同时消费inputImage和inputTargetImage,如果只想要单纯扭曲而不做图片切换,把target设置成同一张图即可。此外它的波纹样式固定,无法自定义衰减曲线,只适合简单场景。
内置滤镜还有CITwirlDistortion、CIBumpDistortion等几何变形可以与波纹叠加,比如先做轻微的Bump让画面有局部隆起,再叠加涟漪,层次感会更强。但要做真正可控的正弦波扭曲,还是得走自定义滤镜的路线。
三、自定义CIFilter与CIWarpKernel实现正弦波扭曲
自定义滤镜的核心是CIWarpKernel,它只负责坐标映射,不需要自己写取样逻辑,Core Image会在后台用GPU执行。对于波浪扭曲这种纯几何效果,WarpKernel比通用的CIKernel更简洁高效。
Wave Warp的Kernel代码用Core Image Kernel Language编写,这种语言类似GLSL,运行时会被编译为Metal或OpenGL着色器。下面实现一个支持方向、波长、振幅、相位的通用波浪扭曲:
// SineWaveWarp.swift
import CoreImage
class SineWaveWarpFilter: CIFilter {
var inputImage: CIImage?
var inputAmplitude: CGFloat = 12
var inputWavelength: CGFloat = 100
var inputPhase: CGFloat = 0
var inputAngle: CGFloat = 0 // 波浪方向
static var kernel: CIWarpKernel = { () -> CIWarpKernel in
let url = Bundle.main.url(forResource: "default", withExtension: "metallib")!
do {
return try CIWarpKernel(functionName: "sineWaveWarp", fromMetalLibraryData: try Data(contentsOf: url))
} catch {
fatalError("内核加载失败: \(error)")
}
}()
override var outputImage: CIImage? {
guard let input = inputImage else { return nil }
return SineWaveWarpFilter.kernel.apply(
extent: input.extent,
roiCallback: { _, rect in rect.insetBy(dx: -30, dy: -30) },
image: input,
arguments: [input, inputAmplitude, inputWavelength,
inputPhase, inputAngle]
)
}
}
对应的Metal内核如下,注意坐标系原点在左下角,方向角通过旋转坐标实现任意角度的波浪:
#include <CoreImage/CoreImage.h>
extern "C" float4 sineWaveWarp(coreimage::destination dest,
coreimage::sampler src,
float amplitude, float wavelength,
float phase, float angle)
{
// 将坐标旋转到波浪方向
float dx = dest.coord().x - src.extent().z * 0.5;
float dy = dest.coord().y - src.extent().w * 0.5;
float ca = cos(angle), sa = sin(angle);
float u = dx * ca + dy * sa;
float v = -dx * sa + dy * ca;
// 沿垂直于波浪方向做正弦位移
float offset = amplitude * sin(2.0 * M_PI * v / wavelength + phase);
float2 newCoord = dest.coord() + float2(offset * ca, offset * sa);
return src.sample(newCoord);
}
这里有一个非常关键的细节:roiCallback必须把感兴趣区域向外扩,扩展量至少等于振幅。因为扭曲后某个输出像素可能要从更远的位置取样,如果ROI没有放大,画面边缘会出现黑边或采样越界。余弦波扭曲只需把sin换成cos,或者简单地给phase加上M_PI / 2。
四、结合CADisplayLink实现动态水波纹扰动
静态扭曲只是一张图,真正的水波纹要持续流动。做法是用CADisplayLink驱动每一帧更新相位,让inputPhase随时间线性递增,然后把渲染结果交给CAAnimatableLayer或直接绘制到MetalKit.MTKView上。
class RippleViewController: UIViewController {
let filter = SineWaveWarpFilter()
let ciContext = CIContext()
func startAnimation() {
let displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink.add(to: .main, forMode: .common)
displayLink.preferredFramesPerSecond = 30
}
@objc private func tick(_ link: CADisplayLink) {
let t = CGFloat(link.timestamp)
filter.inputPhase = t * 4.0 // 波速
filter.inputAmplitude = 10 + 3 * sin(t * 0.7) // 振幅呼吸
if let output = filter.outputImage,
let cg = ciContext.createCGImage(output, from: output.extent) {
imageView.layer.contents = cg
}
}
}
如果想更接近真实水面的扩散涟漪,可以把Kernel改造成径向版本:以波心为原点计算半径r,位移量改为amplitude * sin(r / wavelength - phase) * exp(-r * decay),其中exp(-r * decay)是距离衰减项,让远离波心的区域保持稳定。再叠加两三个不同波长、不同相位的波,随机感会明显提升。
性能方面有几点建议:第一,尽量复用同一个CIContext,它的GPU命令队列创建成本很高;第二,滤镜链上的outputImage只是描述图,真正渲染发生在createCGImage或MTKView绘制时,避免中间多余的UIImage转换;第三,纹理大小超过4096时注意设备Metal纹理上限,必要时分块渲染;第四,帧率控制在30帧足够,水波动画在人眼看来24帧以上就流畅了,降低帧率能显著省电。
最后提醒一点兼容性问题:CIWarpKernel在旧设备上会回退到OpenGL ES路径,Kernel Language代码是通用的,但如果直接用Metal函数名方式加载,要确保工程里正确打包了metallib资源。真机上务必测试iPhone和iPad两种屏幕尺寸,因为以点为单位的波长参数在不同像素密度下视觉密度不同,必要时按screen.scale做换算,保证波纹粗细在所有设备上观感一致。
Core Image波浪扭曲水波纹效果修改时间:2026-09-02 07:54:38