Canvas粒子弹簧模型:从像素采样到悬挂弹性文字特效
简介HTML5 Canvas悬挂弹性文字特效是面向前端学习者与交互设计入门的实践范例重点演示Canvas绘图API、鼠标事件与动画循环的配合用法解决如何在网页中实现带物理弹性的动态文字问题。包内共4个文件整体仅3KB含一个可直接运行的HTML主文件、一份操作说明文本以及两个配套参考链接结构简洁便于对照源码理解页面逻辑。目前已有213人学习浏览资源虽小但覆盖知识点较为集中。读者从中可以学到canvas画布初始化、fillText与strokeText的文字绘制、translate与rotate的坐标变换、基于mousemove的交互响应以及借助requestAnimationFrame和弹性势能—动能模型更新位置与速度的技巧稍作参数调整还能衍生出更多可交互的动画效果适合作为课程设计或个人练手的参考。1. 悬挂弹性文字特效一个小 demo 里藏着 Canvas 最值钱的两件事如果你做过网页设计作业、H5 小游戏开场或者个人站点首页大概率见过这种效果一行文字像被一根看不见的线吊着垂下来之后还会带着余韵来回摆动像风铃或者被拨动后的绸带。这个效果在 canvas 绘图里属于「物理模拟 像素操作」结合的典型核心不是美术而是把文字当成一堆带质量的粒子再给每个粒子挂一根弹簧。理解它之后你顺手能做出粒子爆炸、文字消散、引力吸入等一系列变体。这类效果最常出现在 html5 网页设计作业里当启动页加分项也被不少 h5 小游戏的标题开场挪用。它不像图表库那样拿来即用需要自己处理「文字怎么变成粒子」「粒子为什么会摆动」「怎么让摆动看起来不假」三个问题。这篇文章按这个顺序展开每一段都能落到代码里直接改参。读完你可以拿到一个能在本地跑通的完整 demo并知道每一个参数调大调小分别会发生什么。2. 把「文字」变成「粒子矩阵」离屏渲染与像素采样的核心逻辑2.1 为什么不能直接把文字绘到主 Canvas 上再摆动新手最容易犯的错是用ctx.save()ctx.translate()ctx.rotate()去旋转整段文字。这样做的视觉结果是文字像一块木板在摆动边缘没有撕裂感也不会有「弹性」。悬挂弹性文字的灵魂在于字形内部不同区域受力不同底部比顶部摆动幅度大笔画越细的地方变形越明显。这是单一 transform 做不到的。所以要先把文字渲染到一张不可见的离屏 Canvas 上再用getImageData()读取像素把「有墨水」的位置换成粒子。这样粒子天然继承字形的形状信息后续给粒子挂弹簧时每个粒子的目标位置就是它采样时的原始坐标。这个思路在 canvas 绘图里非常通用做烟花、做像素风文字、做液体字都是同一套前置流程。离屏渲染还能顺带解决字体加载问题如果你在页面里用document.fonts.ready等 Web 字体加载完成后再触发采样得到的字形就是最终形态不会出现第一次动画跑完字体才加载完导致字形闪烁。2.2 采样参数step、alphaThreshold、分辨率三者的取舍采样不是逐像素遍历而是每隔 step 个像素取一个点。step 取 1 粒子数会爆取太大字形又失去细节。我一般先用计算器估算画布宽 640、高 200step4 时大概有 160×508000 个采样点扣掉空白区域可能剩 2000~3000 个粒子这个数量级在普通电脑上能稳定 60 帧。alphaThreshold 是第二个关键参数。getImageData()返回 rgba 数组alpha 为 0 表示透明背景。阈值设 128 比较稳妥大于 128 的像素视为字形内容否则跳过。注意文字最好用纯色填充比如#000000因为抗锯齿边缘会产生半透明像素阈值给一点容忍度能保留边缘细节。分辨率直接影响字形还原度。建议离屏 Canvas 的宽高按主 Canvas 的实际像素比例来但采样时不必全量遍历。核心代码如下function textToParticles(text, fontSize, step 4, alphaThreshold 128) { const off document.createElement(canvas); // 离屏画布只用来渲染一次文字不参与动画循环 off.width fontCanvasWidth; // 根据文本长度估算建议按 fontSize * text.length 再乘 0.6 off.height fontSize * 2; // 留出上下空间避免 descender 被截断 const octx off.getContext(2d, { willReadFrequently: true }); octx.font bold ${fontSize}px Microsoft YaHei, sans-serif; octx.textBaseline middle; octx.fillStyle #000; octx.fillText(text, 0, off.height / 2); const imageData octx.getImageData(0, 0, off.width, off.height); const data imageData.data; const particles []; for (let y 0; y off.height; y step) { for (let x 0; x off.width; x step) { const idx (y * off.width x) * 4; if (data[idx 3] alphaThreshold) { // 只看 alpha 通道rgb 值不重要 particles.push({ originX: x, originY: y, x: x, y: y 20, // y 加偏移营造「还没被提起来」的初始状态 vx: 0, vy: 0, charIndex: text.length ? Math.floor(Math.random() * text.length) : 0 }); } } } return particles; }这段代码的关键在willReadFrequently: true。不设置这个选项时浏览器会为 Canvas 走 GPU 加速路径getImageData()每次调用都要把数据从 GPU 拉回 CPU频繁在动画中做像素读取很容易卡顿。加上这个标记等于告诉浏览器这个画布主要是 CPU 读取用途内部会切换到更合适的内存布局。采样生成的 particles 数组里originX/originY是粒子的归宿x/y是粒子当前显示位置后续弹簧模型的「锚点」就是 origin 坐标。初始把y往下拽了 20 像素这样动画一开始粒子会从下方弹回正确位置形成「被拎起来又松手」的启动动画不需要额外写动画入场逻辑。2.3 粒子数太多怎么办降采样与分区合并如果段落文字或大号字体撑到 8000 粒子合并遍历顺序反而会有视觉规律感字形笔画密集的区域粒子密稀疏区域粒子少看起来不自然。常见做法有两个。一是 step 不固定对密集区域局部增大 step二是把相邻 2×2 的粒子合并成一个取四点的平均位置。我通常先按 step3 全量采样超过 5000 个再执行一次合并function mergeParticles(particles) { const map new Map(); particles.forEach(p { const cx Math.floor(p.originX / 2) * 2; const cy Math.floor(p.originY / 2) * 2; const key ${cx},${cy}; if (!map.has(key)) { map.set(key, { ...p, originX: cx, originY: cy }); } else { // 取均值让合并后的粒子落在两个字形的中间区域 const prev map.get(key); prev.originX (prev.originX cx) / 2; prev.originY (prev.originY cy) / 2; } }); return [...map.values()]; }合并的代价是字形边缘细节变平但换来帧率稳定。对悬挂摆动这种动效来说观众的注意力在「动态」而不是「边缘锐度」上细节损失完全可以接受。3. 悬挂弹性从哪来弹簧-阻尼模型与 Verlet 积分选型3.1 为什么弹簧模型比直接做正弦摆动真实有人图省事会用公式y Math.sin(t) * amplitude让粒子做伪摆动那样每个粒子都是同一个相位看起来像整块纱布在晃没有弹性。真实的悬挂效果是每个粒子都被一个回拉力拉向原始位置字形位置但粒子有质量到达目标后惯性会让它冲过头然后又被拉回来形成衰减振荡。这个模型在物理上叫「弹簧-质点」模型每个粒子独立拥有一根弹簧弹簧的拉力与形变成正比胡克定律再叠加一个与速度成正比的阻尼力。每个粒子的运动互不影响所以字形看起来是一堆独立的小点在各自回弹。粒子距离锚点越远拉力越大所以字形底部摆出去后回弹更猛烈顶部的粒子因为接近锚点几乎不动——这就造成了「悬挂感」。3.2 用 Verlet 积分而不是欧拉积分的三个理由实现质点运动有两种常见时间积分方式欧拉积分和 Verlet 积分。欧拉写法是先算加速度再更新速度最后更新位置。Verlet 写法不显式保存速度用「上一帧位置」和「当前位置」的差值隐式表示速度。我推荐 Verlet原因有三。第一稳定性好在帧率不稳定时欧拉积分容易产生能量累积导致粒子越跳越高Verlet 自带一定能量耗散参数不当也不会直接飞掉。第二便于加约束悬挂场景里如果想让文字受鼠标拖拽用 Verlet 时直接修改当前位置就能模拟拉扯速度信息自动通过位置差更新代码量省一半。第三物理直觉一致摆动效果本质是由惯性引起的Verlet 把「惯性」内建在位置差里写起来反而更直白。3.3 弹簧核心参数的作用gravity、stiffness、damping 怎么配以下是核心积分代码放在requestAnimationFrame循环里每帧执行function updateParticles(particles, springStiffness, damping, gravity, dt) { // dt 是上一帧到当前帧的毫秒差用于适配不同刷新率的屏幕 const timeStep Math.min(dt / 16, 2.5); // 封顶 2.5避免切后台回来时粒子乱飞 for (const p of particles) { const diffX (p.x - p.originX) * springStiffness; const diffY (p.y - p.originY) * springStiffness; // 隐含速度当前位置与上一帧位置之差 const vx (p.x - p.prevX) * damping * timeStep; const vy (p.y - p.prevY) * damping * timeStep; const ax -diffX - vx; const ay -diffY - vy gravity * timeStep; // Verlet 积分不保存速度直接推进位置 const nextX p.x (p.x - p.prevX) ax * timeStep * timeStep; const nextY p.y (p.y - p.prevY) ay * timeStep * timeStep; p.prevX p.x; p.prevY p.y; p.x nextX; p.y nextY; } }参数有三个都很直观stiffness控制回弹强度0.02~0.05 之间字形松散、垂坠感强0.08~0.12 之间回弹快、视觉紧凑超过 0.15 会肉眼可见地抖动damping取 0.94~0.98控制回落速度调太低摆动十几次才停看起来像坏掉的弹簧秤gravity是重力加速度0.5~1.5 之间最自然数字越大文字垂得越展、垂感越足。3.4 让摆动看起来不规则的摇摆相位纯弹簧回弹会有规律感因为所有粒子同时开始回弹你会看到整个字形同步呼吸。打破这个规律有两个手段一是给每个粒子的originY加一个微小的相位差例如按粒子索引取模二是给originX加一个随时间变化的偏移量。第二种是主流做法// 在 updateParticles 里加入摇摆偏移 const swayAmp 3; // 横向摆动幅度像素 const swayFreq 0.002; // 摆动角速度越小摆得越慢 p.originX Math.sin(Date.now() * swayFreq p.charIndex * 0.5) * swayAmp;charIndex是按字符索引取模得到的同一个字内部粒子相位相近不同字之间相位错开视觉上就像每个字在各自微幅摇头。这个细节是让效果从「机械」变「生动」的关键。幅度 3 像素在 36px 字号下刚好能被人感知又不至于破坏字形可读性。4. 完整的悬挂弹性文字 demo从空白 HTML 到能跑的动画4.1 目录结构与初始化顺序建议把整个效果做成单文件 demo文件名比如pendulum-text.html目录下不需要额外静态资源。打开页面立即执行不需要用户交互。整个程序分四步创建主 Canvas - 采样文字生成粒子 - 初始化粒子前值 - 启动动画循环。主 Canvas 要适配窗口大小但粒子坐标基于离屏 Canvas 的坐标系。如果主 Canvas 尺寸和离屏不一致需要通过ctx.scale()统一。我做了一个简单策略主 Canvas 铺满容器离屏 Canvas 的宽度按主 Canvas 的 60% 计算然后居中显示这样在大屏上不会因为粒子覆盖过多导致性能压力。4.2 完整代码把上面几段拼起来!DOCTYPE html html langzh-CN head meta charsetUTF-8 / titleHTML5 Canvas悬挂弹性文字特效/title style body { margin: 0; background: #1a1a2e; overflow: hidden; } canvas { display: block; margin: 0 auto; } /style /head body canvas idstage/canvas script (() { const canvas document.getElementById(stage); const ctx canvas.getContext(2d); let W, H, particles; let lastTime performance.now(); function randomText() { // 可替换成自己的文案中英文均可 return 悬挂弹性文字; } function resize() { W window.innerWidth; H window.innerHeight; canvas.width W; canvas.height H; } function buildParticles() { const text randomText(); const fontSize Math.max(30, Math.floor(W / 14)); // 1. 离屏渲染文字 const off document.createElement(canvas); off.width fontSize * text.length * 0.8; off.height fontSize * 2; const octx off.getContext(2d, { willReadFrequently: true }); octx.font bold ${fontSize}px Microsoft YaHei, PingFang SC, sans-serif; octx.textAlign center; octx.textBaseline middle; octx.fillStyle #fff; // 白色便于后续着色 octx.fillText(text, off.width / 2, off.height / 2); // 2. 像素采样 const imgData octx.getImageData(0, 0, off.width, off.height).data; particles []; const step fontSize 60 ? 4 : 3; for (let y 0; y off.height; y step) { for (let x 0; x off.width; x step) { if (imgData[(y * off.width x) * 4 3] 128) { particles.push({ originX: x, originY: y, x: x, y: y fontSize * 0.6, // 初始下坠距离 prevX: x, prevY: y fontSize * 0.6, color: hsl(${(x y) % 360}, 70%, 70%), // 按位置生成色相 }); } } } // 3. 如果粒子太多执行合并 if (particles.length 6000) particles mergeParticles(particles); } function mergeParticles(list) { const map new Map(); for (const p of list) { const cx Math.floor(p.originX / 2) * 2; const cy Math.floor(p.originY / 2) * 2; const key cx , cy; if (!map.has(key)) map.set(key, { ...p, originX: cx, originY: cy }); } return [...map.values()]; } function update(dt) { const stiffness 0.06; // 回弹力度越大回弹越硬 const damping 0.96; // 阻尼系数越小停得越快 const gravity 0.9; // 重力越大垂感越强 const ts Math.min(dt / 16, 2.5); for (const p of particles) { // 摇摆相位让不同字之间的摆动错开 p.originX Math.sin(performance.now() * 0.002 p.originY * 0.01) * 2; const diffX (p.x - p.originX) * stiffness; const diffY (p.y - p.originY) * stiffness; const vx (p.x - p.prevX) * damping * ts; const vy (p.y - p.prevY) * damping * ts; const ax -diffX - vx; const ay -diffY - vy gravity * ts; const nextX p.x (p.x - p.prevX) ax * ts * ts; const nextY p.y (p.y - p.prevY) ay * ts * ts; p.prevX p.x; p.prevY p.y; p.x nextX; p.y nextY; } } function draw() { // 清屏用半透明填充形成轻微拖影效果纯背景则用 fillRect ctx.fillStyle rgba(26, 26, 46, 0.3); ctx.fillRect(0, 0, W, H); const offX (W - (particles.length ? Math.max(...particles.map(p p.originX)) : 0)) / 2; const offY H / 2.2; for (const p of particles) { ctx.fillStyle p.color; // 用 fillRect 代替 arc减少每帧创建对象的开销 ctx.fillRect(p.x offX, p.y offY, 2, 2); } } function frame(now) { const dt now - lastTime; lastTime now; update(dt); draw(); requestAnimationFrame(frame); } window.addEventListener(resize, () { resize(); buildParticles(); }); resize(); buildParticles(); requestAnimationFrame(frame); })(); /script /body /html4.3 代码逻辑说明与每个参数的指向这段代码的执行顺序是先resize()设置主画布尺寸再buildParticles()采样生成粒子最后启动动画循环。动画循环里update()推进物理状态draw()负责渲染粒子。注意update和draw是分离的如果你想加交互在这两个函数之间插逻辑最方便。参数方面stiffness0.06是一个平衡值字形能明显垂落但不会软成一团damping0.96让摆动大约在 2 秒内衰减到位gravity0.9让底部粒子比顶部粒子下坠多形成上紧下松的悬挂线条。step的选择根据字号动态变化大字用 4 减少粒子数小字用 3 保持还原度。颜色用hsl按坐标生成让每个粒子色相渐变视觉上有流光感——这是很多模板站「高级感」的常见做法。如果你想要纯白色文字把fillStyle固定为#fff即可同时把背景的fillStyle从半透明改成不透明#1a1a2e。半透明背景的拖影效果其实很适合「弹性」这种动态演示粒子掠过时留下的轨迹会强化摆动幅度感知建议保留试一下。5. Canvas 悬挂弹性文字的避坑指南我从 2000 个粒子调到崩溃得到的教训5.1 getImageData 返回全黑但离屏 Canvas 上明明有文字现象粒子数组为空画面上什么都没有但离屏 Canvas 单独拿出来看是有字形的。原因如果你在一个跨域的背景下运行页面比如从本地file://协议打开或者 CDN 上引用了带跨域属性的 CanvasgetImageData()会被安全策略拦截此时返回的 data 是透明的全零数组。另一个常见原因是你把字体样式设置在ctx.font但用了浏览器没有的字体名浏览器自动回退后渲染时间错开采样时 Canvas 还是一片空白。解决开发阶段用本地 HTTP 服务打开页面比如python -m http.server 8080。字体使用系统字体微软雅黑、苹方等可以避免加载时序问题。检查方法是在采样前后各调用一次octx.getImageData()对比或者直接打印imgData.data[3]看前几个像素的 alpha 值。5.2 粒子乱飞像文字突然「炸开」收不回来现象动画刚开始的瞬间粒子冲到画布外然后不回来了。原因初始位置y fontSize * 0.6与目标位置的差值太大同时stiffness太小弹簧拉力不足以把粒子拉回重力又持续叠加导致粒子越掉越低。另一种情况是dt没有做封顶浏览器切后台时dt可能几百毫秒积分步长过大直接冲破弹簧恢复范围。解决把dt封顶在2.5也就是代码里的Math.min(dt / 16, 2.5)。另外初始偏移不要超过 fontSize 的 0.8 倍否则第一帧需要的弹力过大观感上像爆炸。实在要从超远距离回弹先把stiffness临时调高到 0.15等粒子进入目标区域后再换回正常值。5.3 悬挂动态有了但字形完全认不出来现象粒子确实在摆但看起来是一团雾气根本看不出原来的文字轮廓。原因采样时的step太大边缘信息丢失。特别是中文这种笔画密集的文字笔画宽度在 30px 字号下一般只有 2~3 像素step5就会跳穿笔画字形变成离散黑点。另一个原因是粒子颜色渲染太小fillRect(size, size)的 size 为 1 时几乎不可见。解决step 上限控制为fontSize / 10向下取整比如 40px 字号 step 不超过 4。渲染粒子时改用size step 4 ? 2 : 1.5用浮点尺寸配合ctx.fillRect能呈现稍微柔和的边缘。同时把alphaThreshold从 128 降到 100把抗锯齿边缘的半透明像素也纳入采样字形轮廓会明显变连贯。5.4 手机浏览器里一滑动页面特效就卡顿现象页面滚动时 Canvas 动画掉帧明显尤其在 iOS Safari 上。原因Canvas 绘制没有做帧率自适应requestAnimationFrame在页面滚动时仍执行全部粒子更新和绘制挤压了滚动线程的资源。另外 iOS Safari 对 Canvas 的半透明背景性能远低于 Android Chrome大量fillStyle字符串开销也会放大性能损耗。解决用IntersectionObserver监听 Canvas 是否进入视口离开视口就cancelAnimationFrame进入视口再重启。绘制时减少fillStyle赋值次数预先按照颜色把粒子分组每组一次fillStyle设置然后循环fillRect。颜色分组代码很简单const colorGroups {}; for (const p of particles) { (colorGroups[p.color] colorGroups[p.color] || []).push(p); } // 绘制时遍历 colorGroups每个 key 设置一次 fillStyle这个改动在粒子 3000 时效果非常明显从每粒子一次填色降到每颜色一次填色能省掉大量底层绘制状态切换。排查性能可以配合浏览器的 Performance 面板或者代码诊断类的工具看draw函数耗时如果单帧超过 10ms 就得考虑降粒子数或提高 step。5.5 文字从顶部垂落的瞬间是「掉下来」而不是「弹下来」现象动画启动时文字像从天上掉下来砸到地上随后才恢复摆动没有那种被绳子吊着的感觉。原因初始 y 偏移设成了fontSize * 0.6这等于让粒子从下方 60% 处向上弹回物理上是一个「上升回位」过程。但悬挂的语义应该是「文字本来高悬突然释放往下坠后回弹」初始位置应该在目标位置上方。解决把初始偏移改成y - fontSize * 0.4让粒子从高处下坠经过目标位置后由于惯性继续往下再被弹簧拉回来。这个方向性差异是悬挂感和「扔上去掉下来」感的根本区别。初始偏移量取负值后重力要稍微调小一点从 0.9 降到 0.7 左右不然下坠幅度太大会让字形在第一帧彻底散开。6. 进阶玩法交互扰动、粒子连线与手感调参的最后一公里悬挂弹性文字做完基础版之后提升体验的大头在交互。最常见的加法是鼠标扰动检测鼠标位置与粒子位置的距离距离小于某个阈值时给粒子一个向鼠标方向的排斥力。用 Verlet 积分实现特别简单直接在update里对命中的粒子改p.prevX或p.x就能产生速度变化不需要额外维护速度变量。我习惯的阈值是 80 像素排斥系数 0.2这样鼠标划过文字时字形会像被拨动的水草一样荡开松手后弹簧慢慢恢复。第二种加分手法是粒子连线每帧遍历粒子对距离小于 40 像素的画一条半透明线形成蛛网感。但粒子数超过 1000 时两两比较是 O(n) 平方复杂度会直接卡死。优化方案是只对同一行字符范围内的粒子连线即把粒子按originY分桶每桶内再做距离判断能把计算量从 8000×8000 降到大约 8000×40 的量级。连线颜色用白色透明度按距离衰减效果非常精致。手感调参方面我总结了一套基线值初始显示效果要「松」把 stiffness 调到 0.04、gravity 调到 1.0让字形先有充足的垂坠幅度用户看了两秒以后如果想进入「稳定悬挂」状态通过一个定时器把 stiffness 提到 0.08、damping 提到 0.97动态从松散过渡到收紧视觉上有一种文字「醒过来」的感觉。这个过渡用线性插值做大概 60 帧完成很像字体加载完成的收尾动画。最后说一个我自己的习惯调这类动效时我一定把屏幕刷新率切换到 60Hz 和 120Hz 各看一遍因为dt对 Verlet 积分的能量耗散影响远大于外观预期同一组参数在 120Hz 屏上会显得阻尼不够、摆动更久。收工前用 Chrome 的 Performance 面板录制 5 秒动画确认没有超过 16ms 的长帧再交付。希望帮到你。本文还有配套的精品资源点击获取