☰
Canvas悬挂弹性文字:弹簧阻尼物理动画实战
2026/10/6 4:03:17 网站建设 项目流程

简介:这份资源是一套基于HTML5 Canvas的悬挂弹性文字特效代码,面向具备基础HTML与JavaScript语法、希望提升动态网页交互开发能力的开发者。它解决的是如何在浏览器中实现文字随鼠标拖动并产生弹性回弹的动画问题,适合用于个人主页、活动页或教学演示等场景。压缩包共4个文件,包含1个html主页面、1个txt使用说明以及2个url链接文件,整体约3KB,体量轻巧,便于快速导入与二次修改。已有214人学习下载,说明该案例在Canvas动画入门中具有一定参考价值。代码核心围绕canvas.getContext('2d')获取2D上下文,结合fillText、translate、rotate完成文字绘制与坐标变换,并通过onmousemove监听鼠标位置,配合requestAnimationFrame逐帧更新,模拟弹性势能与动能转换,使文字在松开鼠标后呈现自然弹跳。读者可从中掌握Canvas文本渲染、鼠标交互处理与动画循环的完整实现思路,并理解如何控制帧率以避免过度消耗CPU资源。

1. 悬挂弹性文字到底在做什么:从一根会抖的绳子说起

把一行字挂在 Canvas 上,让它像被风吹动的吊牌一样晃起来,松手后还会回弹几下才停——这就是悬挂弹性文字特效。它和普通的 CSS 动画最大的区别在于:每个字的位置由物理量实时算出来,而不是靠关键帧硬编码。你改一下重力、阻尼、绳子刚度,整段文字的抖动节奏就全变了。这类效果常见于活动页标题、游戏开始界面、数据大屏的强调文案,也常被拿来做 canvas 文字 3D 效果的入门练手项目。适合谁?写过一点 JavaScript、知道 canvas 有getContext('2d')、但没系统做过物理动画的前端。看完你能自己搭一套可调参的悬挂文字系统,而不是复制一段看不懂的代码。

2. 悬挂弹性的物理模型:弹簧阻尼系统怎么落到每个字上

2.1 为什么用弹簧阻尼而不是缓动函数

很多人第一反应是用easeOutElastic这类缓动函数。它能做出回弹,但有个硬伤:回弹是预设的,文字之间没有联动,你没法让「第一个字被拽下去,后面的字跟着荡起来」。悬挂效果的核心是耦合——每个字通过绳子连到锚点,锚点一动,力沿着绳子传下去。

物理上最省事的模型是阻尼弹簧振子。对每个字,我们只关心它相对锚点的位移,用胡克定律算回复力,再加一个和速度成正比的阻尼:

F = -k * x - c * v a = F / m

其中k是刚度,c是阻尼系数,m是质量。x是当前位移,v是当前速度。每帧用半隐式欧拉积分更新:

v += a * dt x += v * dt

半隐式欧拉比显式欧拉稳定,k大一点也不会炸。这是血泪经验:早期我用显式欧拉,刚度调到 200 以上文字直接飞出去。

2.2 悬挂结构:锚点、绳长、静止角

光有弹簧还不够,得让字「挂」着。常见做法是给每个字一个锚点,锚点在文字上方一段距离,绳子有固定长度L。字在重力下自然下垂,静止时绳子竖直。当锚点水平移动,字被带着摆,摆角θ满足:

θ'' = -(g / L) * sin(θ) - damping * θ'

这是单摆方程。但纯单摆没有弹性伸缩,字和锚点距离恒定,看起来像刚体摆动,不够「弹」。所以实际项目里我一般用弹簧 + 单摆混合:绳子方向用单摆算角度,绳子长度用弹簧算伸缩。这样既有摆动又有伸缩,视觉上最像「悬挂弹性」。

参数上,L取字号高度的 1.5 到 2 倍比较自然。g用 980(像素/秒²)配合像素坐标系。阻尼系数别超过2 * sqrt(k/m),否则过阻尼,字会慢慢爬回去,没有回弹。

2.3 多字联动:力怎么在字之间传递

如果每个字独立算,锚点动的时候所有字同时动,看起来像整块板在晃,没有「悬挂」的层次。要让力有延迟地传下去,有两种做法:

一是锚点链:第一个字的锚点挂在固定点上,第二个字的锚点挂在第一个字上,依次往后。这样第一个字动了,第二个字的锚点才动,天然有延迟。实现时每个字存一个parentIndex,每帧先算父级位置,再算自己。

二是弹簧连接相邻字:字与字之间加一根水平弹簧,锚点只连第一个字。这种方式更像「一串珠子」,适合做波浪式悬挂。

我一般用第一种,因为物理意义清晰,调参直观。代价是第一个字承受的力最大,k要设小一点,否则第一个字会剧烈抖动。

3. 从零写一个可调参的悬挂文字类:代码逐段拆

3.1 初始化:把字符串拆成带物理状态的字符对象

先定义字符类。每个字符需要:字符本身、目标位置(静止时的位置)、当前位置、速度、锚点引用、绳子长度、刚度、阻尼、质量。

class HangingChar { constructor(char, x, y, options = {}) { this.char = char; this.baseX = x; // 静止时水平位置 this.baseY = y; // 静止时垂直位置 this.x = x; this.y = y; this.vx = 0; this.vy = 0; this.angle = 0; // 摆角,弧度 this.angularVel = 0; this.ropeLength = options.ropeLength || 60; this.stiffness = options.stiffness || 0.08; this.damping = options.damping || 0.92; this.gravity = options.gravity || 0.5; this.parent = null; // 锚点父级,null 表示挂在固定点 this.anchorX = x; this.anchorY = y - this.ropeLength; } }

baseX/baseY是静止位置,用来算位移。anchorX/anchorY是锚点当前位置,会随父级更新。stiffness这里用的是每帧系数而不是物理刚度,因为 canvas 动画通常按帧更新,用帧系数更好调。damping是速度衰减,0.92 表示每帧保留 92% 速度,低于 0.85 会显得很黏。

3.2 每帧更新:先算锚点,再算弹簧,最后积分

更新顺序很重要:必须先更新锚点位置,再算弹簧力,最后积分。如果顺序反了,会出现一帧延迟,快速拖动时文字会「追不上」锚点。

update(dt) { // 1. 更新锚点:如果挂在父级上,锚点跟随父级底部 if (this.parent) { this.anchorX = this.parent.x; this.anchorY = this.parent.y + this.parent.ropeLength * 0.5; } // 2. 单摆:锚点水平移动带动摆角 const dx = this.anchorX - this.x; const dy = this.anchorY - this.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const targetAngle = Math.atan2(dx, dy); // 相对竖直方向 const angleDiff = targetAngle - this.angle; this.angularVel += angleDiff * this.stiffness * 0.5; this.angularVel *= this.damping; this.angle += this.angularVel; // 3. 弹簧伸缩:绳子长度方向上的弹性 const restX = this.anchorX + Math.sin(this.angle) * this.ropeLength; const restY = this.anchorY + Math.cos(this.angle) * this.ropeLength; const springX = (restX - this.x) * this.stiffness; const springY = (restY - this.y) * this.stiffness; // 4. 重力 + 弹簧力,半隐式欧拉 this.vy += this.gravity * dt; this.vx += springX * dt; this.vy += springY * dt; this.vx *= this.damping; this.vy *= this.damping; this.x += this.vx * dt; this.y += this.vy * dt; }

dt是帧间隔,单位秒。如果按 60fps 跑,dt约 0.0167。stiffness在弹簧力里乘了dt,所以它实际是「每秒刚度」,调参时 0.05 到 0.15 之间比较稳。angularVel那部分乘了 0.5,是因为摆角响应要比位置弹簧慢一点,否则文字会像抽搐。

3.3 渲染:用 save/translate/rotate 画每个字

渲染时不要直接fillText在绝对坐标,而是把坐标系移到字符位置,再按摆角旋转。这样字符的朝向会跟着绳子方向变,更像悬挂。

draw(ctx) { ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(this.angle); ctx.font = 'bold 32px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#222'; ctx.fillText(this.char, 0, 0); ctx.restore(); }

textAlign和textBaseline都设成 center/middle,这样旋转中心就是字符中心。如果设成 left/top,旋转会绕左上角,看起来像字在甩尾巴。rotate的角度直接用this.angle,因为前面算的angle就是相对竖直方向的偏角。

3.4 主循环:requestAnimationFrame 与 dt 钳制

主循环里要算dt,并且钳制最大值。如果切到后台再切回来,dt可能变成好几秒,物理积分会直接炸掉。

let lastTime = performance.now(); function loop(now) { let dt = (now - lastTime) / 1000; lastTime = now; if (dt > 0.05) dt = 0.05; // 钳制,防止切后台后大跳 ctx.clearRect(0, 0, canvas.width, canvas.height); chars.forEach(c => { c.update(dt); c.draw(ctx); }); requestAnimationFrame(loop); } requestAnimationFrame(loop);

0.05秒对应 20fps,低于这个帧率物理就不准了,但至少不会飞。如果要做高精度,可以用固定时间步长累加器,但悬挂文字对精度要求不高,钳制就够了。

4. 参数调优与交互:让文字晃得自然而不是癫痫

4.1 刚度、阻尼、重力的三组可用参数

调参是这类效果最玄学的部分。我试过几十组,下面三组覆盖大部分场景:

场景stiffnessdampinggravityropeLength效果
轻柔吊牌0.040.940.380慢摆,回弹少
活泼弹性0.100.900.660明显回弹,适合标题
沉重金属0.150.961.250快速稳定,摆动小

stiffness越大,回弹越快,但超过 0.2 容易震荡。damping越接近 1,衰减越慢,0.98 以上会晃很久。gravity影响静止时的下垂感,设 0 文字会飘起来。

4.2 鼠标拖拽:把锚点绑到指针上

交互最简单的做法:鼠标按下时,把第一个字的锚点设成鼠标位置,其他字通过锚点链跟随。松开后锚点回到原位,文字自然回弹。

canvas.addEventListener('pointerdown', e => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; // 找到最近的字符作为拖拽点 let nearest = chars[0]; let minDist = Infinity; chars.forEach(c => { const d = Math.hypot(c.x - mx, c.y - my); if (d < minDist) { minDist = d; nearest = c; } }); dragging = nearest; dragX = mx; dragY = my; }); canvas.addEventListener('pointermove', e => { if (!dragging) return; const rect = canvas.getBoundingClientRect(); dragX = e.clientX - rect.left; dragY = e.clientY - rect.top; }); canvas.addEventListener('pointerup', () => { dragging = null; });

然后在update里,如果dragging === this,直接把anchorX/anchorY设成dragX/dragY,并且把vx/vy清零,避免拖拽时速度累积。松手后锚点回到baseX, baseY - ropeLength,文字开始回弹。

4.3 用 canvas 绘图引擎的思路做性能优化

如果文字很多(比如 100 个字以上),每帧save/restore和fillText会成为瓶颈。常见优化:

一是离屏 canvas 缓存字符。每个字符预先画到一个小 canvas 上,渲染时用drawImage代替fillText。drawImage比fillText快,尤其是字体复杂的时候。

二是合并路径。如果所有字用同一种颜色和字体,可以把所有字的变换矩阵算好,用setTransform批量画,减少状态切换。

三是限制更新频率。物理更新可以 60fps,但渲染可以降到 30fps,肉眼几乎看不出差别。用frameCount % 2控制。

这些优化在 canvas 绘图引擎里是常规操作,悬挂文字本身计算量不大,瓶颈通常在渲染。

5. 避坑与排查:悬挂文字最常见的 5 个翻车现场

5.1 文字飞出去或瞬间消失

现象:刷新页面后文字直接飞出画布,或者第一帧就没了。

原因:dt第一次是performance.now()的绝对值,可能是几万毫秒,除以 1000 后是几十秒。物理积分一步就炸。

解决:初始化lastTime时用performance.now(),并且在第一次loop里判断dt > 0.1就跳过这一帧,或者直接钳制到 0.05。我一般会在loop开头加if (dt > 0.1) { lastTime = now; requestAnimationFrame(loop); return; }。

5.2 文字抖动不停,像癫痫

现象:静止后文字还在高频抖动,幅度不大但很明显。

原因:stiffness太大加上damping太接近 1,系统进入欠阻尼震荡。或者dt不稳定,帧率波动导致积分误差累积。

解决:把stiffness降到 0.1 以下,damping降到 0.92 左右。如果还抖,检查是不是每帧调用了多次update。另外,angularVel的系数 0.5 可以再降到 0.3。

5.3 锚点链断裂,后面的字不动

现象:拖动第一个字,后面的字完全没反应。

原因:parent引用没设对,或者更新顺序错了。如果先更新子级再更新父级,子级拿到的锚点是上一帧的,看起来就像没动。

解决:确保chars数组按顺序更新,父级在前。或者在update里递归更新父级:if (this.parent) this.parent.update(dt),但这样会重复更新,最好还是按数组顺序。

5.4 旋转中心不对,字像在甩尾巴

现象:文字旋转时不是绕中心,而是绕左上角或底部。

原因:textAlign或textBaseline没设成 center/middle,或者translate的位置不是字符中心。

解决:ctx.textAlign = 'center'和ctx.textBaseline = 'middle'必须都设。如果字符宽度不一致,translate的 x 要用baseX + charWidth / 2,但用 center 对齐后直接translate(x, y)就是中心。

5.5 移动端触摸拖拽失效

现象:桌面正常,手机上拖不动。

原因:用了mousedown/mousemove而不是pointerdown/pointermove,或者没阻止默认触摸滚动。

解决:统一用 Pointer Events,并且在 canvas 上加touch-action: none的 CSS,防止拖拽时页面滚动。另外,移动端pointermove触发频率可能低于 60fps,dt会变大,钳制逻辑要保留。

6. 进阶:把悬挂文字做成可复用的 canvas 组件

6.1 用类封装配置项,支持一行初始化

把前面的逻辑收进一个HangingText类,构造函数接收 canvas、文本、配置对象。配置项包括字体、颜色、刚度、阻尼、重力、绳长、是否可拖拽。这样换一个页面只需要改配置,不用动物理代码。

class HangingText { constructor(canvas, text, options = {}) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.options = Object.assign({ fontSize: 32, fontFamily: 'sans-serif', color: '#222', stiffness: 0.08, damping: 0.92, gravity: 0.5, ropeLength: 60, draggable: true }, options); this.chars = []; this.initChars(text); if (this.options.draggable) this.bindDrag(); this.lastTime = performance.now(); this.loop = this.loop.bind(this); requestAnimationFrame(this.loop); } initChars(text) { const { fontSize, ropeLength } = this.options; const startX = this.canvas.width / 2 - (text.length * fontSize) / 2; for (let i = 0; i < text.length; i++) { const c = new HangingChar(text[i], startX + i * fontSize, 120, this.options); if (i > 0) c.parent = this.chars[i - 1]; this.chars.push(c); } } }

initChars里把字符水平排开,第一个字挂在固定点,后面的字依次挂在前一个字上。startX让整行居中。ropeLength从配置传入,每个字一样长,看起来整齐。

6.2 验证物理是否收敛:静止判定与自动停更

物理系统最终会静止,但浮点数永远不会完全为零。如果一直跑requestAnimationFrame,静止后还在空转,浪费电。可以加一个静止判定:当所有字符的速度和角速度都小于阈值,且持续若干帧,就停止更新,只保留渲染。

isResting() { return this.chars.every(c => Math.abs(c.vx) < 0.01 && Math.abs(c.vy) < 0.01 && Math.abs(c.angularVel) < 0.001 ); }

在loop里,如果isResting()连续 30 帧为真,就cancelAnimationFrame,并在下次拖拽时重新启动。这个技巧在数据大屏上很有用,页面长时间挂着不会一直占 CPU。

6.3 一个具体技巧:用噪声让静止文字微微呼吸

完全静止的文字有点死板。可以在静止后给每个字加一个极小的正弦扰动,幅度 0.5 像素,频率 0.5Hz,相位按字符索引错开。这样文字像在轻轻呼吸,但不会触发物理更新。

draw(ctx, time) { const breathe = Math.sin(time * 0.001 + this.index * 0.5) * 0.5; ctx.save(); ctx.translate(this.x, this.y + breathe); ctx.rotate(this.angle); // ... 绘制字符 ctx.restore(); }

time从performance.now()传入。index是字符在数组里的位置,用来错开相位。幅度 0.5 像素在视网膜屏上几乎看不见,但整体会有微妙的浮动感。这个技巧我用了很久,比完全静止的版本耐看很多。

最后说个习惯:每次调完参数,我会把stiffness、damping、gravity三组值记在代码注释里,标注对应的视觉效果。下次换项目直接翻注释,不用重新试。悬挂弹性文字的参数玄学成分大,记录比记忆靠谱。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询