渲染目标可换:同一引擎驱动 canvas
引擎产出的是值,「把值写到哪」是另一回事。前面各页的值都落到 DOM 的 transform 上,但「时间到值」是纯函数,这个 sink 可以随意替换:同一个 Animation 当时钟,onUpdate(p)
里把每个粒子的位置采样出来,改成往 <canvas> 的 2D context 上画。
1 · 一个 Animation 驱动一片粒子
几十个粒子从散布位置聚拢成一个环再散开。一个 Animation 给出全局进度
,每个粒子的位置是它自己「起点到环上目标」的插值——和 DOM 版的关键帧采样完全一样,只是最后一步从写样式变成 ctx.arc。
2 · 换 sink 换来了什么
canvas 适合大量、逐像素或带残影的视觉。拖尾就是一个 DOM 做不到的例子:不清画布,而是每帧压一层 rgba(42, 34, 51, 0.18) 的半透明底色,旧帧按指数衰减成残影——DOM 侧要做同样的效果,得为每个粒子留一串历史节点。
代价也很实在。canvas 里的粒子不是元素:没有命中测试、没有 :hover、屏幕阅读器读不到、无法用 CSS 选中或调试器里点选。这是一次性的整体取舍,不能逐个粒子地权衡。
注 · 拖尾那层半透明矩形不会把画布擦成全黑。每帧残留
,
帧后残留
,60 帧(1 秒)后约
——但 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 是纯函数」这条主线的完整展开见动画即数据。