为什么模糊要分两趟:单 pass、多 pass 与 buildPasses
先把状态说清楚,免得你按着这篇去翻错仓库:
本文描述的是 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:bridge、compositor、effects、gpu、masks、time,另外 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(用户当前拧到的参数)、width 和 height。返回值是一个 EffectPass[],而且文档特意写明是「带预计算 uniform 的」——意思是该算的 uniform 在这个函数里就算好,别留到后面。
width 和 height 出现在入参里这件事本身是有信息量的。它意味着一个特效的 pass 编排不只跟参数有关,也可以跟画面尺寸有关。文档没有展开这一点,我们也不替它推断具体是怎么用的,但入参签名摆在那里,你自己写 buildPasses 时可以用得上。
★ 最容易踩的一条:静态数组不是「合并」,是「不用」
这是全文最需要记住的一句,也是我认为最反直觉的一点。文档的规则是:
buildPasses 存在时,所有渲染路径都用它,不再用静态 passes 数组。静态数组保留下来只作为结构参考与兜底。
为什么说反直觉?因为「同时写了 passes 和 buildPasses」这种代码形状,很容易让人以为两者会以某种方式合起来生效——比如静态的先跑、动态的追加,或者静态的作为默认值被动态的覆盖。都不是。只要 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 shaders 和 Coordinate systems 两节,我们只读到标题,没有读正文,所以本文不涉及 shader 该怎么写、坐标系怎么约定这两件事。写实际特效时它们大概率是绕不开的,请直接看仓库里的原文。
另外,本文全部内容都是从代码和文档读出来的结构性事实。我们没有编译过 classic 的 WASM,没有跑过它的开发服务,也没有打开过编辑器界面,因此关于渲染出来是什么样子、快不快、清不清楚,这篇一个字都不会讲。
最后再强调一次仓库归属:pass、buildPasses、resolveEffectPasses 这套东西属于已归档的 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.json、Cargo.toml 整理,核对日 2026-08-09。本文内容为仓库源码与文档口径,我们没有编译或运行过任何版本的 OpenCut。
本文描述的是 OpenCut 旧版(classic)的行为。该代码库已归档、不再维护,
opencut.app线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。核对日 2026-08-09。