起点是个很小的念头:手上有一个静态的 logo,一只黑猫的剪影嵌在一块白色多边形里,我想让它动起来。不是加个旋转或淡入那种现成的 CSS 过渡,而是把它拆成一颗一颗的点,让这些点先散开、再聚拢成形——像老式点阵屏那样。

这篇记录的是从这个念头到一个能用的、无缝循环的 loading 动画,中间一步步是怎么走的,以及几个真正卡住、然后想通的地方。做出来的东西最后会顺带说,但它不是重点,重点是这条路本身。

An image to describe post

An image to describe post

第一步:图怎么变成点

An image to describe post

一张图是连续的像素,点阵是离散的网格,第一件事就是采样。逻辑很朴素:把图缩进一个 N×N 的网格,每个格子取它覆盖区域的平均亮度,亮度过了某个阈值就认为这里“有一个点”。

for gy in range(grid):          # 行
    for gx in range(grid):      # 列
        avg = 这个格子的平均亮度
        if avg > 140:           # 亮的当作前景
            记下这个点

两个参数决定观感。网格密度 N 越大越精细,但点太多动画会发腻、性能也吃紧;我试下来 64 是个甜点,原图采出来九百多个点,形状清楚又不臃肿。阈值决定哪些算“前景”——我这张图是黑底白形状,所以“亮=前景”,白色多边形成了点,中间的黑猫自然变成了负空间,反而比正面去描猫更好看。

这一步我栽了个跟头,值得记一笔。我后来把采样逻辑重构成一个独立脚本时,引入了一个中间数组按行列存亮度,结果在还原坐标时把行和列搞反了——gx, gy = i // grid, i % grid,但外层循环是行、内层是列,正确该是 gy, gx = ...。一个字母的位置写反,整张图就沿对角线转置了,肉眼看上去像是“向左转了90度还照了镜子”。这种 bug 最阴险的地方在于它不报错、数据也“对”,只是方向悄悄歪了,得把点重新画回一张图、跟原图并排看才发现。教训是:凡是涉及坐标编解码,写完一定要做一次“还原成图”的回环验证,不要只看数字。

第一次抽象:六种动画其实是同一个东西

An image to describe post

点有了,接下来是怎么让它们动。最直接的做法是给每个点一个目标位置,再给一个起始位置,然后用弹簧力把它从起点拉到终点。中心爆炸式散开、再汇聚,第一版就这么出来了。

然后我想多要几种花样——从中间漩涡卷入的、从左到右像幕帘扫过的、从天上掉下来带回弹的。一开始我以为这是六套不同的动画,要写六遍。写到第三种才反应过来:它们根本是同一套引擎,区别只在三个变量。

第一,每个点的出发点在哪。爆炸是从中心向外的随机远点,幕帘是从画面左侧外,坠落是从正上方。第二,它什么时候开始动。所有点同时动,是整体位移;按列号递增地依次开始,就成了从左扫到右的幕帘;按到中心的距离排序,就成了向外扩散的涟漪。第三,用什么缓动曲线。匀速、先快后慢、还是冲过头再弹回来,决定了它的“手感”。

function easeOutBack(x){           // 冲过头再弹回,坠落用它
  var c1=1.70158, c3=c1+1;
  return 1 + c3*Math.pow(x-1,3) + c1*Math.pow(x-1,2);
}

想通这一点之后,“做特效”就变成了“在一个三维参数空间里挑坐标”。六种风格只是六个采样点,再加新风格几乎不要钱。这是整件事里第一个让我觉得值得停下来记一下的地方——把一堆看似无关的具体,收敛成几个可调的旋钮。

真正的坑:loading 为什么不能用定时器

An image to describe post

把动画接成 loading,理论上很简单:显形,停一下,消散,再显形,循环。我第一反应是用定时器——播一段动画,设一个 setInterval,时间到了触发下一段。

跑出来不对。节奏总是对不上,看着像卡了一下又接上,完全没有 loading 该有的那种连绵不断。问题在于:动画本身的时长是浮动的(不同点有不同的延迟,最后一个点什么时候落位并不固定),而定时器是个写死的固定间隔。两条时间线各跑各的,对不齐,于是每一轮之间都漏出一道缝。

想明白的解法是:根本不该有“两条时间线”。不要让一段动画去“触发”下一段,而是把整个循环——显形、短暂成形、消散——编成一条首尾相接的时间轴,让一个不断前进的相位去驱动它。关键的衔接在于,让消散的终点正好就是下一轮显形的起点:点散开到哪里,下一轮就从哪里聚回来。这样首尾天然咬合,永远不会有缝。

phase = (phase + dt) % CYCLE;     // 一个永远前进、到头归零的相位
// CYCLE 内分成几段:显形 -> 成形 -> 消散
// 消散的目标位置 = 显形的出发位置,于是首尾无缝

这是整篇我最想留下的一段。它不只是一个动画技巧——“用一条连续的相位代替一串离散的触发”是个能复用的思路,很多看起来要靠“播完再播下一个”的循环,其实都该这么改。大多数人做循环动画第一次都会踩定时器这个坑,包括我。

导出:无缝的前提是只渲一个周期

An image to describe post

动画在浏览器里好看,要发出去得变成 gif 或视频。最省事的是录屏,但录屏录的是“真实播放过程”,机器卡一下就掉帧,时长也不精确,循环点更是对不准。

更靠谱的是逐帧渲染:不让它自己跑,而是把动画改造成“给一个时间 t,就能算出那一刻的画面”的函数,然后手动把 t 按固定步进推进,每推一帧抓一张图。它和真实帧率解耦,绝不掉帧。而对一个无缝循环来说,还有个额外的好处——只要正好渲染一个 CYCLE 的帧数(比如 3 秒 × 25 帧 = 75 帧),导出的 gif 循环起来就严丝合缝,因为第 75 帧的下一帧本来就该是第 0 帧。

让别人也能玩:生成图这一环

An image to describe post

到这里东西已经能用了,但只能玩我这一张猫。要让它对别人有意思,得让任何人随便给个图、甚至随便打段字,都能生成自己的点阵动画。

“给个图”好办,采样逻辑通用。“打段字生成图”这一环卡了一下:要把文字变成图,最顺手的是接一个 AI 文生图的服务,但那意味着别人得有对应的 key 和环境,第一次跑大概率失败。后来想到一个更干净的路子——用模型自带的画 SVG 的能力。它本来就会画简洁的图标、字母、剪影,而这些恰恰就是点阵化效果最好的东西(复杂照片塞进 64×64 照样糊)。SVG 的能力边界和点阵的需求边界天然重合,于是“生成图”从一个外部依赖变成了零依赖的一环,整条链就闭合了。

收束

回头看,这件事真正有价值的不是那只会动的猫,是中间几个把具体收敛成抽象的瞬间:六种动画其实是三个旋钮,循环不该用定时器而该用连续相位,无缝的前提是只渲一个周期。这些判断比任何一段具体的代码都活得久。

最后我把整条流程——采样、六种过渡、无缝循环、导出——固化成了一个可复用的工具,省得下次从头讲一遍。但工具会过时,会被更好的轮子替掉;上面这些想通的过程不会。所以先写下来。

完整的 skills:https://github.com/s87343472/dot-matrix-animator