ARTICLE DETAIL

资讯详情

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

Canvas粒子系统实战:手写火焰喷射动效与性能优化

Canvas粒子系统实战:手写火焰喷射动效与性能优化 做网页动效这些年我印象里最出效果的几个方向里“火焰喷射”绝对排得上号。它既不是简单的位移补间也不是一张GIF贴上去就完事——真正的火焰是活的、有温度的、有湍流感的而且用纯JavaScript在Canvas上写出来能玩的花样非常多。这篇文章我打算从一个实际可运行的火焰喷射器说起把粒子系统、颜色渐变、性能优化这些关键点一次讲透让你不光能跑出效果还能知道每一个参数为什么这么定、每一行代码背后在解决什么。我最初做这个火焰喷射效果是给一个游戏角色加技能特效。需求很直接角色手掌朝前喷射出一道持续燃烧的火柱火柱要有明暗变化、要有随风摇曳的感觉还不能让低端手机卡成PPT。刚接到需求时我的第一反应是找现成库后来发现效果好的库要么体积大、要么侵入性强还不如老老实实基于Canvas和粒子系统手写一套顺便把底层逻辑完全掌控在自己手里。这套思路后来被我用在了好几个项目里包括广告页的背景火焰、数据大屏的装饰动效都非常稳。1. 火焰喷射效果的整体设计思路1.1 为什么选粒子系统方案火焰喷射效果有很多实现路径最原始的是逐帧序列帧播放。美术会把火焰动画切成几十张甚至上百张图片然后你用定时器去切换显示。这种方式效果确实细腻但劣势也很明显文件体积大、放大缩小容易模糊、每帧之间的衔接一旦卡顿就很突兀。另一种思路是用Shader在WebGL里写片元着色器做噪声驱动的火焰效果顶级但调试成本和学习成本都不低而且不适合做轻量级业务动效。粒子系统的方案正好卡在中间实现难度适中灵活性强几乎不依赖美术资源所有火焰形态都靠代码计算生成。它的原理可以这样理解——一堆小“火苗颗粒”从喷口处不断生成每个颗粒带着自己的位置、速度、生命周期和颜色经过物理更新后画到画布上。颗粒们彼此独立运动但叠加在一起后就形成了流动、闪烁、扩散的整体视觉效果。火焰的动态感不是靠单颗粒模拟出来的而是靠颗粒之间的集体行为积累出来的。这就是粒子系统的核心哲学个体简单群体复杂。用粒子系统做火焰还天然适配“喷射”这个动作。喷射的本质就是持续供给粒子的源点加上定向发射的初速度这正是粒子发射器的标准配置。你不需要额外写复杂的动画状态机只需要调整发射速率、初速度矢量和重力方向就能在喷火、火球、余烬之间快速切换。1.2 粒子系统的核心运行模型发射-演化-消亡所有粒子系统都遵循同一个运行模型发射Emission→ 演化Update→ 消亡Death再加上一个关键的绘制Render环节。发射环节负责在每一帧按一定频率生成新粒子赋予它们随机的初始参数演化环节则根据物理规律修改粒子的位置、速度、颜色和透明度消亡环节判断粒子的生命周期是否结束如果结束就从粒子数组中移除腾出内存给新粒子。这三个环节每帧都要走一遍所以性能瓶颈通常就出在粒子数量过多、单粒子计算过重、或者频繁创建销毁对象导致垃圾回收压力大。火焰要显得绵延不绝发射速率太低不行粒子太小也不行这背后是帧率和粒子数量的经济学问题。我做这个项目时把运行模型再细拆了一层增加了一个“参数池”的概念。也就是说每一帧不是直接修改粒子对象里的字段而是把所有粒子的数据拆成几个Float数组x、y、vx、vy、life、size等用数组索引来操作。这样不仅减少了对象属性的访问开销也方便后面做对象池复用——已死亡粒子的槽位可以立刻被新粒子覆盖不会产生垃圾内存。对于火焰这种需要持续高吞吐粒子的场景这个设计能明显降低卡顿概率。1.3 火焰喷射与普通粒子的不同点普通粒子系统比如爆炸、雪花、落叶和火焰喷射的核心差异在于“生命状态的计算方式”。雪花粒子通常做恒定速度的漂移受重力影响也偏弱爆炸粒子则是一开始速度很大随后快速衰减整体是向外扩散的形态。火焰粒子则非常特别它在整个生命周期中都受向上的浮力作用而且浮力要大于重力的影响否则火焰就会像瀑布一样往下流。火焰粒子自带温度属性温度决定了它的颜色和大小。温度随生命衰减颜色会从亮白到橙黄再到暗红地过度形成火焰的层次感。火焰有强烈的湍流特性。如果把粒子速度做成常数值火焰看起来就会像平行的喷泉非常假。必须加入随机扰动或噪声偏移让粒子路径呈现摇曳感火焰才“活”起来。还有一个细节容易被忽略火焰不是单一形态它分为内焰高温、颜色偏白蓝和外焰低温、颜色偏橙红而且内外焰的粒子速度分布是不一样的。内焰的粒子初始速度更集中、方向性更强外焰的粒子更容易受环境扰动、飘散得更开。这个细节是我做了好几版之后才摸出来的也是后面让效果“像真火”的关键。2. 核心技术点拆解让火焰像真的2.1 Canvas渲染基础与透明叠加火焰效果的基本渲染载体是Canvas 2D上下文。Canvas 2D虽然听起来简单但它有个非常趁手的特性全局合成模式globalCompositeOperation。默认情况下新绘制的内容会覆盖旧内容这会让火焰粒子重叠时互相遮挡产生难看的硬边。把globalCompositeOperation设置为lighter之后重叠区域的颜色会做加法混合颜色越叠越亮正好贴合火焰发光叠加的物理特性。用代码看就是ctx.globalCompositeOperation lighter;做了这个设置后多个半透明粒子叠加在一起时会呈现出柔和的光晕效果而不是一个粒子硬生生盖住另一个粒子。这也是火焰做出来“发光”的第一个关键技巧。不过要提醒一下lighter模式下颜色叠加是线性的如果粒子太过密集RGB三个通道会迅速冲到255看起来就是一片刺眼的白这就走到另一个极端了。所以粒子透明度、数量、尺寸必须一起配合调节。还有一个我经常踩的坑Canvas清屏方式。如果每帧直接用clearRect清空整个画布在做火焰这种半透明拖尾效果时会发现拖尾硬生生地截断了。我通常采用半透明填充黑底的方式也就是用带透明度的黑色矩形覆盖全屏而不是完全清空。这样上一帧的火焰影像会残留在画布上缓慢消失形成自然的余烬拖尾。ctx.globalCompositeOperation source-over; ctx.fillStyle rgba(0, 0, 0, 0.2); ctx.fillRect(0, 0, canvas.width, canvas.height);2.2 粒子数据结构与生命周期管理每个火焰粒子需要记录的信息不多但必须有字段含义作用x, y当前坐标粒子位置vx, vy水平/垂直速度决定粒子运动方向life剩余寿命控制粒子消失时机maxLife最大寿命用于计算生命比例、映射颜色size粒子尺寸影响火焰粗细程度color当前颜色由温度映射得到生命周期管理有一个很核心的细节粒子不是从头到尾都保持不透明而是要在生命中期最亮开头和结尾都偏暗。新生的粒子如果是全亮状态会形成一坨一坨的色块感快熄灭的粒子如果仍然太亮火焰尾部就没有渐隐过程。合适的做法是用生命比例计算一个“强度曲线”比如用Math.sin(lifeRatio * Math.PI)这种波峰在中间的曲线让粒子经历从微亮到炽热再到衰落的完整过程。实际运行时还有一个优化粒子数量最大值。每一帧都生成新粒子并一直堆积的话任意设备最后都会被拖垮。所以发射器必须记录当前活跃粒子数超过上限时暂停发射或淘汰最老的粒子。我做这版的时候设置了上限240个粒子实测在桌面端和移动端都不卡。2.3 火焰颜色模拟温度与颜色映射火焰的颜色是人类视觉对温度的直觉反应。真实火焰的温度分布大致是最高温区域接近蓝白色然后是亮黄、橙黄、橙红、深红最后是黑灰色的烟。JavaScript粒子系统里模拟这个渐变最简单的做法是预置一组色标然后根据粒子的生命比例在色标之间插值。我常用的色标组是生命比例接近1刚出生白色 #ffffff生命比例0.7左右亮黄 #ffc800生命比例0.4左右橙红 #ff6a00生命比例0.15左右深红 #cc2200生命比例接近0快消亡暗灰 #331100插值的时候不能在RGB空间直接线性插值那样会得到灰蒙蒙的脏色。更稳妥的做法是先转成HSL再插值色相从50黄逐渐过渡到20橙红同时把亮度从95%降到30%饱和度保持在比较高。这样做出来的颜色过渡柔和且始终鲜亮。我把颜色插值封装成一个函数用数组存HSL值通过生命比例进行分段插值function fireColor(progress) { // progress: 1新生, 0消亡 if (progress 0.6) { let t (progress - 0.6) / 0.4; return { h: 50, s: 95, l: 60 t * 35 }; } else if (progress 0.3) { let t (progress - 0.3) / 0.3; return { h: 35 t * 15, s: 95, l: 50 t * 10 }; } else { let t progress / 0.3; return { h: 20 t * 15, s: 60 t * 35, l: 25 t * 25 }; } }再加上2.2节提到的正弦强度曲线最终颜色是“生命比例映射的基础色”乘以“强度系数”让粒子看起来自然发亮。2.4 让火苗自然上飘的物理模型火焰喷射如果只做初速度和重力粒子会走抛物线然后垂直下落完全没有“火向上烧”的直觉。真实火焰里热空气上升带来浮力这个浮力是持续作用在粒子上的。所以在更新循环里除了常规的x vx、y vy之外还要在每一帧给vy减去一个“浮力因子”让粒子持续向上加速。但还有一个更微妙的细节火苗并不是匀速上升的它有加速和减速的波动。为了模拟空气阻力和湍流我会给每个粒子的速度乘上一个非常接近1的阻尼因子比如0.98这样它不会无限加速。同时每一帧给粒子添加一个随机的小扰动扰动幅度随粒子年龄增大而增大模拟越往上升越容易被周围空气卷走的物理过程。我整理一下更真实的火焰物理更新步骤// 浮力持续向上 particle.vy - 0.05; // 阻尼防止速度无限增长 particle.vx * 0.985; particle.vy * 0.985; // 湍流扰动越老的粒子扰动越强 let age 1 - particle.life / particle.maxLife; particle.vx (Math.random() - 0.5) * 0.15 * age; particle.vy (Math.random() - 0.5) * 0.05; // 位置更新 particle.x particle.vx; particle.y particle.vy;这套伪物理模型的效果是粒子从喷口出来后先沿喷射方向快速冲一段然后逐渐减速、受浮力拉扯向上走同时路径变得越来越弯曲。叠加大量粒子后火焰梢部会自然形成卷曲、分裂的感觉。比纯直线抛物线的假火焰要真实得多。3. 完整可运行的代码实操3.1 从一个最简喷火器开始我直接给一份能跑的最简版本。它由三个部分组成Canvas初始化、粒子发射器、渲染循环。把这套代码保存成HTML文件用浏览器打开就能看到火焰。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title火焰喷射效果/title style body { margin: 0; background: #000; overflow: hidden; } canvas { display: block; width: 100vw; height: 100vh; } /style /head body canvas idfireCanvas/canvas script const canvas document.getElementById(fireCanvas); const ctx canvas.getContext(2d); // 高分屏适配 const dpr Math.min(window.devicePixelRatio || 1, 2); canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.scale(dpr, dpr); // 粒子池 let particles []; const MAX_PARTICLES 240; // 辅助函数按进度取火焰颜色 function fireColor(progress) { // progress: 1新生, 0消亡 // 这里直接返回一个 rgb 字符串 let h, s, l; if (progress 0.6) { let t (progress - 0.6) / 0.4; h 48; s 95; l 60 t * 35; } else if (progress 0.3) { let t (progress - 0.3) / 0.3; h 35 t * 13; s 95; l 50 t * 10; } else { let t progress / 0.3; h 20 t * 15; s 60 t * 30; l 25 t * 25; } return hsl(${h}, ${s}%, ${l}%); } class Particle { constructor(x, y, angle, speed) { this.x x; this.y y; this.vx Math.cos(angle) * speed; this.vy Math.sin(angle) * speed; this.maxLife 60 Math.random() * 40; this.life this.maxLife; this.size 6 Math.random() * 8; } update() { this.life - 1; let age 1 - this.life / this.maxLife; this.vy - 0.05; this.vx * 0.985; this.vy * 0.985; this.vx (Math.random() - 0.5) * 0.15 * age; this.vy (Math.random() - 0.5) * 0.05; this.x this.vx; this.y this.vy; if (age 0.7 Math.random() 0.05) { this.vx (Math.random() - 0.5) * 0.2; } } draw() { let progress this.life / this.maxLife; let strength Math.sin(progress * Math.PI); let size this.size * (0.4 0.6 * progress); ctx.globalAlpha Math.min(strength, 0.85); ctx.shadowBlur 10; ctx.shadowColor rgba(255, 120, 0, 0.6); ctx.fillStyle fireColor(progress); ctx.beginPath(); ctx.arc(this.x, this.y, size, 0, Math.PI * 2); ctx.fill(); } get isDead() { return this.life 0; } } // 喷口位置固定在画布左下方朝右上喷 let nozzleX 120; let nozzleY 500; function emitParticle() { if (particles.length MAX_PARTICLES) return; // 喷射方向水平向右上约 -15 度 let angle -0.35; // 速度加一点随机抖动 let speed 5 Math.random() * 3; particles.push(new Particle( nozzleX, nozzleY, angle, speed )); } let frame 0; function tick() { frame; // 在黑色画布上做半透明填充形成拖尾 ctx.globalCompositeOperation source-over; ctx.fillStyle rgba(0, 0, 0, 0.15); ctx.fillRect(0, 0, canvas.width, canvas.height); // 发射粒子每帧2个产生持续喷射 for (let i 0; i 2; i) { emitParticle(); } // 更新粒子 ctx.globalCompositeOperation lighter; for (let i particles.length - 1; i 0; i--) { particles[i].update(); particles[i].draw(); if (particles[i].isDead) { particles.splice(i, 1); } } requestAnimationFrame(tick); } tick(); /script /body /html这里有几个关键的参数选择我得解释一下喷口位置放在(120, 500)是模拟火焰从底部往右上喷射的场景。你完全可以根据实际布局调整。喷射角度-0.35弧度大约是向上偏20度让火焰有一个明显的“喷出去再向上扬”的弧线。2.2节提到粒子最大上限设成240这里发射速率是每帧2个配合每帧90到100的粒子存活时间整个画面会维持大概180左右的活动粒子不会顶到上限。每个粒子的透明度使用了Math.min(strength, 0.85)限制防止中心过曝。3.2 加料喷射角度、湍流与动态调节基础版跑起来后你会发现效果已经像模像样了但仔细看会有几个不足粒子太圆、火焰形态太规整、缺乏长条状的火舌感。这里有两个小技巧一个是把粒子从圆形改成椭圆椭圆的长轴方向对齐粒子速度方向火苗就有了“拖曳”感另一个是加大湍流扰动让粒子路径更卷曲。椭圆粒子绘制核心是调用ctx.ellipse()以粒子速度方向为旋转角// 在draw中 ctx.save(); ctx.translate(this.x, this.y); let angle Math.atan2(this.vy, this.vx); ctx.rotate(angle); // 长轴沿速度方向短轴是长轴的0.4倍 ctx.scale(1, 0.4); ctx.beginPath(); ctx.arc(0, 0, this.size, 0, Math.PI * 2); ctx.fill(); ctx.restore();而真实感湍流的核心是让扰动随粒子年龄非线性增大以及每隔一段时间突然给粒子一个横向爆发的力模拟火焰“挣开”束缚的分叉感。我在update里增加了一个随机概率事件// 随年龄增大横向扰动变得更突然 if (age 0.45 Math.random() 0.02) { this.vx (Math.random() - 0.5) * 1.2; }这样火焰会不时有一个粒子突然横向弹开随后又被浮力拉回视觉上就出现火舌分裂的现象。做出来后我对比了一下整体火焰形态比普通粒子系统更“野”不会那么死板。想要动态调节喷射方向可以把角度和速度做成公开变量监听鼠标或触摸事件来更新。我用过一次的代码是把喷口跟随鼠标位置移动喷射方向固定指向鼠标移动方向这样火焰喷射角度会实时变化互动感一下就上来了canvas.addEventListener(mousemove, (e) { let rect canvas.getBoundingClientRect(); nozzleX (e.clientX - rect.left) * dpr; nozzleY (e.clientY - rect.top) * dpr; });3.3 性能优化经验与参数调优表火焰效果本身非常适合讨巧但如果细节做过头低端设备立刻原形毕露。我做这版火焰喷射时有几条优化心得第一对象复用。基础版每帧new Particle粒子死了就splice删除这在持续喷射场景下会产生大量垃圾对象。改成对象池以后死亡粒子回收到数组新粒子优先从池中取GC压力小很多。实测在240粒子规模下GC次数从每几秒一次降到几乎为0。第二Canvas分层。火焰拖尾的半透明黑底填充不需要每帧覆盖全屏。可以做一个离屏Canvas专门负责火焰渲染再合成到主画布上避免大面积fillRect操作。如果是背景火焰装饰直接把火焰Canvas设成全屏不再额外拼主画布。第三阴影效果谨慎使用。我为了发光效果给每个粒子设置了shadowBlur这个操作非常贵。优化方式是预先把一个带光晕的粒子形状绘制到一个小尺寸离屏Canvas上然后用drawImage快速绘制大幅减少Canvas状态切换和阴影计算开销。我还整理了一张调参表方便你按需调节火焰形态参数影响推荐区间每帧发射数量火焰密度1~4初速度喷射距离3~10浮力vy - 值火焰上升高度0.03~0.08阻尼系数火焰飘散长度0.96~0.99粒子寿命整体火焰长度40~120帧粒子尺寸火焰粗细4~16湍流强度火焰卷曲程度0.1~0.3建议在修改时用“一次只动一个参数”的原则否则很容易调出一团乱麻。比如你想让火焰更长优先加大粒子寿命想让火焰更粗优先加大粒子数量和尺寸而不是去修改浮力。4. 常见问题与排查技巧实录4.1 火焰整体发黑第一次跑火焰代码很多人都会碰到这个情况粒子画上去是灰色的完全没有火焰的鲜亮感。这通常是两个原因叠加的结果。第一全局合成模式设置错误。如果忘记把globalCompositeOperation设成lighter火焰粒子叠在一起就是普通alpha混合区域重叠后颜色变暗而不是变亮灰黑感随之而来。第二颜色映射时过度依赖黑色底。黑色背景配合暗色粒子比如life快结束时饱和度降低到60、亮度25粒子虽然天然应该变暗但如果暗部占比太大整体看起来就是一团脏黑色。排查时先看火焰中段如果中段是明显的橙红亮色说明合成模式没问题如果中段也灰扑扑的优先检查globalCompositeOperation。再看尾部尾部暗色是正常的但暗色不能是纯黑可以用暗红或者深棕更贴近烟的颜色。4.2 帧率不稳、有卡顿火焰粒子系统卡顿98%的情况都出在粒子数量失控上。你可能会发现粒子明明设置了上限但火焰密度还是会越来越大——这通常是粒子的寿命太长或者说发射速率大于消亡速率导致活跃粒子数持续顶在上限附近并且每一帧更新和绘制都要遍历很多粒子。我的处理方式是分开统计两类指标发射速率每帧不超过3个粒子寿命最好控制在80帧以内。如果这两个条件都满足即便粒子尺寸大性能也差不到哪去。还有一个容易被忽视的点是Canvas尺寸过大。在很多情况下Canvas CSS尺寸是正常的但内部width/height没做dpr换算导致实际绘制分辨率是CSS分辨率的好几倍粒子绘制开销成倍上涨。加上dpr适配并把dpr上限设成2是个性价比极高的优化。4.3 火苗一簇一簇的不连贯这个现象特别容易出现火焰不是连续的“喷射”而是一团一团往外吐。根因在于发射逻辑的时间分布不均。如果你把发射写成setInterval而渲染循环又用requestAnimationFrame两者之间的节奏会对不上导致某些帧瞬间生成很多粒子某些帧又颗粒无收。正确做法是把发射逻辑放到requestAnimationFrame循环内部而不是单独用setInterval。我在实操中就是把emitParticle()放在tick()函数最前面每帧按固定数量发射。这样发射和渲染在同一时间基准下粒子分布自然均匀。如果你希望在某一帧多喷、某一帧少喷也是在这个框架内做随机而不是另起一套定时器。4.4 移动端触摸屏幕时火焰变成一片马赛克用鼠标监听做互动时移动端必须加上触摸事件。这本身不是大问题真正的坑出现在坐标系的换算上。Canvas内部尺寸经过dpr缩放后鼠标事件给的clientX和clientY是CSS像素坐标直接拿来当粒子坐标喷口位置会和手指触摸的位置对不上火焰就会偏移甚至闪烁。我的统一解法是写一个getPos(e)函数把事件坐标统一转换成Canvas内部坐标function getPos(e) { let rect canvas.getBoundingClientRect(); let clientX, clientY; if (e.touches) { clientX e.touches[0].clientX; clientY e.touches[0].clientY; } else { clientX e.clientX; clientY e.clientY; } return { x: (clientX - rect.left) * dpr, y: (clientY - rect.top) * dpr }; }这个坑我踩了不止一次。很多动效在桌面端完美一上手机就错位十有八九都是坐标系没归一。4.5 火焰突然变成“瀑布”往下流浮力参数区域调节不当把vy - 0.05改成vy - 0.005火焰的上升力就几乎没了粒子全被重力拽下去看起来像倒流的火。遇到这种情况不要慌把阻尼系数和浮力一起检查。因为浮力是每帧累加的如果阻尼太大比如0.99以上浮力增量就会被阻尼消解粒子同样上不去。建议浮力先给0.04阻尼给0.985有基础效果后再微调。这组参数在我多个项目里跑都比较稳。还有一个小细节粒子的初速度如果是纯水平方向没有向上分量即便浮力正常火焰的喷射段也会太平直。喷射角度建议还是带着一定的仰角像我基础版里给的-0.35弧度就是一个开局不错的视角。5. 从火焰喷射到更多动效扩展火焰喷射效果做完后我心里最大的感受是粒子系统真的是一个“一次学会到处复用的万能工具箱”。喷火器的核心代码改一改初速度方向和颜色映射就能变出很多效果。比如把颜色映射改成冷色调青色、蓝色把浮力方向从向上改成水平或向下就变成了冰霜喷射或水流效果。粒子尺寸和寿命做小就变成了火星迸溅或电子火花效果。发射器位置改成绕原点旋转加上周期性发射脉冲就能模拟旋转喷泉或烟花。其实很多经典动效库底层的粒子引擎并没有多深奥真正花功夫的是针对特定效果调参和设计颜色渐变。这版代码我后来继续扩展过几个方向都比较实用把喷射角度绑到鼠标拖拽方向做喷火互动小游戏把粒子大小改成随机对数分布让火焰里有大颗火星和细小碎屑并存层次更丰富把Canvas黑色填充改成带径向暗角的纹理营造篝火在夜晚氛围下的光照效果。我个人在实际调试中的体会是火焰效果的“真实感曲线”并不是线性上升的它有一个临界点——当粒子数量、浮力、湍流三个参数达成某种平衡时视觉效果会突然从“可以接受”跳到“很惊艳”。这个临界点很难用语言精确描述建议你拿到代码后用半小时专门做参数实验改一组数值就跑一下看效果。纸上得来终觉浅火焰效果的调参手感靠的就是反复烧那一堆粒子的量变积累。如果你需要把这套火焰喷射嵌入实际业务项目还有一个额外提醒尽量做成独立的Canvas组件并做好事件监听器的清理和动画循环的终止控制。很多页面在路由切换后Canvas动画还在后台持续运行白白消耗性能。封装成一个支持start()和stop()的函数式组件比散落在全局的代码要稳妥得多。
返回列表