ARTICLE DETAIL

资讯详情

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

用纯JavaScript与Canvas粒子系统从零实现火焰喷射特效

用纯JavaScript与Canvas粒子系统从零实现火焰喷射特效 试做网页特效的人多半都遇到过这个需求想给页面加一簇跳跃的火焰或者给一个小游戏补上“喷火”技能。第一反应往往是去找火焰序列帧图片甚至有人直接贴一张GIF。但真把需求做进去就会发现序列帧体积大、方向固定、和场景互动生硬一张立着的火苗图怎么看都像贴纸。我最终用的是纯JavaScript加上Canvas粒子系统从零手写了一个火焰喷射效果。这篇文章就把整个实现过程拆开来讲火焰为什么能用粒子模拟、每个参数到底在控制什么、写代码时哪些地方最容易翻车以及怎么样才能让它在多数人的电脑和手机上稳定跑出60帧。适合刚入门Canvas、想做网页动效或给H5小游戏加技能特效的同学也适合那些想理解粒子系统原理但不打算直接上WebGL的朋友。1. 火焰喷射的视觉本质一堆会“退温”的发光粒子想用代码画出火先得搞明白人眼看到的火焰到底是什么。真实火焰是高温气体和燃烧产物的发光区域从喷口冲出后向外扩散、向上飘、逐渐冷却颜色从亮白到橙黄再到暗红最后熄灭变成淡淡的烟。这一连串变化用静态图片很难还原但用几百个报告着“寿命”的粒子叠加效果反而非常接近。1.1 粒子系统的三个核心关系粒子系统里每一个粒子都只是一个普通对象但它必须维护以下三类数据运动状态位置x、y、速度vx、vy、加速度。生命状态出生时间、当前剩余寿命、总寿命比例。渲染状态温度颜色、大小、透明度。火焰粒子的特殊之处在于它的颜色变化不是由美术洗出来的关键帧决定的而是由“剩余寿命比”实时计算出来的。粒子刚从喷射口出来时寿命接近满值颜色是亮黄偏白随着寿命衰减颜色过渡成橙、红、暗红透明度也逐渐降为0。这和现实中火焰冷却变暗的过程在数学上是对应的。1.2 为什么不能用序列帧和CSS动画代替序列帧火焰的问题是状态离散。一帧一帧切换图片只能做到“看起来在烧”但火焰和鼠标、物体、气流之间的关系完全无法联动。CSS动画更是只能做简单的抛物线轨迹火焰该有的湍流扩散、温度退变、粒子交汇叠加这些效果CSS都做不出来或者说要写一大堆keyframes性能和维护成本反而更高。Canvas粒子系统的优势在于火焰是动态生成的每一帧都在根据物理参数重新计算喷射方向、强度、颜色、范围都可以随时改。这一点对交互场景来说几乎是必须的。后面我做的鼠标跟随喷射、键盘控制喷射方向都是在粒子系统的基础上加了两行代码的事。2. 工程准备与最小可用核心逻辑一个Canvas就能跑快速进入正题。先搭一个最小的可运行骨架然后在这个骨架上一层层加东西。2.1 HTML与基础样式不需要任何框架一个canvas标签加一段JavaScript就够。页面设置成全屏黑底这样火焰的发光效果最明显。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title火焰喷射粒子系统/title style html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0a1a; } canvas { display: block; width: 100vw; height: 100vh; } /style /head body canvas idfireCanvas/canvas script src./fire.js/script /body /html2.2 粒子对象与发射器的数据结构先定义两个基础概念粒子Particle和发射器Emitter。发射器负责“造粒子”决定火焰从哪里喷、初始速度往哪走粒子负责“演完一生”从出生到死亡的位置和颜色。// fire.js const canvas document.getElementById(fireCanvas); const ctx canvas.getContext(2d); let particles []; let mouse { x: 0, y: 0 }; function resizeCanvas() { const dpr window.devicePixelRatio || 1; canvas.width window.innerWidth * dpr; canvas.height window.innerHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener(resize, resizeCanvas); resizeCanvas(); function createParticle(origin, angle, speed) { // 给粒子加一点随机速度抖动让火焰不是一条僵硬的直线 const spread (Math.random() - 0.5) * 0.6; const vx Math.cos(angle spread) * speed * (0.7 Math.random() * 0.6); const vy Math.sin(angle spread) * speed * (0.7 Math.random() * 0.6); return { x: origin.x, y: origin.y, vx: vx, vy: vy, life: 1, // 剩余寿命比例1 → 0 decay: 0.008 Math.random() * 0.004, // 每帧衰减速率 size: 8 Math.random() * 12, hueShift: Math.random() * 20 // 细微色差避免颜色过于统一 }; } function emitFlame(origin, angle, speed, count) { for (let i 0; i count; i) { particles.push(createParticle(origin, angle, speed)); } }这里的createParticle函数里有两个关键点值得单独说明spread变量产生±0.3弧度左右的随机角度偏移。没有它火焰会变成一条细细的激光不像喷射的火焰。速度乘以0.7到1.3之间的随机系数是为了让粒子有快有慢。真实火焰内部各股气流的推进速度并不一致这种速度差异会让火焰边缘出现“撕裂感”。2.3 主循环更新与渲染分离渲染循环用requestAnimationFrame而不是setInterval。原因很简单rAF由浏览器vsync驱动帧率和屏幕刷新率同步不会因为定时器不准导致掉帧或撕裂。function updateParticle(p) { p.x p.vx; p.y p.vy; // 模拟空气阻力用摩擦系数让粒子减速火焰喷出后会逐渐无力 p.vx * 0.98; p.vy * 0.98; // 模拟上升热气流给一个很弱的向上的加速度让火焰有飘升感 p.vy - 0.02; // 寿命衰减 p.life - p.decay; } function drawParticle(p) { // 这里只做占位颜色渐变逻辑下一节详细讲 const alpha Math.max(p.life, 0); const size p.size * p.life; // 粒子越小越接近熄灭 ctx.globalAlpha alpha; ctx.fillStyle #ff8800; ctx.beginPath(); ctx.arc(p.x, p.y, size, 0, Math.PI * 2); ctx.fill(); } function frame() { // 用半透明黑色覆盖上一帧形成拖尾效果 ctx.globalCompositeOperation source-over; ctx.globalAlpha 0.25; ctx.fillStyle #0a0a1a; ctx.fillRect(0, 0, canvas.width, canvas.height); // 火焰主体使用“lighter”混合让重叠粒子区域变亮 ctx.globalCompositeOperation lighter; // 喷射源默认跟着鼠标 const angle -Math.PI / 2; // 向上喷 emitFlame(mouse, angle, 3.2, 3); // 更新并绘制所有粒子 particles particles.filter(p { p.life - 0; updateParticle(p); if (p.life 0.02) return false; drawParticle(p); return true; }); requestAnimationFrame(frame); } canvas.addEventListener(mousemove, (e) { mouse.x e.clientX; mouse.y e.clientY; }); requestAnimationFrame(frame);这段代码跑起来后已经能看到一串橙色粒子向上喷射。但这离“火焰”还有不小距离——目前每个粒子只是一个橙色圆点叠在一起时颜色没有过渡看起来更像一串橙色的吐司面包在往上飞。接下来要解决的就是火焰的灵魂温度退变。3. 火焰的温度退变从亮白到暗红的颜色渐变火焰之所以看起来有层次是因为不同位置的粒子温度不同。喷口附近的粒子温度最高颜色接近白色或亮黄稍微远一点变成橙黄色再远一点变成橙红色最后进入暗红、灰烟状态。要实现这个效果需要把颜色color和寿命life绑定起来。3.1 温度颜色表的插值方案最简单的方案是准备一个颜色数组从白到黄到橙到红到暗红然后根据粒子的life比例在数组里取色。但直接用离散颜色会导致火焰一层一层的像要脱壳。更稳的做法是用线性插值lerp在温度颜色之间连续过渡。我来定义一个温度色标它不是一个数组而是一组锚点颜色每个锚点代表一个“温度阶段”。粒子的life值1→0从高到低对应温度从高到低。// 火焰色标[life比例, [r, g, b]] const FIRE_COLORS [ [1.0, [255, 246, 200]], // 亮白黄高温核心 [0.7, [255, 180, 40]], // 橙黄 [0.45, [255, 100, 20]], // 橙红 [0.2, [160, 30, 10]], // 暗红 [0.0, [30, 10, 10]] // 接近烟灰 ]; function getFireColor(lifeValue) { const t Math.max(0, Math.min(1, lifeValue)); for (let i 0; i FIRE_COLORS.length - 1; i) { const [t1, c1] FIRE_COLORS[i]; const [t2, c2] FIRE_COLORS[i 1]; if (t t1 t t2) { const local (t - t1) / (t2 - t1); return { r: Math.round(c1[0] (c2[0] - c1[0]) * local), g: Math.round(c1[1] (c2[1] - c1[1]) * local), b: Math.round(c1[2] (c2[2] - c1[2]) * local) }; } } return FIRE_COLORS[FIRE_COLORS.length - 1][1]; }注意这里的life值我取的是剩余寿命比例1代表刚出生0代表即将熄灭。getFireColor会在每一帧绘制时被调用计算量不大几百个粒子完全应付得过来。3.2 用globalCompositeOperation让火焰“发光”Canvas的globalCompositeOperation属性很多人只在处理裁剪时用过但火焰这类效果必须用“lighter”。它的原理是颜色叠加当多个半透明的像素重叠在同一区域时R、G、B三个通道分别相加叠加越多越亮。这就是火焰发光的秘密中间区域粒子数量多叠加后亮度高边缘粒子稀疏亮度低。不需要任何发光滤镜自然形成中心亮、边缘暗的立体感。function drawParticle(p) { const life Math.max(p.life, 0); const color getFireColor(life); const size p.size * (0.4 0.6 * life); const alpha Math.min(1, life * 1.6); ctx.globalAlpha alpha; ctx.fillStyle rgb(${color.r}, ${color.g}, ${color.b}); ctx.beginPath(); ctx.arc(p.x, p.y, size, 0, Math.PI * 2); ctx.fill(); }到这里火焰已经有温度渐变和发光效果了。但你会发现火焰虽然好看形状还不太对——太圆了。真实火焰是长条形的喷口处窄往前延伸时边缘会扩张。这是由喷射速度和扩散角度决定的下一步就调这个。3.3 火焰独有的“湍流扰动”火焰不是一架笔直的喷气它会有无规律的抖动。在粒子运动里加一点低频噪声效果会立刻不一样。常见做法是在每个粒子的速度上叠加一个随时间变化的微小扰动。let timeOffset 0; function updateParticle(p) { timeOffset 0.001; // 湍流扰动模拟火焰被空气卷动的效果 const turbX Math.sin(p.y * 0.02 timeOffset) * 0.05; const turbY Math.cos(p.x * 0.02 timeOffset) * 0.04; p.vx turbX; p.vy turbY; p.x p.vx; p.y p.vy; p.vx * 0.98; p.vy * 0.98; p.vy - 0.015; p.life - p.decay; }这段是我反复实验后才定下来的。之前的火焰飘动要么太整齐像丝带要么太随机像一群无头苍蝇。用位置做正弦函数的相位会让每个粒子的扰动根据它所在的位置产生关联火焰就有了“整体扭动”的感觉而不是各自乱飞。4. 喷射手感调参速度、扩散角、寿命与噪声的平衡代码写完只是第一步真正花我最多时间的是调参。火焰喷射效果好不好看70%取决于参数平衡。我把经验整理成一张参数表并说明每个参数“调大了会怎样”。4.1 核心参数对照与推荐范围参数推荐范围调大的效果调小的效果喷射速度 speed2.5 ~ 4.0火焰冲得远有冲击感火焰窝在喷口更像冒烟扩散角 spread0.2 ~ 0.8火焰发散成扇形像烟花火焰收束成柱像激光粒子衰减 decay0.006 ~ 0.012火焰变短促、颗粒感强火焰拖长但容易糊成一团粒子尺寸 size6 ~ 20火焰粗壮火焰纤细、密度显得低发射量 count2 ~ 6火力旺盛火焰稀疏上升速度 vy0.01 ~ 0.03火苗冲得高火焰贴着地面平流一开始我按照“模拟真实物理”的思路去做给粒子加了很大重力结果火焰全部往下掉。后来想明白了火焰喷射效果追求的是视觉上的“喷”和“烧”不是严格的气体动力学模拟。重力方向不对效果立马出戏。火焰需要的一方面是喷射初速另一方面是轻微的浮力让粒子在减速过程中自然上扬。4.2 喷射角度与鼠标跟随的平滑处理鼠标跟随最常见的坑是直接赋坐标导致火焰角度瞬间跳变。喷射器在鼠标坐标上鼠标快速移动时火焰会像拉面条一样被扯断。解决办法是给喷射源的位置加一层缓动lerp让火焰源点以一定速度“追”鼠标而不是瞬移。let jetPoint { x: 0, y: 0 }; let targetPoint { x: 0, y: 0 }; canvas.addEventListener(mousemove, (e) { targetPoint.x e.clientX; targetPoint.y e.clientY; }); function updateJetPoint(smoothFactor 0.15) { jetPoint.x (targetPoint.x - jetPoint.x) * smoothFactor; jetPoint.y (targetPoint.y - jetPoint.y) * smoothFactor; }smoothFactor决定跟随速度。0.15会让火焰飘逸地落在鼠标后面像一条火带1则完全瞬移。具体用多少取决于你想要的“手感”。4.3 喷射速度的主动变化推一把和喷一束想要火焰有一种“喷射”的发力感可以给发射器加一个脉冲逻辑。比如键盘按下时速度从3飙到6同时发射量瞬间翻倍。这种动态变化在游戏技能场景里很常用。let currentSpeed 3.0; const BASE_SPEED 3.0; const BUST_SPEED 6.0; let isBusting false; document.addEventListener(keydown, (e) { if (e.code Space) { isBusting true; } }); document.addEventListener(keyup, (e) { if (e.code Space) { isBusting false; } }); // 在frame()中 currentSpeed ((isBusting ? BUST_SPEED : BASE_SPEED) - currentSpeed) * 0.1;这种“指数趋近”的写法比起直接切换速度值会让火焰的喷射力度变化过程更自然——从柔火到猛火是逐渐顶上去的。5. 性能优化与移动端适配60帧是底线粒子系统最大的风险是粒子数量膨胀。如果每帧发射3个粒子每个粒子存活70帧系统里峰值就有210个粒子在同时渲染。这数量Canvas完全扛得住。但如果过渡调大参数粒子数上千帧率立刻血崩。所以性能优化不是“要不要做”而是必须提前设计的。5.1 粒子对象池消灭垃圾回收很多初学者写粒子系统用的是“每帧新建对象死了就从数组里挤出”。这在JavaScript里会造成大量短命对象引发频繁的Minor GC画面会间歇性卡顿。更好的方式是用数组模拟对象池把死掉的粒子回收复用。由于粒子寿命和内存位置无关完全可以让新粒子“顶替”死粒子的位置。const MAX_PARTICLES 1200; let particlePool []; let aliveCount 0; function getParticle() { // 从池尾取一个粒子没有活粒子的情况下活粒子数增加但限制总数 if (particlePool.length MAX_PARTICLES) { const p { x: 0, y: 0, vx: 0, vy: 0, life: 1, decay: 0.008, size: 12, color: null }; particlePool.push(p); return p; } // 所有粒子都在用就找一个寿命最短的强行回收等价于限制总量 return null; }这种做法的关键在于数组长度固定为峰值避免反复push/splice造成的数组扩容、缩容。render循环只需要更新那些life大于0的粒子到末尾再一次性把死掉的粒子“挪”到数组尾部。更彻底的池化写法会维护两个数组active活粒子和idle死粒子新粒子从idle里弹出一个重置死粒子再推回idle。这样GC压力几乎为零。5.2 渲染优化的三个隐藏杀手阴影滤镜shadowBlur是性能杀手我曾经为了提高发光效果给每个粒子设置了shadowBlur结果帧率从60瞬间掉到25。粒子数量一多shadowBlur需要做高斯模糊消耗极大。改用lighter混合模式之后视觉效果反而更好性能也回来了。全屏fillRect重复clear太冲有人在每帧开始用全透明clearRect清屏再重新铺黑。实际上用一个半透明黑色fillRect做拖尾更合适。fillRect比clearRect的代价低而且自带拖尾效果。canvas.width和height不要每帧重设重置画布尺寸会导致上下文状态重置所有canvas状态都要重新设置非常慢。只有resize时才需要重设。5.3 移动端适配DPR和粒子数量上限移动端和桌面端性能差异巨大。一个中端手机可以顺利渲染300个粒子但1200个就会发热掉帧。所以要做两套配置const isMobile window.innerWidth 768 || ontouchstart in window; const MAX_PARTICLES isMobile ? 300 : 1200; const EMIT_COUNT isMobile ? 1 : 3;另一个移动端问题出现在高分屏上。DPR为2或3的手机canvas实际像素是CSS像素的4到9倍。如果你用CSS像素尺寸绘制不乘dpr画出来的火会糊。但乘了dpr之后canvas像素总量暴增内存和渲染压力都上升。折中方案是动态缩放画布分辨率const scaleFactor isMobile ? 1 : Math.min(window.devicePixelRatio, 2); canvas.width window.innerWidth * scaleFactor; canvas.height window.innerHeight * scaleFactor; ctx.setTransform(scaleFactor, 0, 0, scaleFactor, 0, 0);这样桌面端最多按2倍渲染火焰已经很清晰移动端直接用1倍省下渲染开销。6. 踩坑实录从“跑起来了”到“在别人电脑上也跑起来了”这段是我实际经历过的故障集合。每一条都曾让我排查到深夜。6.1 devicePixelRatio导致鼠标坐标偏移问题症状火焰喷射点距离鼠标有好几像素的偏移尤其在Mac外接显示器时特别明显。原因canvas的物理宽高被设置成CSS宽高乘以DPR但鼠标坐标event.clientX是基于CSS像素坐标的两者如果不做坐标系转换鼠标位置就会在canvas内部偏移。解决方案很直接用ctx.setTransform把坐标系整体放大DPR倍之后所有绘制坐标都以CSS像素为单位鼠标坐标不用换算。这段我在前面的resizeCanvas里已经写进去了但那是一次在双显示器上调试后才记住的教训。6.2 滚动页面时火焰喷射点错位页面需要滚动时如果喷射源绑定的是文档坐标如getBoundingClientRect一旦滚动就会出现火焰和鼠标错开的现象。正确做法是始终用clientX/clientY视口坐标并且确保canvas是fixed定位。我之前改成absolute定位滚动条一场火焰源点直接飞掉半屏。老老实实把画布fixed到视口即可。6.3 事件监听器没有清理导致内存暴涨在单页应用里如果我每次切换页面都new一个火焰实例而不销毁事件监听器会越积越多内存也一路涨。解决方式是创建火焰类时返回一个destroy方法专门移除事件监听并取消requestAnimationFrame。class FireParticleSystem { constructor(canvas) { this.canvas canvas; this.ctx canvas.getContext(2d); this.rafId null; this.cleanups []; } start() { const onMove (e) this.handleMove(e); this.canvas.addEventListener(mousemove, onMove); this.cleanups.push(() this.canvas.removeEventListener(mousemove, onMove)); } destroy() { this.cleanups.forEach(fn fn()); cancelAnimationFrame(this.rafId); } }这个destroy方法在React/Vue组件卸载时调用一下内存问题就没了。看起来简单但网上很多粒子弹幕代码从来没提过导致很多人写的特效“切几次页面就卡死”。6.4 Firefox下火焰偏色同样是#ff8800Firefox渲染的偏色和老版本Chrome不一样。原因是Firefox对Canvas的gamma处理有细微差异而且lighter混合时各通道的合并规则在极端高亮下会有不同。解法是把颜色范围控制在0到255以内避免纯255白叠加多次导致裁切。我的色标里核心颜色是255,246,200而不是255,255,255就是为这个准备的。6.5 默认的arc画圆太费换成fillRect也能用arc方法在绘制大量粒子时并不是最优解。火焰粒子本质上是模糊的小光斑用圆形和用正方形在“lighter”混合模式下视觉差异极小但fillRect比arc快很多。为了性能我最后在高密度模式下改用了fillRect绘制粒子function drawParticleFast(p) { const life Math.max(p.life, 0); const color getFireColor(life); const size p.size * life; const half size / 2; ctx.globalAlpha Math.min(1, life * 1.5); ctx.fillStyle rgb(${color.r}, ${color.g}, ${color.b}); ctx.fillRect(p.x - half, p.y - half, size, size); }视觉上因为粒子小且半透明正方形边缘根本看不出来但绘制开销降了一个量级。7. 从喷射火焰升级为游戏技能交互与扩展思路前面实现的是一个以鼠标为喷射源的效果。把它升级成游戏技能只需要在设计上多走几步。7.1 给火焰增加碰撞判定粒子坐标天然可以用于碰撞检测。比如在射击游戏里遍历一部分粒子的位置和敌方矩形做包围盒检测命中就扣血。为了控制开销不必检测全部粒子只需每隔几帧采样一部分粒子的位置做粗检测。火焰命中的区域比子弹更宽可以作为范围伤害基础。7.2 从Canvas 2D迁移到WebGL当粒子上限需要上万Canvas 2D就不够了。WebGL可以用GPU并行计算粒子位置和渲染火焰数量可以轻松达到几万。这是后续的性能升级方向。迁移时核心逻辑不变——粒子数据结构、温度色标、运动更新都可以保留只需要把CPU更新的那一部分换成顶点着色器或者Transform Feedback。这里给一个简单的迁移思路对照表Canvas 2D方案WebGL方案遍历粒子数组更新顶点着色器按时间戳计算粒子位置ctx.arc逐个画粒子用纹理Texture渲染GPU自动绘制颜色插值在CPU做温度色标传成Uniform片元着色器插值粒子数量上限约1500单次draw call支持数万粒子7.3 做成粒子参数编辑器调参这件事最怕的是每次改代码刷页面。我直接把常用参数绑到一个dat.GUI式的面板上拖动滑块实时改喷射速度、扩散角、衰减率、颜色梯度。这不仅是调试工具也可以作为交给美术同事的“火焰调校面板”。核心逻辑是把粒子系统的公共参数提到一个config对象里const config { speed: 3.2, spread: 0.4, decay: 0.009, emitCount: 3, sizeBase: 10, burstOnSpace: true };任何UI控件只需修改config的属性运行中的粒子系统下一帧就会自动用新参数不需要重建任何对象。最后说点实在的这套火焰喷射效果从零到能看我大概花了两个晚上从“能看”到“耐看”又花了一周。反复调参的最大体会是火焰的美感来自“不均匀”——粒子大小不均匀、速度不均匀、颜色衰减不均匀太均匀的东西一眼假。写粒子系统不要急着加复杂物理先把温度和混合处理好效果已经能超过大部分序列帧方案。如果你刚开始模仿这个项目建议按这个顺序做先把粒子喷射跑通第2章再加颜色退变第3章最后才调参和优化第4、5章。一上来就追求湍流和对象池只会被细节淹没。踩坑实录里那些问题尤其值得提前看——特别是devicePixelRatio那一条90%的Canvas特效都会遇到早做准备能省下不少排查时间。
返回列表