TypeScript 决定跑什么 shader,Rust 和 wgpu 负责怎么跑

2026-08-09

先把状态说清楚,再谈技术:本文描述的是 OpenCut 旧版(classic)的行为。该代码库已归档、不再维护,opencut.app 线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。 核对日 2026-08-09。下文所有文件路径、字段名都来自已归档仓库 OpenCut-app/opencut-classicdocs/ 架构文档与目录结构,我们没有编译或运行过任何版本的 OpenCut。

一句话的边界,比一张架构图更有用

docs/effects-renderer.md 里有一句话,值得单独拎出来读:所有特效共用同一个 GPU 渲染器,TypeScript 决定跑哪些 shader 标识符、传哪些 uniform;Rust 和 wgpu 拥有设备创建、纹理和 pass 执行

这句话之所以重要,是因为它把「加特效」这件事从一个模糊的跨语言问题,压缩成了一个可以回答的问题:你要加的东西,是要跑什么,还是怎么跑?前者在 TypeScript 侧,后者在 Rust 侧。绝大多数「加一个调色特效」「加一个模糊变体」的需求落在前者,你根本不需要碰 Rust——这是这条边界给出的第一个判断依据。

反过来说,凡是涉及设备/适配器怎么拿、纹理怎么分配和复用、一个 pass 怎么被真正提交上去的问题,TypeScript 侧再怎么改都够不着。这类问题一旦出现,你要找的是 Rust 那一层,不是特效定义文件。

这条边界在目录上长什么样

classic 仓库的 rust/ 目录,README 描述为平台无关的核心:GPU 合成器、特效、蒙版、WASM 绑定,并写明他们正在把业务逻辑从 TypeScript 迁过来。实读 rust/crates/ 下面是六个 crate:bridgecompositoreffectsgpumaskstime;另有 rust/wasm/,发布成 npm 包 opencut-wasm

有一个细节能佐证「同一套 GPU 代码要同时服务浏览器与原生」:gpu crate 的 Cargo.toml 里有 [target.'cfg(target_arch = "wasm32")'.dependencies] 段,也就是它针对 wasm32 目标有一组单独的依赖。同一份 Rust 代码,编到 wasm32 给浏览器用,编到原生给桌面端用——桌面端在 classic 的 README 里标注为 in progress,用 GPUI 构建。

另一头,classic 的 apps/web/package.json 的 60 个 dependencies 里能看到 opencut-wasm 赫然在列,媒体侧还有 mediabunny,浏览器端模型侧有 @huggingface/transformers。从包名能确认的只是用途方向:媒体与渲染的重活放在浏览器本地做,这跟 classic README 自述的三条 Why 中的 Privacy(视频留在你自己设备上)是一致的——那三条 Why 是项目方的主张,不是我们的评价,另外两条是 Free features 与 Simple,原文出自 classic README。

一个特效在代码里由什么构成

classic 的 docs/effects-renderer.md 给的新增流程是三步:在 apps/web/src/lib/effects/definitions/(classic 仓库)建一个文件(文档举例 brightness.ts),导出一个 EffectDefinition(参照同目录的 blur.ts),然后在该目录的 index.ts 里注册。

EffectDefinition 的字段是这条边界的具体化:

字段职责落在哪一侧
type唯一字符串标识TypeScript
name显示名TypeScript
keywords供搜索用TypeScript
params面向用户的控件,滑块、开关等TypeScript
rendererGPU pass 模板,解析成 shader 标识符 + uniformsTypeScript 描述,Rust/wgpu 执行

前四个字段一眼就知道是产品侧的东西。真正跨过边界的是 renderer:它是一份模板,被解析成 shader 标识符和 uniforms 之后交出去。TypeScript 在这里写的不是渲染逻辑,是一份「跑哪个、带什么参数」的清单。

最简形态是这样:

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

uniforms 是个函数,从 effectParams 里取值——也就是用户在 params 里调的那些控件值,最终以 uniform 的形式喂给这一趟 pass。这条链路是理解整个特效系统的主干:用户拨滑块 → effectParams 变 → uniforms 重新算 → 同一个 shader 标识符带着新参数再跑一遍。

什么时候一个 pass 不够

渲染器支持 passes 数组,单 pass 特效(比如调色一类)就只有一个条目。文档给出的判断标准很干脆:当特效必须处理自己的输出时,就需要多 pass

文档举的例子有三个:模糊(先横后纵)、辉光 bloom(提取 → 模糊 → 合成)、glow。这三个例子放在一起看,规律就出来了——它们的共同点不是「效果复杂」,而是后一步的输入是前一步的输出。横向模糊的结果要再被纵向模糊一遍;bloom 要先把亮部提取出来,模糊之后再跟原图合成。你没法把这些塞进一趟里,因为一趟 pass 读的是同一份输入。

