尧图精选

用Canvas 2D实现粒子汇聚成春联:从文字点阵到完整动画

🕒 发布时间:2026/9/15 1:29:19 📁 来源:尧图网络
说实话我在做这个Demo之前已经见过不少“粒子汇聚成字”的特效网上有粒子玫瑰、粒子爱心、粒子LOGO但真正和春节场景结合起来的并不多。今年给家里布置电子春联我想做一个不一样的东西不是静态的红色方块贴图而是由几千个光点一点点汇聚成“福”再从“福”字拉出整副上下联。折腾了两三天最后用纯HTML/CSS/JavaScript写出一个单文件版本打开浏览器就能跑不需要安装任何依赖。这篇文章就把完整源码和实现思路拆开讲清楚包括文字点阵怎么提取、粒子怎么切换、春联的上下联横批布局怎么算以及我调试过程中踩过的几个实实在在的坑。如果你是初次接触Canvas粒子动画跟着这篇文章做也能跑出一个效果很喜庆的版本如果你已经有粒子基础可以重点看第3节和第5节那部分是我觉得最能提升完成度的地方。1. 为什么用Canvas 2D做粒子春联而不是Three.js或CSS做粒子特效很多人第一反应是Three.js或者WebGL因为展示出来的效果确实更“重”粒子多、有景深、有光照动辄好几万粒子。但我这个场景的需求其实很简单粒子数量最多四五千要展示的是汉字和春联不需要3D旋转不需要复杂光照也不需要鼠标拾取物体。这种情况下用Canvas 2D反而更合适。我做了个简单的对比方便你理解不同方案各自的边界方案适合的粒子量级依赖移动端兼容开发成本CSS动画几百无好低但难以精密控制坐标SVG几百到一千无好交互灵活但大量节点卡顿Canvas 2D几千到一万无较好中等适合坐标型粒子Three.js/WebGL几万到几十万需要引入库受设备限制较高需要理解场景、相机、材质我最终选了Canvas 2D主要有三条理由。第一零依赖单文件。一个HTML文件里既能写样式又能写逻辑发给朋友直接双击就开不需要起Node服务不需要npm install不用处理跨域这在“过年发给家里人看”的场景里特别重要。Three.js虽然也能引CDN但如果对方电脑或手机网络不好页面就白屏了。而这套方案纯本地也能跑。第二四五千粒子的量级对Canvas 2D来说压力并不大。每个粒子画成一个圆形用globalCompositeOperation lighter做叠加发光中端手机也能稳定在60帧左右。我实测过iPhone 11和一台几年前的安卓中端机都能跑得动。Canvas 2D的瓶颈主要在fillText、阴影这类重操作以及粒子数量超过两万之后CPU渲染跟不上。但只要控制在合理范围内它就是一个“够用且稳”的方案。第三坐标控制比WebGL直观得多。Canvas 2D里每个粒子就是一个(x, y)坐标想让它去找哪个目标点直接改坐标就行不需要把数据塞进Buffer里再通过着色器处理。写起来快调试也快对于这种偏“设计感”的项目开发效率是第一位的。那什么时候该上Three.js我的判断标准很简单如果粒子数量超过一两万或者字幕需要在3D空间里旋转或者要做复杂的交互拾取和光影再考虑Three.js。如果你的核心诉求只是“让粒子组成字”那Canvas 2D完全够不要为了炫技引入不必要的复杂度。技术选型不是越高级越好而是匹配需求。2. 粒子变幻的核心原理怎么把汉字变成可以流动的点阵粒子变幻特效最大的难点不在“画粒子”而在“如何让几千个粒子最终组成一个可辨认的汉字”。这背后其实要做两件事第一从文字本身提取出足够多的采样点第二给每一颗粒子分配目标点并让它以合理的轨迹移动过去。2.1 文字点阵提取离屏Canvas画字再读Alpha通道文字点阵提取的思路不复杂先创建一个不在页面上的Canvas也就是离屏Canvas把目标文字用尽量粗的字体画上去然后用getImageData读取像素点只要像素点的Alpha值大于某个阈值就认为这是属于文字的像素。把所有满足条件的像素坐标收集起来就得到了一份“文字点阵”。这里有个特别重要的经验提取文字点阵时不能只fillText还要配合strokeText加粗否则汉字笔画的中心区域可能不是实心的。笔画太细、点太少最后粒子汇聚出来的字会“缺胳膊少腿”。我一般是先描边再填充并且把lineWidth设为字号的一定比例比如6%。采样的步长也很关键。如果逐像素提取一个200px大小的“福”字可能会产生几万个坐标点但我们的粒子总数可能才四五千大量点根本用不上纯属浪费内存。更好的做法是按照步长采样比如每3个像素取一个点这样点阵密度足够显示笔画轮廓又不会数据量爆炸。步长越小编号越精细但粒子数要跟上否则每个点分到的粒子太少字会显得稀疏。下面这段就是文字点阵提取的核心代码完整单文件版在本文第4节function getTextPoints(text, fontSize, step) { var off document.createElement(canvas); var octx off.getContext(2d); off.width Math.ceil(fontSize * text.length * 1.1) 40; off.height Math.ceil(fontSize * 1.5) 40; octx.font bold fontSize px KaiTi,STKaiti,Microsoft YaHei,sans-serif; octx.textAlign center; octx.textBaseline middle; octx.shadowBlur 10; // 让笔画更厚实 octx.lineWidth Math.max(3, fontSize * 0.06); octx.strokeText(text, off.width / 2, off.height / 2); octx.fillText(text, off.width / 2, off.height / 2); var img octx.getImageData(0, 0, off.width, off.height); var pts []; for (var y 0; y off.height; y step) { for (var x 0; x off.width; x step) { var alpha img.data[(y * off.width x) * 4 3]; if (alpha 128) { pts.push({ x: x - off.width / 2, y: y - off.height / 2 }); } } } return pts; }注意这里返回的坐标是相对于画布中心的偏移量也就是以文字中心为原点。后面把它平移到屏幕任意位置缩放任意大小都非常方便。这样的设计我们后面排春联会用到。2.2 粒子与目标点为什么每次切换要“随机抽点”粒子本身非常简单只需要记录当前坐标、粒子大小、随机相位等少量字段。真正的核心在于如何给它指定目标点。我见过很多实现是“粒子和目标点一一对应”第0颗粒子去第0个目标点第1颗粒子去第1个目标点。这个方法的问题在于不同文字的点阵数量可能差别很大。比如“新年纳福”四个字可能有800个点而单独的“福”字可能只有200个点粒子数是固定的4000怎么分配如果强行一一对应就需要对点阵做增删代码复杂度上来了效果还不一定好。我采用的方案是“随机抽点”每次切换的时候每个粒子从当前目标文字的点阵里随机抽取一个点作为自己的目标点。这样多个粒子会落在同一个目标点上视觉上相当于给笔画增加了“厚度”字的边缘会更饱满看起来更亮更实。而且代码极其简单不需要维护任何映射关系每次切换目标只是一个随机索引的事。随机抽点还会带来一个有趣的视觉效果切换时粒子不是各自平移到自己对应的点而是会“交叉换位”很多粒子会互相穿过形成一种类似烟花绽放又聚拢的动态很有年味。如果你想追求更平滑的“笔画流动”效果可以用一一对应的方案但需要额外处理点阵数量不一致的问题。对于我这个项目随机抽点最后的效果反而是最热闹的。2.3 动画状态机聚拢、停留、切换三态循环整个动画本质上是一个三态循环停留态文字已经清晰显示粒子在做微小的呼吸浮动这个阶段保持SWITCH_INTERVAL毫秒。切换态生成新的目标点所有粒子从当前位置插值到新目标点这个过程持续ANIM_DURATION毫秒。聚拢完成后进入新的停留态然后再循环。我在代码里用STATE.progress来记录当前动画进度0表示刚切换1表示已经聚拢完成。每次时钟触发时先判断是否到了切换时间如果到了就换下一组文字重新分配目标点progress归零然后粒子坐标在动画循环里用缓动函数从旧位置插值到新位置。缓动函数我选择的是easeInOutCubic图形上是一个先慢后快再慢的S曲线。粒子在起步阶段缓慢离开原有位置中间加速冲向新文字接近目标时又慢下来这样视觉上才不会像“瞬移”一样突兀。如果直接用线性插值粒子移动速度恒定看起来会非常机械缺少那种“被吸引过去”的张力。为了让粒子看起来更灵动我还会在坐标上叠加一个小的正弦波动波动的幅度和动画进度有关切换初期波动大一些聚拢完成后波动几乎为零。这样粒子在聚拢过程中会有一点“颤抖”和“挣扎”感更有生命力。3. 春联内容编排与粒子分配不是把字画出来那么简单很多类似项目做到“粒子组成字”就结束了但既然标题是“新年春联”就必须考虑春联的排版和内容设计。春联和普通文字海报有一个区别它有上下联有横批位置关系是固定的粒子不能只是在屏幕中央组成一行字而是要在正确的位置上组成正确的结构。3.1 三段式内容设计四字吉语、大字福、完整春联我把动画设计成三个状态循环切换这样观众看到的不是一块静止的红布而是有叙事感的视觉变化。第一段是四字吉语“新年纳福”文字横排在屏幕中央字号适中作用是开场让观众明白“这是文字粒子汇聚”建立认知。第二段是单独一个“福”字字号放大到接近屏宽的一半视觉冲击力最强也是整副春联的视觉中心。第三段是完整春联“喜居宝地千年旺福照家门万事兴”上联在右侧竖排下联在左侧竖排横批“喜迎新春”落在上方构成完整的传统春联版式。这样三段内容各有侧重第一段展示文字聚集第二段突出年味符号第三段展示完整作品。观众不会感到单调。3.2 春联布局算法竖排字要单独取点春联的排版看起来简单但实现时有一个容易踩的坑如果要显示竖排文字不能把一行文字通过旋转canvas来一次性绘制因为旋转后文字依然属于同一行粒子点阵的结构还是左到右的横向排列竖排效果会非常奇怪。正确的做法是每一个字单独提取点阵然后把点阵整体平移到指定的坐标位置。我的布局逻辑大概是这样的上联右侧每个字单独取点第一个字在顶部后面的字依次向下排列字与字之间的间距通过lineH控制。下联左侧同理第一个字在顶部往下排。横批四个字作为一个整体横向排列在画布上方约12%高度位置。坐标计算公式第c个字的中心Y坐标等于“整列的中心Y坐标”加上“(c - (字数-1)/2) * lineH”这样无论7个字还是4个字都能自动居中。每个字的X坐标根据上联、下联分别固定在右0.78倍屏宽和左0.22倍屏宽。这个比例我测试下来在手机和电脑屏幕上都很协调太靠边会被裁掉太靠中间会跟横批挤在一起。字号也要动态适配屏幕。我的处理方式是让单个字的字号lineH取Math.min(W * 0.075, H * 0.09)也就是宽度和高度的较小比例这样在手机竖屏和电脑宽屏上都不会离谱。3.3 粒子分配预算不同内容需要多少粒子给不同内容分配多少粒子是个需要估算的问题。以四字吉语“新年纳福”为例如果字号是120px采样步长是3px每个字的点阵大约150到250个点四个字加起来600到1000个点。如果粒子总数设4000个相当于每个目标点上会叠3到6个粒子视觉上笔画会非常饱满。完整春联的情况更特殊18个字每个字字号较小大概60到80px单字点阵可能只有50到80个点总点数大约1000到1500个。同样用4000个粒子某些笔画密集的点上会叠更多粒子远处看字体会偏粗但整体辨识度反而更高。如果你希望完整春联阶段笔画纤细一些可以把粒子总数动态调低比如在春联阶段用2500个粒子。不过Demo里为了简单我统一用固定数值效果也可以接受。我的经验是粒子总数控制在目标点数的2到5倍之间效果最好。太少字会稀疏破洞太多字会糊成一团。如果你换了别的文字内容可以根据这个经验调整粒子数。3.4 颜色与氛围金红配色最容易出年味粒子颜色我用了金色和暖白红黑渐变做背景光晕。金色粒子在深色背景上用lighter混合模式叠加会有一种自带发光的感觉很接近烟花或灯火的效果。传统春联是大红底配黑字但粒子本身如果全用大红在暗背景上反而看不清。我最终把主色调定为暖金配合背景中央偏红的光晕既有年味又不会因为红色面积过大造成视觉疲劳。有一点要注意不要直接在每个粒子绘制时设置fillStyle为带透明度的颜色因为粒子数量多透明叠加会带来不可控的亮度不均。我倾向于直接生成RGB值然后通过globalCompositeOperation lighter来做发光叠加颜色更干净。4. 完整源码单文件“粒子春联”可以这样落地下面是一个完整可运行的单文件版本。把整段代码保存成html文件用浏览器打开就能看到粒子从随机位置汇聚成“新年纳福”再切换成“福”字最后组成完整春联。点击或触摸屏幕可以手动切换到下一组文字。4.1 HTML骨架与全局样式!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta http-equivX-UA-Compatible contentIEedge meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno title新年春联 · 粒子变幻/title style * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; background: #120007; } canvas { display: block; } .tips { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); color: rgba(255, 220, 150, 0.75); font: 14px/1.8 KaiTi, STKaiti, Microsoft YaHei, sans-serif; text-align: center; pointer-events: none; white-space: nowrap; letter-spacing: 2px; } /style /head body canvas idcanvas/canvas div classtips idtips新春大吉 · 点击切换/div script // JS代码见下面4.2 /script /body /html4.2 完整JS代码(function () { use strict; var canvas document.getElementById(canvas); var ctx canvas.getContext(2d); var W 0, H 0; var DPR Math.min(window.devicePixelRatio || 1, 2); var TOTAL 4000; // 粒子总数 var ANIM_DURATION 1600; // 切换动画时长(ms) var SWITCH_INTERVAL 3500; // 文字停留时间(ms) var SAMPLESTEP 3; // 文字点阵采样步长 var particles []; var phaseList []; var currentTargets []; var lastStart []; var STATE { phase: 0, progress: 1, lastAt: 0 }; var lastManual 0; // ---------- 工具与布局 ---------- function getTextPoints(text, fontSize, step) { step step || SAMPLESTEP; var off document.createElement(canvas); var octx off.getContext(2d); off.width Math.ceil(fontSize * text.length * 1.1) 40; off.height Math.ceil(fontSize * 1.5) 40; octx.font bold fontSize px KaiTi,STKaiti,Microsoft YaHei,sans-serif; octx.textAlign center; octx.textBaseline middle; octx.lineJoin round; octx.shadowColor #fff; octx.shadowBlur 10; octx.lineWidth Math.max(3, fontSize * 0.06); octx.strokeStyle #fff; octx.strokeText(text, off.width / 2, off.height / 2); octx.fillStyle #fff; octx.fillText(text, off.width / 2, off.height / 2); var img octx.getImageData(0, 0, off.width, off.height); var pts []; for (var y 0; y off.height; y step) { for (var x 0; x off.width; x step) { var a img.data[(y * off.width x) * 4 3]; if (a 128) { pts.push({ x: x - off.width / 2, y: y - off.height / 2 }); } } } return pts; } function placePoints(pts, cx, cy, scale) { scale scale || 1; var result []; for (var i 0; i pts.length; i) { result.push({ x: cx pts[i].x * scale, y: cy pts[i].y * scale }); } return result; } function buildPhases() { phaseList []; // 第0组四字吉语 var s1 Math.min(W * 0.16, H * 0.24); phaseList.push({ name: 新年纳福, points: placePoints(getTextPoints(新年纳福, s1), W / 2, H / 2) }); // 第1组大字福 var s2 Math.min(W, H) * 0.42; phaseList.push({ name: 福, points: placePoints(getTextPoints(福, s2), W / 2, H / 2) }); // 第2组完整春联 phaseList.push({ name: 喜居宝地千年旺 · 福照家门万事兴, points: buildCoupletPoints() }); } function buildCoupletPoints() { var all []; var lineH Math.min(W * 0.075, H * 0.09); var couplet [喜居宝地千年旺, 福照家门万事兴]; var positions [ { cx: W * 0.78, cy: H * 0.52 }, // 上联右侧 { cx: W * 0.22, cy: H * 0.52 } // 下联左侧 ]; for (var i 0; i couplet.length; i) { var lineText couplet[i]; for (var c 0; c lineText.length; c) { var charCx positions[i].cx; var charCy positions[i].cy (c - (lineText.length - 1) / 2) * lineH; var pts getTextPoints(lineText[c], Math.ceil(lineH * 0.92)); all all.concat(placePoints(pts, charCx, charCy)); } } var hFont Math.ceil(lineH * 1.1); var hPts getTextPoints(喜迎新春, hFont); all all.concat(placePoints(hPts, W / 2, H * 0.12)); return all; } // ---------- 粒子 ---------- function createParticles() { particles []; for (var i 0; i TOTAL; i) { particles.push({ x: Math.random() * W, y: Math.random() * H, size: Math.random() * 1.8 0.5, seed: Math.random() * Math.PI * 2 }); } } function reallocateTargets() { var targets phaseList[STATE.phase].points; currentTargets []; for (var i 0; i TOTAL; i) { var t targets[(Math.random() * targets.length) | 0]; currentTargets.push({ x: t.x, y: t.y }); } lastStart particles.map(function (p) { return { x: p.x, y: p.y }; }); STATE.progress 0; STATE.lastAt performance.now(); } function switchPhase() { STATE.phase (STATE.phase 1) % phaseList.length; reallocateTargets(); } function manualSwitch() { var now performance.now(); if (now - lastManual 500) return; lastManual now; switchPhase(); } function easeInOutCubic(t) { return t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2; } // ---------- 动画 ---------- function tick(now) { var elapsed now - STATE.lastAt; if (elapsed SWITCH_INTERVAL STATE.progress 1) { switchPhase(); } if (STATE.progress 1) { STATE.progress Math.min(1, elapsed / ANIM_DURATION); } var f easeInOutCubic(STATE.progress); for (var i 0; i TOTAL; i) { var p particles[i]; var start lastStart[i]; var target currentTargets[i]; var wave Math.sin(now * 0.003 p.seed) * (1 - f) * 2.5; p.x start.x (target.x - start.x) * f wave; p.y start.y (target.y - start.y) * f wave * 0.8; } render(now); requestAnimationFrame(tick); } function render(now) { ctx.clearRect(0, 0, W, H); // 背景光晕 var g ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.65); g.addColorStop(0, rgba(220, 30, 40, 0.14)); g.addColorStop(1, rgba(5, 0, 10, 0)); ctx.fillStyle g; ctx.fillRect(0, 0, W, H); // 粒子 ctx.globalCompositeOperation lighter; for (var i 0; i TOTAL; i) { var p particles[i]; var r 255; var gg Math.min(255, 220 Math.sin(now * 0.002 p.seed) * 30); var b 150 Math.sin(now * 0.001 p.seed * 2) * 60; ctx.fillStyle rgb( r , gg , b ); ctx.fillRect(p.x, p.y, p.size, p.size); } ctx.globalCompositeOperation source-over; } // ---------- 初始化 ---------- function resize() { W window.innerWidth; H window.innerHeight; canvas.width Math.floor(W * DPR); canvas.height Math.floor(H * DPR); canvas.style.width W px; canvas.style.height H px; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); buildPhases(); if (particles.length 0) { createParticles(); } reallocateTargets(); } canvas.addEventListener(click, manualSwitch); canvas.addEventListener(touchstart, function (e) { e.preventDefault(); manualSwitch(); }, { passive: false }); window.addEventListener(resize, resize); resize(); requestAnimationFrame(tick); })();把4.1和4.2合在一起保存成html双击打开就能看到效果。如果你需要手动切换文字点击或触摸画面任意位置就行。4.3 核心参数速查与调节入口调参是这个项目最好玩的部分我把几个关键常量的作用和推荐值整理了一下方便你快速改出自己想要的效果参数作用推荐值调大/调小的影响TOTAL粒子总数2500~6000调大更饱满更亮但低端机可能掉帧ANIM_DURATION切换动画时长1200~2000调大更优雅缓慢调小更干脆有力SWITCH_INTERVAL文字停留时间3000~5000决定观众读字时间太短看不清SAMPLESTEP点阵采样步长2~4调小点阵更精细调大更稀疏DPR设备像素比上限1~2调2保清晰调1省性能4.4 运行效果的验证方法打开页面后你应该能看到几千个金色光点从屏幕各处向中心聚拢先是“新年纳福”停留约3.5秒后变成一个大大的“福”字再变成左右两侧的春联。整个过程画面是暗色背景中央偏红粒子自带金色光晕。如果打开后粒子没有动可能是浏览器不支持Canvas的某些API建议换Chrome或Edge。如果是手机打开确认不是用的是过旧的内置浏览器。5. 调试过程中踩过的坑与解决路径这类作品看起来简单但真自己写一遍会遇到不少只有动手才懂的细节。我把自己踩过的几个坑列出来按排查链路的方式写你可以少走很多弯路。5.1 字体加载导致目标点为空或稀少最开始我在电脑上调试时一切正常但拿到另一台电脑上打开发现粒子只是随机乱飞始终组成不了文字。排查第一步是确认目标点数组有没有生成。打印出来发现有些字返回的点阵数量是0或者很少。原因在于canvas绘制文字时使用了“KaiTi”等字体如果系统没有这个字体canvas会回落成默认sans-serif。大多数系统对中文都有一个可用的默认字体所以字还是能画出来但笔画结构和加粗效果可能完全不一样导致提取出来的点阵稀疏。解决办法是给font-family加一个比较长的回退链比如“KaiTi, STKaiti, Microsoft YaHei, sans-serif”并且用描边填充双重绘制来保证笔画厚度。如果你在设计阶段特别依赖某种字体可以用document.fonts.load在页面加载时预加载字体等字体就绪后再提取点阵。5.2 离屏Canvas尺寸过大导致getImageData报错做完整春联阶段时我一开始试图在一张离屏Canvas上一次画出整副春联包括上联、下联和横批。结果在某些屏幕上直接报错getImageData拿不到数据页面白屏。原因是浏览器对Canvas的最大尺寸有硬性限制不同浏览器不一样但一般边长超过一万多像素就会出问题。排查逻辑先给离屏Canvas的宽度和高度打印出来发现超过了合理范围。因为7个字横排字号又大离屏宽度轻松超过几千甚至上万像素。解决办法是拆字取点每个字单独创建一个离屏Canvas单独提取点阵然后通过placePoints把点阵平移到目标位置。这样单次画布的尺寸永远只取决于一个字完全避开最大尺寸限制。这个思路也适用于任何超长文本的点阵提取。5.3 Canvas模糊必须处理设备像素比DPR第一次在手机上测试时整体画面发虚字的边缘也有明显锯齿。这是因为CSS像素和物理像素不是一比一很多手机一个CSS像素对应两个或多个物理像素。如果不处理canvas绘图区域就会按CSS像素大小分配物理像素再被浏览器拉伸到物理屏幕尺寸自然就模糊了。排查方法是在Chrome DevTools里切换到设备模拟器看一眼canvas.width和canvas.style.width是否一致。不一致说明没处理DPR。修复很简单var DPR Math.min(window.devicePixelRatio || 1, 2); canvas.width Math.floor(W * DPR); canvas.height Math.floor(H * DPR); canvas.style.width W px; canvas.style.height H px; ctx.setTransform(DPR, 0, 0, DPR, 0, 0);注意DPR要设一个上限比如2否则有些高端手机物理像素密度过高Canvas计算压力成倍增加性能反而下降。5.4 4000粒子在某些机型掉帧我用iPhone 11测试没问题但换到一台几年前的安卓中端机上帧率明显下降。排查后发现粒子绘制用的是ctx.arc方法并且每个粒子每次都new了一次Path。Canvas 2D的arc调用开销比fillRect大很多尤其粒子数量多的时候差距很明显。解决方案也简单把圆形粒子改成填充小矩形fillRect。当粒子尺寸只有一两像素时圆和矩形的视觉差异肉眼几乎分辨不出来但性能能提升不少。我实测在同样粒子数量下fillRect比arc快30%以上。另外尽量避免在粒子绘制循环里使用shadowBlur那会让性能指数级下降。运行时发光效果用globalCompositeOperation lighter实现不要依赖阴影。5.5 Resize时粒子瞬间跑飞窗口大小改变时我重新构建了所有阶段的目标点并且重新分配了粒子目标。这个操作本身没问题但如果你在resize事件里直接调用reallocateTargets会让粒子瞬间从旧位置飞向新目标。视觉上看起来像“粒子散了”有时候还挺好看但如果你不需要这种效果最好对resize做debounce等用户停止调整窗口几百毫秒后再重新布局。我的处理方式比较简单粗暴直接调用resize。因为粒子本身是动态的重新布局反而给画面增加了随机感。如果你希望更稳定可以加一个300ms的防抖定时器。5.6 移动端点击同时触发click和touchstart导致切两次在手机上测试时我发现点击一次屏幕文字会连跳两下。原因是移动端浏览器在触摸结束时通常会触发一次click而我也监听了touchstart于是同一次点击触发了两次切换逻辑。排查方法是在切换函数里加时间戳防抖记录上一次手动切换的时间如果当前时间距上次小于500ms就忽略这次切换。这样哪怕click和touchstart同时触发也只有第一次生效。6. 从Demo到完整电子春联还能往哪些方向扩展这个Demo跑通之后玩法空间其实很大我列几个我觉得最有实际操作价值的扩展方向也都是我曾经试过或准备尝试的路线。6.1 交互玩法点击切换、鼠标扰动、拖拽粒子目前的交互是点击切换文字已经很基础了。你可以进一步做“鼠标附近粒子被推开”的效果在鼠标移动时计算粒子与鼠标的距离如果小于一定半径就给粒子施加一个斥力。这个交互在Canvas上写很直观实测非常出彩尤其是粒子聚成春联后鼠标从字上扫过会带起一道金色的“粒子涟漪”。拖拽粒子更复杂一些需要做粒子拾取但也不是做不到。6.2 氛围增强烟花粒子、光晕、雪景既然定位是新年主题可以在背景加入烟花粒子在页面加载或者每次切换文字时从屏幕底部射出几颗烟花炸开成更多小粒子散落。也可以加入雪花飘落让画面更有冬日气息。注意粒子数量总预算如果烟花和文字粒子共用同一个粒子池需要做好任务调度否则会出现“放烟花时字缺了一大块”的情况。6.3 音频联动用Web Audio驱动粒子颜色接音频是一个很带感的扩展方向播放一段春节BGM通过Web Audio API的AnalyserNode获取实时频域数据把不同频段的能量映射到粒子颜色和大小上。低频对应红色中频对应金色高频对应白色。这样粒子在聚成春联的同时还会跟着音乐“呼吸”过年场景表现力会很强。6.4 导出动画分享如果想让家人朋友更方便地看到成品可以把动画录制成GIF或者MP4。浏览器录屏推荐用CCapture.js它可以精确逐帧捕获Canvas画面再通过whammy或gif.js合成视频。需要注意帧率不要设置太高15帧每秒在视觉上已经完全够用导出的文件体积也小很多。不过CCapture对Canvas 2D的捕获兼容性不错对WebGL反而更容易出问题这也是我用Canvas 2D的另一个隐藏好处。6.5 做成一个春联“小工具”更进一步你可以把春联内容参数化做成一个小工具用户输入上联、下联、横批程序自动提取点阵、计算布局、生成粒子动画。这种工具放在家族群里一定会被大量使用。技术上就是把buildCoupletPoints里的固定字符串替换成用户输入然后注意一下字数显示的适配就行。最后说点个人体会。把“新年春联”做成粒子动画真正难的其实不是粒子系统本身而是“把文字拆成点”和“让点听话地组成文字”这两个环节的衔接。粒子系统谁都能写但文字点阵提取的质量、春联布局的合理性、切换动画的节奏才是决定作品最终观感的东西。如果你也想做一个我建议先把单个“福”字跑通再扩展成完整春联。遇到问题优先检查离屏画布尺寸和字体回退这两个坑我折腾的时间最多。希望这套源码和踩坑记录能帮你少走点弯路做出一款让家人眼前一亮的电子春联。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →