为什么模糊要分两趟:单 pass、多 pass 与 buildPasses

2026-08-09

先把状态说清楚,免得你按着这篇去翻错仓库:

本文描述的是 OpenCut 旧版(classic)的行为。该代码库已归档、不再维护,opencut.app 线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。

OpenCut 现在有两个仓库,状态完全不一样。OpenCut-app/opencut-classic 是旧版,GitHub 元数据显示它已归档(archived 为 true),最后一次推送停在 2026-05-17,star 213。OpenCut-app/OpenCut 是重写版主仓,star 81917,README 的 Status 段第一句就是「OpenCut is being rewritten from the ground up」。上面这些数字的核对日都是 2026-08-09。本文讲的特效 pass 机制,来自 classic 仓库 docs/effects-renderer.md 这份架构文档,跟重写版没有关系。

一个特效要跑几趟,不是风格问题

先看 classic 的特效是怎么定义的。按 docs/effects-renderer.md 的说法,加一个新特效分三步:在 apps/web/src/lib/effects/definitions/ 建一个文件(文档举的例子是 brightness.ts),导出一个 EffectDefinition(参照同目录的 blur.ts),再在 index.ts 里注册。

EffectDefinition 有五个字段:type 是唯一的字符串标识,name 是显示名,keywords 供搜索用,params 是面向用户的控件(滑块、开关这些),renderer 则是 GPU pass 模板,会被解析成 shader 标识符加上一组 uniform。

关键就在 renderer 上。它里面有一个 passes 数组:

renderer: {
  passes: [
    { shader: "my-effect-shader", uniforms: ({ effectParams }) => ({ ... }) },
  ],
}

单 pass 的特效,这个数组里就一个条目。文档给的归类是:像调色这类特效只需要一个条目。

那什么时候需要多个?文档的判据只有一句话,但这句话是整个机制的分界线——当特效必须处理自己的输出时,就需要多 pass

这句话值得停下来想一秒。GPU 上跑一个 pass,输入是纹理,输出是纹理。如果一个特效的计算只需要看输入的每个像素,算完写出去就完了,那一趟够了。但如果它算到一半,需要拿「刚刚算出来的中间结果」当输入再算一遍,那就没法在一趟里做完——你得把中间结果落到一张纹理上,再发起第二趟。

文档举的多 pass 例子包括模糊(先横后纵)、辉光 bloom(提取 → 模糊 → 合成)、glow 等,原文用的是「等」,所以这不是一份穷举清单。模糊为什么要横一趟纵一趟,文档没有展开讲原理,只是把它列为多 pass 的典型;这是图形学里常见的做法,本文不替官方文档补充其中的数学细节。你只要记住判据本身就够用了:问自己「第二步的输入是不是第一步的输出」,是就是多 pass

辉光那个例子更直白。文档给的三步名字就是「提取 → 模糊 → 合成」:第一趟做提取,第二趟对提取结果做模糊——它吃的正是第一趟的产物,第三趟做合成。这三个步骤名字本身已经说明了链式依赖:中间那一趟的输入不可能是原始输入,只能是上一趟写出来的纹理,所以没有任何办法压进一趟。至于第三趟「合成」具体拿哪几张纹理做输入,文档没有写,本文不做推断。

一条容易被忽略的分工边界

在往下讲动态 pass 之前,先补一条文档里写得极清楚、但很多人写着写着就忘的边界。

docs/effects-renderer.md 的原话是:所有特效共用同一个 GPU 渲染器;TypeScript 决定跑哪些 shader 标识符、传哪些 uniform,Rust/wgpu 拥有设备创建、纹理和 pass 执行

这条边界解释了为什么 EffectDefinition 里的 renderer 字段长成「模板」的样子而不是一段真正的绘制代码。TypeScript 这一侧做的事情,本质上是在填一张单子:这一趟用哪个 shader、给它哪些 uniform 值。至于设备怎么创建、中间纹理怎么分配、这几趟按什么顺序发出去,全部在 Rust 那边。

对应到仓库结构上也能看到这条线。classic 的 rust/crates/ 下有六个 crate:bridgecompositoreffectsgpumaskstime,另外 rust/wasm/ 发布成 npm 包 opencut-wasm,而 apps/web/package.json 的 60 个 dependencies 里就有 opencut-wasm 这一项。也就是说浏览器侧的 web 应用是通过这个 WASM 包去够到 Rust 核心的。gpu crate 的 Cargo.toml 里还有一段 [target.'cfg(target_arch = "wasm32")'.dependencies],说明同一套 GPU 代码针对 wasm32 目标有单独的依赖,要同时服务浏览器和原生两边。

所以当你在 TypeScript 里写 pass 定义时,你写的是意图,不是执行。这决定了你能调整的自由度到哪儿为止:你能决定发几趟、每趟用什么 shader、传什么参数,但你决定不了纹理生命周期和调度。

pass 数量随参数变化时:buildPasses

