着色器应该跟着工程走

设计札记

一个着色器常常只有二十行文字。它应该和工程里的其他东西一样被保存、打开、打关键帧、审阅,而不是编译成插件,装到每一台要渲染它的电脑上。

二十行的着色器,为什么要一个安装包?

给动态图形写过着色器的人,大概都遇到过这一刻:想法二十行就能写完,一道光扫过标志,一层扫描线在画面上漂移,一圈波纹把底下的图像扭弯。数学不长,语言也不难,跑起来以后几分钟就能调好。接着的问题是:这二十行该放在哪儿?

在 After Effects 里,这个问题的答案这些年变过几次,每变一次,离工程本身就更远一点。

  1. 2008 · CS4可以把 Pixel Bender 着色器当作效果加载
  2. 2012 · CS6Pixel Bender 支持被移除
  3. 今天用 C++ SDK 编译 GPU 效果插件,或装 PixelsWorld 这类第三方桥接插件

今天的正路是用 C++ SDK 写插件,把里面的 CUDA、Metal 或 DirectX 代码按平台分别编译,再装到每一台要打开这个工程的电脑上。捷径是装 PixelsWorld 这类桥接插件,它本身也是编译好的插件,你在它里面写 GLSL 风格的代码。不管走哪条路,这二十行都要靠机器上装好的东西,工程也只在装了它的电脑上才渲染得对。

这不是谁的疏忽。After Effects 的核心是闭源的,效果插件是它留给外部开发者画像素的那扇门。插件是本机代码,本机代码能做电脑上任何程序能做的事,所以只能像对待软件那样去信任它:编译、分发、有意识地安装。当年合情合理的选择,到今天决定了一段很小的程序只能放在哪里。

我们的看法是:这二十行是内容,不是软件。它应该和驱动它的关键帧、表达式放在同一个工程文件里,跟着工程保存、跟着工程复制,换一台电脑打开不用装任何东西,改动也像别的修改一样可以审阅。下面讲的是要让这件事安全需要做什么,以及要付出什么。

这片水面就是一段正在你浏览器里运行的程序

下面的水面不是视频,是一段画面程序。它是 Keyframe 能放进项目的三种程序里最宽的一种,此刻正在你的显卡上一帧一帧地画。它只被告知画第几帧,别的一概不知,所以直接跳到某一刻和一路播放到那一刻,画面完全相同。

它和作品集里的其他几件一样,是为本站手写的,不是 Agent 做的。 水面的光

着色器“存在项目里”是什么意思?

Keyframe 的着色器效果把程序当作数据保存:源码、它所依据的接口版本,以及它声明的设置。保存项目就把这些存下来;把项目发给别人,效果也就跟着过去。下文截图里那个扫描线效果,在项目里是这样存的:

"shader": {
  "interfaceVersion": "1.0.0",
  "source": "fn shade(s: Shade) -> vec4<f32> {\n  let picture = layer(s.uv);\n  …",
  "parameters": [
    { "id": "spacing", "name": "间距", "kind": "number", "default": 14, "minimum": 2, "maximum": 80 },
    { "id": "speed", "name": "速度", "kind": "number", "default": 4, "minimum": 0, "maximum": 20 },
    { "id": "tint", "name": "线条颜色", "kind": "color", "default": [0.12, 0.1, 0.22, 1] }
  ]
}
项目里保存的着色器效果程序,这里把源码截短了。三个设置的数值和关键帧作为效果的普通属性存在旁边。

能这样做,靠的是接口版本。程序自己不初始化显卡、不绑定缓冲区、不声明入口;这些都由宿主在源码前面声明好,连同一套噪声、形状、颜色、混合和模糊的标准库。程序只需填一个函数 shade:给它一个像素,它返回这个像素的颜色。想越出这个接口的源码,在运行之前就会被拒绝。

和插件的区别全在这个窄口上。本机代码要先被信任才能安装;照着一个小而有版本的接口写成的程序,却可以被检查、试跑,并作为文档的一部分保存下来,就像表达式⁠ (在新标签页打开)那样。也正因为代码是随项目而来,不是经安装包进来的,打开一个含有这类代码的项目时,编辑器会告诉你。

三种程序,从最窄到最宽

动态设计师想做的东西,一种程序覆盖不全,所以有三种。每一种都是 效果和预设 里的一个效果,内容是一段程序,后一种能做的都比前一种多:

  • 着色器:对图层的每个像素各运行一次,用 WebGPU⁠ (外部网站,在新标签页打开) 的着色语言 WGSL⁠ (外部网站,在新标签页打开) 来写。它读取图层画面和自己的设置,返回一个颜色,用来做图案、调色、扭曲和图形。只有一遍,也不会画到图层外面。
  • 模拟:处理一切取决于之前发生过什么的东西,比如粒子、布料、波纹和拖尾。它是一格一格的元素,状态由显卡逐帧记住。你只写元素怎样开始、怎样前进一步;缓冲区、绑定和每帧跑几遍归编辑器管,画出来时元素是柔和的圆点,或连成一张带着图层画面的面。
  • 画面程序:一个用 WebGPU 画出整个图层的 JavaScript⁠ (外部网站,在新标签页打开) 模块,需要的话可以分好几遍画,做前两种做不出来的东西。

原则是用能做出效果的最窄的那一种。程序越窄,能出错的地方越少,编辑器要防的也越少;这也是最宽的那种被关得最严的原因。

三种程序有一点相同:都没有自己的时钟,也不用随机数。时间只以“正在画的这一帧”的形式给它,随机来自按位置和时间算出的噪声,模拟和画面程序从一帧带到下一帧的东西,都存在编辑器替它保管的状态里。所以不管是跳到某一帧还是播放到那一帧,画出来都一样,这正是动画工具对时间线上任何东西的要求。

