用 Canvas + AI 实现登录页的 Logo 粒子动画

需求:登录页的 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 画到一块离屏 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. 像素坐标要归一化成"世界坐标":以图片中心为原点、图片高度为 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
  1. 粒子数量要设上限:一张 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 帮助最大的部分):

  1. DPR 坐标系 :canvas.width = css宽 × devicePixelRatio 之后,所有绘制坐标必须按物理像素算。最早整幅画面缩在左上角 1/DPR 的区域里,AI 一眼看出来是布局换算漏乘了 DPR。
  2. Canvas 跨域污染 :Logo 图一旦跨域,getImageData 直接抛 SecurityError。组件里统一 img.crossOrigin = 'anonymous',Demo 里干脆把 Logo 转成 dataURI 内嵌。
  3. prefers-reduced-motion:尊重系统"减少动态效果"设置,直接呈现聚拢后的静态 Logo。一行判断,无障碍分就有了。
  4. 性能自适应 :粒子数按容器面积自动增减(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 的设计师"降到了"会描述动画的前端"。

替换一张 PNG,零代码改动。下次品牌升级,再也不用满世界找会 AE 的设计师了。

相关推荐
Amos_Web1 小时前
Rspack 源码解析(十六):多类型资源如何进入 Compilation.assets
前端·rust·前端框架
GAMC1 小时前
chrome-devtools-mcp:让 AI 编码助手真正"看见"浏览器
前端·人工智能
deli0071 小时前
高尔顿板:把球一颗颗丢下去,为什么最后总堆成一座钟形山
前端
java_nnnn1 小时前
JavaEE进阶-CSS初识
java·前端·css·java-ee·html
good_ideal1 小时前
从零实现一个自动提交 PR 的 MCP 工具:用 Skill 串起 Git 与 Azure DevOps
前端
喜欢吃豆1 小时前
Agent 前端协议正在分层:彻底讲清 MCP Apps、AG-UI 与 A2UI
前端·大模型
赵锦川1 小时前
css代替表格
前端·css
其实防守也摸鱼1 小时前
内网安全防护实践:从攻击视角理解防御要点
java·前端·安全·架构·自动化·nps
JavaGuide2 小时前
DeepSeek Harness 官方桌面端终于有了!
前端·后端