烟花这种东西,看起来是视觉特效,做起来其实是数学题。用 Canvas 做烟花,粒子发光明灭、上升下坠都是常规活儿,真正决定"像不像"的是拖尾——每颗粒子身后那条渐隐渐细的光带。我前后写过三四个版本的烟花 Demo,也帮别人改过几份代码,最后沉淀下来的拖尾实现就两条路:一条是全屏半透明遮罩的反馈式拖尾,一条是历史点队列的轨迹式拖尾。这两种 canvas 拖尾效果实现烟花的思路看起来都能出画面,但底层机制完全不同,参数调整的方式、性能开销的位置、能做出什么样的视觉效果也都不一样。这篇就围绕这两条路,把我在实际写代码时踩过的坑、算过的参数、调过的数值全部摊开讲清楚,从粒子数据结构到主循环时间步长,从 DPR 适配到移动端掉帧排查,尽量做到你拿着这份东西就能直接跑起来一个像样的烟花。
不管你是刚开始碰 canvas 的新手,还是早就会画粒子但拖尾总觉得"糊成一团"的老手,下面的内容应该都能用得上。我会先讲清楚两种方案的机制和选型逻辑,再拆解粒子的参数推导,然后给出可以直接抄的完整代码,最后聊排查和调优。
1. 两种拖尾方案的整体设计与选型拆解
1.1 先把"拖尾"这件事翻译成渲染语言
拖尾的本质是让"过去几帧的像素留在屏幕上"。围绕这个目标,实现上只有两种范式:一种是把屏幕上已有的像素当作历史,每帧不擦除,只让它们逐渐变暗,这叫反馈式,因为画面自己吃自己的输出;另一种是完全不依赖屏幕回读,每个粒子自己记住自己走过的坐标点,每帧重新把这段轨迹画一遍,这叫数据式或者轨迹式。
反馈式的实现极其省事,一行fillRect就完事,但它的自由度低,拖尾的颜色完全由"粒子色 × 衰减系数"决定,你没法单独给拖尾调色,也没法让拖尾比粒子本体更亮。轨迹式的代码量大概多四五倍,但每一个点的位置、透明度、线宽、颜色都由你说得算,还能做出"尾部渐变成另一种颜色"这种效果。
我一般是这样判断用哪个:如果只是想让画面有一种发光流动的氛围,粒子数量又特别多(比如超过三千颗),选反馈式;如果想让每一颗子弹、每一颗烟花粒子都有清晰可辨的尾巴,甚至要做不同粒子不同长度的拖尾,选轨迹式。下面把两条路逐个拆开。
1.2 方案一:全屏半透明遮罩的反馈式拖尾
核心代码就三行:不清屏,改成铺一层带透明度的背景色。每帧执行一次,屏幕上所有内容都会被"蒙上一层纱",上一帧的粒子因此变暗一点,显得像残留。
ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = 'rgba(5, 6, 10, 0.14)'; ctx.fillRect(0, 0, W, H);这一层 0.14 的 alpha,每帧会让已有像素的亮度乘以 0.86。也就是说,一个像素如果在第 0 帧是满亮,到第 n 帧的亮度就是 0.86 的 n 次方。这个等比数列决定了一切,我把它算成表格放在下面,因为很多人在调这个参数时是"试试看",其实它是能被精确计算的。
设遮罩的每帧不透明度为 α,单帧保留率 k = 1 - α。亮度衰减到初始值 10% 所需要的帧数 n 满足 k 的 n 次方 = 0.1,两边取自然对数就能解出 n = ln(0.1) / ln(k)。
| 遮罩 alpha | 单帧保留率 k | 衰减到 10% 的帧数 | 60fps 下的拖尾时长 |
|---|---|---|---|
| 0.05 | 0.95 | 45.0 | 约 750 ms |
| 0.08 | 0.92 | 27.6 | 约 460 ms |
| 0.12 | 0.88 | 18.0 | 约 300 ms |
| 0.20 | 0.80 | 10.3 | 约 172 ms |
| 0.30 | 0.70 | 6.5 | 约 108 ms |
我实测下来,烟花粒子用 0.10 到 0.14 之间手感最好,拖尾长度在 300 到 370 毫秒,既不会糊成一片光斑,也不至于尾巴太短像有棱角的小点。如果你想做那种"流星划过"的长尾,把 alpha 压到 0.05,但要注意这时候画面整体会积压亮度,亮的地方会越来越白,因为加法混合的粒子在不断往上叠加。
注意:遮罩法有个大坑——如果你的 canvas 本身设成透明(希望页面背景透出来),用
rgba(0,0,0,0.14)去填充会让 canvas 的 alpha 通道不断累积,最终整块画布变成不透明的黑色,页面背景彻底看不见。这时候要换混合模式,改用destination-out,让遮罩去"擦除"而不是"覆盖":
// 背景透明、希望露出页面底色时的写法 ctx.globalCompositeOperation = 'destination-out'; ctx.fillStyle = 'rgba(0, 0, 0, 0.14)'; ctx.fillRect(0, 0, W, H); ctx.globalCompositeOperation = 'lighter'; // 接下来画粒子destination-out的作用是用新绘制的图形去抠掉已有内容,所以它减的是 alpha 而不是朝某个颜色混合,配合 0.14 的不透明度,刚好实现"逐帧淡出一部分"的效果。我第一次做透明背景烟花时不知道这个,调了半小时以为是颜色算错了。
1.3 方案二:历史点队列的轨迹式拖尾
这个方案的思路是把粒子当成一个会记录足迹的东西。每颗粒子内部维护一个坐标数组,每隔固定时间(比如 16 毫秒)往里存一次当前位置,超过上限就把最老的丢掉。绘制时,从这个数组的尾部(最老的点)画到头部(最新位置),越靠近尾部透明度越低、线宽越细。
// 每隔 sampleInterval 秒采样一次,存扁平数组省内存 this.sampleTimer += dt; if (this.sampleTimer >= this.sampleInterval) { this.sampleTimer -= this.sampleInterval; this.trail.push(this.x, this.y); // 存成 [x0, y0, x1, y1, ...] const maxLen = this.maxPoints * 2; if (this.trail.length > maxLen) this.trail.splice(0, this.trail.length - maxLen); }为什么存扁平数组而不是{x, y}对象数组?因为对象数组在粒子多的时候会产生大量临时对象,垃圾回收的压力会直接影响帧率的稳定性,尤其是移动端上你能明显感觉到每隔几秒卡一下。扁平数组是连续内存,splice的搬运成本也更低。这个细节在粒子数超过一千时非常关键。
绘制阶段,从尾部往头部逐段连线,用位置索引算出归一化进度 t(0 在尾,1 在头),然后把透明度、线宽、色相都按 t 插值:
ctx.globalCompositeOperation = 'lighter'; const n = this.trail.length; for (let i = 2; i < n; i += 2) { const t = i / (n - 1); // 越靠近头部越接近 1 ctx.beginPath(); ctx.moveTo(this.trail[i - 2], this.trail[i - 1]); ctx.lineTo(this.trail[i], this.trail[i + 1]); ctx.strokeStyle = `hsla(${this.hue}, 100%, ${52 + 28 * t}%, ${0.75 * t})`; ctx.lineWidth = 0.8 + 2.4 * t; ctx.stroke(); }这段代码有一个性能隐患要注意:每一段都beginPath加stroke,等于一段一次绘制调用。如果三颗粒子、每颗 20 个点,那就是 57 次 stroke,还扛得住;一旦粒子涨到八百颗,每秒的 stroke 调用就上万了,帧率必然崩。后面第 3 章我会给出按透明度分档批量绘制的优化版本。
1.4 一张表看清两条路的取舍
| 维度 | 反馈式(遮罩) | 轨迹式(点队列) |
|---|---|---|
| 代码量 | 约 3 行 | 约 30 行 |
| 拖尾长度控制 | 改 alpha,全局统一 | 改 maxPoints,可逐粒子不同 |
| 拖尾颜色 | 只能是粒子色衰减 | 可做色相渐变、双色尾 |
| 背景要求 | 必须是纯色或接近纯色 | 任意背景都能用 |
| 性能特征 | 恒定,与粒子数无关 | 线性,与粒子数 × 点数正相关 |
| 典型问题 | 亮度堆积发灰、色阶断层 | 采样疏密导致折线感 |
| 适合场景 | 大面积光雾、海量粒子 | 少量精修、强调轨迹感 |
我个人做烟花时经常两个一起用:底层用遮罩铺一层氛围光雾,把整体亮度托起来;然后对那些"主角粒子"(比如爆炸瞬间最外圈的那几十颗)再叠加上轨迹式拖尾,让它们的尾巴有明确的起止点。两套机制叠加反而比单用一套更有层次,因为一个是糊的、一个是锐的,正好互补。
2. 粒子系统的核心细节与参数推导
2.1 粒子对象池:别让垃圾回收打断你的节奏
烟花动画里粒子的生死非常频繁,一颗烟花炸开就是一两百个粒子,一两秒后全部消亡,接着又是一轮。如果每次都new Particle()然后让旧对象自然回收,浏览器每隔一阵就要做一次垃圾回收,表现出来就是画面每隔几秒顿一下,尤其是在低端设备上特别明显。
我的做法是预分配一个固定大小的池子,粒子对象里带一个active标记,启动时清空数据重新赋值,不重新创建。
const MAX_PARTICLES = 2400; const pool = Array.from({ length: MAX_PARTICLES }, () => ({ active: false, x: 0, y: 0, vx: 0, vy: 0, life: 0, maxLife: 1, hue: 0, size: 1, gravity: 260, drag: 0.985, trail: [], sampleInterval: 0.016, sampleTimer: 0, maxPoints: 14 })); function acquire() { for (let i = 0; i < pool.length; i++) { if (!pool[i].active) return pool[i]; } return null; // 池子满了就丢弃,宁可少画不能崩 } function release(p) { p.active = false; p.trail.length = 0; // 清空但保留数组容量 }trail.length = 0这个写法比p.trail = []好的地方在于,前者复用原本的数组对象,容量还在,后续 push 不用重新扩容。这种细枝末节的优化单看没什么,但在两千颗粒子同时跑的时候,累积起来的收益是能看见的。
2.2 速度更新:为什么必须用秒而不是帧
早期我写粒子都是"每帧 vx += 0.2、vy += 0.15"这种写法,在 60Hz 屏幕上看着挺正常,一到 120Hz 的屏幕上整朵烟花就像被按了快进,两倍速飘走。原因很简单:帧率翻倍,每秒执行的更新次数也翻倍,位移自然翻倍。
正确做法是把所有与时间相关的量都换算成"每秒",更新时乘上真实的时间差 dt。
// dt 单位:秒 const damp = Math.pow(this.drag, dt * 60); // 把"每帧衰减"换算成"本帧衰减" this.vx *= damp; this.vy = this.vy * damp + this.gravity * dt; this.x += this.vx * dt; this.y += this.vy * dt;Math.pow(0.985, dt * 60)这一句是整个动画速度一致性的关键。0.985 是我按"每帧衰减 1.5%"设定的经验值,但当 dt 不等于 1/60 秒时,直接乘 0.985 就错了,必须把它换算成等效的连续衰减。这个写法保证了无论 60Hz 还是 144Hz,粒子在同样长的时间后会到达同样的位置。
还有一个容易被漏掉的细节:先更新速度再更新位置,这是所谓的半隐式欧拉(symplectic Euler)。如果反过来先更新位置再更新速度,在重力较大时粒子会呈现出轻微的"跳跃感",尤其是垂直下落阶段,肉眼能看出速度不连贯。别问我怎么知道的,改过一整晚。
注意:
dt一定要做上限截断。用户切到别的标签页再切回来,requestAnimationFrame会攒下很大的时间差,如果不做保护,粒子会一瞬间飞出屏幕。我一般写成const dt = Math.min((now - last) / 1000, 0.05);,也就是单帧最多推进 50 毫秒,相当于掉到 20fps 就不再补偿了。
2.3 拖尾长度的时间换算
这一步是很多人凭感觉调的,其实可以算得很准。
轨迹式拖尾的物理时长等于点数 × 采样间隔。如果每个渲染帧采一次(约 16 毫秒),那么:
| 保留点数 | 采样间隔 | 拖尾时间长度 | 速度 600px/s 时的视觉长度 |
|---|---|---|---|
| 6 | 16 ms | 96 ms | 约 58 px |
| 10 | 16 ms | 160 ms | 约 96 px |
| 14 | 16 ms | 224 ms | 约 134 px |
| 20 | 16 ms | 320 ms | 约 192 px |
| 28 | 16 ms | 448 ms | 约 269 px |
这里的"视觉长度"是拿当前速度乘拖尾时长估的,注意粒子有阻力,实际会比这个值短一些,因为老点的位置是在速度更快时记下的。我一般会让爆炸初期的粒子配 14 到 20 个点,落下来快熄灭时只有 6 到 10 个点,长度随速度自动变化,看起来就会自然很多——快的时候尾巴长,慢的时候尾巴短,符合直觉。
如果采样间隔和渲染帧率绑定,在高刷屏上采样频率也会翻倍,同样点数下拖尾就变短了。想要跨设备一致,得把采样间隔设成固定的时间值(比如 0.018 秒),而不是"每帧都采"。这一点稍微反直觉,但实测非常必要。
2.4 颜色与亮度的分层设计
烟花好看不光是轨迹,颜色也很要命。我的习惯是用 HSL 而不是 RGB,因为色相滑移用 HSL 一行就能写。
一颗爆炸粒子的色相随寿命线性滑移,比如从 48(金黄)滑到 0(正红),过程中亮度从 70% 降到 40%,饱和度始终 100%:
const age = 1 - this.life / this.maxLife; // 0 刚出生,1 快消亡 const hue = this.hue - 48 * age; const light = 70 - 30 * age; ctx.fillStyle = `hsl(${hue}, 100%, ${light}%)`;配合globalCompositeOperation = 'lighter'(加法混合),两个粒子重叠的地方会变亮,这正是烟花需要的发光感。但加法混合有个副作用:中心密集区的亮度会直接冲到顶,再叠加就是一片死白,层次全没了。
我的处理思路是给粒子的基础透明度设一个很低的起点,比如globalAlpha = 0.35,让亮度靠"多次叠加"慢慢积累出来,而不是单次就画满。这样中心区域的过曝能缓解不少,同时边缘的单颗粒子也依然可见。你可以把这个值和拖尾点数一起调,两者会互相影响观感。
3. 从零实现两种拖尾烟花
3.1 画布初始化与 DPR 适配
视网膜屏幕上如果不处理devicePixelRatio,画面会糊得像隔着毛玻璃。标准写法是把 canvas 的物理像素尺寸设为 CSS 尺寸乘 DPR,然后用setTransform把绘图坐标系缩回逻辑像素。
const canvas = document.getElementById('sky'); const ctx = canvas.getContext('2d', { alpha: true }); let W = 0, H = 0; const DPR = Math.min(window.devicePixelRatio || 1, 2); // 封顶 2,别让 3x 屏拖死性能 function resize() { const rect = canvas.getBoundingClientRect(); W = rect.width; H = rect.height; canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR); ctx.setTransform(DPR, 0, 0, DPR, 0, 0); // 之后所有坐标按 CSS 像素算 ctx.fillStyle = '#05060a'; ctx.fillRect(0, 0, W, H); } window.addEventListener('resize', resize); resize();DPR 封顶 2 是我的经验做法。3x 屏的物理像素是逻辑像素的 9 倍,填充率压力陡增,而这多出来的清晰度在一个每秒都在动的粒子上几乎看不出来,属于白花的性能。封在 2 已经足够细腻。
另外,setTransform在每次重置 canvas 尺寸之后都必须重新调用,因为改变canvas.width会把画布上所有的绘制状态、变换矩阵全部重置。我在别人的代码里见过不少次"调整窗口大小后画面就错位/变模糊",基本都是忘记重设变换导致的。
3.2 方案一:遮罩残影版的完整实现
const BG = [5, 6, 10]; const TRAIL_ALPHA = 0.12; function updateParticles(dt) { for (const p of pool) { if (!p.active) continue; p.life -= dt; if (p.life <= 0) { release(p); continue; } const damp = Math.pow(p.drag, dt * 60); p.vx *= damp; p.vy = p.vy * damp + p.gravity * dt; p.x += p.vx * dt; p.y += p.vy * dt; if (p.y > H + 20 || p.x < -40 || p.x > W + 40) release(p); } } function renderGhost() { // 第一步:用半透明背景铺满,制造衰减 ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1; ctx.fillStyle = `rgba(${BG[0]}, ${BG[1]}, ${BG[2]}, ${TRAIL_ALPHA})`; ctx.fillRect(0, 0, W, H); // 第二步:加法混合绘制粒子 ctx.globalCompositeOperation = 'lighter'; for (const p of pool) { if (!p.active) continue; const age = 1 - p.life / p.maxLife; ctx.globalAlpha = Math.max(0, 1 - age) * 0.55; ctx.fillStyle = `hsl(${p.hue - 40 * age}, 100%, ${68 - 26 * age}%)`; ctx.beginPath(); ctx.arc(p.x, p.y, p.size * (1 - age * 0.4), 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } let last = performance.now(); function loopGhost(now) { const dt = Math.min((now - last) / 1000, 0.05); last = now; updateParticles(dt); renderGhost(); requestAnimationFrame(loopGhost); }这段代码里有两个点值得单独说。
一是TRAIL_ALPHA越小,拖尾越长但画面越容易"发灰"。灰的成因是 8 位色深的量化:当亮度衰减到很低的时候,相邻两个色阶之间的差值已经小于最小可表示单位,于是画面里会留下一层均匀的低亮度雾气,且永远抹不掉。解决办法是把遮罩的底色设成和背景纯色完全一致(我这里用的是 RGB 5,6,10),不要用纯黑;纯黑和深蓝背景混在一起会拉出色偏。
二是粒子的globalAlpha和遮罩衰减是两条独立的衰减曲线,相乘之后衰减速度远快于预期,所以粒子的基础上限我只给 0.55,把剩下的亮度交给画遮罩之前的帧去积累。这两个数需要联动调,不能单独改。
3.3 方案二:轨迹队列版的完整实现
先写粒子里负责采样的部分,然后写绘制。
function updateTrailParticles(dt) { for (const p of pool) { if (!p.active) continue; p.life -= dt; if (p.life <= 0) { release(p); continue; } const damp = Math.pow(p.drag, dt * 60); p.vx *= damp; p.vy = p.vy * damp + p.gravity * dt; p.x += p.vx * dt; p.y += p.vy * dt; // 固定时间间隔采样,与帧率无关 p.sampleTimer += dt; while (p.sampleTimer >= p.sampleInterval) { p.sampleTimer -= p.sampleInterval; p.trail.push(p.x, p.y); const maxLen = p.maxPoints * 2; if (p.trail.length > maxLen) p.trail.splice(0, p.trail.length - maxLen); } } }绘制时我先给一个直观版,再给批量优化版。
function renderTrailBasic() { ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#05060a'; ctx.fillRect(0, 0, W, H); ctx.globalCompositeOperation = 'lighter'; ctx.lineCap = 'round'; for (const p of pool) { if (!p.active) continue; const n = p.trail.length; if (n < 4) continue; const age = 1 - p.life / p.maxLife; for (let i = 2; i < n; i += 2) { const t = i / (n - 1); ctx.beginPath(); ctx.moveTo(p.trail[i - 2], p.trail[i - 1]); ctx.lineTo(p.trail[i], p.trail[i + 1]); ctx.strokeStyle = `hsla(${p.hue - 40 * age}, 100%, ${45 + 30 * t}%, ${0.7 * t})`; ctx.lineWidth = 0.8 + 2.6 * t; ctx.stroke(); } // 头部补一个亮点,避免尾巴末端太突兀 ctx.beginPath(); ctx.fillStyle = `hsla(${p.hue - 40 * age}, 100%, 85%, 0.9)`; ctx.arc(p.x, p.y, p.size * (1 - age * 0.35), 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = 'source-over'; }这个版本直观好懂,但性能一般。优化思路是把透明度分档:把 t 量化成 4 档(0.25 / 0.5 / 0.75 / 1.0),每一档只设一次样式,然后一次性把所有段路径拼进同一个Path2D里提交。
const BUCKETS = 4; const pathCache = Array.from({ length: BUCKETS }, () => new Path2D()); function renderTrailBatch() { ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#05060a'; ctx.fillRect(0, 0, W, H); ctx.globalCompositeOperation = 'lighter'; ctx.lineCap = 'round'; for (let b = 0; b < BUCKETS; b++) pathCache[b] = new Path2D(); for (const p of pool) { if (!p.active) continue; const n = p.trail.length; for (let i = 2; i < n; i += 2) { const t = i / (n - 1); const b = Math.min(BUCKETS - 1, Math.floor(t * BUCKETS)); pathCache[b].moveTo(p.trail[i - 2], p.trail[i - 1]); pathCache[b].lineTo(p.trail[i], p.trail[i + 1]); } } for (let b = 0; b < BUCKETS; b++) { const t = (b + 1) / BUCKETS; ctx.strokeStyle = `hsla(40, 100%, ${45 + 30 * t}%, ${0.75 * t})`; ctx.lineWidth = 0.8 + 2.6 * t; ctx.stroke(pathCache[b]); } ctx.globalCompositeOperation = 'source-over'; }代价是所有粒子共用一个颜色了(因为分档的色相是写死的)。如果想让每颗粒子保留自己的色相,可以把档位数拆成"色相 6 档 × 透明度 4 档"共 24 个桶,绘制调用从几千次降到 24 次,颜色精度也够用。这个权衡在实际项目里很划算——24 次 stroke 和 3000 次 stroke 在手机上就是流畅与卡死的区别。
3.4 发射、爆炸和二次爆炸的节奏编排
烟花好看不好看,一半靠节奏。我的参数是这样的:升空阶段 0.9 到 1.3 秒,垂直初速度 -620 到 -880 px/s,同时给一点点水平速度制造斜率;升空粒子走的是短拖尾,点数只有 4 到 6,因为它速度快、要显得利落;到顶点附近速度绝对值小于一个阈值时触发爆炸。
function launch(x) { const p = acquire(); if (!p) return; p.active = true; p.x = x; p.y = H + 10; p.vx = (Math.random() - 0.5) * 70; p.vy = -(620 + Math.random() * 260); p.gravity = 320; p.drag = 0.992; p.life = p.maxLife = 1.1 + Math.random() * 0.25; p.hue = 40 + Math.random() * 20; p.size = 2.2; p.maxPoints = 6; // 升空用短拖尾 p.trail.length = 0; p.isShell = true; // 标记为弹体 }爆炸的触发我更喜欢用"速度衰减到接近 0"而不是"到达某个高度",因为高度是写死的,而速度衰减是物理结果,不同的初速度会自然产生不同的爆炸时机,看起来更有随机感。
if (p.isShell && p.vy > -30) { explode(p.x, p.y, p.hue); release(p); } function explode(x, y, baseHue) { const count = 110 + Math.floor(Math.random() * 60); for (let i = 0; i < count; i++) { const p = acquire(); if (!p) return; const angle = (i / count) * Math.PI * 2 + Math.random() * 0.08; const speed = 120 + Math.random() * 340; p.active = true; p.x = x; p.y = y; p.vx = Math.cos(angle) * speed; p.vy = Math.sin(angle) * speed * 0.92; // 稍微压扁,看起来更像球体透视 p.gravity = 240 + Math.random() * 80; p.drag = 0.976 + Math.random() * 0.012; p.life = p.maxLife = 0.9 + Math.random() * 0.8; p.hue = baseHue + (Math.random() - 0.5) * 26; p.size = 1.2 + Math.random() * 1.1; p.maxPoints = 12 + Math.floor(Math.random() * 8); p.trail.length = 0; p.isShell = false; } }二次爆炸(也就是"子母弹")的做法是给一部分粒子打标记,在寿命过半时把它们替换成一小组更小的粒子。这个效果在视觉上非常抓人,但要注意控制数量,否则粒子总数会指数爆炸,池子瞬间被填满。
if (p.canSplit && p.life / p.maxLife < 0.5) { p.canSplit = false; for (let k = 0; k < 6; k++) { const c = acquire(); if (!c) break; c.active = true; c.x = p.x; c.y = p.y; c.vx = p.vx * 0.35 + (Math.random() - 0.5) * 180; c.vy = p.vy * 0.35 + (Math.random() - 0.5) * 180; c.gravity = p.gravity * 1.1; c.drag = 0.97; c.life = c.maxLife = 0.45 + Math.random() * 0.3; c.hue = p.hue + 10; c.size = 0.9; c.maxPoints = 8; c.trail.length = 0; c.canSplit = false; } }3.5 主循环与帧率解耦的最后一道工序
主循环我写过一个很朴素的版本,就是开头展示的那种。真正上线前还要加两件事:一是页面不可见时暂停(document.hidden判断),防止后台标签页还在耗电和占内存;二是把累计时间做一次补偿,避免高刷屏上dt太小导致每帧位移几乎为 0 而出现"走不动"的观感。
let last = performance.now(); let acc = 0; const FIXED_DT = 1 / 120; // 固定步长,物理更新用 function loop(now) { requestAnimationFrame(loop); if (document.hidden) { last = now; return; } let delta = (now - last) / 1000; last = now; if (delta > 0.1) delta = 0.1; acc += delta; while (acc >= FIXED_DT) { updateParticles(FIXED_DT); acc -= FIXED_DT; } renderTrailBatch(); // 或者 renderGhost() } requestAnimationFrame(loop);固定步长的好处是物理表现完全可复现,不会因为帧率抖动导致同一个爆炸每次落点都不一样。渲染还是按屏幕刷新率走,两者解耦。这套结构在写游戏时是标配,搬到烟花上同样受益——尤其是做录屏对比的时候,你会看到位置能精确对上。
4. 常见问题与排查技巧实录
4.1 拖尾发灰、发糊、像一团雾
这是遮罩法最高频的问题。三个原因,按出现概率排序:一是遮罩 alpha 太小(拖尾太长),低亮度像素堆积成雾;二是遮罩颜色用了纯黑而背景是深蓝,混出偏色;三是粒子的globalAlpha给得太高,中心区域早就过曝了。
我的排查顺序是:先把 alpha 从当前值往上调到 0.14 到 0.2 之间看是否改善,如果雾散了但拖尾太短,说明是拖尾长度和清晰度不可兼得,此时应该改用轨迹法;如果调了还是灰,检查遮罩底色 RGB 和 canvas 背景是否完全一致。还有个隐蔽原因:有些代码在fillRect之后忘了把globalAlpha复位成 1,导致后面的粒子绘制也被整体压暗,画面看着就是脏的。
4.2 高刷屏上速度明显变快
这就是没做时间归一化的典型症状。检查三点:更新位移有没有乘dt;阻力衰减有没有用Math.pow(drag, dt * 60)换算;粒子寿命有没有用秒而不是帧计数。三者缺一,在 120Hz 屏幕上就会跑出两倍速。我见过最离谱的写法是把life写成整数字段然后每帧减 1,那在 90Hz 和 144Hz 上完全是两个不同的动画。
顺便说个验证技巧:打开浏览器的帧率面板,或者干脆在代码里累加dt打点到控制台,看每秒累计值是不是接近 1.0。如果不是,说明有地方漏了归一化。
4.3 移动端帧率忽高忽低
手机上跑烟花最容易遇到的就是帧率不稳,一会 60 一会 30。排查时我按这个顺序来:先看粒子总数峰值(用一个计数器统计active的数量),如果超过 1500 就考虑降;再看是不是每帧都在new对象,chrome://那种性能面板上看到的锯齿状波动基本就是垃圾回收;最后看绘制调用次数,如果用的是轨迹法且每个粒子每段都 stroke,那绝对是瓶颈,换成批量 Path2D 的方案。
还有一点容易被忽略:arc()画圆比fillRect()画方块慢不少,在粒子很小时(半径小于 1.5px)直接用 2x2 的矩形代替圆,肉眼完全看不出区别,性能能省三成左右。
4.4 常见问题速查表
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 拖尾一层灰雾散不掉 | 遮罩 alpha 过小或底色不匹配 | alpha 提到 0.14 以上,底色改成与背景同值 |
| 背景透不出来,整块变黑 | 用了 source-over 叠加 alpha | 改用 destination-out 抠除 |
| 高刷屏速度快一倍 | dt 未参与位移/衰减计算 | 全部改成按秒计算并乘 dt |
| 切换标签页回来粒子飞走 | dt 无上限截断 | Math.min(dt, 0.05) |
| 拖尾折线感明显 | 采样间隔过大 | 采样间隔降到 0.012 秒,或改用二次贝塞尔平滑 |
| 手机上前几秒顺滑后卡顿 | 对象频繁创建触发回收 | 改用对象池复用 |
| 尾巴末端突然断掉 | 头部补点缺失或 alpha 归零 | 在最新位置补一个高亮小圆点 |
| 粒子穿出画面还在计算 | 越界未回收 | y 超出画布外一定距离即 release |
| 爆炸瞬间帧率掉落 | 一次性创建过多粒子 | 分 2 到 3 帧错峰生成 |
| 重设窗口尺寸后画面模糊 | DPR 变换丢失 | resize 里重新 setTransform |
5. 视觉调优与我的实操参数表
5.1 一份可以直接照抄的参数表
下面这组数值是我在多个设备上反复磨出来的,直接填进去就能出一个观感不错的烟花。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 升空初速度 | -620 ~ -880 px/s | 太低飞不起来,太高炸得太快没看头 |
| 升空重力 | 320 px/s² | 到顶点约 1.1 秒 |
| 爆炸粒子数 | 110 ~ 170 | 超过 200 视觉上开始糊 |
| 爆炸初速度 | 120 ~ 460 px/s | 内圈慢外圈快,制造分层 |
| 爆炸重力 | 240 ~ 320 px/s² | 越大下落越快,越"重" |
| 空气阻力 drag | 0.976 ~ 0.988 | 每帧衰减 1.2% ~ 2.4% |
| 粒子寿命 | 0.9 ~ 1.7 s | 太短看不到轨迹,太长拖成一片 |
| 轨迹采样间隔 | 0.014 ~ 0.018 s | 与帧率解耦的关键 |
| 轨迹保留点数 | 12 ~ 20 | 对应拖尾 170 ~ 360 ms |
| 遮罩 alpha(方案一) | 0.10 ~ 0.14 | 对应拖尾 300 ~ 370 ms |
| 粒子基础透明度 | 0.4 ~ 0.6 | 配合 lighter 防止中心过曝 |
| 粒子尺寸 | 1.2 ~ 2.4 px | 大屏可再加 0.5 |
有一组关系要特别提醒:drag和maxLife是联动的。阻力越大,粒子越快停下来,如果寿命还很长,就会看到一堆静止的光点挂在空中慢慢变暗,非常出戏。我的经验是让粒子在寿命结束时速度已经衰减到初速的 5% 以下,这样收尾才自然。按 0.98 每帧衰减算,60 帧后剩 0.3,120 帧后剩 0.09,对应的寿命就控制在 1.5 秒上下比较合适。
5.2 拖尾和背景必须做视觉隔离
一个我踩过两次的坑:拖尾颜色和背景颜色接近时,尾巴会凭空"消失"。比如深蓝色背景配蓝白色粒子,粒子头很亮,但尾巴衰减到 30% 亮度时和背景几乎同色,看起来就像被切断了。
解决办法有两个。一是在尾巴的中段加一个固定的高亮色相,比如不管粒子本体是什么颜色,尾巴末端统一偏黄白,形成一条暖色引导线,视觉上立刻就有分辨度。二是把背景做成极深但带一点点色偏的值,比如 RGB 8, 6, 12,比纯黑有温度,也不会和任何常见粒子色撞车。
5.3 我调试时用的几个小手段
调这种动画,最有效的办法是把它拆开单独看。我会临时把粒子速度乘 0.2、把dt缩到原来的五分之一,让整个动画变成慢动作,这时候拖尾的每一个点、每一次衰减都清清楚楚,哪里折线感重、哪里颜色断层,一眼就看出来。调完再恢复原速。
还有个小技巧:在角落用fillText打上当前active粒子数和最近一秒的平均帧率。这个不到十行的调试信息,比任何性能工具都来得直接,边逛边看,粒子数一超过某个阈值帧率就掉,那个阈值就是你设备的上限,撒粒子的时候心里就有数了。
5.4 一点实际操作后的体会
回到两种拖尾方案的选择上。我后来发现真正决定用哪个的,不是性能,而是你的背景有没有内容。如果烟花是叠在一张星空图或者一段视频上,反馈式遮罩根本没法用,因为它会把背景一起衰减掉,几秒钟之后背景就被抹平了。这种场景只能走轨迹式,你必须在数据层自己记住历史,画面才能干净。反过来,如果背景是纯色,遮罩法的性价比高得离谱,三行代码就能出效果,还不用担心点多了性能崩。
我现在默认写的是轨迹式,因为它可控性太强了,色相渐变、长度变化、尾部加亮这些细节都是遮罩法给不了的。遮罩法我只保留在"粒子特别多、只想要整体氛围"的场景里,比如三千颗细碎的火星同时飘。
另外说个可能有点反直觉的经验:拖尾不是越长越好看。我一开始调的拖尾将近 500 毫秒,看着像烟花在漏油。后来压到 200 毫秒左右,反而更有速度感,因为短尾巴会强迫观众的眼睛去追粒子头,那个"追"的动作就是动感的来源。长尾巴会把注意力分散到整条轨迹上,速度感就没了。这个尺度大概在 150 到 250 毫秒之间最舒服,你可以从 14 个采样点起手,再上下浮动着试。