A particle is four numbers and a deadline粒子就是四个数,加一个截止时间
Every particle effect on this site — the firework shell, the fountain, the falling snow, the confetti — is the same program with different numbers. Once you see the shared skeleton, the hard-looking ones stop being hard.本站所有粒子类效果——烟花、喷泉、飘雪、纸屑——是同一个程序换了不同的数字。看清这具骨架之后,那些看起来很唬人的效果就不唬人了。
A particle is four numbers and a deadline
Strip away the colours and a particle is a position, a velocity, and a counter that counts down to zero. That is the whole data model:
Nothing else is required. Not a sprite, not a rotation, not a mass. The firework in the demo carries exactly these fields plus a hue, and that is enough for a burst that reads as a real shell.
The lifetime field l does double duty. It is the countdown, and it is also the alpha value used when drawing. That single decision — one number serving as both timer and opacity — is why the firework fades instead of blinking out.
const p = {
x: cx, y: cy, // position
vx: 0, vy: 0, // velocity, units per frame
l: 1 // life: 1 → 0, also the alpha
};
Four numbers. The colour is derived from the particle index or a random hue, not stored per-frame.
Why you integrate velocity instead of solving for position
You could write the position as a closed-form function of time — x = x0 + v0·t — and that works for a lone projectile. It stops working the moment anything interacts: a wind field that varies, a mouse that repels, a neighbouring particle to bounce off. Closed-form maths cannot express "whatever happened last frame".
So instead each frame adds velocity to position, then changes velocity. Two additions, no trigonometry at draw time:
The order matters and is easy to get wrong. Position first, using the velocity from the previous frame, then apply the force. If you accelerate first, every particle starts one step ahead of where it should be, and fast particles visibly outrun their own trail.
The 0.06 is gravity in units of pixels per frame squared. It is not 9.8 and it is not metres; it is whatever makes the arc look right at this scale and frame rate. That is the honest answer for almost all hand-written motion: pick the constant by eye, not by physics.
p.x += p.vx; p.y += p.vy; p.vy += 0.06; // gravity, px/frame² p.l -= 0.015; // lifetime decay
Emitters: turning one point into a shape
A single particle is a dot. The shape comes from how you spread the initial velocity. The firework uses equal angular steps:
Because the angle is i/60 * 2π rather than Math.random()*2π, the burst is an even ring instead of a clump. Random angles look wrong in a way that is hard to name — you get dense patches and visible gaps, and the eye reads it as "random dots", not "explosion".
The speed is also randomised, 2 + Math.random()*5. That randomness is what breaks the ring into something organic. Even angles plus uneven speeds: structured enough to read as a shape, uneven enough to look alive.
A fountain does the opposite. All particles start at the same point with a narrow cone of upward velocity and a wide speed range, so they form a column that spreads as it rises. Snow is simpler still — no initial velocity at all, just a random x and a small downward drift. Same loop, three completely different pictures.
for (let i = 0; i < 60; i++) {
const a = i / 60 * Math.PI * 2; // even ring
const s = 2 + Math.random() * 5; // uneven speed
const p = { x: cx, y: cy, vx: Math.cos(a) * s, vy: Math.sin(a) * s, l: 1 };
animate(p);
}
The loop is the expensive part
The simplest implementation gives every particle its own requestAnimationFrame callback, and that is what the firework demo does. It is fine for 60 particles that live for 70 frames. It is not fine for 600.
Each callback is a separate closure holding its own particle, and the browser has to schedule and invoke each one per frame. With a few hundred particles you are paying function-call overhead per particle per frame, plus you have lost any chance of batching — no shared beginPath, no grouping by colour.
The production shape is one loop for all particles, and dead ones removed in place:
Two things changed. The array is walked backwards, so splice does not skip the element that just shifted down. And the canvas is cleared once per frame instead of the particle clearing up after itself. That is the difference between 60 particles and 6000.
function frame() {
ctx.clearRect(0, 0, W, H);
for (let i = ps.length - 1; i >= 0; i--) {
const p = ps[i];
p.x += p.vx; p.y += p.vy; p.vy += 0.06; p.l -= 0.015;
if (p.l <= 0) { ps.splice(i, 1); continue; } // backwards walk
ctx.globalAlpha = p.l;
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, 7);
ctx.fill();
}
requestAnimationFrame(frame);
}
Walking backwards matters: with a forward loop, splice() reindexes the array and you silently skip the next particle.
Three cheats that make it look expensive
Real fireworks have thousands of sparks with varying mass and air drag. You are not going to simulate that. Three approximations carry most of the visual weight:
Uniform size, varying opacity. All particles are drawn at radius 3 and differ only in alpha. Growing particles as they fade looks like a cartoon; shrinking them looks like they are moving away. Keeping the radius fixed and dropping alpha reads as "burning out".
Colour by index, not by particle. A firework is one shell with one hue, so the hue is picked once per burst. Give each particle its own random hue and you get confetti, not fireworks.
A glow, not a blur. shadowBlur plus a matching shadowColor produces the bloom around each spark for a fraction of the cost of an offscreen blur pass. It is genuinely expensive per draw call, so the demo keeps the particle count at 60 — with 600 you would see the frame rate drop on a laptop.
The last one is the honest trade-off of the whole approach: shadowBlur is the single most expensive thing in these demos, and it is also what makes them look like anything at all. Budget for it by keeping counts low.
When DOM beats Canvas
Confetti on this site uses no canvas. It creates eighty absolutely positioned divs and animates two CSS properties:
Why bother, when Canvas would obviously work? Because each piece is a rectangle with its own rotation, and CSS transforms are composited on the GPU. The browser does the per-frame transform work off the main thread, so eighty spinning rectangles cost the JS thread almost nothing.
The trade-off flips as soon as you need interaction between particles. Two divs cannot easily detect each other, and you have no access to a shared pixel buffer. So: DOM for independent, style-driven pieces that only need to fall and spin; Canvas the moment particles need to collide, connect, or be affected by a force field.
const el = document.createElement('div');
el.style.cssText = 'position:absolute;width:10px;height:10px;background:' + colour;
el.animate([
{ transform: 'translateY(0) rotate(0deg)', opacity: 1 },
{ transform: 'translateY(100vh) rotate(720deg)', opacity: 0 }
], { duration: 3000 + Math.random() * 2000, easing: 'linear' })
.onfinish = () => el.remove();
Where to take this
The skeleton is four fields, two additions and a deletion. Everything else — the ring, the column, the snowfall — is a choice about initial velocity. If you want to see how far that skeleton stretches, the black hole and tornado demos add a single force toward a point and get a completely different picture from the same loop.
粒子就是四个数,加一个截止时间
把颜色抽掉,一个粒子就是位置、速度,和一个倒数到零的计数。整个数据模型就这么点:
别的都不需要。不需要贴图,不需要旋转,不需要质量。烟花示例里的粒子就只有这几个字段,多一个色相,已经足够炸出一发像样的礼花。
生命值 l 干了两件事:它既是倒计时,也是绘制时用的透明度。这一个决定——同一个数既当计时器又当不透明度——就是烟花会慢慢熄灭而不是突然消失的原因。
const p = {
x: cx, y: cy, // 位置
vx: 0, vy: 0, // 速度,单位是「每帧」
l: 1 // 生命:1 → 0,同时就是 alpha
};
四个数。颜色要么由索引推出来,要么整发随机一次,不存在每个粒子每帧存一份。
为什么用累加速度,而不是把位置解出来
位置当然可以写成时间的解析式——x = x0 + v0·t——对一颗孤立抛体完全够用。但只要有任何东西参与进来,它立刻失效:会变化的风场、推开粒子的鼠标、要撞上的邻居。解析式表达不了「上一帧到底发生了什么」。
所以每帧的做法是:速度加到位置上,再改速度。两次加法,绘制时完全不需要三角函数:
顺序很重要,而且很容易写反。先用上一帧的速度更新位置,再施加力。如果你先加速,每个粒子都会比它该在的地方快一步,快的粒子会明显跑到自己的拖尾前面去。
0.06 是重力,单位是「像素每帧的平方」。它不是 9.8,也不是米;它只是在这个尺寸、这个帧率下看起来顺眼的那个数。对手写的动效来说,这几乎总是诚实的答案:常数靠眼睛调,不靠物理公式。
p.x += p.vx; p.y += p.vy; p.vy += 0.06; // 重力,px/帧² p.l -= 0.015; // 生命衰减
发射器:把一个点变成一种形状
单个粒子只是一个点,形状来自你怎么铺开初速度。烟花用的是均分角度:
角度取 i/60 * 2π 而不是 Math.random()*2π,炸开的是一圈均匀的环,而不是一坨。随机角度会以某种难以名状的方式显得不对劲——你得到的是疏密不匀的斑块和肉眼可见的空隙,读起来是「一堆随机点」,不是「爆炸」。
速度本身是随机的,2 + Math.random()*5。正是这份随机把环打散成有机的形状。角度均匀 + 速度不匀:结构感足以被读成一个形状,不匀感又足以显得活着。
喷泉刚好相反。所有粒子从同一点出发,初速度限定在一个很窄的向上锥角里,速度范围很宽,于是它们升起来的过程中自然散开成柱。飘雪更简单——根本没有初速度,随机一个 x,加一点向下飘。同一个循环,三张完全不同的画面。
for (let i = 0; i < 60; i++) {
const a = i / 60 * Math.PI * 2; // 均匀的环
const s = 2 + Math.random() * 5; // 不匀的速度
const p = { x: cx, y: cy, vx: Math.cos(a) * s, vy: Math.sin(a) * s, l: 1 };
animate(p);
}
开销在循环里,不在数学里
最直白的写法是给每个粒子一条自己的 requestAnimationFrame 回调,烟花示例就是这么写的。60 个粒子、活 70 帧,没问题。600 个就不行了。
每条回调都是一个独立闭包,各自握着自己的粒子,浏览器每帧都要逐个调度、逐个调用。几百个粒子时,你在为「每粒子每帧的函数调用开销」持续付费,而且彻底失去了批处理的机会——没法共用一个 beginPath,没法按颜色分组。
能上生产的形状是一条循环管所有粒子,死掉的原地删掉:
改了两件事。数组倒着走,splice 就不会跳过后面前移的那一个。画布每帧清一次,而不是让每个粒子自己收拾自己。这就是 60 和 6000 的差别。
function frame() {
ctx.clearRect(0, 0, W, H);
for (let i = ps.length - 1; i >= 0; i--) {
const p = ps[i];
p.x += p.vx; p.y += p.vy; p.vy += 0.06; p.l -= 0.015;
if (p.l <= 0) { ps.splice(i, 1); continue; } // 倒着走
ctx.globalAlpha = p.l;
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, 7);
ctx.fill();
}
requestAnimationFrame(frame);
}
倒着走是关键:正着遍历时 splice() 会让数组重排,你会悄悄跳过紧随其后的那个粒子。
三个让它显得很贵的作弊
真烟花是几千颗质量、阻力各不相同的火星。你不会真去模拟。三个近似扛住了绝大部分视觉分量:
尺寸不变,透明度变。所有粒子都画成半径 3,只在 alpha 上有差别。让粒子一边淡出一边变大,像卡通;让它变小,像在远离。半径不动、只掉透明度,读起来才是「烧完了」。
颜色按整发取,不按粒子取。一发礼花是一个色相,所以色相在每次炸开时随机一次。给每个粒子自己的随机色相,你得到的不是烟花,是纸屑。
要的是辉光,不是模糊。shadowBlur 加一个同色的 shadowColor,用离屏模糊一个零头的代价就能给每颗火星加一圈光晕。它确实贵——贵在每次 draw call 上——所以示例把粒子数压在 60;换成 600,笔记本上就能看见掉帧。
最后这条就是整个做法的真实取舍:shadowBlur 是这些示例里最贵的一项,同时也是它们看起来「像那么回事」的唯一原因。预算得靠压低数量来挤。
什么时候该用 DOM 而不是 Canvas
本站的纸屑完全不用 canvas。它建八十个绝对定位的 div,只动两个 CSS 属性:
既然 Canvas 明明能做,为什么要这么绕?因为每一片都是一个带自转的矩形,而 CSS transform 走的是合成层,由 GPU 处理。浏览器把逐帧的变换放到主线程之外,八十个旋转矩形对 JS 线程几乎是零成本。
一旦粒子之间要相互作用,这个取舍就反过来。两个 div 很难互相检测,你也拿不到共享的像素缓冲。所以:互相独立、只靠样式驱动的碎片用 DOM;粒子一旦需要碰撞、连线、或被力场影响,立刻换 Canvas。
const el = document.createElement('div');
el.style.cssText = 'position:absolute;width:10px;height:10px;background:' + colour;
el.animate([
{ transform: 'translateY(0) rotate(0deg)', opacity: 1 },
{ transform: 'translateY(100vh) rotate(720deg)', opacity: 0 }
], { duration: 3000 + Math.random() * 2000, easing: 'linear' })
.onfinish = () => el.remove();
接下来可以怎么用
骨架就是四个字段、两次加法、一次删除。剩下的——环、柱、雪——全是关于初速度的选择。黑洞和龙卷只是在同一个循环里加了一个「朝某点」的力,画面就完全不同了,想看骨架能拉多长可以去翻这两个。
Related demos相关示例
Each one runs on its own page, with the source shown below the stage.每个都在独立页面实时运行,舞台下方附完整源码。
Every effect on this site is a single self-contained file. Copy it, change one number, see what happens.本站每个特效都是单个自包含文件。复制走,改一个数字,看会发生什么。
Browse all 131 effects浏览全部 131 个特效