交互式网页动画
活在网页上、会回应使用者的手绘作品。它们跟随指针,对悬停和获得焦点的按钮做出反应,随着你滚动把自己画出来,还会跟着表单一起读。这些都不是视频,也不是嵌入的播放器:作品就是页面 UI 的一部分。
三个完整的演示。每个都是一个自包含的 HTML 文件,离线就能打开:
| 演示(作品) | 它做什么 |
|---|---|
落地页吉祥物(mascotHero) | Bit 注视着你在页面上任何地方的指针,眼睛先动,头慢一拍跟上。悬停或用 Tab 移到一个按钮上,他就转过身,用靠近按钮那一侧的手臂把它展示出来,扬起眉毛,把头上的灯泡调亮。按下:一个下蹲。点击「Get started」:他跳起来欢呼,还带着铅笔画的闪光。「Take the tour」换来一次挥手,戳他一下会让他咯咯笑。 |
滚动绘制的首屏(scrollHero) | 故事书样张随着你滚动把自己画出来:结构椭圆、先画头部的铅笔草稿、从画笔落下处晕开的淡彩,最后是定稿线条,全程笔尖处都有一支铅笔或画笔。往回滚,它就反向擦掉。最后一帧就是样张本身,逐像素一致。 |
机械探索器(mechanismExplorer) | 一个八音盒机芯演奏着它的曲子。悬停、聚焦或按下「Take it apart」,它就由外向内拆开,每个组件各自坐在一根有弹性的弹簧上;指向一个零件,它就亮起来,读数告诉你它是做什么的。基于 machines.md。 |
表单吉祥物(formMascot) | 他读着你的光标,每敲一个键就点一下头,到了密码框就闭上眼睛、举起双手。按下「Show」,他就被抓到在偷看。提交出错会换来一次担心的摇头。「Busy」状态让他的发条钥匙呼呼转起来,成功时就是那次欢呼。 |
三个都用故事书样张的手笔(铅笔 + 水彩)。Bit 由样张自己的代码(drawBit、limb、hand)画出,原封不动。
契约:给定输入日志,一帧就是一个事实
契约里没有别的东西会动。画作保持纯粹:一个 Piece 永远看不到时钟、事件或 DOM。它拿到一个 60 Hz 的 tick 和一个 InputState,然后画出整帧。宿主(hosts/interactive.ts)掌管时间和 DOM,把使用者的操作变成一份只追加的 InputLog。stateAt(canvas-core/input.ts)是对这份日志的纯折叠(fold),每 60 个 tick 存一个检查点。同一份日志在任何页面里、以任何顺序、冷启动或热启动重放,都画出相同的像素,tools/replay.mjs 对每一个生成的页面证明这一点。
| 问题 | 回答 |
|---|---|
| 一个 tick 里有两个事件? | 按日志顺序应用。tick 永远不会减小;如果减小了,reducer 会抛错。 |
| 已经画出的帧以后会变吗? | 不会。事件的时间戳打在每一个已经画出的 tick 之后:max(lastDrawn + 1, clock)。 |
| 初始配置? | 它在第 0 个 tick 就写进日志,早于任何绘制:目标矩形、滚动位置、减弱动态效果、状态和设备缩放。 |
| 改变窗口大小、DPR? | 日志坐标在作品的逻辑空间里,所以改变大小会记下新的 rect 和一个 view(设备缩放),不改变任何状态。宿主在后台以新的缩放重新烘焙,并在帧边界上切换过去。 |
| 被打断的运动? | 弹簧是闭式解,默认临界阻尼;zeta: 0.8 到 0.9 落定时带一次轻微的过冲,有真实零件的分量感。目标是离散状态的纯函数,而离散状态只在事件发生时改变。位置和速度跨过打断,带进一个新的精确分段。 |
| 一次性的反应? | 定长片段,每次触发都从头开始(clip(s, "click:start", 72)),随机性用日志做种子(reactionSeed)。 |
| 键盘? | 键盘焦点算作悬停(attention 取两者中较新的那个),Enter 或 Space 算作点击。每一种悬停反应都有对应的焦点版本。 |
| 触屏? | 一次轻点会记下它的位置。手指抬起时记下 exit 和 leave,所以不会有东西一直停在悬停状态。 |
| 打字? | 只记录长度和光标位置,绝不记录文字。日志里不含任何个人数据。 |
| 减弱动态效果? | 它在日志里。没被碰过的作品是静止的,反应是短暂的表情交叉淡化,滚动作品直接显示它完成后的画面。 |
做一个
-
作品,放在
engine/src/canvas-core/<name>.ts:export const myPiece: Piece = { meta: { title, W: 560, H: 560, loop: 480, alt: "what a screen reader hears" }, input: { springs: { look: { omega: 0.3, target: (d) => lookTarget(d, FACE) }, // eyes, quick arm: { omega: 0.16, target: (d) => (attentionOf(d) === "cta" ? 0.5 : 0) }, } }, bake: function* (env) { usePaper(env, SHEETS); yield; bg(env); yield* bakeBit(env); }, draw: (ctx, tick, env, s) => { /* sprites + a few live marks */ }, };InputState给画作提供pointer、hover、focus、attention、pressed、scroll、state、rects、fields、since、count、spring、vel和reduced。辅助函数有gaze、lean、parallax、lookTarget、clip、ticksSince、reactionSeed、scrollSpan和ease。没人碰的时候,作品必须循环(meta.loop),而且没有一秒是冷场。 -
网页方案(
bake.ts)。一张样张在 DPR 2 下画一个角色大约要 130 ms;一个页面每帧只有 6 ms。挂载时画一次,之后每帧只做合成。bake把画作实际触及的区域精确地裁成一个精灵图(sprite)。bakePart运行某张样张自己的绘制函数里选定的几组。usePaper把纸张一次性正片叠底进每个精灵图(正片叠底对 source-over 满足分配律),所以每帧都不需要再叠一遍纸张。- 在整设备像素上做
blit能保持清晰。 - 每帧最多旋转一个合成好的图层:在软件画布上,一次旋转的 blit 成本大约是轴对齐 blit 的 8 倍。
- 绝不放大位图。
- 滚动首屏展示了另一种模式:样张被记录成一份笔触日志、四张阶段图,笔画用作遮罩。
- 页面。 用
data-anidoodle="<name>"标记 UI,加上<ani-doodle piece="<kebab-name>">(滚动作品再加scroll-track="#selector"),并在模块要放的位置写上<!--anidoodle-->。UI 状态:document.querySelector("ani-doodle").setState("success")。 -
生成、过闸、录制:
node tools/emit-interactive.mjs myPiece --page page.html --out out/my-piece.html # also writes out/my-piece.mjs node tools/replay.mjs out/my-piece.html --out out/my-piece.replay.json node tools/record-interactive.mjs out/my-piece.html choreo.mjs --out out/my-piece.mp4.mjs是真实网站加载的模块,通过<script type="module" src>或import { mount, piece }加载。.html把它内联进去,因为浏览器拒绝从file://导入模块。
mount(el, piece, opts) 返回 setState、pause、play 和 destroy。没有框架。宿主提供:
- 一个看得见的暂停按钮;
- 带有作品
alt的role="img"; - 烘焙完成后淡入;
- 不在屏幕外或隐藏的标签页里绘制(时钟照常计时);
- 没有声音。
重放闸门证明了什么
- 一个按脚本行动的使用者在 DPR 2 下实时操作页面:横扫、悬停每一个目标、快速地进进出出来回反转、按下、点击、取消、Tab 和 Enter、打字、悬停的同时改变窗口大小、滚轮滚到底再滚回来,以及状态变化。每画出三帧就实时取一次哈希。
- 在触屏手机上做同样的事(轻点、一次滑动)。
- 一个全新的页面只拿到日志,把每一个采样的 tick 先倒序重画一遍,再正序重画一遍,然后冷启动重画一遍(全新的 env,每个精灵图都重新烘焙)。三遍的哈希都必须和实时的完全一致。
- 空日志等于
restState(不经过 reducer 构建);实时的第 0 tick 配置什么都不改变;循环能闭合;没被碰过的作品会动。 - 减弱动态效果:空闲时静止,并且一次完整的会话能渲染出来。
- DPR 2 下的帧时间(强制软件光栅化和默认两种情况)、启动时间、精灵图内存和包大小。
取哈希需要单一的渲染器:Chromium 会在 GPU 画布第一次回读之后悄悄把它转到软件渲染,所以要取哈希的页面从一开始就以 window.__ANI_RASTER__ = "cpu" 运行。
闸门抓到的两个陷阱,都是平台行为,不是画作里的 bug:
- 画布会记住渐变。 在 Chromium 里,一个曾经用渐变填充过的 2D 画布(径向或线性,比如
Gfx.glow或Gfx.vignette),之后的绘制会以不同的方式光栅化,差异最多 8/255。即使它的像素已经清空、它的fillStyle已经重置,这一点依然成立。纯色填充和图案填充不会这样。由于 Gfx 图层池和烘焙用的草稿画布是共享的,一个精灵图画出来的结果取决于在它之前烘焙了哪些精灵图:冷启动 != 热启动。bake()现在会先对每一个共享画面调用ctx.reset()(在没有reset的地方就丢掉图层池)。影片引擎在各帧之间共享同一个池,所以那里可能存在同样的顺序依赖。这是一条线索,指向确定性准则里那个无法解释的首次运行 0/12,但还没有被证实就是原因。 - 回读时 GPU 转软件,见上文。只对从一开始就是软件渲染的画布取哈希。
实测(无头 Chromium 1140,这台 Mac,1280x800、DPR 2,2026 年 9 月)
| 演示 | 闸门 | 帧时间 p95(强制光栅化 / 默认) | 就绪 | 精灵图 | 模块 |
|---|---|---|---|---|---|
| 落地页吉祥物 | 22/22 | 4.1 ms / 0.3 ms(更早一次运行是 2.8) | 1.9 s | 152 个,缩放 1.5 时 35 MB | 50 KB(gzip 后 20 KB) |
| 表单吉祥物 | 22/22 | 3.5 ms / 0.3 ms(更早一次运行是 2.2) | 2.0 s | 152 个,缩放 1.25 时 31 MB | 51 KB(gzip 后 20 KB) |
| 滚动首屏 | 22/22 | 1.8 ms / 0.4 ms | 2.0 s | 4 个阶段 + 每笔一块补丁,1.25 时 34 MB,2 时 58 MB | 48 KB(gzip 后 19 KB) |
预算是 DPR 2 下 p95 6 ms。要守住它,就把多部件的头部合成为一个精灵图,而不是每帧旋转每一个脸部精灵图;把纸张纹理烘焙进精灵图,而不是每帧在整个画布上叠一遍纸张。上面的演示用这种方式跑在 2-4 ms(在共享机器上每次运行都有波动)。当一个反应第一次需要某张赛璐珞帧(cel)时,或者在缩放切换期间,单帧会达到 10-22 ms。
局限
- 帧时间是在无头 Chromium 的软件画布上测的,这是这台机器上诚实的上限。GPU 合成的页面画同一帧,JS 耗时远低于 1 ms。没有在任何真实手机上测过。
- 精灵图内存是速度的代价。 缩放 2 时是几十 MB,滚动首屏最重(每笔一块补丁)。在低内存手机上要为它做预算,或者给
maxScale设上限。 - 启动。 烘焙大约要 2 秒,之后画布才淡入(软件光栅化,DPR 2);在此期间页面照常可用。改变窗口大小导致缩放变化时会再烘焙一次(有防抖,而且不在可见帧上进行,但两种缩放同时存在期间内存会翻倍)。
- 移动的部件带着它们的纸纹(
usePaper):静止时精确,运动时色调会偏几个百分点。 - 连续的姿态被量化了,手绘动画本来就是这样:手臂抬起分 16 张赛璐珞帧,钥匙分 8 个相位。只有头部作为一个图层旋转。
- 日志会增长,随指针移动而增长(每个 tick 合并成一次移动)。长达几小时的会话不会被压缩。
- 只验证过 Chromium。 其他浏览器预期在自身内部是确定的,但不会与 Chromium 逐位一致。顺序无关性的修复需要
ctx.reset();没有它就改为丢掉图层池。 - 这些样张本来不是为此而画的。 Bit 的表情(眉毛、嘴巴、眼睑)是用样张的笔新画的笔触,不是样张原有的。一个新角色需要它自己的零件清单。