所以你要判断自己的特效是不是多 pass,别看它「看起来难不难」,问一句:中间是否存在一个必须先落地、再被当作输入读回来的结果?没有,就是单 pass;有,几趟就是几趟。

buildPasses:这里是最容易踩的一脚

有些特效的 pass 数量不是固定的,会随参数变。文档举的例子是模糊——在高强度时需要更多迭代来保证质量。这种情况下加一个 buildPasses 函数:

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

buildPasses 拿到的除了 effectParams,还有 widthheight——尺寸参与 pass 的构造,这本身就说明它算的不只是「几趟」,还有跟分辨率相关的预计算 uniform。

关键规则在这里,写错会让人排查很久:文档明写,buildPasses 存在时,所有渲染路径都用它,不再使用静态 passes 数组;静态数组保留下来是作为结构参考与兜底。

这条规则的实际后果是:你如果给一个已经有 buildPasses 的特效去改 passes 数组里的参数,改完一点反应都没有,因为那个数组已经不在生效路径上了。这类「改了没反应」的问题最耗时间,因为它不报错。所以碰到特效行为跟你的改动对不上,第一件事不是去怀疑 shader,而是回到这个特效的定义文件,看它有没有 buildPasses——有,就去改 buildPasses;没有,才去改 passes

同一份文档里还有一节强调,pass 的解析必须走 resolveEffectPasses。把这两条放在一起看,意思就清楚了:静态数组与 buildPasses 这两种写法之间的取舍,是在解析这一步统一裁决的,绕开它自己去读 passes,你拿到的就可能不是真正会被执行的那一份。任何自行读取 pass 定义的地方都是隐患。

所以你的改动该落在哪一侧

把上面的东西收成一条可执行的判断路径:

  • 想加一个新效果,或者调一个已有效果的参数范围、控件、默认值 → 全部在 apps/web/src/lib/effects/definitions/ 里,EffectDefinition 的前四个字段,不碰 Rust。
  • 想改一个效果跑几趟、每趟带什么 uniform → 还是 TypeScript 侧,但要先确认这个特效是靠静态 passes 还是 buildPasses 在跑,改错地方等于没改。
  • 想改一个效果算得对不对、算法本身长什么样 → 那是 shader,文档另有 Writing shadersCoordinate systems 两节,我们只读了标题,没读正文,这里不展开也不猜
  • 遇到的问题是设备/纹理/pass 执行层面的 → 边界的另一侧,rust/crates/ 下的 gpueffectscompositor 那几个 crate,跟 bridgerust/wasm/ 的绑定一起看。

这套分法对读者的价值不限于 OpenCut 本身。「声明式的一侧描述要跑什么,另一侧拥有资源与执行」是个复用性很强的结构:描述侧可以用弱类型语言快速迭代、随时热改,执行侧把设备与内存握在自己手里、不让上层直接摸。代价是要额外维护一层解析(也就是 resolveEffectPasses 干的事),以及像 buildPasses 覆盖 passes 这种优先级规则必须写进文档——否则就会变成只有作者知道的隐性知识。

顺便把重写版划清楚

需要特别说明的是,上面这一整套东西属于 classic(旧版)仓库OpenCut-app/opencut-classic,最后推送 2026-05-17,已归档,star 213(核对日 2026-08-09)。

重写版是另一个仓库 OpenCut-app/OpenCut,star 81917(同一核对日),README 的 Status 段明写 OpenCut 正在被从头重写,并列出了一批「即将到来」的东西——Editor API、一等公民的第三方插件、桌面移动浏览器共用一套代码库(Rust 核心)、面向 AI agent 的 MCP server、无头模式(自动化与批量渲染)、编辑器内置的脚本标签页。这几条都是官方 README 声明的计划,尚未发布,不是现有功能,我们没有见到可用实现。

而且重写版的 Rust 部分现在跟 classic 完全不是一回事:我们实读主仓的 Cargo.toml,workspace 只有 apps/desktop 一个成员,crates/* 那一行是被注释掉的。也就是说 classic 里那六个 crate 的结构,不能拿来描述重写版当前的样子。README 自己也说旧版本仍在 opencut-app/opencut-classic,那才是今天该用的那个,opencut.app 跑的仍然是 classic,重写版会先落在 new.opencut.app,直到它准备好接管——没有给时间表。

如果你打算基于本文的内容去做点什么,把版本对上是第一步:读 classic 的 docs/effects-renderer.md,改 classic 的 definitions/。重写版目前不接受外部贡献,README 原文说架构还在设计中,他们还没准备好接受外部贡献,想跟进可以去 issue 或官方 Discord。

延伸阅读


本文依据 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 线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。

涉及重写版的部分,描述的是其仓库当前的代码结构与官方 README 声明的路线图。该版本尚未发布,Editor API、插件体系、MCP server、无头模式等均为官方声明的计划,我们没有见到可用实现。

许可条款请以官方 LICENSE 原文为准,本文不构成法律意见。

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