# 交互式网页动画

活在网页上、会回应使用者的手绘作品。它们跟随指针，对悬停和获得焦点的按钮做出反应，随着你滚动把自己画出来，还会跟着表单一起读。这些都不是视频，也不是嵌入的播放器：作品就是页面 UI 的一部分。

三个完整的演示。每个都是一个自包含的 HTML 文件，离线就能打开：

| 演示（作品） | 它做什么 |
|---|---|
| **落地页吉祥物**（`mascotHero`） | Bit 注视着你在页面上任何地方的指针，眼睛先动，头慢一拍跟上。悬停或用 Tab 移到一个按钮上，他就转过身，用靠近按钮那一侧的手臂把它展示出来，扬起眉毛，把头上的灯泡调亮。按下：一个下蹲。点击「Get started」：他跳起来欢呼，还带着铅笔画的闪光。「Take the tour」换来一次挥手，戳他一下会让他咯咯笑。 |
| **滚动绘制的首屏**（`scrollHero`） | 故事书样张随着你滚动把自己画出来：结构椭圆、先画头部的铅笔草稿、从画笔落下处晕开的淡彩，最后是定稿线条，全程笔尖处都有一支铅笔或画笔。往回滚，它就反向擦掉。最后一帧就是样张本身，逐像素一致。 |
| **机械探索器**（`mechanismExplorer`） | 一个八音盒机芯演奏着它的曲子。悬停、聚焦或按下「Take it apart」，它就由外向内拆开，每个组件各自坐在一根有弹性的弹簧上；指向一个零件，它就亮起来，读数告诉你它是做什么的。基于 [machines.md](machines.md)。 |
| **表单吉祥物**（`formMascot`） | 他读着你的光标，每敲一个键就点一下头，到了密码框就闭上眼睛、举起双手。按下「Show」，他就被抓到在偷看。提交出错会换来一次担心的摇头。「Busy」状态让他的发条钥匙呼呼转起来，成功时就是那次欢呼。 |

三个都用故事书样张的手笔（铅笔 + 水彩）。Bit 由样张自己的代码（`drawBit`、`limb`、`hand`）画出，原封不动。

## 契约：给定输入日志，一帧就是一个事实

```
draw(ctx, tick, env, stateAt(tick, log, piece.input))
```

契约里没有别的东西会动。画作保持纯粹：一个 `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`，所以不会有东西一直停在悬停状态。 |
| 打字？ | 只记录长度和光标位置，绝不记录文字。日志里不含任何个人数据。 |
| 减弱动态效果？ | 它在日志里。没被碰过的作品是静止的，反应是短暂的表情交叉淡化，滚动作品直接显示它完成后的画面。 |

## 做一个

1. **作品**，放在 `engine/src/canvas-core/<name>.ts`：

    ```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`），而且没有一秒是冷场。

2. **网页方案**（`bake.ts`）。一张样张在 DPR 2 下画一个角色大约要 130 ms；一个页面每帧只有 6 ms。挂载时画一次，之后每帧只做合成。
    - `bake` 把画作实际触及的区域精确地裁成一个精灵图（sprite）。
    - `bakePart` 运行某张样张自己的绘制函数里选定的几组。
    - `usePaper` 把纸张一次性正片叠底进每个精灵图（正片叠底对 source-over 满足分配律），所以每帧都不需要再叠一遍纸张。
    - 在整设备像素上做 `blit` 能保持清晰。
    - 每帧最多旋转一个合成好的图层：在软件画布上，一次旋转的 blit 成本大约是轴对齐 blit 的 8 倍。
    - 绝不放大位图。
    - 滚动首屏展示了另一种模式：样张被记录成一份笔触日志、四张阶段图，笔画用作遮罩。
3. **页面。** 用 `data-anidoodle="<name>"` 标记 UI，加上 `<ani-doodle piece="<kebab-name>">`（滚动作品再加 `scroll-track="#selector"`），并在模块要放的位置写上 `<!--anidoodle-->`。UI 状态：`document.querySelector("ani-doodle").setState("success")`。
4. **生成、过闸、录制：**

    ```bash
    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"`；
- 烘焙完成后淡入；
- 不在屏幕外或隐藏的标签页里绘制（时钟照常计时）；
- 没有声音。

## 重放闸门证明了什么

1. 一个按脚本行动的使用者在 DPR 2 下实时操作页面：横扫、悬停每一个目标、快速地进进出出来回反转、按下、点击、取消、Tab 和 Enter、打字、**悬停的同时**改变窗口大小、滚轮滚到底再滚回来，以及状态变化。每画出三帧就实时取一次哈希。
2. 在触屏手机上做同样的事（轻点、一次滑动）。
3. 一个全新的页面只拿到日志，把每一个采样的 tick 先倒序重画一遍，再正序重画一遍，然后冷启动重画一遍（全新的 env，每个精灵图都重新烘焙）。三遍的哈希都必须和实时的完全一致。
4. 空日志等于 `restState`（不经过 reducer 构建）；实时的第 0 tick 配置什么都不改变；循环能闭合；没被碰过的作品会动。
5. 减弱动态效果：空闲时静止，并且一次完整的会话能渲染出来。
6. 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 的表情（眉毛、嘴巴、眼睑）是用样张的笔新画的笔触，不是样张原有的。一个新角色需要它自己的零件清单。
