Metal 图像处理与滤镜实践

0 阅读11分钟

metal-ai-cover.png 很多移动端图像处理功能,一开始都很容易写成“把图片拿出来,处理一下,再塞回去”。这在单张图片、低频操作里没什么问题,但一旦进入相机实时预览、视频逐帧处理、批量导出、复杂滤镜叠加,瓶颈通常不是某一行算法,而是整条链路的设计。

图像处理真正昂贵的地方有三类:

  • 像素数量巨大,每一帧都是百万级计算单元
  • 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
}

这个模型简单,但它把图像当成普通对象。问题是,图片一旦变成 UIImageCGImage,你很可能已经发生了 CPU 可见内存、解码、格式转换、甚至 GPU readback。

Metal 里更合适的思路是:

source texture
  -> color transform pass
  -> blur pass
  -> LUT pass
  -> blend pass
  -> drawable / output texture

也就是说,滤镜系统的核心对象应该是 MTLTexture,而不是 UIImage

metal-ai-pipeline.png 一条比较健康的链路通常长这样:

  1. 输入层:图片、相机帧、视频帧
  2. 纹理绑定层:MTKTextureLoaderCVMetalTextureCache
  3. 滤镜图:Core Image、MPS、自定义 compute pass 混合编排
  4. 中间纹理池:复用临时纹理,避免每帧分配
  5. 输出层:MTKViewAVAssetWriter、文件导出

这里最重要的原则是:中间过程尽量不要回到 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 适合标准、高频、计算密集的图像算子,比如:

  • MPSImageGaussianBlur
  • MPSImageLanczosScale
  • MPSImageSobel
  • MPSImageConvolution
  • MPSImageHistogram

示例:

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。

metal-ai-pingpong.png

概念上是这样:

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 的路径

如果处理的是相机预览,输入通常来自 AVCaptureVideoDataOutputCVPixelBuffer。这时最忌讳的是把每帧转成 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 混用
  • bgra8Unormbgra8Unorm_srgbrgba16Float 选择不当
  • CI / Metal / UIKit / AVFoundation 的颜色空间没有统一

普通 UI 展示里,bgra8Unorm 很常见。但如果你要做多轮调色、HDR、强曝光拉伸、bloom,8-bit 格式可能会导致 banding 或精度损失。这时可以在中间 pass 使用 rgba16Float,最后输出再转换到展示格式。

经验上可以这么选:

场景建议格式
普通图片预览bgra8Unorm
UI 最终 drawableMTKView.colorPixelFormat 保持一致
多轮调色/曝光/HDRrgba16Float
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,这条路基本走不远。

更合理的分层是:

metal-ai-flutter-bridge.png

Dart 层负责:

  • UI 状态
  • slider 参数
  • 滤镜预设
  • 触发拍照/导出
  • 展示 native texture

iOS 原生层负责:

  • 相机帧接入
  • CVPixelBufferMTLTexture
  • 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 写得不够“数学优化”,而是管线里出现了同步和拷贝。

优先检查这些点:

  • 是否每帧创建 CIContextMTLCommandQueueMTLComputePipelineState
  • 是否频繁 makeTexture
  • 是否每个滤镜都输出一个新中间纹理
  • 是否发生了 getBytesCGImageUIImage 转换
  • 是否在主线程等待 GPU 完成
  • 是否把多个单像素滤镜拆成了多个 pass
  • 是否用了过高精度的中间格式

调试时可以用 Xcode GPU Frame Capture 看每一帧有哪些 command encoder、创建了多少资源、纹理格式是什么。真正上线前,也建议用 Instruments 看 CPU/GPU 时间线,确认没有主线程等待和内存峰值异常。

如果要快速做判断,可以按这个顺序排查:

  1. 去掉 CPU/GPU readback
  2. 复用 pipeline、context、command queue
  3. 复用中间纹理
  4. 合并单像素颜色 pass
  5. 邻域算法改成可分离或降采样
  6. 再优化 shader 内部采样和分支

优化顺序很重要。先省一次纹理拷贝,通常比在 shader 里省几条 ALU 指令更划算。

9. 小结

Metal 图像处理真正要解决的不是“怎么写一个滤镜”,而是“怎么让一组滤镜长期稳定地跑在 GPU 管线里”。

我会把实践经验压缩成几条:

  • MTLTexture 当成核心数据结构,不要围着 UIImage 设计实时链路
  • Core Image 适合表达常见效果,MPS 适合标准高性能算子,自定义 Metal 适合产品差异化
  • 单像素颜色变换尽量 fusion,邻域算法重点优化采样结构
  • 相机链路优先使用 CVMetalTextureCache,避免每帧转图片
  • 中间纹理要复用,pipeline state 要预热,主线程不要等 GPU
  • Flutter 接入时只跨通道传参数,不跨通道传像素

当这些原则都做到后,Metal 滤镜系统就不再只是一个 demo shader,而是一套可以支撑实时预览、图片编辑、视频导出和跨端 UI 的图像处理运行时。