类型擦除之后,Metal 滤镜组合为什么不能只执行一个 Filter?

文章来源声明: 原文作者:茶底世界之下; 来源站点:掘金; 原文链接:https://juejin.cn/post/7693225151681380362; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

类型擦除的边界常被低估:统一 API 仍需保留组合执行语义。此文用 Metal 真实 GPU 测试厘清顺序与并行数据依赖,适合图像处理、渲染图等适配器设计参考。

问题与范围 -----

Swift 图像处理代码经常用协议收拢不同效果:调用方只持有 C7FilterProtocol,无需提前知道对象是单个亮度调整、几何操作,还是由多个 child filter 组成的组合管线。这个设计降低了调用方复杂度,但会留下一个容易漏掉的边界:类型擦除只能隐藏具体类型,不能抹掉对象在运行时仍具备的组合执行语义。

本文讨论 Metal 逐帧滤镜的嵌套执行合同,不讨论相机采集、视频编码、设备级性能或视觉效果调校。

先给结论

  • 叶子滤镜与组合滤镜可以共享基础协议入口,但执行前仍要识别组合能力。
  • sequential 与 parallelFromSource 的差异不在“执行几个滤镜”,而在每个 child 从哪里读取输入。
  • 组合节点应交给专用执行器管理顺序、临时纹理、最终合成和 command buffer 关系。
  • 非组合对象保留原有叶子执行路径,避免为了修复嵌套语义改变普通滤镜行为。
  • 用最小真实 GPU 测试同时覆盖顺序与并行数据依赖,才能证明类型擦除没有吞掉管线语义。

问题不是能不能调用一次 apply

假设一个组合滤镜遵从 C7FilterPipelineProtocol,内部有多个 child filter。它可以声明两种不同的数据流:

sequential:          source → child A → child B → destination

parallelFromSource:          ┌→ child A ─┐
                      source ─┤           ├→ combine → destination
                              └→ child B ─┘

在上层容器中,它可能已经被保存成基础协议:

let erased: C7FilterProtocol = pipeline

如果执行处只把 erased 当作一个叶子节点并走普通 apply 路径,组合对象会退化:child filter 没有按自身计划被调度,最终结果可能只代表一个末端操作。对图像处理来说,这不是可忽略的实现细节;顺序、输入来源和中间纹理传递都会改变像素输出。

统一入口不等于统一执行器

Harbeth 的基础入口仍是 C7FilterProtocol.applyAtTexture(form:to:for:)。修复位于 Sources/Basic/Core/Filtering.swift:执行前先检查运行时对象是否还遵从 C7FilterPipelineProtocol。若是,就转交给 FilterPipelineExecutor.apply(filter:source:destination:commandBuffer:);若不是,才保留原有普通 apply 路径。

if let pipeline = self as? any C7FilterPipelineProtocol {
    return try FilterPipelineExecutor.apply(
        filter: pipeline,
        source: texture,
        destination: destTexture,
        commandBuffer: buffer
    )
}
return try apply(form: texture, to: destTexture, for: buffer, complete: nil)

这不是为某个效果增加特例,而是在入口处恢复 owner:叶子 filter 由单次编码路径执行;组合 filter 由管线执行器承担调度责任。调用方仍只面对稳定的基础协议,执行器却不会把“组合对象”误当成“单节点对象”。

顺序与并行的合同不同

在 sequential 模式里,前一个 child 的输出是下一个 child 的输入。两个每次增加 0.125 的 child 依次执行,结果应增加 0.25。在 parallelFromSource 中,两个 child 都从相同 source 独立计算,最后合成两个相同结果,输出只增加 0.125。

因此,测试不能只断言“执行了两个 filter”。它还必须断言每一个 filter 的输入关系:顺序模式的中间输出要被正确转交,并行模式的 child 不能意外读取彼此的结果。

FilterPipelineExecutor 也因此需要负责临时纹理:每个 stage 使用独立目的纹理,最终 filter 把子结果作为辅助输入写入最终 destination。把同一张纹理同时绑定为读与写会造成未定义行为,不能用“少一次分配”替代正确的数据依赖。

用最小真实 Metal 路径验证

本轮回归位于 Tests/HarbethTests/FilterPipelineTests.swift。测试动态编译最小 Metal library,使用 1×1 rgba16Float 输入纹理,构造嵌套子管线后再把外层对象擦除为 C7FilterProtocol。

它在同一个测试中覆盖两个执行模式:

  • sequential:两个 child 的累加结果为 +0.25;
  • parallelFromSource:两个 child 都从 source 计算,均值结果为 +0.125。

这比 mock 调用计数更接近真正要守住的合同:Metal shader 实际编码到 command buffer,测试会读取 GPU 输出并比较数值。它不等于真机、吞吐量或生产环境验证;本轮在 macOS arm64e 的 swift test --filter FilterPipelineTests 通过 6 项、0 失败,证明的是当前 XCTest 覆盖范围内的执行正确性。

对渲染适配器的启示

当一个基础协议同时容纳叶子节点和组合节点时,可以采用同一套原则:

  1. 对外暴露稳定的基础协议;
  2. 执行前识别运行时组合能力;
  3. 让组合执行器独占顺序、并行、资源回收和最终合成;
  4. 保持叶子路径不变;
  5. 用不同的数据依赖模式做最小端到端断言。

这同样适用于渲染图、音频节点和任务编排器:类型擦除应减少调用方的类型负担,而不应降低对象实际承诺的执行能力。

Harbeth 与 Kakapos 的职责边界

Harbeth 在这里是逐帧 GPU 图像处理案例:组合 filter 的嵌套语义、临时纹理和 Metal 编码都属于渲染层。Kakapos 则负责媒体生命周期与编排;它通过 app-owned FrameProcessor 接入任意渲染器,Core 不导入 Harbeth。因此,采集、预览、录制和导出仍由媒体层主控,本文的组合滤镜合同属于 Harbeth 或应用自己的渲染适配器内部。

小结

类型擦除本身不是问题;问题是执行端把静态类型误当成了完整能力描述。保留运行时的组合协议识别,并用真实 GPU 输出覆盖顺序和共享 source 两种数据流,才能让统一 API 与正确渲染结果同时成立。

源码与参考实现