类型擦除之后,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 与正确渲染结果同时成立。

源码与参考实现

相关推荐
aiopencode5 小时前
局域网设备抓包怎么做?用 TraceEagle 抓电视、游戏机和 IoT 流量
后端·ios
奇牙coding7 小时前
GPT-5.5 报 401 invalid_api_key 怎么办?换密钥前按 4 层排查请求头、项目权限和端点
数据库·gpt·ios·ai
szp20058 小时前
中文字幕烧进视频后整片空白:CoreText 低报行高,导出照样成功
ios·swift
二流小码农8 小时前
HarmonyOS开发助手,0代码让元服务从开发到上架
android·ios·harmonyos
zhongben123_12 小时前
从0到1开发一款生鲜配送APP:核心技术难点与架构避坑指南
大数据·运维·人工智能·ios·架构·web app
zhongben_keji13 小时前
批发商城APP怎么做:价格引擎、授信台账与资金合规的设计要点
ios·小程序·web app
茶底世界之下15 小时前
夜景加胶片颗粒,先看天空:Awaking 两版真实导出
ios
2501_9151063216 小时前
iOS 单元测试怎么跑?用 KXApp 在 VS Code 里跑 XCTest 和 Swift Testing
ide·vscode·ios·单元测试·个人开发·swift·敏捷流程
2501_9159214316 小时前
iOS 实时日志怎么看?KeyMob 的日志过滤、崩溃报告与符号化
android·ios·小程序·https·uni-app·iphone·webview
天蒙蒙亮16 小时前
打ios app包+“应用商城”发布
ios