ARTICLE DETAIL

资讯详情

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

用JavaScript粒子系统实现Canvas火焰喷射特效:从原理到性能优化

用JavaScript粒子系统实现Canvas火焰喷射特效:从原理到性能优化 这几天帮朋友调一个网页项目需求很直接在页面上加一个火焰喷射的特效。最开始我图省事想找个现成的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 langzh-CN head meta charsetUTF-8 title火焰喷射效果/title style body { margin: 0; overflow: hidden; background: #0b0b10; } canvas { display: block; } /style /head body canvas idfire/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里一样适用频繁创建销毁对象会触发GCGC一跑主线程就卡火焰就开始掉帧。用对象池后对象总量恒定内存稳定帧率曲线平滑很多。4.2 去掉spliceswap-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混合或颜色值过暗粒子阶段设置globalCompositeOperationlighter背景越刷越亮拖尾fillRect也开了lighterfillRect阶段恢复source-over火苗生硬、像圆点颜色单一、无渐变使用多段颜色插值函数断帧、卡顿频繁new/delete粒子引入对象池swap-pop删除粒子消失闪烁alpha/size降至0过小而闪烁设置最小绘制尺寸1.5pxRetina屏模糊未做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这类调试工具挂到页面上滑着调参数。看到什么效果觉得好再把数值锁死进正式版本。这种可视化调试比盲改代码效率高太多了。火焰这个效果看着花哨但拆解到粒子、颜色、混合、优化这几步之后逻辑非常清晰。你按照这套流程走一遍不仅能做出火焰喷射粒子爆炸、烟雾、水流也都能举一反三实现。真遇上参数怎么调都不对的情况优先检查颜色混合和生命周期衰减这两个是最容易出问题的环节。
返回列表