算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 渲染目标可换:同一引擎驱动 canvas 待审核 20 / 47
render target · canvas · sink 可换

渲染目标可换:同一引擎驱动 canvas

引擎产出的是,「把值写到哪」是另一回事。前面各页的值都落到 DOM 的 transform 上,但「时间到值」是纯函数,这个 sink 可以随意替换:同一个 Animation 当时钟,onUpdate(p) 里把每个粒子的位置采样出来,改成往 <canvas> 的 2D context 上画。

1 · 一个 Animation 驱动一片粒子

几十个粒子从散布位置聚拢成一个环再散开。一个 Animation 给出全局进度 pp,每个粒子的位置是它自己「起点到环上目标」的插值——和 DOM 版的关键帧采样完全一样,只是最后一步从写样式变成 ctx.arc

图 1-1 · 一个时钟驱动的粒子聚散。可调粒子数,或打开拖尾观察不清画布带来的残影。

2 · 换 sink 换来了什么

canvas 适合大量、逐像素或带残影的视觉。拖尾就是一个 DOM 做不到的例子:不清画布,而是每帧压一层 rgba(42, 34, 51, 0.18) 的半透明底色,旧帧按指数衰减成残影——DOM 侧要做同样的效果,得为每个粒子留一串历史节点。

代价也很实在。canvas 里的粒子不是元素:没有命中测试、没有 :hover、屏幕阅读器读不到、无法用 CSS 选中或调试器里点选。这是一次性的整体取舍,不能逐个粒子地权衡。

注 · 拖尾那层半透明矩形不会把画布擦成全黑。每帧残留 10.18=0.821 - 0.18 = 0.82nn 帧后残留 0.82n0.82^n,60 帧(1 秒)后约 10510^{-5}——但 fillRect 走的是 8 位整数通道,衰减到 1/255 以下就再也减不动了,底色会永久停在一个极暗的非零值上。所以叠加色必须选得与画布底色一致,否则拖尾区域与未拖尾区域会留下一道看得见的色差边界。

import { Animation } from '@vega/anim';

const ctx = canvas.getContext('2d');
const anim = new Animation({
  keyframes: [0, 1], duration: 2400, ease: 'easeInOut',
  repeat: Infinity, repeatType: 'mirror',
  onUpdate: (p) => {                       // p: 全局进度
    ctx.clearRect(0, 0, W, H);             // ← sink 换成 canvas
    for (const pt of particles) {
      const x = pt.sx + (pt.tx - pt.sx) * p;   // 同样的插值
      const y = pt.sy + (pt.ty - pt.sy) * p;
      ctx.beginPath(); ctx.arc(x, y, pt.r, 0, 2 * Math.PI); ctx.fill();
    }
  },
});

值源、时序、播放控制都只产出数;把数写到 DOM transform、SVG 属性(沿路径运动)还是 canvas 像素,都只是 onUpdate 里的最后一步。由外部信号而非时钟驱动的 canvas 可视化见声音驱动,无尽微动见噪声。「render 是纯函数」这条主线的完整展开见动画即数据