☰
用JavaScript粒子系统实现Canvas火焰喷射特效:从原理到性能优化
2026/10/5 7:28:26 网站建设 项目流程

这几天帮朋友调一个网页项目,需求很直接:在页面上加一个火焰喷射的特效。最开始我图省事,想找个现成的GIF一贴了事,结果被透明通道、固定尺寸、边缘锯齿这些问题折腾得够呛。后来一咬牙,直接用JS写了一版粒子系统,效果反而自然得多,而且还能配合鼠标交互动态喷射。

这篇文章就把我打磨这套火焰喷射JS代码的完整过程记录下来。从最基础的Canvas粒子原理,到颜色渐变、叠加混合、性能优化,再到实际调试时踩过的坑,都会展开讲。你照着敲一遍,就能在自己的页面里喷出一股有模有样的火,想喷多久喷多久。

1. 火焰特效的核心思路:为什么选粒子系统

1.1 视频与GIF方案的三个硬伤

我刚接到需求时,第一反应是找现成素材。但实际一测就发现了问题:

第一,透明通道不好处理。网上下的火焰GIF大多是黑底或者绿幕,要抠出来得做色键,边缘容易带一圈白边或者绿边,放到深色背景上尤其明显。

第二,尺寸和方向锁定。GIF的分辨率是固定的,想喷射得又细又长,素材就得放大,一放大概率糊掉。想要火焰自然摆动、受到鼠标位置影响,素材动画根本做不到。

第三,体积和加载成本。一段几秒钟的高清循环视频动辄几兆,为了一个页面装饰拖慢首屏加载,得不偿失。

所以最后我决定放弃素材路线,直接用代码生成。火焰本质上是一堆高温粒子的集合,用JS生成、更新、绘制粒子,是最灵活也最可控的方式。

1.2 粒子系统为什么能模拟火焰

粒子系统的核心思想,是把一个复杂视觉现象拆成大量微小个体的运动。每一个粒子只负责一小团光斑,但成百上千个粒子叠加起来,就能呈现出喷射、扩散、上升、熄灭的连续过程。

火焰这个现象特别适合粒子系统模拟,因为它的规律性很强:火焰从喷射源涌出,带着初速度往上冲,过程中受空气阻力减速、发生扰动,颜色从内焰的白黄过渡到外焰的橙红,最后变暗消失。这些特征都能用粒子的位置、速度、生命周期、颜色来一一对应。

用JS做粒子系统,渲染层建议选Canvas 2D。不要一上来就上WebGL,虽然WebGL性能上限更高,但开发复杂度也高得多。Canvas 2D的API简单直观,圆形、径向渐变、半透明叠加都支持得很好,单帧几百个粒子的火焰渲染,优化后跑满60帧完全没问题。

2. 搭建最小火焰喷射框架

2.1 Canvas初始化与画布尺寸处理

