需求:登录页的 Logo 动画要换新 Logo。难点:动画是设计工具导出的 JSON 文件,而公司没有专职 UI,没人能改源文件。本文记录我作为前端,如何借助 AI 用 Canvas 从一张 Logo 图片直接"生成"同款动画。
效果先看
Logo 炸裂成一片碎片,再从碎片聚拢回 Logo,循环往复:

一、背景:被 JSON 文件"卡脖子"的动画
我们的登录页 Banner 上有一个 Logo「爆炸 → 聚拢」的动画。这类丝滑的动效通常出自设计师之手:AE 里做好动画,用 Bodymovin 插件导出一份 JSON,前端用 Lottie 渲染。一切都很美好------直到需求说:换个新 Logo。
问题来了:
- JSON 是从
.aep源文件导出的编译产物,改不了; - 修改意味着要打开 AE、替换素材、重新调动画、重新导出------这是 UI 的活;
- 而我们没有专职 UI,外包?排期不可控。
作为一个前端,我盯着那个动画看了一会儿,发现它本质上并不是什么 AE 黑魔法:
把 Logo 打散成一堆碎片抛出去,再把碎片收回来拼成 Logo。
这玩意儿,用粒子系统完全可以复刻。而且复刻之后有个巨大的额外收益:素材只要一张 PNG,以后再换 Logo,改一行代码。说干就干,我和 AI 结对,用 Canvas 重写了整个动画。
二、拆解:这个动画的本质是粒子系统
把动画逐帧看几遍,可以拆出三个核心问题:
| 问题 | 答案 |
|---|---|
| 碎片从哪来? | 对 Logo 位图做像素采样------每个不透明的像素点就是一个"碎片" |
| 怎么散开、怎么收回? | 每个粒子存两套坐标:家位置 (Logo 上的采样点)和爆炸位置 ,用一个 0~1 的 progress 在两者之间插值 |
| 节奏怎么控制? | 一条时间线 驱动 progress 往返,配不同的缓动函数 |
只要解决了这三件事,动画就有了骨架。剩下的是化妆:光晕、冲击波、尘埃。
三、第一步:从 Logo 图片采样粒子
粒子从哪来?把 Logo 画到一块离屏 Canvas 上,用 getImageData 读出像素,逐个扫描:
ts
function buildParticles(img: HTMLImageElement) {
const off = document.createElement('canvas')
off.width = img.naturalWidth
off.height = img.naturalHeight
const octx = off.getContext('2d', { willReadFrequently: true })
octx.drawImage(img, 0, 0)
const px = octx.getImageData(0, 0, img.naturalWidth, img.naturalHeight).data
// 透明底图 → 用 alpha 作权重;白底图 → 用亮度反推
// (两种图都兼容,设计同学给哪种素材都能跑)
let transparent = 0
const total = iw * ih
for (let i = 3; i < px.length; i += 4) if (px[i] < 250) transparent++
const isTransparent = transparent > total * 0.05
const pts: number[] = [] // [x, y, weight, ...]
for (let y = 0; y < ih; y++) {
for (let x = 0; x < iw; x++) {
const o = (y * iw + x) * 4
let w = 0
if (isTransparent) {
if (px[o + 3] > 32) w = px[o + 3] / 255 // alpha 越高,粒子越亮
} else {
const diff = 255 - Math.min(px[o], px[o + 1], px[o + 2])
if (diff > 2) w = Math.min(1, diff / 6) // 越黑的地方,粒子越亮
}
if (w > 0) pts.push(x, y, w)
}
}
// ...
}
两个细节值得注意:
- 像素坐标要归一化成"世界坐标":以图片中心为原点、图片高度为 1。这样动画尺寸跟随容器缩放时,粒子逻辑完全不用改:
ts
// u/v ∈ 约 [-0.7, 0.7],与图片尺寸、容器尺寸都解耦
const u = (sx - iw / 2) / ih + (Math.random() - 0.5) / ih
const v = -((sy - ih / 2) / ih) + (Math.random() - 0.5) / ih
- 粒子数量要设上限:一张 2072×1464 的 Logo 有 300 万像素,全采样会卡死。按面积推算一个合理值(几千到一万六),再随机降采样:
ts
const MAX = Math.max(2000, Math.min(16000, Math.round((iw * ih) / 200)))
const step = pts.length / 3 / MAX
// 第 k 个粒子在 [k*step, (k+1)*step) 区间内随机取一个,保持均匀
四、第二步:给每个粒子算一个"爆炸方向"
这是最影响观感的一步。如果每个粒子方向完全随机,炸开后就是一团没有形状的雾;我们的目标是炸开之后依然能隐约看出 Logo 的轮廓。
做法:爆炸方向以径向(从中心向外)为主,混入一部分随机扰动:
ts
// 径向单位向量:从图心指向该粒子
const rl = Math.hypot(u, v)
const rx = rl < 0.02 ? jx : u / rl
const ry = rl < 0.02 ? jy : v / rl
// 70% 听径向的,30% 听随机的 ------ 轮廓保得住,又不呆板
const R = 0.7
let ex = rx * R + jx * (1 - R)
let ey = ry * R + jy * (1 - R)
let ez = jz * (1 - R) * 1.5 // z 方向纯随机,制造纵深
// 归一化后再乘一个随机强度,让碎片飞得有远有近
const mag = 0.3 + Math.pow(Math.random(), 1.6) * 1.05
最终每个粒子的数据结构非常简单:
ts
interface Particle {
tx: number; ty: number; tz: number // 家位置(Logo 采样点)
ex: number; ey: number; ez: number // 爆炸位移
rnd: number // [0,1) 随数,用于错峰、闪烁等
weight: number // 像素权重 → 粒子亮度
}
给 AI 的提示 :这一步如果直接说"帮我写粒子爆炸",AI 大概率给你纯随机方向(糊成一团)。更好的说法是:"爆炸方向 = 径向单位向量与随机单位向量的线性插值,径向权重 0.7,保留 Logo 轮廓"。把视觉期望翻译成数学描述,是人和 AI 分工的关键。
五、第三步:时间线------一个标量驱动整场动画
粒子怎么知道此刻该在哪?答案是不逐个管 ,用一条时间线维护一个全局 progress(1 = 完全炸开,0 = 聚拢完成),粒子绘制时各自插值:
ts
interface Step { from: number; to: number; dur: number; ease?; burst?; wave? }
function play(steps: Step[]) { queue = steps.slice(); stepT = 0 }
// 「爆炸 → 收回」一轮
function explodeCycle() {
play([
// 0.75s 炸开,easeOutCubic:先快后慢,像被冲开
{ from: progress, to: 1, dur: 0.75, ease: easeOutCubic, burst: 1, wave: true },
// 3.75s 收回,easeOutBack:结尾会越过目标再弹回来,产生"回弹呼吸感"
{ from: 1, to: 0, dur: 3.75, ease: easeOutBack, burst: 0.2 }
])
}
function updateTimeline(dt: number) {
const s = queue[0]
stepT += dt
const t = Math.min(1, stepT / s.dur)
progress = s.from + (s.to - s.from) * (s.ease ? s.ease(t) : t)
burst = (s.burst || 0) * Math.sin(Math.PI * t) // 爆炸中段的闪光脉冲
if (t >= 1) { queue.shift(); stepT = 0 }
}
缓动函数只用了三个,但各司其职:
ts
const easeOutCubic = t => 1 - Math.pow(1 - t, 3) // 炸开:先猛后缓
const easeOutQuint = t => 1 - Math.pow(1 - t, 5) // 开场聚拢:更干脆
const easeOutBack = t => { // 收回:带过冲回弹
const c = 1.7
return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2)
}
六、第四步:逐帧绘制------真正的"化妆"都在这
6.1 基础插值
每帧遍历粒子,在「家位置」和「爆炸位置」之间按 progress 插值,画一个白色小方块:
ts
function drawParticles(t: number) {
const p = progressSmooth // 对 progress 再做一层平滑,消除跳变
ctx.globalCompositeOperation = 'lighter' // 叠加发光,粒子重叠处更亮
for (const pt of particles) {
const rnd = pt.rnd
// ★ 错峰:每个粒子有自己的延迟,聚拢时不是整齐划一,而是此起彼伏
const delay = rnd * 0.42
let pp = clamp((p - delay) / (1 - delay), 0, 1)
pp = 1 - Math.pow(1 - pp, 3)
// 家位置 + 聚拢后的轻微漂浮(idle 时 Logo 是"活"的)
const idle = 1 - pp
const ax = pt.tx + Math.sin(t * 0.7 + rnd * 41) * 0.0032 * idle
const ay = pt.ty + Math.cos(t * 0.9 + rnd * 23) * 0.0032 * idle
// 爆炸位置:绕 Y 轴旋转制造螺旋感 + 纵向漂移
const ang = pp * (1 + rnd * 2.6)
let bx = pt.tx + pt.ex
const bz = pt.tz + pt.ez
bx = bx * Math.cos(ang) - bz * Math.sin(ang)
const by = ay + pt.ey + pp * (rnd - 0.5) * 0.62
// 最终位置 = 家 ↔ 爆炸 两点间插值
const mx = ax + (bx - ax) * pp
const my = ay + (by - ay) * pp
// easeOutBack 过冲时 progress < 0,整体轻微内缩 → 回弹呼吸感
const over = 1 + Math.min(0, p) * 0.55
const sx = cx + mx * over * s
const sy = cy - my * over * s
let size = dotPx * (0.7 + rnd * 1.2) * (1 + pp * 0.5 + burstSmooth * 0.6)
const flick = 0.9 + 0.1 * Math.sin(t * 2.1 + rnd * 90) // 闪烁
let alpha = pt.weight * (0.8 + 0.2 * pp) * flick
ctx.fillStyle = `rgba(255,255,255,${alpha.toFixed(3)})`
ctx.fillRect(sx - size / 2, sy - size / 2, size, size)
}
}
其中两个"调味料"对质感影响最大:
- 错峰(delay) :
progress是全局的,但每个粒子按rnd错开一段进度再响应。聚拢时碎片不是齐刷刷归位,而是像沙漏一样陆续落定------动画立刻有了"呼吸感"。 - 漂浮(idle):聚拢完成后粒子仍然带着极小幅度的正弦漂移,Logo 静止得"若有似无",比死板的静帧高级得多。
6.2 关键细节:结尾必须是清晰 Logo,不是一堆马赛克
粒子再密,聚拢完成后也是一颗颗方块,凑近看是糊的。解法是交叉淡入 :当 progress 降到 0.55 以下,把真正的 Logo 位图(预渲染成纯白、保留 alpha)叠加淡入,同时粒子淡出:
ts
// 收拢后半程:清晰 Logo 淡入,粒子淡出(settleFade)
let logoA = clamp((0.55 - p) / 0.45, 0, 1)
logoA = logoA * logoA * (3 - 2 * logoA) // smoothstep,过渡更柔
ctx.globalAlpha = logoA
ctx.drawImage(logoCanvas, 0, 0) // 预渲染的白色清晰 Logo
这是整个组件里性价比最高的一行思路:粒子负责动感,位图负责画质,两者在时间轴上无缝交接。
6.3 氛围组:光晕、冲击波、尘埃
- 光晕 :一张预渲染的径向渐变图(512×512),用
globalCompositeOperation = 'lighter'垫在 Logo 底下,聚拢时变亮、爆炸时散去; - 冲击波:爆炸瞬间画一个扩张的圆环,透明度随半径衰减;
- 尘埃 :300 来个随机小点用
lighter混合缓缓漂浮,让 banner 空区不空。
这三样代码都在 30 行以内,但占最终观感的一半。动效这东西,主体 60 分靠算法,最后 40 分全靠氛围。
七、工程化:那些 AI 提醒我处理的坑
骨架写完只是及格,下面这些是真正花时间的部分(也是 AI 帮助最大的部分):
- DPR 坐标系 :
canvas.width = css宽 × devicePixelRatio之后,所有绘制坐标必须按物理像素算。最早整幅画面缩在左上角 1/DPR 的区域里,AI 一眼看出来是布局换算漏乘了 DPR。 - Canvas 跨域污染 :Logo 图一旦跨域,
getImageData直接抛 SecurityError。组件里统一img.crossOrigin = 'anonymous',Demo 里干脆把 Logo 转成 dataURI 内嵌。 prefers-reduced-motion:尊重系统"减少动态效果"设置,直接呈现聚拢后的静态 Logo。一行判断,无障碍分就有了。- 性能自适应 :粒子数按容器面积自动增减(2000~16000);清晰 Logo、光晕都预渲染成离屏 Canvas,主循环里只做
drawImage,每帧稳定 60fps。
八、AI 是怎么参与这段工作的
复盘整个过程,AI 的角色更像是"一个随叫随到的动效工程师",而我的工作重心发生了变化------从写代码,变成描述需求和验收效果。
几个实用的协作经验:
1. 把动画"翻译"成可量化的描述,再交给 AI。 「帮我做个炫酷的 Logo 爆炸动画」得到的是随机玩具;而下面这种提示词,一遍就能得到接近成品的骨架:
用 Canvas 2D 实现粒子动画:对 Logo PNG 做像素采样生成约 8000 个粒子;每个粒子存储家位置与爆炸位置,爆炸方向为径向 70% + 随机 30%,保留轮廓;时间线驱动 progress 从 1 到 0,收拢用 easeOutBack 产生过冲回弹;每个粒子有 0~0.42 的随机错峰延迟;收拢到 55% 以下时交叉淡入清晰 Logo 位图;使用 lighter 混合模式与中心光晕。
2. 人负责"看效果调参",AI 负责"改结构"。 R = 0.7(径向占比)、delay = 0.42(错峰幅度)、c = 1.7(过冲强度)这些数字没有理论答案,只能跑起来看、微调。但方向性改动(加时间线、改数据结构、修 DPR bug)丢给 AI 效率极高。
3. AI 当"第二双眼睛"排查诡异 bug。 画面缩在左上角、getImageData 报 SecurityError、高分屏下粒子密度翻倍......这类问题自己盯着代码很容易盲,把现象和关键代码贴给 AI,基本都能一次定位。
一句话总结:AI 时代,"公司没有 UI"正在从死局变成前端的机会------动效的实现成本,已经从"会 AE 的设计师"降到了"会描述动画的前端"。
九、换 Logo 的成本
替换一张 PNG,零代码改动。下次品牌升级,再也不用满世界找会 AE 的设计师了。