首页 / 资讯中心 / 文章详情

用Canvas手写烟花动画:从零打造专属浪漫礼物页面

用Canvas手写烟花动画:从零打造专属浪漫礼物页面 ★ FEATURED ARTICLE
1. 项目缘起与整体设计思路1.1 为什么选择用代码做礼物先说清楚这个项目到底在做什么。简单讲就是用前端代码写一个烟花动画效果打开浏览器就能看到满屏烟花绽放可以配上文字、音乐、照片做成一个专属的页面。它解决的核心问题是情人节、纪念日、生日这些节点想送点不一样的东西但既不会画画也不会做视频买现成的又觉得没诚意。代码烟花这个东西门槛低、可定制、效果直观而且“我专门为你写了一个页面”这句话本身就有分量。我最早接触这类需求是帮朋友做一个求婚用的页面。当时试过几个方向用视频剪辑软件做特效渲染慢、改起来麻烦用现成的H5模板好看是好看但所有人打开都一样没有专属感。最后选了Canvas手写烟花原因有三第一浏览器原生支持不需要装任何东西手机电脑都能打开第二代码完全可控颜色、形状、文字、节奏都能改第三文件小一个HTML文件就能跑发给对方直接打开就行。这个项目适合几类人完全不懂代码但愿意照着改的有一点前端基础想练手的以及想给对象做点特别东西但预算有限的。你不需要会写复杂的逻辑只要会改文字、换颜色、调参数就够了。1.2 技术选型的取舍逻辑做烟花效果技术上主要有三条路纯CSS动画、Canvas 2D绘制、WebGL渲染。我三个都试过最后主力用Canvas 2D原因如下。纯CSS做烟花本质是用多个div模拟粒子通过keyframes控制位移和透明度。优点是代码简单不需要JS逻辑。缺点是粒子数量一多就卡一般超过200个粒子就开始掉帧而且运动轨迹是预设的做不出真实的抛物线衰减效果。我试过用CSS做了一版烟花升空那段还行但爆炸散开的时候明显僵硬像是一群小方块同时往外推没有那种“炸开”的随机感。WebGL性能最好可以轻松跑几千个粒子还能加辉光、拖尾这些高级效果。但问题是学习成本高要写着色器改一个参数可能牵涉到好几个地方。对于“改改就能用”这个目标来说太重了。Canvas 2D是折中方案。它用JavaScript在画布上逐帧绘制粒子数量控制在500以内完全流畅手机上也稳。运动轨迹可以用物理公式模拟重力、阻力、随机初速度都能加。代码结构清晰一个Particle类管粒子一个Firework类管烟花主循环用requestAnimationFrame驱动。想改颜色就改一个数组想改爆炸范围就调一个系数非常直观。所以这个项目的核心架构就是Canvas负责渲染JavaScript负责物理模拟和状态管理HTML负责承载和文字层。三层各司其职改哪层都不影响其他层。1.3 整体文件结构与运行方式整个项目最终就是一个文件夹里面放这些东西fireworks/ ├── index.html # 主页面包含画布和文字层 ├── style.css # 样式控制文字位置、背景、字体 ├── fireworks.js # 核心逻辑烟花和粒子的全部代码 └── assets/ ├── bgm.mp3 # 背景音乐可选 └── photo.jpg # 照片可选运行方式极其简单双击index.html浏览器打开就能看到效果。不需要服务器不需要安装任何依赖。如果要发给对方把这个文件夹压缩成zip发过去对方解压后双击打开就行。如果想让对方在手机上也能看可以把整个文件夹传到任意静态托管服务上得到一个链接发链接即可。这里有个细节要注意如果用了背景音乐浏览器的自动播放策略会拦截。解决办法是加一个“点击开始”的按钮用户点一下之后再播放音乐这样就不受限制了。这个后面会详细讲。提示如果只是自己看效果直接双击HTML文件就行。如果要分享给别人建议用静态托管生成链接因为很多人不知道zip解压后怎么打开。2. 核心细节解析与实操要点2.1 Canvas画布的正确初始化方式画布初始化看着简单但有几个坑我踩过。第一个坑是画布尺寸和显示尺寸不一致导致模糊。比如你设置canvas width800 height600但CSS里又写了width: 100%浏览器会把画布拉伸粒子边缘就糊了。正确做法是用devicePixelRatio来适配高清屏。具体操作是先获取屏幕的像素比然后把画布的width和height属性设成CSS尺寸 × 像素比再用ctx.scale()把绘图坐标系缩放回来。这样在高清屏上粒子边缘是锐利的在普通屏上也不浪费性能。const canvas document.getElementById(fireworks); const ctx canvas.getContext(2d); const dpr window.devicePixelRatio || 1; function resizeCanvas() { const w window.innerWidth; const h window.innerHeight; canvas.width w * dpr; canvas.height h * dpr; canvas.style.width w px; canvas.style.height h px; ctx.scale(dpr, dpr); } resizeCanvas(); window.addEventListener(resize, resizeCanvas);第二个坑是背景色。Canvas默认是透明的如果你想要黑色夜空效果有两个选择用CSS给canvas设background: #000或者在每一帧绘制前用ctx.fillRect铺一层半透明黑色。我推荐后者因为半透明黑色能做出拖尾效果——粒子移动后会留下淡淡的残影看起来更梦幻。具体做法是每帧开始时执行ctx.fillStyle rgba(0, 0, 0, 0.15); ctx.fillRect(0, 0, w, h);透明度越低拖尾越长0.1到0.2之间比较合适。第三个坑是坐标系原点。Canvas的原点在左上角y轴向下。做烟花的时候发射点一般在底部中间爆炸点在上方。所以计算位置的时候要注意y轴方向重力加速度是正值向下初速度向上是负值。这个搞反了烟花就往下掉了。2.2 粒子系统的物理模型设计烟花的核心是粒子。一个烟花爆炸后产生几十到上百个粒子每个粒子有自己的位置、速度、颜色、生命周期。要让运动看起来真实需要模拟三个物理量初速度、重力、空气阻力。初速度决定了爆炸的范围。我的做法是给每个粒子一个随机的角度和速度大小。角度用Math.random() * Math.PI * 2生成0到360度的随机方向速度大小用一个基础值加上随机偏移。这样爆炸出来是一个近似圆形的散开效果但每个粒子飞的距离不一样看起来更自然。const angle Math.random() * Math.PI * 2; const speed Math.random() * 4 2; // 2到6之间 this.vx Math.cos(angle) * speed; this.vy Math.sin(angle) * speed;重力是每帧给垂直速度加一个固定值模拟向下加速。我一般用this.vy 0.05这个值越大粒子下落越快烟花看起来越“重”。空气阻力是每帧把速度乘以一个小于1的系数比如this.vx * 0.98; this.vy * 0.98;这样粒子飞出去之后会慢慢减速不会一直匀速飞。生命周期用透明度来控制。每个粒子有一个life值从1开始每帧减一个很小的数减到0就移除。绘制的时候用ctx.globalAlpha this.life粒子就逐渐变淡消失。这里有个技巧不要让life线性递减用this.life * this.life或者Math.pow(this.life, 2)淡出效果更柔和像真的熄灭一样。颜色方面我建议用HSL而不是RGB。HSL的色相是一个0到360的圆环随机取色相就能得到彩虹色而且调整亮度很方便。比如hsl(${Math.random() * 360}, 100%, 60%)就是随机鲜艳颜色。如果想要金色烟花就把色相限制在30到60之间。2.3 烟花发射与爆炸的节奏控制如果所有烟花同时爆炸看起来会很乱。真实的烟花表演是有节奏的一发接一发有的高有的低有的快有的慢。代码里也要模拟这种节奏感。我的做法是用一个定时器或者基于帧数的计数器来控制发射间隔。比如每60帧发射一发新烟花60帧在60fps下就是1秒一发。但不要固定间隔加一点随机Math.random() * 40 40帧这样间隔在0.7到1.3秒之间波动更自然。发射阶段也要做。烟花不是瞬间出现在爆炸点的而是从底部升上去。所以一个完整的烟花有两段升空段和爆炸段。升空段是一个粒子从底部往上飞带一条尾迹飞到目标高度后移除同时在那个位置生成爆炸粒子。class Firework { constructor() { this.x Math.random() * window.innerWidth; this.y window.innerHeight; this.targetY Math.random() * window.innerHeight * 0.5 100; this.vy -(Math.random() * 3 8); this.exploded false; } update() { this.y this.vy; this.vy 0.1; // 减速 if (this.vy 0 || this.y this.targetY) { this.exploded true; // 在这里生成爆炸粒子 } } }升空段的尾迹可以用一个数组记录最近几帧的位置绘制时画一条渐变的线。或者更简单每帧在当前位置画一个小圆透明度逐渐降低看起来就像拖尾。爆炸的时候粒子数量要控制。我试过每个烟花200个粒子同时三四个烟花就快1000个粒子了手机上开始卡。后来改成每个烟花60到100个粒子同时最多3个烟花总粒子数控制在300以内流畅度就很好。如果要做大型烟花可以给粒子加一个“大小”属性大的粒子少一点小的粒子多一点视觉上丰富但不卡。2.4 文字层与照片的叠加方式光有烟花还不够礼物要有专属信息。文字层我建议用HTML的div叠在canvas上面而不是用canvas的fillText绘制。原因有三第一HTML文字可以用CSS设置字体、阴影、渐变比canvas灵活第二文字可以选中、可以加动画比如淡入、打字机效果第三改文字不用动JS代码直接改HTML就行。结构大概是canvas idfireworks/canvas div classmessage h1生日快乐/h1 p致我最爱的你/p /divCSS里把canvas设为position: fixed; top: 0; left: 0; z-index: 1;message设为position: fixed; z-index: 2;这样文字就在烟花上面。文字颜色用白色加一点发光效果text-shadow: 0 0 20px rgba(255, 255, 255, 0.8);在黑色背景上很醒目。照片的叠加有两种方式。一种是放在页面角落用img标签加圆角和边框像一张拍立得。另一种是做成圆形头像放在文字上方。我推荐后者更像礼物。照片要提前裁剪成正方形不然圆形裁切会变形。CSS用border-radius: 50%就能做圆形。注意照片文件不要太大建议压缩到500KB以内。太大的图片加载慢而且手机浏览器可能因为内存不足而崩溃。3. 实操过程与核心环节实现3.1 从零搭建一个可运行的烟花页面现在从头走一遍完整流程。新建一个文件夹里面创建三个文件index.html、style.css、fireworks.js。先写HTML骨架!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title给你的惊喜/title link relstylesheet hrefstyle.css /head body canvas idfireworks/canvas div classmessage img srcassets/photo.jpg alt我们的照片 classphoto h1生日快乐/h1 p愿你每天都像烟花一样灿烂/p /div button idstartBtn点击开始/button audio idbgm srcassets/bgm.mp3 loop/audio script srcfireworks.js/script /body /htmlCSS部分* { margin: 0; padding: 0; box-sizing: border-box; } body { background: #000; overflow: hidden; font-family: Microsoft YaHei, sans-serif; } #fireworks { position: fixed; top: 0; left: 0; z-index: 1; } .message { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2; text-align: center; color: #fff; pointer-events: none; } .photo { width: 120px; height: 120px; border-radius: 50%; border: 3px solid rgba(255,255,255,0.8); object-fit: cover; margin-bottom: 20px; box-shadow: 0 0 30px rgba(255,255,255,0.5); } .message h1 { font-size: 48px; text-shadow: 0 0 20px rgba(255,255,255,0.8), 0 0 40px rgba(255,100,100,0.6); margin-bottom: 10px; letter-spacing: 4px; } .message p { font-size: 18px; opacity: 0.8; letter-spacing: 2px; } #startBtn { position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); z-index: 3; padding: 12px 36px; background: transparent; border: 2px solid rgba(255,255,255,0.6); color: #fff; font-size: 16px; border-radius: 30px; cursor: pointer; letter-spacing: 2px; transition: all 0.3s; } #startBtn:hover { background: rgba(255,255,255,0.2); }JS核心逻辑分三块粒子类、烟花类、主循环。粒子类class Particle { constructor(x, y, color) { this.x x; this.y y; const angle Math.random() * Math.PI * 2; const speed Math.random() * 4 2; this.vx Math.cos(angle) * speed; this.vy Math.sin(angle) * speed; this.color color; this.life 1; this.decay Math.random() * 0.015 0.01; this.size Math.random() * 2 1; } update() { this.vx * 0.98; this.vy * 0.98; this.vy 0.05; this.x this.vx; this.y this.vy; this.life - this.decay; } draw(ctx) { ctx.globalAlpha this.life * this.life; ctx.fillStyle this.color; ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fill(); } }烟花类class Firework { constructor() { this.x Math.random() * window.innerWidth; this.y window.innerHeight; this.targetY Math.random() * window.innerHeight * 0.4 100; this.vy -(Math.random() * 3 9); this.color hsl(${Math.random() * 360}, 100%, 60%); this.exploded false; this.particles []; } update() { if (!this.exploded) { this.y this.vy; this.vy 0.15; if (this.vy 0 || this.y this.targetY) { this.exploded true; this.explode(); } } else { this.particles.forEach(p p.update()); this.particles this.particles.filter(p p.life 0); } } explode() { const count Math.floor(Math.random() * 40) 60; for (let i 0; i count; i) { this.particles.push(new Particle(this.x, this.y, this.color)); } } draw(ctx) { if (!this.exploded) { ctx.globalAlpha 1; ctx.fillStyle this.color; ctx.beginPath(); ctx.arc(this.x, this.y, 3, 0, Math.PI * 2); ctx.fill(); } else { this.particles.forEach(p p.draw(ctx)); } } }主循环const fireworks []; let frameCount 0; function loop() { ctx.globalAlpha 1; ctx.fillStyle rgba(0, 0, 0, 0.15); ctx.fillRect(0, 0, window.innerWidth, window.innerHeight); frameCount; if (frameCount % 50 0 fireworks.length 4) { fireworks.push(new Firework()); } fireworks.forEach(f { f.update(); f.draw(ctx); }); for (let i fireworks.length - 1; i 0; i--) { if (fireworks[i].exploded fireworks[i].particles.length 0) { fireworks.splice(i, 1); } } requestAnimationFrame(loop); } loop();音乐播放控制document.getElementById(startBtn).addEventListener(click, function() { document.getElementById(bgm).play(); this.style.display none; });这套代码复制过去就能跑。打开页面点击开始按钮音乐响起烟花从底部升起、爆炸、消散文字和照片叠在中间。整个文件加起来不到10KB非常轻量。3.2 参数调优让烟花更好看的几个关键值代码能跑之后接下来是调参。同样的逻辑参数不同效果差很多。我整理了几个关键参数和推荐范围。参数作用推荐值调大效果调小效果粒子初速度爆炸范围2~6散得更大更集中重力系数下落速度0.05下落快沉重飘浮感阻力系数减速快慢0.98飞得远很快停下生命周期衰减消失速度0.01~0.025消失快停留久拖尾透明度残影长度0.15拖尾短拖尾长发射间隔烟花密度40~80帧稀疏密集粒子数量爆炸饱满度60~100更饱满更稀疏我自己的习惯是重力0.05、阻力0.98、衰减0.015、拖尾0.15、间隔60帧、粒子80个。这套参数在手机和电脑上表现都比较均衡。如果想要金色烟花把颜色改成hsl(45, 100%, 60%)。如果想要粉色系色相限制在300到360之间。如果想要多色烟花可以在爆炸时给每个粒子单独随机色相而不是统一用烟花的颜色。还有一个提升质感的小技巧给粒子加一点“闪烁”。具体做法是在绘制时随机跳过一些粒子或者让粒子的透明度随机波动。代码很简单if (Math.random() 0.1) p.draw(ctx);10%的粒子不绘制看起来就有闪烁感。3.3 文字动画与入场效果静态文字有点呆加一点入场动画会更有仪式感。最简单的做法是用CSS的keyframes做淡入和上浮。.message { animation: fadeInUp 2s ease-out 0.5s both; } keyframes fadeInUp { from { opacity: 0; transform: translate(-50%, -40%); } to { opacity: 1; transform: translate(-50%, -50%); } }如果想要打字机效果用JS逐字显示const text 生日快乐; const el document.querySelector(.message h1); let index 0; function typeWriter() { if (index text.length) { el.textContent text.charAt(index); index; setTimeout(typeWriter, 200); } } setTimeout(typeWriter, 1000);照片也可以加动画比如从中心放大出现或者从上方飘落。用CSS的transform: scale()配合transition就行。提示文字动画不要太多两三个就够了。太多动画会分散注意力而且手机上可能卡顿。3.4 适配手机屏幕的注意事项手机屏幕和电脑差别很大有几个地方要专门处理。第一字体大小。电脑上48px的标题在手机上可能太大超出屏幕。用clamp()函数做响应式font-size: clamp(28px, 8vw, 48px);这样最小28px最大48px中间按屏幕宽度缩放。第二照片尺寸。手机上120px可能偏大改成clamp(80px, 25vw, 120px)。第三触摸事件。电脑上用鼠标手机上用手指。如果要做点击放烟花的效果要同时监听click和touchstart。不过要注意touchstart和click可能同时触发导致放两次。解决办法是用e.preventDefault()或者加一个标志位。第四性能。手机GPU性能有限粒子数量要减少。可以用window.innerWidth 768判断是不是手机手机上把粒子数量降到40发射间隔拉长到80帧。const isMobile window.innerWidth 768; const particleCount isMobile ? 40 : 80; const fireInterval isMobile ? 80 : 60;第五音频。手机浏览器对自动播放限制更严必须用户交互后才能播放。所以“点击开始”按钮在手机上尤其重要。另外音频文件要压缩建议用128kbps的mp3一分钟的曲子大概1MB左右。4. 常见问题与排查技巧实录4.1 烟花不显示或显示异常的排查清单代码写完了打开一片黑这是最常见的问题。我整理了一个排查顺序按这个顺序查基本都能解决。现象可能原因排查方法解决方法完全黑屏JS报错按F12看Console根据报错行号检查代码有文字无烟花canvas尺寸为0检查canvas的width/height确保resizeCanvas被调用烟花在左上角坐标系没缩放检查ctx.scale在resize后重新scale烟花颜色不对HSL格式错误检查模板字符串确保是hsl(角度, 100%, 60%)粒子不动主循环没启动检查requestAnimationFrame确保loop()被调用手机上看不到视口没设置检查meta viewport加上viewport标签音乐不播放自动播放被拦截检查是否有用户交互加点击开始按钮我遇到最多的问题是ctx.scale在resize时重复调用导致坐标系越来越小。解决办法是在resize时先重置变换ctx.setTransform(1, 0, 0, 1, 0, 0);然后再scale。还有一个坑是requestAnimationFrame在页面不可见时会暂停切回页面后时间戳跳跃导致粒子瞬移。解决办法是用performance.now()计算时间差而不是假设每帧间隔固定。不过对于烟花这种短时间效果一般不影响切回来重新放就行。4.2 性能卡顿的优化手段卡顿通常出现在低端手机或粒子数量过多时。优化手段按优先级排列第一减少粒子数量。这是最直接有效的。把每个烟花的粒子从100降到50总粒子数减半流畅度立刻提升。第二减少绘制操作。每个粒子绘制时都要beginPath和arc这些操作比较耗时。可以改用fillRect画小方块速度快很多。虽然形状不如圆形好看但粒子很小的时候看不出来。第三合并绘制。把颜色相同的粒子合并成一次绘制减少状态切换。不过实现起来复杂一般不需要。第四降低帧率。用setTimeout代替requestAnimationFrame把帧率控制在30fps。烟花效果对帧率要求不高30fps看起来也流畅。第五关闭拖尾。拖尾效果需要每帧绘制一个全屏的半透明矩形这个操作在低端设备上很耗时。如果卡顿严重可以去掉拖尾改成每帧清空画布。// 去掉拖尾改为清空 ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);4.3 分享给对方时的常见障碍代码在自己电脑上跑得好好的发给对方却打不开这种情况很常见。原因和解决办法如下。对方收到zip文件解压后不知道点哪个。解决办法是文件名要明确把index.html改成双击打开.html或者在文件夹里放一个说明.txt写清楚“双击index.html打开”。对方用手机打开发现没有声音。这是因为手机浏览器拦截了自动播放而对方没有点“开始”按钮。解决办法是把按钮做得显眼一点或者用文字提示“点击屏幕任意位置开始”。对方打开后文字乱码。这是因为HTML文件没有声明编码。确保meta charsetUTF-8在head里并且文件保存时选择UTF-8编码。对方打开后照片不显示。检查照片路径是否正确assets/photo.jpg要和HTML文件在同一目录下。如果照片文件名有中文或空格改成英文和数字。如果以上都没问题但还是打不开最稳妥的方式是把整个文件夹传到静态托管服务生成一个链接发给对方。对方点链接就能看不用下载任何东西。常见的静态托管有GitHub Pages、Netlify、Vercel等上传文件夹就行不需要服务器知识。4.4 内容定制的几个实用建议最后分享几个定制化的经验让这个礼物更贴合具体场景。文字内容不要只写“生日快乐”可以写一句只有你们两个人懂的话。比如第一次见面的日期、一个内部笑话、一句歌词。越私人越有感觉。照片可以放多张做成轮播。用CSS的animation或者JS定时切换src就行。不过手机上多张照片加载慢建议不超过3张。背景音乐的选择很关键。不要用太吵的选一首对你们有意义的慢歌。音量要调低作为背景存在不要盖过烟花的效果。可以在audio标签上加volume0.3。如果是在特定时间点送比如零点可以用JS判断时间到点自动开始。代码很简单function checkTime() { const now new Date(); if (now.getHours() 0 now.getMinutes() 0) { startShow(); } else { setTimeout(checkTime, 1000); } }如果想要更复杂的烟花形状比如心形可以在爆炸时不用随机角度而是用参数方程生成心形轨迹的粒子位置。这个稍微复杂一点但效果很惊艳。核心是把粒子的初速度方向按照心形曲线来分配而不是随机圆。// 心形参数方程 for (let i 0; i count; i) { const t (i / count) * Math.PI * 2; const x 16 * Math.pow(Math.sin(t), 3); const y -(13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t)); const speed 0.5; this.particles.push(new Particle(this.x, this.y, this.color, x * speed, y * speed)); }这样爆炸出来就是一个心形。把这段替换掉原来的随机角度逻辑就行。心形烟花配上一句“爱你”效果比普通烟花好很多。我在实际做这类页面的时候最大的体会是技术不是重点心意才是。代码写得再漂亮不如一句走心的话。所以花在文字和照片上的时间应该比调参数的时间多。参数差不多就行文字要反复改改到你自己看了都觉得感动那才是合格的礼物。
阅读完成 · 觉得有帮助?
咨询建站