ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Canvas 悬挂弹性文字特效:简谐运动与阻尼物理动画实战

Canvas 悬挂弹性文字特效:简谐运动与阻尼物理动画实战 简介这份资源是一套基于HTML5 Canvas的悬挂弹性文字特效代码面向具备基础HTML与JavaScript语法、希望提升动态网页开发能力的开发者。它解决的是如何在浏览器中实现文字随鼠标交互产生弹性运动的问题适合用于个人主页、活动页或交互演示场景。压缩包共4个文件包含1个html主页面、1个txt使用说明以及2个url链接整体约3KB主页面内嵌实现文字绘制、鼠标交互与动画逻辑的脚本。已有214人学习下载。通过研读代码读者可以掌握canvas.getContext(2d)获取2D上下文、fillText与strokeText绘制文字、translate与rotate变换坐标等基础用法并理解如何借助onmousemove监听鼠标位置、结合弹性势能与动能转换计算文字速度与位移最终用requestAnimationFrame驱动流畅动画。代码结构紧凑便于快速理解物理弹性模拟与Canvas动画的配合方式可作为交互特效开发的入门参考案例。1. 悬挂弹性文字一个被低估的 Canvas 物理动画切入点你可能在不少产品首页见过那种字——鼠标划过去文字像被拨动的琴弦一样上下弹几下松手后慢慢归位带着一点阻尼感。它不是 CSS 动画能轻松做出来的效果因为每个字符的位移、速度、回弹系数都要独立计算还要在 60fps 下保持稳定。这份 HTML5 Canvas 悬挂弹性文字特效代码做的就是这件事把一串文字拆成独立字符每个字符挂在一根虚拟弹簧上用简谐运动加阻尼衰减驱动它回到平衡位置。它适合两类人一类是想给落地页加一点交互质感的前端另一类是正在学 Canvas 动画、想找一个比“小球弹跳”更完整的物理模拟案例的开发者。代码不依赖任何框架纯 Canvas 2D 加 requestAnimationFrame拿到就能跑改参数就能调手感。2. 弹性文字背后的物理模型从胡克定律到 Canvas 渲染循环2.1 为什么不用 CSS 动画而用 Canvas 逐帧计算CSS 的 transition 和 keyframes 本质上是时间轴上的插值你告诉浏览器“从 A 到 B 用 0.3 秒”浏览器帮你补中间帧。但悬挂弹性的核心不是插值而是受力。字符被鼠标“拉”离平衡位置后受到的回复力与位移成正比同时还有与速度成正比的阻尼力。这两个力共同决定下一帧的位置和速度是一个连续迭代的过程。CSS 做不到“根据当前速度动态改变下一帧位移”所以必须用 Canvas 逐帧计算。另一个原因是字符级控制。CSS 里你要把每个字包一层 span然后分别加动画DOM 节点一多布局和重绘的开销就上来了。Canvas 里每个字符就是一次 fillText位置由物理变量决定没有布局计算性能上限高得多。常见做法是初始化时把字符串拆成字符数组每个字符记录自己的平衡位置、当前位移、当前速度然后在每一帧里遍历数组更新物理量并绘制。2.2 简谐运动加阻尼的离散化公式连续时间的阻尼简谐运动方程是 ( m \cdot a -k \cdot x - c \cdot v )其中 ( x ) 是位移( v ) 是速度( k ) 是劲度系数( c ) 是阻尼系数。在 Canvas 里我们不做微分方程求解而是用欧拉法离散化每一帧 a (-k * x - c * v) / m v v a * dt x x v * dt这里 ( dt ) 是帧间隔通常取 1 或者归一化到 60fps 的倍数。( k ) 越大回弹越快( c ) 越大震荡衰减越快。当 ( c ) 足够大时字符会像浸在糖浆里一样缓慢归位当 ( c ) 很小甚至为负时字符会持续震荡甚至发散。实际调参时我一般把 ( m ) 固定为 1只调 ( k ) 和 ( c )这样公式简化为a -k * x - c * v这个简化不影响视觉效果但少一个变量调起来更直观。2.3 字符拆分与坐标映射Canvas 的 fillText 是按基线绘制的每个字符的宽度需要用 measureText 单独测量。初始化时遍历字符串累加每个字符的宽度得到每个字符的平衡 x 坐标。y 坐标统一取一个基线值比如画布高度的一半。每个字符对象至少包含这些字段字段含义初始值char字符本身字符串中的单个字符baseX平衡位置 x累加宽度得到baseY平衡位置 y画布高度 / 2x当前位移 x 分量0y当前位移 y 分量0vx当前速度 x 分量0vy当前速度 y 分量0注意这里的 x、y 是相对于平衡位置的偏移量不是绝对坐标。绘制时用 baseX x、baseY y 作为 fillText 的坐标。这样做的好处是物理计算只关心偏移量平衡位置变化时不需要重新初始化物理状态。2.4 鼠标交互的力施加方式鼠标移动时计算鼠标位置与每个字符平衡位置的距离。如果距离小于某个阈值比如 80 像素就给这个字符施加一个与距离成反比的力方向从鼠标指向字符。常见做法是if (dist radius) { const force (1 - dist / radius) * strength; char.vx (dx / dist) * force; char.vy (dy / dist) * force; }其中 dx、dy 是字符平衡位置减去鼠标位置。这样鼠标靠近时字符被“推开”远离时力消失弹簧把它拉回来。strength 控制推力大小一般取 5 到 15 之间。注意这里直接改速度而不是改位移因为改位移会破坏物理连续性导致字符瞬移。3. 从零复现初始化、渲染循环与参数调优3.1 搭建画布与字符对象数组先写一个最小的 HTML 骨架只放一个 canvas样式全屏铺满。然后获取 2D 上下文设置画布尺寸为窗口大小并监听 resize 事件重新初始化。字符对象数组的构建逻辑如下const canvas document.getElementById(stage); const ctx canvas.getContext(2d); let chars []; const text 悬挂弹性文字; const fontSize 64; const fontFamily sans-serif; function initChars() { chars []; ctx.font ${fontSize}px ${fontFamily}; let totalWidth 0; const metrics []; for (let i 0; i text.length; i) { const w ctx.measureText(text[i]).width; metrics.push(w); totalWidth w; } let cursorX (canvas.width - totalWidth) / 2; const baseY canvas.height / 2; for (let i 0; i text.length; i) { chars.push({ char: text[i], baseX: cursorX, baseY: baseY, x: 0, y: 0, vx: 0, vy: 0 }); cursorX metrics[i]; } }这段代码做了三件事测量每个字符宽度、计算整行文字的起始 x 坐标使其水平居中、为每个字符创建物理状态对象。注意 measureText 必须在设置 font 之后调用否则测量结果不准。另外中文字符和英文字符的宽度差异很大不要用固定宽度去乘字符数必须逐个测量。3.2 物理更新与绘制循环渲染循环用 requestAnimationFrame 驱动每一帧先更新物理量再清空画布最后绘制所有字符。核心代码如下const k 0.08; // 劲度系数 const damping 0.92; // 阻尼系数 const mouse { x: -9999, y: -9999, radius: 100, strength: 8 }; function update() { for (const c of chars) { // 鼠标推力 const dx c.baseX c.x - mouse.x; const dy c.baseY c.y - mouse.y; const dist Math.sqrt(dx * dx dy * dy); if (dist mouse.radius dist 0.001) { const force (1 - dist / mouse.radius) * mouse.strength; c.vx (dx / dist) * force; c.vy (dy / dist) * force; } // 弹簧回复力 const ax -k * c.x; const ay -k * c.y; c.vx (c.vx ax) * damping; c.vy (c.vy ay) * damping; c.x c.vx; c.y c.vy; } } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font ${fontSize}px ${fontFamily}; ctx.textBaseline middle; ctx.fillStyle #1a1a1a; for (const c of chars) { ctx.fillText(c.char, c.baseX c.x, c.baseY c.y); } } function loop() { update(); render(); requestAnimationFrame(loop); }这里把阻尼直接乘在速度上而不是用 ( c \cdot v ) 的形式效果等价但少一次乘法。damping 取 0.92 意味着每帧速度衰减 8%手感偏“软”如果取 0.98衰减慢震荡持续时间长适合做“余音绕梁”的效果。k 取 0.08 时回弹周期大约 1 秒k 调到 0.2 会明显更“紧”。3.3 鼠标事件绑定与坐标转换鼠标坐标要转换成 Canvas 内部坐标不能直接用 clientX/clientY因为 Canvas 可能被 CSS 缩放或偏移。用 getBoundingClientRect 做转换canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; mouse.x (e.clientX - rect.left) * scaleX; mouse.y (e.clientY - rect.top) * scaleY; }); canvas.addEventListener(mouseleave, () { mouse.x -9999; mouse.y -9999; });mouseleave 时把鼠标坐标设到画布外避免字符被“吸”在边缘。如果画布尺寸和 CSS 尺寸一致scaleX 和 scaleY 都是 1但写上更保险。触摸设备上对应 touchmove 和 touchend逻辑一样取 touches[0] 的坐标即可。3.4 参数调优对照表不同参数组合出来的手感差异很大下面这张表是我实际试出来的几组配置可以直接抄场景kdampingstrengthradius效果描述轻快回弹0.120.90680拨动后 2 到 3 次震荡归位绵软余韵0.060.9710120震荡持续 5 秒以上适合标题硬朗干脆0.200.85460几乎不震荡快速归位夸张果冻0.050.9915150大幅摆动适合活动页调参时建议先固定 damping 和 strength只调 k 看回弹速度再调 damping 看衰减快慢最后调 strength 和 radius 控制鼠标影响范围。每次只改一个参数否则你分不清是哪个在起作用。4. 避坑与排查弹性文字特效最常见的五个翻车点4.1 字符间距忽大忽小现象渲染出来的文字有的字符之间空隙明显偏大有的挤在一起。原因measureText 测量时没有考虑字符的左右边距或者字体加载未完成就测量了。解决确保在 document.fonts.ready 之后再初始化字符数组如果用的是自定义字体先 await document.fonts.load(64px YourFont)。另外 measureText 返回的是 advance width对于斜体或手写体可能偏大可以手动减 1 到 2 像素做补偿。4.2 高刷新率屏幕上动画速度翻倍现象在 144Hz 显示器上弹性衰减明显比 60Hz 快手感不对。原因物理更新没有乘以 dt每帧固定更新一次帧率越高单位时间内更新次数越多。解决引入时间步长归一化把每帧的更新量乘以dt / (1/60)其中 dt 是当前帧与上一帧的时间差秒。或者用固定时间步长累加器保证物理更新频率恒定。常见做法是let lastTime performance.now(); function loop(now) { const dt Math.min((now - lastTime) / 1000, 0.05); lastTime now; const step dt * 60; // 归一化到 60fps update(step); render(); requestAnimationFrame(loop); }update 里所有速度累加都乘以 step位移累加也乘以 step。这样 144Hz 和 60Hz 的手感一致。4.3 鼠标快速划过时字符“飞出去”现象鼠标快速扫过文字字符被推得很远甚至飞出画布。原因鼠标推力直接加在速度上快速移动时鼠标位置跳变单帧内施加了过大的力。解决限制单帧最大速度或者对鼠标位置做平滑插值。我一般会在 update 里加一句速度钳制const maxSpeed 30; c.vx Math.max(-maxSpeed, Math.min(maxSpeed, c.vx)); c.vy Math.max(-maxSpeed, Math.min(maxSpeed, c.vy));maxSpeed 取 20 到 40 之间根据画布尺寸调整。另外鼠标事件里可以记录上一帧位置用插值后的位置去算力避免跳变。4.4 文字模糊或锯齿现象Canvas 上的文字边缘发虚不如 DOM 文字清晰。原因Canvas 的 CSS 尺寸和实际像素尺寸不一致浏览器做了缩放。解决设置 canvas.width 和 canvas.height 为 CSS 尺寸乘以 devicePixelRatio然后用 ctx.scale(dpr, dpr) 缩放上下文。注意缩放后所有坐标都要按 CSS 像素来算物理参数不用改。代码示例const dpr window.devicePixelRatio || 1; canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.scale(dpr, dpr);这样在高分屏上文字边缘锐利物理计算仍然按逻辑像素走。4.5 页面切到后台再回来时字符乱跳现象切换到其他标签页几秒后切回来字符位置突然跳变或剧烈震荡。原因requestAnimationFrame 在后台被暂停恢复时 dt 非常大物理更新一步跨太大导致发散。解决在 loop 里对 dt 做上限截断比如dt Math.min(dt, 0.05)这样即使后台停留很久恢复时也只按 50ms 更新一步不会炸。另外可以监听 visibilitychange 事件页面隐藏时记录时间恢复时重置 lastTime。5. 进阶玩法把弹性文字接到真实数据与多行布局上5.1 用振幅映射数据做可视化标题弹性文字的位移量天然适合做数据映射。比如把每个字符的初始位移设成某个数值的归一化结果让标题在加载时从杂乱状态“弹”回整齐排列。具体做法是初始化时给每个字符一个随机 x 偏移和 y 偏移然后让弹簧自然收敛。收敛过程本身就是一段动画不需要额外写缓动函数。如果想让振幅对应真实数据比如每个字符代表一个地区的数值可以把初始位移设为value / maxValue * 40这样数值大的字符弹得更远视觉上形成对比。5.2 多行文字的换行与基线处理单行文字好办多行就需要处理换行和行高。常见做法是先把文本按换行符拆成行数组每行单独测量总宽度然后逐行创建字符对象baseY 按行号递增。行高一般取 fontSize 的 1.4 到 1.6 倍。注意每行的水平居中要独立计算不能共用同一个起始 x。如果一行里混有中英文measureText 逐个测量仍然适用但要注意空格字符的宽度也要算进去否则单词会挤在一起。5.3 性能边界多少字符开始掉帧在普通笔记本上纯 Canvas 逐帧绘制 200 个字符以内基本无压力500 个字符开始有可感知的帧率下降1000 个字符以上建议做离屏缓存或分帧绘制。优化的思路是静态部分用离屏 Canvas 缓存只重绘运动中的字符。但弹性文字的特点是所有字符都在动所以缓存意义不大。更实际的做法是限制同时运动的字符数量比如只让鼠标半径内的字符参与物理更新半径外的字符直接画在平衡位置。这样即使总字符数上千每帧实际计算的只有几十个。5.4 一个我常用的调试习惯调弹性参数时我会在画布角落画一个小面板实时显示当前 k、damping、strength 和平均帧率。这样改参数时不用来回翻代码直接看数值和效果对应关系。面板用 fillText 画就行不需要 DOM。另外我会把鼠标半径用半透明圆画出来确认影响范围是否符合预期。这个习惯帮我省了很多“改了参数不知道有没有生效”的时间。从那以后我每次调物理动画都强制先把实时参数面板加上再开始调手感。希望帮到你。本文还有配套的精品资源点击获取
返回列表