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

问题与范围

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

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

先给结论

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

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

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

text 复制代码
sequential:          source → child A → child B → destination

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

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

swift 复制代码
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 路径。

swift 复制代码
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 与正确渲染结果同时成立。

源码与参考实现

相关推荐
传奇开心果编程3 小时前
【SwiftUI提高练中学】第8课 网络层架构:拦截器、重试、缓存与离线优先
学习·macos·ui·ios·swiftui
传奇开心果编程10 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
tink12 小时前
告别 Xcode IDE:用 VS Code + SweetPad + XcodeGen 开发 iOS 应用的完整指南
ios·swiftui·xcode
传奇开心果编程13 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程14 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程15 小时前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
黑科技iOS上架15 小时前
iOS隐私合规扫描工具
ios·审核
传奇开心果编程15 小时前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
传奇开心果编程15 小时前
【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
android·windows·学习·ui·ios·kotlin·composer