做烟花特效这件事,最容易被低估的部分从来不是爆炸那一瞬间,而是爆炸之后那半秒的拖尾。我在 canvas 里写烟花写了挺久,爆炸形状、配色、发射节奏这些都能靠调参解决,唯独拖尾,一开始总是要么糊成一团黑烟,要么断成一串小圆点,怎么看都不像烟花。后来把各种做法归了归类,发现真正主流的就两条路:整屏衰减法和轨迹缓存法。前者用一层半透明蒙版把历史帧一点点擦淡,几行代码就能出效果;后者给每个粒子记一本位置账,从尾巴到头逐点描出来,能精确控制每一段的光衰。这篇文章就把这两条路的原理、参数怎么算、代码怎么写、坑在哪里,一次讲清楚,不管你是在做活动页、游戏特效,还是单纯想在个人主页放个小彩蛋,都能直接抄走。
1. 拖尾不是"画一条线",而是"让历史慢慢消失"
初学者最常见的误解,是以为拖尾需要在每一帧画出粒子的运动轨迹。实际上屏幕上的拖尾之所以看起来像一条连续的光带,是因为观众的眼睛把过去十几帧的残影连了起来。canvas 本身并不保留历史,它是一个"每帧全清全画"的画布,你不主动保留,上一帧的内容就没了。所以拖尾的本质,是想办法让旧的内容在屏幕上多停留一段时间,同时让它越来越淡。理解这一点,后面两种方案的区别就很好懂了:一个是从全局下手,让整块画布缓慢褪色;另一个是从局部下手,主动把每个粒子的旧位置重新画一遍。
1.1 两种路线的本质区别
整屏衰减法的思路非常直接:不在每帧清空画布,而是盖一层透明度很低的纯色矩形上去。假设每帧盖一层 10% 不透明的黑,那么上一帧的画面还剩 90%,再上一帧剩 81%,如此衰减下去,几帧之内旧内容就淡得看不见了。因为粒子一直在动,新位置亮、旧位置暗,视觉上就是一条尾巴。这个方案里,拖尾根本不是"粒子的一部分",而是"画布本身的余晖",这是它最大的特点,也是它最大的局限。
轨迹缓存法反过来,它完全不依赖画布的残留,每帧该清就清,只是额外维护一个历史坐标数组。绘制时从这个数组里取出最近若干个点,用逐渐降低的透明度和逐渐变细的线宽连起来。拖尾是粒子自己"拖着"的,跟画布状态无关。这就意味着每个粒子可以有自己独立的拖尾长度、颜色和粗细,甚至可以让同一场烟花里,大的主爆点拖尾长、小的碎屑拖尾短,层次一下子就出来了。
这两条路在工程上的差异,比视觉效果上的差异更大。整屏衰减法的每帧开销基本恒定,跟粒子上限无关,因为它只是画一个矩形;而轨迹缓存法的开销随粒子数和轨迹点数成倍增长,因为它每帧要画 N 乘以 L 条线段。选哪个,很大程度上取决于你的粒子规模和目标设备。
1.2 一张表看清选型
| 对比项 | 整屏衰减法 | 轨迹缓存法 |
|---|---|---|
| 每帧开销 | 常数级(1 次 fillRect)+ N 个粒子 | N × L 次线段绘制 |
| 拖尾长度控制 | 全局统一,只能调一个系数 | 每个粒子独立可调 |
| 对静态内容的影响 | 会一起被擦淡,除非分层 | 完全无影响 |
| 背景适应性 | 深色背景最友好 | 任意背景都能用 |
| 实现复杂度 | 十几行 | 需要环形缓冲、渐变描边 |
| 适合的粒子量 | 数千级 | 数百级以内 |
| 高刷新率屏幕 | 需要按 dt 归一化 | 需要按距离或时间记录 |
我一般的判断标准是:如果这是个"氛围型"特效,比如页面背景、节日彩蛋,粒子要多、要满屏飞,选整屏衰减;如果这是个"展示型"特效,比如单个烟花特写、需要精细调色的作品,粒子不多但要求质感,选轨迹缓存。两者其实不冲突,后面会讲怎么混着用。
2. 方案一:整屏衰减法,用半透明蒙版擦历史
先把这个最简单的方案吃透,因为它的坑最隐蔽。核心代码只有一行:每帧用带透明度的颜色铺满整块画布。
ctx.fillStyle = 'rgba(5, 7, 13, 0.11)'; ctx.fillRect(0, 0, W, H);注意这里用的是fillRect而不是clearRect。clearRect会把像素彻底清成透明,一点残影都不留;fillRect配合低 alpha,等于给整块画布做了一次轻微的"蒙尘",旧内容被压暗一点点,新内容再叠上去,如此循环。就是这一行,让静止的烟花变成了流动的烟花。
2.1 一帧一次 fillRect,拖尾就出来了
理解这一行的关键在于搞清楚混合模式。默认的source-over模式下,新绘制的半透明颜色会和下面的像素做 alpha 混合,结果是旧像素的颜色被拉向蒙版色。如果蒙版是黑色,那旧像素就是逐帧变暗;如果蒙版是深蓝色,旧像素就会慢慢偏向深蓝,这其实可以用来做氛围色,比如夜空主题配rgba(8, 12, 30, 0.1),残影会带一点蓝调,比纯黑好看。
还有一个容易被忽略的细节:蒙版色应该和页面背景色一致。如果你的画布是透明的、背景色写在了 CSS 上,那么每帧铺一层黑色操作会在画布上逐渐积累出一块不透明的黑色区域,最后整个画布变成黑的,把 CSS 背景完全盖住。这个现象在开发阶段非常迷惑,看起来像是"画布突然不透明了"。我的做法是干脆把背景色统一交给 canvas 自己管,CSS 上只留一个兜底色,两边色值写成同一个常量。
2.2 擦除系数怎么定:算一下拖尾到底多长
0.11这个数字不是随便写的。设每帧擦除系数为a,经过n帧之后,一个像素残留的亮度比例是(1 - a)^n。想让它衰减到 1%(基本看不见),需要的帧数是:
n = ln(0.01) / ln(1 - a)代入a = 0.11,ln(0.01) ≈ -4.605,ln(0.89) ≈ -0.1165,得到n ≈ 39.5帧。在 60 帧每秒下,大约是 0.66 秒。这个时长对烟花来说刚好:够长,尾巴能拖出去;够短,屏幕不会积压太多残影变成一团雾。
反过来推算也很有用。如果你希望拖尾持续 0.3 秒,60 帧下就是 18 帧,要求(1-a)^18 = 0.01,解得a = 1 - 0.01^(1/18) ≈ 0.225。所以想缩短拖尾,就把系数往 0.2 以上推;想拉长,就压到 0.05 附近。但注意别压太低,低于 0.03 之后残影会在屏幕上停留超过两秒,多放几个烟花就糊成一片,而且人眼会开始注意到那些缓慢变暗的方块噪点。
2.3 destination-out 变体:保住背景,颜色不发灰
基础的fillRect方案有个硬伤:它会无差别地把画布上所有东西都擦淡,包括你可能想保留的星空、山峦剪影、标题文字。解决办法是把混合模式换成destination-out:
ctx.globalCompositeOperation = 'destination-out'; ctx.fillStyle = 'rgba(0, 0, 0, 0.11)'; ctx.fillRect(0, 0, W, H); ctx.globalCompositeOperation = 'source-over';destination-out的语义是"用新绘制的图形的 alpha 去削减已有内容的 alpha",颜色通道完全不参与。效果是:已有像素的 RGB 保持不变,只有不透明度在下降。这带来两个好处。第一,如果你的背景是画在 canvas 下面一层元素上的,那这层擦除只会影响 canvas 上的粒子,背景图纹丝不动。第二,颜色不会发灰。基础方案里,黑色蒙版会不断把像素往黑色拉,导致高饱和的颜色很快就变成脏兮兮的暗调;而destination-out只降 alpha,颜色纯度得以保持,残影看起来更"亮"。
注意:
destination-out有一个经典的"擦不干净"问题。alpha 是有量化精度的(8 位,取值 0 到 255),每次乘以 0.89 之后如果结果落在 0 到 1 之间,会被舍入成 1,下一帧再乘一次还是 1,于是屏幕上留下一层永远擦不掉的极淡残影。粒子数量多、运行时间长的时候,这层残影会慢慢积累成可见的雾。我的处理办法是把擦除系数稍微调大一点(0.11 提到 0.14),缩短残留时间;或者在检测到画面静止超过一定帧数时,直接做一次彻底清屏。
2.4 可直接跑的完整实现
下面这份代码是完整的单文件版本,复制成.html双击就能看效果,包含了发射弹、重力、爆炸、拖尾和按 dt 归一化的擦除。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>烟花 · 整屏衰减拖尾</title> <style> html, body { margin: 0; height: 100%; background: #05070d; overflow: hidden; } canvas { display: block; width: 100vw; height: 100vh; } </style> </head> <body> <canvas id="stage"></canvas> <script> const canvas = document.getElementById('stage'); const ctx = canvas.getContext('2d'); const BG = [5, 7, 13]; const FADE_BASE = 0.11; // 以 60fps 为基准的擦除系数 const GRAVITY = 260; // px / s^2 const DRAG = 0.985; // 每帧速度保留比例 const MAX_PARTICLES = 2600; let W = 0, H = 0; function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.floor(W * dpr); canvas.height = Math.floor(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = `rgb(${BG.join(',')})`; ctx.fillRect(0, 0, W, H); } const particles = []; function spawn(p) { if (particles.length >= MAX_PARTICLES) particles.shift(); particles.push(p); } function launchRocket() { const x = W * (0.15 + Math.random() * 0.7); const peakY = H * (0.12 + Math.random() * 0.28); const rise = H - peakY; // 由运动学公式反推初速度:到达顶点时速度近似为 0 const vy = -Math.sqrt(2 * GRAVITY * rise); spawn({ kind: 'rocket', x, y: H, vx: (Math.random() - 0.5) * 40, vy, hue: Math.random() * 360, life: 999, maxLife: 999 }); } function explode(x, y, hue) { const count = 80 + (Math.random() * 70 | 0); const power = 150 + Math.random() * 130; for (let i = 0; i < count; i++) { const ang = Math.random() * Math.PI * 2; // 关键:开方保证圆盘内均匀分布,否则中心会挤成一坨 const r = power * Math.sqrt(Math.random()); const life = 1.2 + Math.random() * 0.9; spawn({ kind: 'spark', x, y, vx: Math.cos(ang) * r, vy: Math.sin(ang) * r, hue: hue + (Math.random() - 0.5) * 40, life, maxLife: life }); } } function update(dt) { const dragFactor = Math.pow(DRAG, dt * 60); for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.vy += GRAVITY * dt; p.vx *= dragFactor; p.vy *= dragFactor; p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; if (p.kind === 'rocket' && p.vy >= 0) { explode(p.x, p.y, p.hue); particles.splice(i, 1); continue; } if (p.life <= 0) particles.splice(i, 1); } } let lastTime = performance.now(); let acc = 0; const STEP = 1 / 120; function frame(now) { let dt = (now - lastTime) / 1000; lastTime = now; if (dt > 0.1) dt = 0.1; // 切标签页回来时防跳帧 acc += dt; while (acc >= STEP) { update(STEP); acc -= STEP; } // 擦除:把 60fps 基准系数换算成当前 dt 下的等效系数 const fade = 1 - Math.pow(1 - FADE_BASE, dt * 60); ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = `rgba(${BG[0]}, ${BG[1]}, ${BG[2]}, ${fade})`; ctx.fillRect(0, 0, W, H); // 粒子用加法混合叠,亮度会自然累加 ctx.globalCompositeOperation = 'lighter'; for (let i = 0; i < particles.length; i++) { const p = particles[i]; const t = p.life / p.maxLife; ctx.fillStyle = `hsla(${p.hue}, 100%, ${50 + 30 * t}%, ${0.3 + 0.7 * t})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.kind === 'rocket' ? 2.2 : 1.8, 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = 'source-over'; requestAnimationFrame(frame); } window.addEventListener('resize', resize); resize(); setInterval(() => { if (Math.random() < 0.85) launchRocket(); }, 700); requestAnimationFrame(frame); </script> </body> </html>这段代码里有两处值得单独说。一是const fade = 1 - Math.pow(1 - FADE_BASE, dt * 60);这行。如果不做这层换算,直接用 0.11,那么在 120Hz 屏幕上,每秒擦 120 次,拖尾长度只有 60Hz 屏幕的一半,同一个特效在高刷手机上看起来就是"尾巴特别短、光点特别硬"。换算之后,拖尾的持续时间才真正和刷新率解耦,视觉效果在任何设备上一致。二是用lighter混合模式绘制粒子,重叠的地方亮度会相加,这正是光效该有的行为,普通 alpha 混合叠出来的重叠区域只会更暗更脏。
2.5 这个方案的三个硬伤
第一个硬伤是无法区分对象。不管你画的是烟花、粒子还是背景装饰,只要在同一张画布上,都会被同一个蒙版擦淡。想保留静态元素,唯一的办法是把它们画在另一张画布或者 DOM 元素上,用 CSS 叠起来。这个改动看似麻烦,实际上非常值得,因为它同时解决了性能问题——静态层只需要画一次。
第二个硬伤是拖尾长度只有一个。所有粒子共享同一个衰减系数,你想让主爆点拖得长一点,整屏都会跟着变长,最后远处的碎屑糊成一片。要做分层拖尾,得用多张画布分别衰减,这就开始复杂了。
第三个硬伤是颜色会累积偏移。虽然用了lighter混合,但每帧的蒙版仍然是黑色,长时间运行之后,慢速粒子的颜色会逐渐被拉向背景色,高饱和的品红、青色在尾巴末端会变成灰紫色。要缓解这个问题,可以把背景色调成和主色调相近的深色,或者把擦除系数提高,缩短残留时间。
3. 方案二:轨迹缓存法,给每个粒子记一本账
切换到第二种思路,整个模型就变了:画布每帧老老实实清空,拖尾的"记忆"完全由数据结构承担。每个粒子除了位置速度,还要额外维护一个历史坐标列表,每次更新时把新位置压进去,绘制时把最近若干点连成一条逐渐变淡的线。这样做的好处是控制粒度极细,代价是画线比画点贵得多。
3.1 历史点数组与环形缓冲区
最直观的写法是给每个粒子一个普通数组,每帧push新坐标,shift掉最旧的一个。这样写起来舒服,但shift在数组长度较大时是 O(n) 操作,而且每帧都在制造和销毁对象,垃圾回收压力会明显升高。粒子数上千的时候,光这一项就能吃掉可观的时间。
正确的做法是用Float32Array做一个环形缓冲区,用一个head指针标记写入位置,写满就回绕覆盖最老的。这样每次写入是 O(1),不产生任何对象分配。
const TRAIL_LEN = 10; class Spark { constructor(x, y, vx, vy, hue, life) { this.x = x; this.y = y; this.vx = vx; this.vy = vy; this.hue = hue; this.life = life; this.maxLife = life; this.buf = new Float32Array(TRAIL_LEN * 2); this.head = 0; this.count = 0; } record() { this.buf[this.head * 2] = this.x; this.buf[this.head * 2 + 1] = this.y; this.head = (this.head + 1) % TRAIL_LEN; if (this.count < TRAIL_LEN) this.count++; } // 返回逻辑上第 i 个点在 buf 中的下标,i = 0 是最老的点 indexAt(i) { const start = (this.head - this.count + TRAIL_LEN) % TRAIL_LEN; return (start + i) % TRAIL_LEN; } }indexAt这个函数是整个方案的核心,写成这样是有原因的。很多教程会把历史点存在普通对象数组里,绘制时直接遍历,代码短,但当粒子数到几百、拖尾点十几个的时候,每帧产生的临时对象数量就是几千级别。换成Float32Array之后,所有坐标存在一块连续内存里,缓存命中率高,而且写入时不会触发 GC。这个优化在桌面浏览器上感知不明显,在中低端手机上差别很大。
3.2 从尾到头画:渐变描边的两种写法
有了历史点,绘制有两条路。第一条是分段描线:把相邻两点单独描一段,每段的颜色和线宽根据它在尾巴里的位置决定。写起来直白,效果也最可控。
function drawBySegments(ctx, p, baseAlpha) { const n = p.count; if (n < 2) return; for (let i = 1; i < n; i++) { const a = p.indexAt(i - 1); const b = p.indexAt(i); const k = i / (n - 1); // 0 最尾,1 头部 ctx.strokeStyle = `hsla(${p.hue}, 100%, ${55 + 25 * k}%, ${baseAlpha * k * k})`; ctx.lineWidth = 0.6 + 2.2 * k; ctx.beginPath(); ctx.moveTo(p.buf[a * 2], p.buf[a * 2 + 1]); ctx.lineTo(p.buf[b * 2], p.buf[b * 2 + 1]); ctx.stroke(); } }注意这里的透明度用了平方,也就是k * k。为什么不用线性?因为线性衰减的尾巴看起来"太均匀",像一根被均匀磨砂的棒子,缺少光的质感。平方衰减让头部附近保持高亮,尾部迅速变暗,更接近真实燃烧颗粒的发光曲线。这个细节调不调,观感差别很明显。
第二条路是整条渐变描边:把所有点连成一条路径,用createLinearGradient从头到尾铺一个渐变,一次stroke搞定。开销小得多,因为整个粒子只调一次stroke。
function drawByGradient(ctx, p, baseAlpha) { const n = p.count; if (n < 2) return; const tailIdx = p.indexAt(0); const headIdx = p.indexAt(n - 1); const tx = p.buf[tailIdx * 2], ty = p.buf[tailIdx * 2 + 1]; const hx = p.buf[headIdx * 2], hy = p.buf[headIdx * 2 + 1]; const g = ctx.createLinearGradient(tx, ty, hx, hy); g.addColorStop(0, `hsla(${p.hue}, 100%, 55%, 0)`); g.addColorStop(1, `hsla(${p.hue}, 100%, 78%, ${baseAlpha})`); ctx.strokeStyle = g; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.moveTo(tx, ty); for (let i = 1; i < n; i++) { const k = p.indexAt(i); ctx.lineTo(p.buf[k * 2], p.buf[k * 2 + 1]); } ctx.stroke(); }渐变版有个视觉上的妥协:线性渐变是沿着尾到头这条直线方向分布的,如果粒子轨迹是弯的(受重力影响一定会弯),渐变方向就和实际弧长不完全对应,弯得厉害时尾部的透明度会略微不均匀。不过在十几个点、拖尾长度几十像素的尺度上,这点偏差眼睛基本看不出来,换来的是数倍的性能提升。我的经验是:粒子数超过 300 就用渐变版,低于这个数用分段版。
提示:渐变的第一端不要用
rgba(0,0,0,0),用同色相但 alpha 为 0 的hsla更稳妥。透明黑在抗锯齿边缘会和背景色做混合,如果叠加了lighter混合模式,头部附近可能出现一圈脏边。
3.3 完整实现与关键参数
const TRAIL_LEN = 12; const TRAIL_MIN_DIST = 3; // 像素,距离不足就不记录新点 function updateSpark(p, dt) { p.vy += 220 * dt; p.vx *= Math.pow(0.98, dt * 60); p.vy *= Math.pow(0.98, dt * 60); p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; const lastIdx = p.indexAt(p.count - 1); const dx = p.x - p.buf[lastIdx * 2]; const dy = p.y - p.buf[lastIdx * 2 + 1]; // 只有移动足够远才记录,避免高刷屏上点挤在一起 if (p.count === 0 || dx * dx + dy * dy > TRAIL_MIN_DIST * TRAIL_MIN_DIST) { p.record(); } }这段里TRAIL_MIN_DIST那个判断是踩坑之后加的。如果按帧记录,120Hz 屏幕上粒子每帧只移动几像素,十几个点挤在很短的物理距离内,画出来的拖尾会"缩水";反过来在低帧率设备上,粒子每帧跳动几十像素,点之间空隙太大,拖尾会断成虚线。改成按距离采样之后,拖尾的物理长度就固定了:12 个点乘以 3 像素起步,大约能拖出 36 像素以上的光带,无论设备帧率如何都一致。
3.4 什么时候该放弃这个方案
轨迹缓存法的性能拐点比我最初想的要早。粗略估一下:每帧要执行的描边操作数是粒子数 × 轨迹点数,500 个粒子配 12 个点就是 6000 次路径操作,再加上每次strokeStyle字符串拼接(如果没做缓存的话),在中端手机上已经能看到掉帧了。而同样 500 个粒子,整屏衰减法只多了一次fillRect。
我实测下来,纯轨迹方案在桌面端撑到 800 粒子左右开始掉帧,中端手机 400 粒子就吃紧了。所以我的建议是:粒子总数超过 500,就别指望纯轨迹方案扛住,要么改成只有主爆点带拖尾、碎屑不带,要么改用混合方案。混合方案很简单:用整屏衰减法做底层的整体氛围拖尾,系数调大一点(0.2 以上)让衰减变快,同时给少量"主角粒子"额外加上轨迹描线。这样既有整体光感,又有关键位置的精细质感,开销还能接受。
4. 把烟花本身做对:粒子系统的几个关键点
拖尾只是表现层,底下的粒子系统要是做错了,再好的拖尾也救不回来。这一节把我踩过的几个典型问题捋一遍,这些跟拖尾方案选择无关,是烟花本身该有的东西。
4.1 发射弹、重力与顶点判定
发射弹的初速度不该拍脑袋定。想要烟花在最合适的高度炸开,就得让它在那个高度速度归零。用运动学公式反推:上升高度h、重力加速度g,初速度v = -√(2gh)。这样无论屏幕多高,烟花都恰好在预设高度炸开,不用手动调参数适配不同尺寸的屏幕。
顶点判定用vy >= 0就行,因为竖直方向速度从负变正的那一刻,就是运动学上的最高点。这比设定一个目标 y 坐标再比较要可靠,避免了数值误差导致的"永远到不了"。当然,如果你想让发射弹稍微过冲一点再炸(更自然,因为真实烟花是引信烧完才炸,不是到顶就炸),可以把判定条件改成vy >= 某个小的正值,让它多飞几帧。
4.2 爆炸形状:为什么你的烟花像个圆饼
这个问题我第一次做烟花时卡了很久。代码逻辑是每个粒子随机一个角度、随机一个速度,看起来应该均匀铺满整个圆,但实际效果总是中间亮、边缘也不匀,整体像个歪掉的花生。原因在于极坐标下的面积分布不是均匀的:半径为r的圆环面积正比于r,所以如果速度在[0, R]上均匀取值,粒子会大量堆积在圆心附近。
正确的做法是对随机数开平方:
const ang = Math.random() * Math.PI * 2; const r = power * Math.sqrt(Math.random()); // 注意这个 sqrt开方之后,落在半径r以内的粒子数比例恰好等于(r/R)²,正好等于圆面积比例,分布就均匀了。这个技巧在所有需要圆盘均匀采样的地方都适用,比如粒子出生位置的随机散布、圆形冲击波的分层。
如果你的烟花要做伪 3D(用 z 轴决定粒子的大小和亮度,让爆炸有纵深),那就得用球面采样,公式是phi = Math.acos(1 - 2 * u),其中u是 [0,1) 的随机数。注意千万别直接用Math.acos(Math.random())或者对极角均匀采样,那样粒子会在两极堆积,爆炸看起来像两个对称的漏斗。
还有一个让爆炸更自然的细节:每个粒子的速度不要精确等于统一半径,要加一点随机扰动,比如乘以0.85 + Math.random() * 0.3。严格等距的爆炸看起来像个完美的机械圆环,反而假。带一点厚度的爆炸才有那种"炸开一团"的蓬松感。
4.3 生命周期、阻力与亮度衰减
粒子的寿命不能是统一值,否则整个爆炸会在一瞬间同时熄灭,像被人"啪"地关掉了灯。每个粒子给一个随机寿命(比如 1.2 到 2.1 秒之间),熄灭就是渐次的,观感自然得多。
亮度衰减我习惯绑在剩余寿命比例上,而不是单独设一个衰减速率。绘制时用t = life / maxLife算比例,然后色相不变、亮度从 80% 降到 50%、alpha 从 1 降到 0.3。这样粒子从亮到暗的过渡是连续的,而且不会出现"还没消失就已经看不见"的情况。如果用固定的 alpha 衰减率,粒子在最后 20% 的寿命里就已经完全透明了,白白浪费了计算。
阻力这块有个小陷阱:vx *= 0.98这种写法是"每帧乘一次",帧率不同阻力效果就不同。正确写法是vx *= Math.pow(0.98, dt * 60),把每帧系数换算成基于时间的形式,物理行为才一致。这个换算跟前面拖尾擦除系数的换算是一个道理,凡是"每帧乘一次"的地方都要警惕。
4.4 固定步长积分,别让掉帧毁掉轨迹
用requestAnimationFrame拿到的dt直接做积分,会有一个隐藏问题:如果某一帧卡了一下,dt突然变成 100 毫秒,粒子的位置会一步跳出去很远,轨迹上留下一个巨大的空档,拖尾看起来就像断了一截。在低端设备上这种情况相当常见。
解决办法是固定步长积分:物理更新以固定的1/120秒推进,每帧累积dt,累够了就更新一次物理,剩下的零头留到下一帧。
let acc = 0; const STEP = 1 / 120; function frame(now) { let dt = (now - lastTime) / 1000; lastTime = now; if (dt > 0.1) dt = 0.1; // 切标签页回来会有一个巨大的 dt,必须夹住 acc += dt; let guard = 0; while (acc >= STEP && guard++ < 6) { // guard 防止卡顿后的螺旋死亡 update(STEP); acc -= STEP; } render(dt); requestAnimationFrame(frame); }那个guard计数器很重要。如果设备卡到一帧 200 毫秒,累加器里会攒下 24 个步长,理论上要连续跑 24 次物理更新,这段时间里又会产生新的延迟,越积越多,直接卡死。加上上限之后,最坏情况下会丢弃一部分物理时间,表现为粒子稍微慢一点,但界面不会崩。
5. 性能压测与优化清单
特效类代码的性能问题往往不是"某个函数太慢",而是一堆小开销叠起来。这一节列几项我反复验证过的优化点,按收益从大到小排。
5.1 粒子数、拖尾长度与帧率的三角关系
先说结论性的经验数据。在桌面端 Chrome 上,整屏衰减法跑到 2000 到 2500 个粒子还能保持 60 帧;轨迹缓存法(12 个点、渐变描边)在 500 粒子左右开始有压力,800 时明显掉帧。中端安卓机上这两个数字大概要打四折。
| 方案 | 桌面端舒适区间 | 中端手机舒适区间 | 主要瓶颈 |
|---|---|---|---|
| 整屏衰减 | 1500 到 2500 | 600 到 900 | 逐粒子的 arc 绘制 |
| 轨迹缓存(分段) | 300 到 500 | 150 到 200 | 路径状态切换次数 |
| 轨迹缓存(渐变) | 500 到 800 | 200 到 350 | createLinearGradient 调用 |
| 混合方案 | 800 到 1200 | 300 到 500 | 两者叠加 |
还有一个容易被忽略的成本:画圆(ctx.arc)比画矩形(ctx.fillRect)贵不少,因为涉及路径构建和抗锯齿。如果粒子足够小(直径 2 到 3 像素),直接用fillRect画方块,视觉上几乎看不出区别,性能能提升一大截。这是个很不"优雅"但极其有效的技巧。
5.2 状态切换、字符串拼接与对象分配
每帧在循环里修改strokeStyle或fillStyle都会触发一次状态切换,浏览器要重新解析颜色字符串。颜色字符串的拼接本身也是开销——hsla(${hue}, 100%, ${light}%, ${alpha})这种模板字符串每帧每个粒子都要生成一次,几千个粒子就是几千次字符串分配。
优化手段有两个。第一是量化颜色:把色相取整到 5 度一档,透明度取整到 0.05 一档,然后用一个 Map 缓存拼好的字符串。实际测试下来,这样能减少 80% 以上的字符串生成。第二是分组绘制:如果一批粒子颜色相同,就把它们的路径合并到一个beginPath()里,一次性fill()。这招对整屏衰减法特别管用,因为碎屑往往只有几种色相。
另外,粒子对象本身不要频繁创建销毁。用一个对象池,粒子"死亡"时标记为不可用,下次发射时复用这个对象,把属性重置一遍。整屏衰减法里我用splice删除死粒子,如果每帧都有几十个粒子死亡,数组的移动开销也不容小觑;换成"交换到末尾再pop"是个更快的删法。
5.3 高 DPI 屏幕上的分辨率陷阱
这个问题坑了我不止一次。手机和 Mac 的devicePixelRatio常常是 2 或 3,如果直接把 canvas 的width属性设成 CSS 宽度,整个画面会糊得像蒙了层毛玻璃。标准做法是按 DPR 放大画布尺寸,然后用ctx.setTransform把坐标系缩回来:
const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.floor(W * dpr); canvas.height = Math.floor(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);注意那个Math.min(dpr, 2)的上限。DPR 为 3 的手机上如果用真实比例,画布像素数是 DPR 为 1 时的 9 倍,填充率直接爆掉。限制在 2 之后,画面依然清晰,但性能可控。这是个非常划算的取舍。
还有一个陷阱:setTransform之后,你在代码里用的所有坐标都是 CSS 像素,这很好。但ctx.fillRect的尺寸也必须用 CSS 像素,如果你在某个地方误用了canvas.width(设备像素),擦除区域就会超出画面,导致边缘出现奇怪的残留。我的做法是在初始化时就把W、H两个变量固定成 CSS 像素尺寸,全程只用这两个变量。
5.4 分层渲染:背景、特效、拖尾分开放
一旦你需要保留静态背景,分层几乎是唯一解。典型的布局是三层:底层用 CSS 背景或者一张静态画的 canvas 放星空、远山;中间层铺烟花粒子;顶层可以叠一层模糊或者光晕。只有中间层需要每帧清