静态的 passes 数组有个天然限制——它是写死的。可有些特效的 pass 数量本来就应该随用户拧的参数变化。文档举的例子就是模糊:强度高的时候需要更多迭代来保证质量。

这时候要加的是 buildPasses 函数:

renderer: {
  passes: [ /* 静态兜底 —— buildPasses 缺席时使用 */ ],
  buildPasses: ({ effectParams, width, height }) => {
    // 返回带预计算 uniform 的 EffectPass[]
  },
}

它拿到三个东西:effectParams(用户当前拧到的参数)、widthheight。返回值是一个 EffectPass[],而且文档特意写明是「带预计算 uniform 的」——意思是该算的 uniform 在这个函数里就算好,别留到后面。

widthheight 出现在入参里这件事本身是有信息量的。它意味着一个特效的 pass 编排不只跟参数有关,也可以跟画面尺寸有关。文档没有展开这一点,我们也不替它推断具体是怎么用的,但入参签名摆在那里,你自己写 buildPasses 时可以用得上。

★ 最容易踩的一条:静态数组不是「合并」,是「不用」

这是全文最需要记住的一句,也是我认为最反直觉的一点。文档的规则是:

buildPasses 存在时,所有渲染路径都用它,不再用静态 passes 数组。静态数组保留下来只作为结构参考与兜底。

为什么说反直觉?因为「同时写了 passesbuildPasses」这种代码形状,很容易让人以为两者会以某种方式合起来生效——比如静态的先跑、动态的追加,或者静态的作为默认值被动态的覆盖。都不是。只要 buildPasses 在,静态数组就完全出局。

这条规则直接决定了两个实践判断:

第一,静态数组不能拿来放「无论如何都要跑的那一趟」。如果你把某个必需的 pass 只写在静态数组里,指望 buildPasses 只负责补充可变的部分,那这一趟在实际渲染时根本不会发生。buildPasses 返回的必须是完整的 pass 序列,不是增量。

第二,调试时先看有没有 buildPasses。当一个特效实际发出的 pass 序列和你在静态数组里读到的定义对不上,第一件事不是怀疑 shader,而是确认这个 EffectDefinition 里是不是有 buildPasses——如果有,你刚才读的那段静态数组只是文档性质的存在,跟本次渲染无关。

文档另外还强调了一句:pass 的解析必须走 resolveEffectPasses。这是解析入口的名字,绕开它自己去读 passes 字段,就会掉进上面那个坑里——因为「有 buildPasses 就优先用它」这个逻辑,正是解析这一步负责的。

自己做决定的顺序

把上面这些收拢成一条可以自己走的判断路径:

你的特效该怎么写
每个输出像素只依赖输入像素,算一遍就完事单 pass,passes 里一个条目
后一步要吃前一步的输出(先横后纵、提取→模糊→合成这类)多 pass,passes 里按顺序列条目
pass 的数量会随用户参数或画面尺寸变化buildPasses,返回完整序列
写了 buildPasses,还想保留静态数组可以,但要清楚它只是结构参考与兜底,不参与渲染

顺序是从上往下走的:先判断有没有自我依赖,再判断这个依赖链的长度是不是固定的。长度固定就静态列,长度不固定才上 buildPasses。为了「以后可能要变」而提前写 buildPasses,代价是你从此得自己保证返回的序列是完整的,静态数组那份声明性的可读性也就用不上了。

这篇没有覆盖的部分

说清楚边界比多写两段更有用。docs/effects-renderer.md 里还有 Writing shadersCoordinate systems 两节,我们只读到标题,没有读正文,所以本文不涉及 shader 该怎么写、坐标系怎么约定这两件事。写实际特效时它们大概率是绕不开的,请直接看仓库里的原文。

另外,本文全部内容都是从代码和文档读出来的结构性事实。我们没有编译过 classic 的 WASM,没有跑过它的开发服务,也没有打开过编辑器界面,因此关于渲染出来是什么样子、快不快、清不清楚,这篇一个字都不会讲。

最后再强调一次仓库归属:pass、buildPassesresolveEffectPasses 这套东西属于已归档的 OpenCut-app/opencut-classic。重写版主仓 OpenCut-app/OpenCut 的 README 列了 Editor API、第三方插件、桌面移动浏览器共用一套代码、MCP server、无头模式、编辑器内脚本标签页这些「即将到来」的条目,那些是官方声明的计划,不是现有能力,我们没有见到可用实现——它的 Cargo.toml 里 workspace 目前只有 apps/desktop 一个成员,crates/* 还被注释着。别把两边的事混在一起。

延伸阅读


本文依据 OpenCut 官方仓库(github.com/OpenCut-app/OpenCut 与已归档的 github.com/OpenCut-app/opencut-classic)的 README、docs/ 架构文档、package.jsonCargo.toml 整理,核对日 2026-08-09。本文内容为仓库源码与文档口径,我们没有编译或运行过任何版本的 OpenCut。

本文描述的是 OpenCut 旧版(classic)的行为。该代码库已归档、不再维护,opencut.app 线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。核对日 2026-08-09。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。