很多移动端图像处理功能,一开始都很容易写成“把图片拿出来,处理一下,再塞回去”。这在单张图片、低频操作里没什么问题,但一旦进入相机实时预览、视频逐帧处理、批量导出、复杂滤镜叠加,瓶颈通常不是某一行算法,而是整条链路的设计。
图像处理真正昂贵的地方有三类:
- 像素数量巨大,每一帧都是百万级计算单元
- CPU/GPU 之间的数据搬运很贵
- 多个滤镜 pass 会持续放大纹理读写和内存分配成本
Metal 的意义不是“用 GPU 写一个反色 shader”这么简单,而是把图片处理建模成一条稳定的纹理管线:输入是纹理,滤镜是 pass,输出仍然是纹理。只要像素尽量不离开 GPU,很多看起来很重的处理就能变成实时能力。
这篇文章不按 API 清单来讲,而从工程实践角度总结:iOS 里如何组织 Metal 图像处理和滤镜系统,Core Image、MPS、自定义 Shader 分别放在哪一层,以及 Flutter 项目接入时应该避开哪些坑。
1. 先换一个视角:滤镜不是函数,而是 texture graph
在 CPU 视角里,滤镜常常被写成这样:
func applyFilter(_ image: UIImage) -> UIImage {
// decode -> process -> encode
}
这个模型简单,但它把图像当成普通对象。问题是,图片一旦变成 UIImage 或 CGImage,你很可能已经发生了 CPU 可见内存、解码、格式转换、甚至 GPU readback。
Metal 里更合适的思路是:
source texture
-> color transform pass
-> blur pass
-> LUT pass
-> blend pass
-> drawable / output texture
也就是说,滤镜系统的核心对象应该是 MTLTexture,而不是 UIImage。
一条比较健康的链路通常长这样:
- 输入层:图片、相机帧、视频帧
- 纹理绑定层:
MTKTextureLoader或CVMetalTextureCache - 滤镜图:Core Image、MPS、自定义 compute pass 混合编排
- 中间纹理池:复用临时纹理,避免每帧分配
- 输出层:
MTKView、AVAssetWriter、文件导出
这里最重要的原则是:中间过程尽量不要回到 CPU。你可以在调试时把纹理读出来看结果,但不要把 readback 放进正式链路。
2. Core Image、MPS、自定义 Metal 怎么选
很多文章会把这三者当成平级方案,其实工程里更像三层工具箱。
Core Image:表达滤镜意图
Core Image 适合快速搭建图片编辑能力。它的价值是滤镜丰富、API 友好、颜色处理能力成熟,而且可以和 Metal 后端结合。
let device = MTLCreateSystemDefaultDevice()!
let ciContext = CIContext(mtlDevice: device)
let input = CIImage(mtlTexture: texture, options: [.colorSpace: colorSpace])
let filter = CIFilter.colorControls()
filter.inputImage = input
filter.saturation = 1.2
filter.contrast = 1.08
适合放在这些位置:
- 常见调色参数:曝光、对比度、饱和度、色温
- 产品早期快速验证滤镜效果
- 静态图编辑、非极限实时链路
但 Core Image 有两个容易被忽略的点:
CIImage是惰性计算,真正执行发生在 render 阶段- 复杂链式滤镜不等于免费,最后仍然会落到实际 GPU pass 和中间纹理上
所以它适合作为“效果表达层”,但如果你需要严格控制每个 pass 的调度和中间资源,还是要回到 Metal 管线。
MPS:使用系统优化过的标准算子
MPS 适合标准、高频、计算密集的图像算子,比如:
MPSImageGaussianBlurMPSImageLanczosScaleMPSImageSobelMPSImageConvolutionMPSImageHistogram
示例:
let blur = MPSImageGaussianBlur(device: device, sigma: 8)
blur.encode(commandBuffer: commandBuffer,
sourceTexture: source,
destinationTexture: destination)
MPS 的优势不是“代码少”,而是你把一个成熟算子交给系统实现。模糊、缩放、卷积这类算法,Apple 已经针对 GPU、纹理格式、采样路径做过优化,通常比业务侧临时写一个 shader 更稳。
它适合放在“标准算法层”:只要算法是常见算子,不需要非常定制,优先考虑 MPS。
自定义 Shader:做产品差异化
自定义 Metal 的价值是完全控制每个像素怎么计算。
#include <metal_stdlib>
using namespace metal;
kernel void toneMap(texture2d<float, access::read> input [[texture(0)]],
texture2d<float, access::write> output [[texture(1)]],
constant float& exposure [[buffer(0)]],
uint2 gid [[thread_position_in_grid]]) {
if (gid.x >= output.get_width() || gid.y >= output.get_height()) {
return;
}
float4 c = input.read(gid);
float3 mapped = 1.0 - exp(-c.rgb * exposure);
output.write(float4(mapped, c.a), gid);
}
适合自定义 shader 的场景:
- LUT 之外的特殊色彩风格
- 人像分区调色、蒙版混合
- 多输入纹理融合
- 自定义降噪、锐化、边缘增强
- 相机实时滤镜中的 pass fusion
简单说:Core Image 负责效率,MPS 负责标准算子,自定义 shader 负责产品差异化。
3. 单 pass 和多 pass:性能差距经常来自“组织方式”
很多滤镜可以分成两类。
第一类是单像素操作,也就是当前像素只依赖自己:
- brightness
- contrast
- saturation
- temperature
- tint
- tone mapping
- 反色、灰度
- 1D/3D LUT
这类操作非常适合合并。假设你有亮度、对比度、饱和度、色温四个 slider,如果每个 slider 都单独跑一个 pass,就会产生多次纹理读写:
texture A -> brightness -> texture B
texture B -> contrast -> texture C
texture C -> saturation -> texture D
texture D -> temperature-> texture E
更好的方式是把它们合并到一个 shader:
texture A -> color adjustment shader -> texture B
这种优化通常比你在 shader 里省几次乘法更有价值,因为纹理读写和 pass 切换才是大头。
第二类是邻域采样,也就是当前像素依赖周围像素:
- blur
- sharpen
- convolution
- sobel edge
- denoise
- bloom
这类算法不能盲目合并。以高斯模糊为例,如果直接做二维卷积,半径越大采样成本越高。工程里更常见的做法是利用高斯核可分离特性,拆成横向和纵向两个 pass。
概念上是这样:
source -> horizontal blur -> temp
temp -> vertical blur -> output
如果 blur radius 是 r,二维卷积大致是 O(r * r),拆成两次一维卷积后变成 O(2r)。这类优化在实时预览里非常明显。
这里会自然引出一个重要结构:ping-pong texture。
你不需要为每一个 pass 都创建一个新纹理,而是维护一组可复用的中间纹理:
final class TexturePool {
func makeTemporaryTexture(width: Int,
height: Int,
pixelFormat: MTLPixelFormat) -> MTLTexture {
// 真实项目里可以按尺寸、格式、usage 做缓存
}
}
实际项目里还要把 usage 配好:
descriptor.usage = [.shaderRead, .shaderWrite, .renderTarget]
descriptor.storageMode = .private
storageMode = .private 的含义是:这个资源主要给 GPU 用。只要你不需要 CPU 读它,就不要让它对 CPU 友好。
4. 相机实时滤镜:关键是 CVPixelBuffer 到 MTLTexture 的路径
如果处理的是相机预览,输入通常来自 AVCaptureVideoDataOutput 的 CVPixelBuffer。这时最忌讳的是把每帧转成 UIImage。
推荐路径是:
CVPixelBuffer -> CVMetalTextureCache -> MTLTexture -> filter graph -> drawable
大致代码:
var cvTexture: CVMetalTexture?
CVMetalTextureCacheCreateTextureFromImage(
kCFAllocatorDefault,
textureCache,
pixelBuffer,
nil,
.bgra8Unorm,
width,
height,
0,
&cvTexture
)
let texture = CVMetalTextureGetTexture(cvTexture!)
这样做的重点是“绑定”而不是“复制”。你把 CVPixelBuffer 对应的图像内存包装成 Metal 可用的纹理,后续滤镜都在 GPU 里完成。
相机实时链路里还要注意三件事。
第一,所有 pipeline state 尽量预热:
let function = library.makeFunction(name: "toneMap")!
let pipeline = try device.makeComputePipelineState(function: function)
不要在每帧里创建 MTLComputePipelineState。
第二,每帧只做必要调度:
let commandBuffer = commandQueue.makeCommandBuffer()!
filterGraph.encode(input: cameraTexture,
output: drawable.texture,
into: commandBuffer)
commandBuffer.present(drawable)
commandBuffer.commit()
第三,不要在主线程做像素转换、纹理创建策略计算、滤镜图重建。主线程应该只负责 UI 参数变化,渲染线程消费一份稳定的参数快照。
5. 颜色空间和像素格式:滤镜“不对味”往往不是算法问题
图像处理里一个很常见的坑是:shader 算法看起来没错,但结果发灰、过曝、偏色,或者和系统相册预览不一致。
这通常来自三个地方:
- sRGB 和 linear RGB 混用
bgra8Unorm、bgra8Unorm_srgb、rgba16Float选择不当- CI / Metal / UIKit / AVFoundation 的颜色空间没有统一
普通 UI 展示里,bgra8Unorm 很常见。但如果你要做多轮调色、HDR、强曝光拉伸、bloom,8-bit 格式可能会导致 banding 或精度损失。这时可以在中间 pass 使用 rgba16Float,最后输出再转换到展示格式。
经验上可以这么选:
| 场景 | 建议格式 |
|---|---|
| 普通图片预览 | bgra8Unorm |
| UI 最终 drawable | 跟 MTKView.colorPixelFormat 保持一致 |
| 多轮调色/曝光/HDR | rgba16Float |
| LUT 输入 | 根据 LUT 资源格式决定,注意采样精度 |
| 视频导出 | 结合编码目标和色彩空间统一处理 |
另一个建议是:在滤镜系统里显式保存颜色配置,而不是到处隐式依赖默认值。
struct RenderColorConfig {
let workingColorSpace: CGColorSpace
let outputColorSpace: CGColorSpace
let workingPixelFormat: MTLPixelFormat
let outputPixelFormat: MTLPixelFormat
}
这样后面接 Core Image、Metal shader、视频导出时,不会每一层都偷偷按自己的默认规则处理颜色。
6. 一个可维护的 Filter Graph 应该长什么样
如果项目里只有一个滤镜,直接写一个 Renderer 没问题。但只要产品里出现滤镜栈、参数调节、预设、导出、实时预览,就应该把滤镜抽象成节点。
可以定义一个很薄的协议:
protocol ImageFilterNode {
var inputCount: Int { get }
var outputFormat: MTLPixelFormat { get }
func encode(inputs: [MTLTexture],
output: MTLTexture,
commandBuffer: MTLCommandBuffer)
}
然后每个滤镜节点只关心自己的编码:
final class GaussianBlurNode: ImageFilterNode {
private let blur: MPSImageGaussianBlur
init(device: MTLDevice, sigma: Float) {
self.blur = MPSImageGaussianBlur(device: device, sigma: sigma)
}
func encode(inputs: [MTLTexture],
output: MTLTexture,
commandBuffer: MTLCommandBuffer) {
blur.encode(commandBuffer: commandBuffer,
sourceTexture: inputs[0],
destinationTexture: output)
}
}
真正复杂的部分交给 graph runtime:
- 根据节点声明分配中间纹理
- 决定哪些 pass 可以 fusion
- 处理 ping-pong 纹理
- 统一 command buffer 编码
- 在预览和导出之间复用同一套滤镜图
这样做的好处是,滤镜不会散落在 ViewController 或 Flutter plugin 里。后面你要加预设、加视频导出、加批处理,都不用重写算法层。
7. Flutter 项目里怎么接:Dart 传参数,Native 处理像素
Flutter 做图片编辑或相机滤镜时,常见错误是把像素数据当普通业务数据跨通道传递。比如每帧从 native 传到 Dart,再从 Dart 传回 native,这条路基本走不远。
更合理的分层是:
Dart 层负责:
- UI 状态
- slider 参数
- 滤镜预设
- 触发拍照/导出
- 展示 native texture
iOS 原生层负责:
- 相机帧接入
CVPixelBuffer到MTLTexture- filter graph
MTKView/ Flutter Texture 输出- 视频编码和图片导出
MethodChannel 只传小参数:
await channel.invokeMethod('updateFilter', {
'exposure': 0.2,
'contrast': 1.08,
'lut': 'cinematic_01',
});
原生层收到后更新参数快照:
struct FilterParameters {
var exposure: Float
var contrast: Float
var lutName: String
}
渲染线程在下一帧读取这份参数。不要让 Dart 层参与每一帧像素计算。
一句话:Flutter 控制意图,Metal 处理像素。
8. 性能定位:先看带宽和同步,再看 shader 算法
很多性能问题并不是 shader 写得不够“数学优化”,而是管线里出现了同步和拷贝。
优先检查这些点:
- 是否每帧创建
CIContext、MTLCommandQueue、MTLComputePipelineState - 是否频繁
makeTexture - 是否每个滤镜都输出一个新中间纹理
- 是否发生了
getBytes、CGImage、UIImage转换 - 是否在主线程等待 GPU 完成
- 是否把多个单像素滤镜拆成了多个 pass
- 是否用了过高精度的中间格式
调试时可以用 Xcode GPU Frame Capture 看每一帧有哪些 command encoder、创建了多少资源、纹理格式是什么。真正上线前,也建议用 Instruments 看 CPU/GPU 时间线,确认没有主线程等待和内存峰值异常。
如果要快速做判断,可以按这个顺序排查:
- 去掉 CPU/GPU readback
- 复用 pipeline、context、command queue
- 复用中间纹理
- 合并单像素颜色 pass
- 邻域算法改成可分离或降采样
- 再优化 shader 内部采样和分支
优化顺序很重要。先省一次纹理拷贝,通常比在 shader 里省几条 ALU 指令更划算。
9. 小结
Metal 图像处理真正要解决的不是“怎么写一个滤镜”,而是“怎么让一组滤镜长期稳定地跑在 GPU 管线里”。
我会把实践经验压缩成几条:
- 把
MTLTexture当成核心数据结构,不要围着UIImage设计实时链路 - Core Image 适合表达常见效果,MPS 适合标准高性能算子,自定义 Metal 适合产品差异化
- 单像素颜色变换尽量 fusion,邻域算法重点优化采样结构
- 相机链路优先使用
CVMetalTextureCache,避免每帧转图片 - 中间纹理要复用,pipeline state 要预热,主线程不要等 GPU
- Flutter 接入时只跨通道传参数,不跨通道传像素
当这些原则都做到后,Metal 滤镜系统就不再只是一个 demo shader,而是一套可以支撑实时预览、图片编辑、视频导出和跨端 UI 的图像处理运行时。