先写一个最小的HTML骨架。CSS里把margin归零、隐藏滚动条,Canvas铺满视口,背景用深色,这样火焰亮色衬出来才明显。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>火焰喷射效果</title> <style> body { margin: 0; overflow: hidden; background: #0b0b10; } canvas { display: block; } </style> </head> <body> <canvas id="fire"></canvas> <script> // ... JS 代码 </script> </body> </html>

初始化Canvas时,有个细节新手常忽略:要把画布宽度高度设成视口大小,否则Canvas默认是300x150,画出来就只有一小块。

const canvas = document.getElementById('fire'); const ctx = canvas.getContext('2d'); let W = canvas.width = window.innerWidth; let H = canvas.height = window.innerHeight;

这里直接赋值给canvas.width和canvas.height,注意这跟CSS里写width、height不一样。Canvas宽高属性决定的是绘图缓冲区尺寸,CSS尺寸决定的是页面显示尺寸。两者不一致时,绘图会按比例缩放,可能出现模糊,后面第4部分我会讲DPR适配,那是另一层优化。

2.2 粒子对象的设计:一个粒子的完整生命周期

粒子类的字段不需要太多,但每一个都有明确作用。我的最小版本是这样:

class Particle { constructor(x, y, angle) { // 喷射位置 this.x = x; this.y = y; // 初速度方向 const speed = 4 + Math.random() * 5; const spread = (Math.random() - 0.5) * 0.35; this.vx = Math.cos(angle + spread) * speed; this.vy = Math.sin(angle + spread) * speed; // 生命周期 this.life = 1; this.decay = 0.008 + Math.random() * 0.008; // 粒子半径 this.size = 8 + Math.random() * 14; } update() { this.x += this.vx; this.y += this.vy; // 空气阻力:速度缓慢衰减 this.vx *= 0.985; this.vy = this.vy * 0.985 - 0.015; // 生命衰减,逐渐熄灭 this.life -= this.decay; } draw() { const alpha = Math.max(0, this.life); ctx.fillStyle = `rgba(255,150,30,${alpha * 0.6})`; ctx.beginPath(); ctx.arc(this.x, this.y, this.size * alpha, 0, Math.PI * 2); ctx.fill(); } }

这里的核心概念是"喷射角度"和"扰动脉宽"。火焰不是一条直线喷出去的,而是一个圆锥体。angle是中心喷射方向,spread是每一颗粒子偏离中心的角度偏移量。偏移量越大,火柱越粗,但太大会散掉,我一般控制在正负0.2弧度以内。

2.3 主循环、发射控制与拖尾效果

发射逻辑放在主循环里。每帧喷射几颗粒子,但不能无限增加,得设一个上限。这里MAX_PARTICLES我设成300,后面优化时再谈这个值的意义。

const particles = []; const MAX_PARTICLES = 300; function spawn() { const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.2; const p = new Particle(W / 2, H - 20, angle); particles.push(p); } function loop() { // 半透明填充,形成拖尾 ctx.fillStyle = 'rgba(11, 11, 16, 0.25)'; ctx.fillRect(0, 0, W, H); if (particles.length < MAX_PARTICLES) { for (let i = 0; i < 3; i++) spawn(); } for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.update(); if (p.life <= 0) { particles.splice(i, 1); continue; } p.draw(); } requestAnimationFrame(loop); } loop();

这段代码最关键的是那个半透明的fillRect。它用一层很淡的不透明度覆盖上一层画面,让旧粒子慢慢消失在背景里,产生拖尾感。如果用clearRect清除整块画布,上一帧直接消失,火焰就会不停闪烁,完全没有连续感。

angle = -Math.PI / 2表示向上喷射。Canvas坐标系里,y轴向下为正,所以向上是负y方向,对应角度就是-90度。很多新手在这里写反,火焰直接喷向地面,我调的时候也栽过一次。

3. 把代码进化成"真火焰":颜色渐变与混合

3.1 火焰颜色为什么不能只用一种橙

如果你运行上面那版代码,会看到一团橙色的圆点往上飘。远看像个火,近看假得不行。问题出在颜色。

真实火焰的颜色分布是分层的:内焰温度最高,接近白黄色;中层是亮黄;外焰逐渐变成橙红;再往边缘温度下降,变成暗红然后熄灭。如果所有粒子都用同一个rgba颜色,火焰就变成均匀的色块,没有温度层次。

解决思路是以粒子的life值作为"温度"代理。life从1衰减到0,就代表粒子从高温到熄灭的完整过程。我写了一个颜色插值函数,把生命阶段映射到多段颜色:

function flameColor(t) { if (t > 0.75) { // 内焰:白黄 const k = (t - 0.75) / 0.25; return [255, 255 - (1 - k) * 40, 220]; } else if (t > 0.5) { // 中焰:亮黄 const k = (t - 0.5) / 0.25; return [255, 220 - (1 - k) * 40, 80]; } else if (t > 0.25) { // 外焰:橙红 const k = (t - 0.25) / 0.25; return [255, 120 * k, 20]; } else { // 余烬:暗红 const k = t / 0.25; return [180 * k, 30 * k, 5 * k]; } }

调用时把返回的RGB组合成rgba字符串。注意这个函数里我用了固定的数学插值,实际使用时可以再调成曲线插值,但线性分段已经够用了。

3.2 叠加模式lighter:火焰亮起来的秘密

颜色分段之后火焰有了层次,但还是不够亮。原因在于Canvas默认的混合模式是source-over,也就是半透明圆点叠加时,后来的像素会直接覆盖前面的像素。火焰这种大量粒子重叠的场景,覆盖式混合会让中间变暗,甚至发灰。

解决方法是把混合模式改成lighter。lighter模式也叫加法混合,重叠区域的RGB值直接相加,越叠越亮,白色区域接近过曝——这恰好是真实火焰的特征,核心区域亮得发白,边缘逐渐衰减。

在粒子绘制前设置一次:

ctx.globalCompositeOperation = 'lighter';

画出所有粒子后,再恢复默认模式:

ctx.globalCompositeOperation = 'source-over';

配合半透明fillRect做拖尾时有个坑:拖尾层的填充色是深色背景,如果全屏都开lighter模式,背景填充本身也会被加法计算,反复叠加后整个背景会越来越亮。正确做法是拖尾fillRect用默认模式,粒子draw阶段单独开lighter。

3.3 湍流与细节:让火苗真正"动"起来

颜色到位了,但火焰还是直直往上飘,少了那种摇摆的动感。真实火焰会受到气流扰动,火苗会摆动、卷曲,甚至产生分叉。

我给粒子的速度增加一个周期性扰动,模拟湍流效果。最省性能的做法是叠加正弦波:

update(time) { this.x += this.vx; this.y += this.vy; this.vx *= 0.98; this.vy = this.vy * 0.98 - 0.02; // 横向摆动 this.x += Math.sin(time * 0.01 + this.seed) * 0.3; // 纵向波动 this.vy += Math.cos(time * 0.015 + this.seed * 2) * 0.02; this.life -= this.decay; }

这里time是累计运行时间,seed是每个粒子初始化时随机生成的相位偏移量。没有seed的话,所有粒子同一帧摆动方向和幅度都一致,会出现"整团火一起抖"的僵硬感。每个粒子都有自己的相位,摆动才是错落的,火焰才有自然的涡旋感。

还有一个小细节:渲染粒子时不一定要用实心圆。用径向渐变画粒子,中心亮、边缘淡,火焰会更有体积感。渐变粒子创建成本高,可以预渲染到一个离线Canvas上,绘制时直接drawImage,这个优化我在第4部分详细说。

4. 性能优化:从20帧到60帧的实战记录

4.1 对象池与粒子回收

第一版代码跑起来,粒子数300,帧率只有二十几帧。我开DevTools的Performance面板看了下,主要耗时在两部分:构造函数里Math.random的频繁调用,以及数组splice删除时的元素搬移。

优化手段是引入对象池。先初始化一个池子,存N个空闲粒子对象。需要发射时,从池子里取一个空闲对象,重新赋值字段;粒子生命结束后,不销毁对象,而是标记为空闲放回池子。

class ParticlePool { constructor(size) { this.pool = []; this.active = []; for (let i = 0; i < size; i++) { this.pool.push(new Particle(0, 0, 0)); } } get() { const p = this.pool.pop() || new Particle(0, 0, 0); p.life = 1; this.active.push(p); return p; } release(p) { p.life = 0; this.pool.push(p); } }

核心好处是减少了GC压力。JVM风格的垃圾回收机制在JS里一样适用,频繁创建销毁对象会触发GC,GC一跑主线程就卡,火焰就开始掉帧。用对象池后,对象总量恒定,内存稳定,帧率曲线平滑很多。

4.2 去掉splice:swap-pop换来的性能提升

另一个被忽略的性能点是数组删除。粒子数组循环里用splice(i, 1),删除中间元素时,后面所有元素都要往前挪,最坏情况是O(n)复杂度。粒子多的时候,这个开销非常可观。

改用swap-pop技巧:把最后一个元素拷贝到当前删除位置,然后pop掉末尾。虽然元素顺序会变,但粒子本身不需要稳定顺序。

for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.update(); if (p.life <= 0) { particles[i] = particles[particles.length - 1]; particles.pop(); pool.release(p); continue; } p.draw(); }

倒序遍历加上swap-pop,删除变成O(1)操作。粒子数量大时,这一项能让主线程单帧耗时下降好几毫秒。

4.3 离屏Canvas预渲染光斑

如果粒子用arc加fillStyle画实心圆,每帧要设置颜色、创建路径、填充,几百个粒子就有几千次绘图操作。Canvas小对象绘制的性能瓶颈主要在路径创建和状态切换上。

优化思路是:在内存里建一个离屏Canvas,预渲染一个带径向渐变的"光斑"图案,粒子绘制时直接用drawImage把整个光斑贴上去。drawImage的GPU加速效果远好于arc加fill的组合。

const spriteCanvas = document.createElement('canvas'); spriteCanvas.width = 64; spriteCanvas.height = 64; const spriteCtx = spriteCanvas.getContext('2d'); const grad = spriteCtx.createRadialGradient(32, 32, 0, 32, 32, 32); grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(0.4, 'rgba(255,200,50,0.8)'); grad.addColorStop(1, 'rgba(255,50,0,0)'); spriteCtx.fillStyle = grad; spriteCtx.fillRect(0, 0, 64, 64);

绘制时设置好尺寸:

const s = p.size * p.life * 2; ctx.drawImage(spriteCanvas, p.x - s / 2, p.y - s / 2, s, s);

注意drawImage缩略图到很小尺寸时,边缘可能产生锯齿,尤其是粒子快熄灭时s接近0。我给s设个最小值,比如1.5,避免缩放太小导致闪烁。

4.4 高DPI屏幕适配

默认Canvas在Retina屏上会发虚,像蒙了一层雾。原因是显示器的物理像素密度高于CSS像素,Canvas按CSS像素绘图,会被拉伸放大。

适配方式是让Canvas的绘图缓冲区尺寸等于物理像素尺寸,再用scale缩放上下文:

const DPR = window.devicePixelRatio || 1; canvas.width = W * DPR; canvas.height = H * DPR; ctx.scale(DPR, DPR); // 之后所有坐标都按CSS像素写,无需关心DPR

体积小的优化项,但对观感影响巨大。不处理的话,颗粒子和细火焰在Retina屏上会糊成一片。

5. 实战问题排查:我踩过的那些坑

5.1 火焰颜色发灰、不够亮

这是我调试时遇到最多的问题。表现是粒子叠在一起后中间区域变暗,一副脏脏的样子。

原因大概率是混合模式错误。确认粒子绘制阶段设置了ctx.globalCompositeOperation = 'lighter',并且拖尾fillRect阶段确实恢复成了source-over。如果两个阶段都开了lighter,背景会越刷越亮,最后整屏泛白。

另一种情况是颜色值设置过暗。火焰亮部RGB应该接近255,255,200,很多人习惯用255,100,50,画出来就是暗橙色,没有高温感。

5.2 粒子上冲太快,火苗断成几截

调整初速度数值时我踩过这个坑。speed给到10以上,粒子几帧就飞出画面,火焰看起来像喷了一条直线,没有完整的火舌形态。

正确的做法是让速度分布范围更大一点,slow粒子负责火舌主体,fast粒子负责飞溅的火星。我最后的配置是:

  • 主体粒子:speed在3到5之间,生命周期衰减慢,形成长火舌
  • 火星粒子:speed在6到9之间,生命周期衰减快,飞出后迅速熄灭

两类粒子混合发射,火焰既有稳定的根部,又有一闪而过的火星,层次感好很多。

5.3 粒子消失时闪烁,尾巴不连贯

粒子衰减到生命值接近0时,alpha和size都乘了一个接近0的系数,绘制出来的光斑极小极淡,但屏幕上的表现不是平滑消失,而是突然没了,甚至出现抖动。

这是因为线性衰减在视觉上不是线性感知的。我的处理方式是给alpha和size都加一个下限:

const alpha = Math.max(0, p.life); const s = Math.max(1.5, p.size * p.life * 2);

另外,拖尾填充的透明度也影响连贯性。fillStyle的alpha值在0.2到0.3之间比较合适,太高会留下明显的分界,太低拖尾就消失了。

5.4 常见问题速查表

现象原因解决办法
火焰发灰、中间暗未用lighter混合或颜色值过暗粒子阶段设置globalCompositeOperation='lighter'
背景越刷越亮拖尾fillRect也开了lighterfillRect阶段恢复source-over
火苗生硬、像圆点颜色单一、无渐变使用多段颜色插值函数
断帧、卡顿频繁new/delete粒子引入对象池,swap-pop删除
粒子消失闪烁alpha/size降至0过小而闪烁设置最小绘制尺寸1.5px
Retina屏模糊未做DPR适配Canvas宽高乘以devicePixelRatio
火焰摆动整齐僵硬所有粒子扰动相位相同每个粒子记录随机seed偏移

5.5 移动端与低端设备策略

移动端GPU性能远弱于桌面,粒子数要动态降级。我在代码里用一个quality参数控制粒子上限:

const quality = window.innerWidth < 768 ? 120 : (window.innerWidth > 1600 ? 500 : 300);

另外注意iOS Safari对Canvas有纹理上限,单个Canvas尺寸太大也可能掉性能。移动端尽量维持Canvas尺寸不超过设备屏幕物理分辨率,DPR适配时取min(devicePixelRatio, 2),没必要上3倍甚至更高。

还有一个移动端特有的坑:touch事件和mouse事件要分开处理。如果火焰喷射位置跟随鼠标/手指移动,触屏上只监听mousemove是没反应的,需要加touchmove,并且记得在touch事件里调用preventDefault,否则页面会跟着滚动。

6. 喷射控制的交互扩展

6.1 用鼠标控制喷射方向

静态火焰已经能看,但加一点交互,视觉效果马上不一样。最常见的玩法是让火焰跟着鼠标位置喷。

修改spawn函数,让它使用鼠标坐标作为发射源:

let mouseX = W / 2; let mouseY = H / 2; canvas.addEventListener('mousemove', (e) => { mouseX = e.clientX; mouseY = e.clientY; }); function spawn() { const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.2; const p = new Particle(mouseX, mouseY + 10, angle); particles.push(p); }

这样鼠标移到哪里,火焰就从哪里冒出来。如果再想加方向控制,计算鼠标移动方向作为喷射角度,粒子会像喷火器一样拖出一条彩带,效果更炸。

6.2 键盘与手柄扩展

更进阶的玩法是把喷射方向绑定到键盘方向键或者手柄摇杆角度,作为游戏技能使用。这个场景下,粒子系统需要每帧读取外部输入状态,而不是依赖事件回调。

我一般维护一个inputState对象:

const inputState = { left: false, right: false, up: false, down: false };

然后主循环中实时计算喷射角度:

let angle = -Math.PI / 2; if (inputState.left) angle -= 0.3; if (inputState.right) angle += 0.3;

这样火焰始终指向按键方向,形成稳定的控制的喷流,适合做拖尾、爆炸、灼烧等游戏技能特效。

最后分享一个小技巧

调火焰参数的时候,我发现最有效的做法不是反复改代码里的硬编码数值,而是把速度、衰减、颜色分段、粒子数全部抽到一个config对象里:

const CONFIG = { speedMin: 3, speedMax: 5, decay: 0.008, spread: 0.2, maxParticles: 300, gravity: -0.015, turbulence: 0.3 };

然后用lil-gui这类调试工具挂到页面上,滑着调参数。看到什么效果觉得好,再把数值锁死进正式版本。这种可视化调试比盲改代码效率高太多了。

火焰这个效果看着花哨,但拆解到粒子、颜色、混合、优化这几步之后,逻辑非常清晰。你按照这套流程走一遍,不仅能做出火焰喷射,粒子爆炸、烟雾、水流也都能举一反三实现。真遇上参数怎么调都不对的情况,优先检查颜色混合和生命周期衰减,这两个是最容易出问题的环节。

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

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

立即咨询