没在你的显卡上跑通,就进不了项目

程序编辑器:源码、它读取的设置,以及应用按钮
  1. 程序的源码。
  2. 它读取的设置。
  3. 检查、试跑,通过后写进项目。

写程序就是上图这个对话框:源码、它读取的设置,加一个应用按钮。检查就发生在按下应用的时候。先对照接口查源码:不能自己声明绑定或入口,循环次数必须固定,长度有上限。然后编译,在这台电脑上试跑,只有通过的程序才写进项目,算一步,可以撤销。

试跑的内容随种类加码。着色器要编译、估耗时、画一帧。模拟还要先跑最初几帧,看数值是否稳定,不会越算越大。画面程序必须能加载,按时画出最初几帧,确实画出了东西而不是一片空白,并且第二次画同一帧时逐像素相同。程序没通过时,对话框会说明原因,能定位的会标出你源码里的行号。

插件从来不经过这道关。插件被信任,是因为有人装了它;这里的程序被接受,是因为它刚刚在你的显卡上、在接口之内跑过一遍,并且画出了画面。

最宽的那种程序,为什么要放到另一个源上运行?

编辑器的页面

  • 你的项目与存储
  • 登录状态
  • 网络
  • 帧号
  • 参数
  • 尺寸

画面

沙箱(另一个源)

  • 画面程序在 Worker 里运行
  • 没有时钟,不用随机数

着色器和模拟只能填宿主会调用的那几个函数,接口本身就是它们的围栏。画面程序是一整个能随意调用 WebGPU 的 JavaScript 模块,光靠接口围不住。所以它在另一个源的页面里的 Worker 中运行:浏览器对不同源之间的隔离,让它碰不到编辑器的页面、你的项目、浏览器存储和登录状态,它也连不了网络。

穿过这道边界的东西很少:进去的是要画的帧号、各项设置的值和画面尺寸,出来的是一张画面。在里面,Math.random、Date.now 和定时器一调用就报错,也不能导入任何东西,模块本身就是全部程序。我们从不在编辑器自己的源上运行画面程序,也不扩大交给它的东西;需要更多才能做的功能,我们选择不做。

程序声明的设置,就是可以打关键帧的属性

效果控件里的程序效果:编辑程序按钮和它声明的设置,设置旁有关键帧秒表
  1. 程序效果。
  2. 打开程序编辑器。
  3. 程序声明的设置。
  4. 给设置打关键帧。

单独一段程序只是一个外观固定的黑盒,让它成为动画一部分的是它的设置。程序声明的每个设置,无论是数值、角度、颜色、图层上的点、开关还是选项,都会以你起的名字出现在 效果控件 里,成为效果的一个属性。你可以拖动它、给它打关键帧⁠ (在新标签页打开)、用表达式驱动它,和别的属性没有两样,程序每一帧都读它当时的值。

扫描线的间距和速度就是两个这样的数值。给速度打关键帧,线条就会加速;用表达式把颜色链到另一个图层上,它就跟着那个图层变。以后改写程序时,id 和种类没变的设置会保留原来的数值和关键帧,改进代码不会把建立在它上面的动画丢掉。预览和导出跑的是同一段程序,所以在同一台电脑上,你在查看器里调好的帧,就是导出文件里的帧。

程序是数据,Agent 才能替你写

我们坚持让程序成为数据,还有一个原因。模型绝不该往编辑器里写应用代码,也没法安全地写一个本机插件让你去装。但它可以照着人用的那个窄接口写程序,过同样的检查。

把想要的效果告诉 Agent⁠ (在新标签页打开),三种程序它都能写。它读的是使用文档⁠ (在新标签页打开)里讲的同一套接口,每写一版就在你的电脑上试跑,看画出来的帧,改好了才提出来。交到你面前之前,可信代码还会把同样的检查再跑一遍,Agent 没试过的程序混不过去。

你拿到的是一份草稿⁠ (在新标签页打开):先预览,再决定采用还是丢弃。采用之后它就归你了,可以像自己写的程序一样用 编辑程序 修改。Agent 的自查只保证你看不到坏掉的程序;好不好看由你判断,它照你的意见再改。让 Agent 写程序属于 Agent 订阅(即将开放);自己写是免费的。

我们放弃了什么

第一个代价是跨机器的完全一致。查看器和导出跑同一段程序,在同一台电脑上画出同样的帧;换一台电脑,显卡的舍入可能略有不同。我们保证的是项目在渲染它的那台电脑上前后一致,不保证在所有电脑上分毫不差。

沙箱也有它的代价。含画面程序的帧在编辑器自己的页面上渲染,不能分给并行的导出 Worker,因为只有页面够得着沙箱。沙箱把程序和你的数据隔开,却隔不开你的显卡:特别重的程序照样能拖慢整个浏览器。

检查也不等于证明。模拟和画面程序只用默认设置试跑最初几帧,所以你后来调出的数值、很晚才发散的模拟、画到图层外面的画面,这次试跑都发现不了。真在渲染时出错,会报告在 问题 里。

还有一扇我们没开的门。Keyframe 不提供本机插件 SDK,现有的 After Effects 插件和 C++ 效果代码都搬不过来,程序要做的事都得放进 WGSL 或沙箱里的 JavaScript 模块,守接口的上限。编辑器自带的效果仍是手写的,随编辑器发布;只有你或 Agent 写的程序才存在项目里。

程序该放在哪里

程序放在哪里,决定了它的其他一切。放在项目里,着色器换电脑不用安装,设置排在时间线上,被接受之前先试跑过,Agent 也能起草一段交给你像审别的修改一样审阅。二十行画面就该只花二十行的成本,而不是一次编译、一次下载,再加上每台电脑都得配齐的一套插件。

接着看