问题与范围
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 覆盖范围内的执行正确性。
对渲染适配器的启示
当一个基础协议同时容纳叶子节点和组合节点时,可以采用同一套原则:
- 对外暴露稳定的基础协议;
- 执行前识别运行时组合能力;
- 让组合执行器独占顺序、并行、资源回收和最终合成;
- 保持叶子路径不变;
- 用不同的数据依赖模式做最小端到端断言。
这同样适用于渲染图、音频节点和任务编排器:类型擦除应减少调用方的类型负担,而不应降低对象实际承诺的执行能力。
Harbeth 与 Kakapos 的职责边界
Harbeth 在这里是逐帧 GPU 图像处理案例:组合 filter 的嵌套语义、临时纹理和 Metal 编码都属于渲染层。Kakapos 则负责媒体生命周期与编排;它通过 app-owned FrameProcessor 接入任意渲染器,Core 不导入 Harbeth。因此,采集、预览、录制和导出仍由媒体层主控,本文的组合滤镜合同属于 Harbeth 或应用自己的渲染适配器内部。
小结
类型擦除本身不是问题;问题是执行端把静态类型误当成了完整能力描述。保留运行时的组合协议识别,并用真实 GPU 输出覆盖顺序和共享 source 两种数据流,才能让统一 API 与正确渲染结果同时成立。