particle-system
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseParticle System
粒子系统
Drive many small elements with simple per-particle rules to get emergent, organic motion. Use 2D canvas for hundreds, GPU for thousands.
Points通过简单的单粒子规则驱动大量微小元素,以产生自然涌现的有机运动。数百个粒子可使用2D Canvas实现,数千个粒子则使用GPU 实现。
PointsWhen to use
适用场景
- Particle/constellation backgrounds and ambient motion.
- Celebratory bursts: confetti, sparks. Weather: snow, rain. Volumetric: smoke.
- Flow-field / curl-noise swirls and data-driven point clouds.
- Connected-dot networks (lines between nearby particles).
- 粒子/星座背景与动态氛围效果。
- 庆祝类爆发效果:彩屑、火花;天气效果:雪花、雨滴;体积效果:烟雾。
- 流场/curl-noise漩涡与数据驱动的点云。
- 连接点网络(邻近粒子间绘制线条)。
Core loop: integrate per particle
核心循环:单粒子积分
Each particle holds state and is advanced every frame: accumulate forces into acceleration, integrate velocity and position, age it, respawn when dead. Scale by for frame-rate independence.
dtjs
class Particle {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * W; this.y = Math.random() * H;
this.vx = 0; this.vy = 0;
this.life = 1; this.size = 1 + Math.random() * 2;
}
step(dt, forces) {
let ax = 0, ay = 0;
for (const f of forces) { const [fx, fy] = f(this); ax += fx; ay += fy; }
this.vx += ax * dt; this.vy += ay * dt;
this.vx *= 0.99; this.vy *= 0.99; // drag
this.x += this.vx * dt; this.y += this.vy * dt;
this.life -= dt * 0.2;
if (this.life <= 0) this.reset();
}
}Prefer semi-implicit Euler (update velocity first, then position with the new velocity, as above) — it is stable for the spring/drag forces particles use. Use a fixed or clamped () so a stalled tab does not explode the simulation.
dtMath.min(dt, 1/30)每个粒子都保存自身状态,并在每一帧更新:将力累积为加速度,积分计算速度与位置,更新生命周期,死亡后重生。通过缩放以保证帧率无关性。
dtjs
class Particle {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * W; this.y = Math.random() * H;
this.vx = 0; this.vy = 0;
this.life = 1; this.size = 1 + Math.random() * 2;
}
step(dt, forces) {
let ax = 0, ay = 0;
for (const f of forces) { const [fx, fy] = f(this); ax += fx; ay += fy; }
this.vx += ax * dt; this.vy += ay * dt;
this.vx *= 0.99; this.vy *= 0.99; // drag
this.x += this.vx * dt; this.y += this.vy * dt;
this.life -= dt * 0.2;
if (this.life <= 0) this.reset();
}
}推荐使用semi-implicit Euler(先更新速度,再用新速度更新位置,如上所示)——这种方法对于粒子使用的弹簧/阻力场来说稳定性更好。使用固定或限制范围的(),避免标签页卡顿导致模拟崩溃。
dtMath.min(dt, 1/30)Forces
力场
A force is a function returning an acceleration . Compose a list.
[fx, fy]js
const gravity = () => [0, 400]; // constant downward
const drag = (p) => [-p.vx * 0.5, -p.vy * 0.5]; // proportional resistance
function attract(tx, ty, strength) { // pull toward a point (e.g. mouse)
return (p) => {
const dx = tx - p.x, dy = ty - p.y;
const d2 = dx*dx + dy*dy + 100; // +100 softens the singularity
const f = strength / d2;
return [dx * f, dy * f];
};
}Repulsion is with negative strength. Springs toward a home position give "settle back" effects.
attract力是返回加速度的函数,可以组合成列表。
[fx, fy]js
const gravity = () => [0, 400]; // constant downward
const drag = (p) => [-p.vx * 0.5, -p.vy * 0.5]; // proportional resistance
function attract(tx, ty, strength) { // pull toward a point (e.g. mouse)
return (p) => {
const dx = tx - p.x, dy = ty - p.y;
const d2 = dx*dx + dy*dy + 100; // +100 softens the singularity
const f = strength / d2;
return [dx * f, dy * f];
};
}排斥力是强度为负值的。指向初始位置的弹簧力可实现“回弹”效果。
attractFlow fields / curl noise (organic swirl)
流场/curl-noise(有机漩涡)
Sample a noise field to derive a velocity direction per particle. Use the noise value as an angle:
js
// `noise2D` from a library (e.g. simplex-noise's createNoise2D), range -1..1
function flowField(noise2D, scale = 0.002, speed = 60) {
return (p) => {
const angle = noise2D(p.x * scale, p.y * scale) * Math.PI * 2;
return [Math.cos(angle) * speed - p.vx, Math.sin(angle) * speed - p.vy];
};
}True curl noise is divergence-free (no sources/sinks → fluid-like). Compute the curl of a potential by finite differences:
js
function curl(noise2D, x, y, eps = 1e-2) {
const n1 = noise2D(x, y + eps), n2 = noise2D(x, y - eps);
const n3 = noise2D(x + eps, y), n4 = noise2D(x - eps, y);
return [ (n1 - n2) / (2*eps), -(n3 - n4) / (2*eps) ]; // (dN/dy, -dN/dx)
}Add time to the noise input () to make the field evolve.
noise2D(x*scale, y*scale + t)采样噪声场来为每个粒子推导速度方向。将噪声值用作角度:
js
// `noise2D` from a library (e.g. simplex-noise's createNoise2D), range -1..1
function flowField(noise2D, scale = 0.002, speed = 60) {
return (p) => {
const angle = noise2D(p.x * scale, p.y * scale) * Math.PI * 2;
return [Math.cos(angle) * speed - p.vx, Math.sin(angle) * speed - p.vy];
};
}真正的curl noise是无散度的(无源头/汇点→类流体效果)。通过有限差分计算势场的旋度:
js
function curl(noise2D, x, y, eps = 1e-2) {
const n1 = noise2D(x, y + eps), n2 = noise2D(x, y - eps);
const n3 = noise2D(x + eps, y), n4 = noise2D(x - eps, y);
return [ (n1 - n2) / (2*eps), -(n3 - n4) / (2*eps) ]; // (dN/dy, -dN/dx)
}在噪声输入中加入时间参数()可让场动态演化。
noise2D(x*scale, y*scale + t)Emission: burst vs continuous
发射模式:爆发式 vs 持续式
- Burst (confetti, sparks): spawn N particles at once at a point with randomized angle/speed within a cone, then let gravity + drag take over. No respawn — remove when dead.
- Continuous (snow, smoke): spawn a steady rate; respawn dead particles at the top/source.
Randomize within a range for natural spread: .
angle = base + (Math.random()-0.5)*spread; speed = min + Math.random()*(max-min)js
function burst(x, y, n = 120) {
const out = [];
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const s = 200 + Math.random() * 400;
out.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s - 200, // upward bias
life: 1, size: 4 + Math.random()*4,
color: `hsl(${Math.random()*360},90%,60%)`,
rot: Math.random()*6.28, vr: (Math.random()-0.5)*10 });
}
return out;
}Confetti reads as confetti because of rotation + flat rectangles + gravity + air drag, not round dots. Snow reads as snow from slow fall + gentle horizontal sine sway + size-varied depth.
- 爆发式(彩屑、火花):在某一点一次性生成N个粒子,随机赋予锥角范围内的角度/速度,然后让重力+阻力接管。无需重生——死亡后移除。
- 持续式(雪花、烟雾):稳定速率生成粒子;死亡粒子在顶部/源头重生。
在范围内随机取值以实现自然扩散:。
angle = base + (Math.random()-0.5)*spread; speed = min + Math.random()*(max-min)js
function burst(x, y, n = 120) {
const out = [];
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const s = 200 + Math.random() * 400;
out.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s - 200, // upward bias
life: 1, size: 4 + Math.random()*4,
color: `hsl(${Math.random()*360},90%,60%)`,
rot: Math.random()*6.28, vr: (Math.random()-0.5)*10 });
}
return out;
}彩屑之所以看起来像彩屑,是因为旋转+扁平矩形+重力+空气阻力,而不是圆形点。雪花的视觉效果来自缓慢下落+轻微水平正弦摇摆+不同大小的深度感。
Connected-dot network without O(n²)
非O(n²)的连接点网络
Naively checking every pair is O(n²) and dies past ~300 particles. Use a uniform spatial grid: bin particles by cell, only compare against the 8 neighboring cells.
js
function connect(ctx, parts, radius) {
const cell = radius, cols = Math.ceil(W / cell);
const grid = new Map();
const key = (cx, cy) => cx + cy * cols;
for (const p of parts) {
const cx = (p.x / cell) | 0, cy = (p.y / cell) | 0;
(grid.get(key(cx, cy)) ?? grid.set(key(cx, cy), []).get(key(cx, cy))).push(p);
}
for (const p of parts) {
const cx = (p.x / cell) | 0, cy = (p.y / cell) | 0;
for (let oy = -1; oy <= 1; oy++) for (let ox = -1; ox <= 1; ox++) {
const bucket = grid.get(key(cx+ox, cy+oy)); if (!bucket) continue;
for (const q of bucket) {
if (q === p) continue;
const dx = p.x - q.x, dy = p.y - q.y, d = Math.hypot(dx, dy);
if (d < radius) {
ctx.globalAlpha = 1 - d / radius; // fade line with distance
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke();
}
}
}
}
ctx.globalAlpha = 1;
}This is O(n) for evenly distributed particles. Each pair is found twice; halve work by only checking forward neighbors if needed.
朴素地检查每一对粒子的时间复杂度是O(n²),粒子数超过~300就会失效。使用均匀空间网格:将粒子按单元格分类,仅与8个相邻单元格的粒子进行比较。
js
function connect(ctx, parts, radius) {
const cell = radius, cols = Math.ceil(W / cell);
const grid = new Map();
const key = (cx, cy) => cx + cy * cols;
for (const p of parts) {
const cx = (p.x / cell) | 0, cy = (p.y / cell) | 0;
(grid.get(key(cx, cy)) ?? grid.set(key(cx, cy), []).get(key(cx, cy))).push(p);
}
for (const p of parts) {
const cx = (p.x / cell) | 0, cy = (p.y / cell) | 0;
for (let oy = -1; oy <= 1; oy++) for (let ox = -1; ox <= 1; ox++) {
const bucket = grid.get(key(cx+ox, cy+oy)); if (!bucket) continue;
for (const q of bucket) {
if (q === p) continue;
const dx = p.x - q.x, dy = p.y - q.y, d = Math.hypot(dx, dy);
if (d < radius) {
ctx.globalAlpha = 1 - d / radius; // fade line with distance
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke();
}
}
}
}
ctx.globalAlpha = 1;
}对于均匀分布的粒子,时间复杂度为O(n)。每对粒子会被找到两次;如果需要,可以仅检查前向邻居来减少一半工作量。
GPU particles: Three.js Points + shader
GPU粒子:Three.js Points + 着色器
For thousands+, push all positions into a and render as . Animate in the vertex shader for true GPU scale.
BufferGeometryPointsjs
const N = 50000;
const pos = new Float32Array(N * 3);
for (let i = 0; i < N * 3; i++) pos[i] = (Math.random() - 0.5) * 20;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.ShaderMaterial({
uniforms: { u_time: { value: 0 }, u_size: { value: 6 } },
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: `
uniform float u_time, u_size;
void main(){
vec3 p = position;
p.y += sin(u_time + position.x) * 0.5; // animate on GPU
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = u_size * (10.0 / -mv.z); // perspective size
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
void main(){
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard; // round, soft points
gl_FragColor = vec4(1.0, 0.8, 0.4, smoothstep(0.5, 0.0, d));
}`,
});
scene.add(new THREE.Points(geo, mat));
// loop: mat.uniforms.u_time.value = clock.getElapsedTime();AdditiveBlendingdepthWrite: falsediscardgl_PointCoord对于数千个以上的粒子,将所有位置存入并以渲染。在顶点着色器中实现动画以实现真正的GPU级规模。
BufferGeometryPointsjs
const N = 50000;
const pos = new Float32Array(N * 3);
for (let i = 0; i < N * 3; i++) pos[i] = (Math.random() - 0.5) * 20;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.ShaderMaterial({
uniforms: { u_time: { value: 0 }, u_size: { value: 6 } },
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: `
uniform float u_time, u_size;
void main(){
vec3 p = position;
p.y += sin(u_time + position.x) * 0.5; // animate on GPU
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = u_size * (10.0 / -mv.z); // perspective size
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
void main(){
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard; // round, soft points
gl_FragColor = vec4(1.0, 0.8, 0.4, smoothstep(0.5, 0.0, d));
}`,
});
scene.add(new THREE.Points(geo, mat));
// loop: mat.uniforms.u_time.value = clock.getElapsedTime();AdditiveBlendingdepthWrite: falsegl_PointCoorddiscardDeliver & verify (standalone HTML)
交付与验证(独立HTML文件)
Packaged helper ():scripts/freezes thescripts/seek-shot.sh anim.html 0 1.5 3harness and screenshots each moment;?t=Ntiles them for one-glance review. Seescripts/contact-sheet.sh sheet.png frame-*.png.scripts/README.md
For a self-contained particle effect (constellation background, confetti burst, flow field, GPU points) the deliverable is one HTML file that opens directly in a browser — canvas 2D inline, or Three.js from a CDN via an importmap for GPU , one render loop, no build step. A single file is the right tier; don't reach for a bundler when one file does the job.
PointsOutput contract:
- One : for canvas, the simulation + 2D draw loop in one inline
.html; for GPU points, importmap pins<script>to a CDN with thethreesetup inline.Points - Drive the sim from one accumulated (sum of clamped
time, ordt/clock.getElapsedTime()for GPU). Nou_timescattered per particle.Date.now() - Seed the RNG — replace bare with a seeded PRNG (e.g. mulberry32) so spawn positions, angles, and bursts reproduce frame-for-frame.
Math.random()
Seek/freeze harness — advance to a fixed time, render ONE frame for screenshots. re-seeds, steps the sim deterministically to seconds with a fixed timestep, renders once, and stops the loop.
?t=NNhtml
<script>
let rng = mulberry32(1234); // fixed seed → reproducible
const particles = spawn(() => rng());
function render() { /* draw particles to canvas / renderer.render(...) */ }
const t = new URLSearchParams(location.search).get("t");
if (t !== null) {
const step = 1 / 60, end = parseFloat(t);
for (let s = 0; s < end; s += step) update(step); // fixed-step to t
render(); // one frozen frame
window.__ready = true;
} else {
let prev = performance.now();
(function loop(now){ update(Math.min((now-prev)/1000, 1/30)); prev = now;
render(); requestAnimationFrame(loop); })(prev);
}
</script>Verify loop — render → freeze → screenshot → check: open at three instants — start, mid, settle (, , ; for a burst, t≈0 spawn / t≈0.5 spread / t≈1.5 settle) — screenshot each, and check both fidelity (matches the brief) and artifacts: a blank canvas = parse/init error (check the console), particles escaping the frame (clamp/wrap missing), NaN positions (everything vanishes), all particles bunched at the origin (RNG not wired). For GPU points, WebGL needs a GPU context; Playwright/Chromium supplies one (swiftshader) headless.
?t=0?t=<mid>?t=<end>bash
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/particles.html?t=1.0" frame-mid.pngBefore you finish:
- Canvas renders particles — not blank, no console/WebGL errors, no CDN 404s.
- freezes a reproducible frame (seeded RNG + fixed timestep → same N → same pixels).
?t=N - Screenshotted at start / mid / settle — matches the brief, no escaped/NaN/origin-bunched particles.
- Disposed and leak-free if embedded in an SPA (cancel the rAF loop; for GPU, dispose geometry/material/renderer).
- honored — fewer particles or a static field where motion is decorative.
prefers-reduced-motion
打包工具(目录):scripts/可冻结scripts/seek-shot.sh anim.html 0 1.5 3测试框架并截取每个时刻的截图;?t=N可将截图拼接为一张预览图。详见scripts/contact-sheet.sh sheet.png frame-*.png。scripts/README.md
对于独立的粒子效果(星座背景、彩屑爆发、流场、GPU点),交付物应为可直接在浏览器中打开的单个HTML文件——2D Canvas代码内联,或通过importmap从CDN引入Three.js以实现GPU ,包含一个渲染循环,无需构建步骤。单个文件是合适的交付形式;当单个文件就能完成任务时,无需使用打包工具。
Points输出规范:
- 单个文件:对于Canvas效果,将模拟逻辑+2D绘制循环放在内联
.html中;对于GPU点效果,通过importmap将<script>固定到CDN,three设置代码内联。Points - 从一个累积的驱动模拟(累加限制后的
time,或GPU场景使用dt/clock.getElapsedTime())。不要在单个粒子中分散使用u_time。Date.now() - 为随机数生成器(RNG)设置种子——将原生替换为带种子的伪随机数生成器(如mulberry32),使生成位置、角度和爆发效果能够逐帧重现。
Math.random()
定位/冻结框架——跳转到固定时间,渲染一帧用于截图。 会重新设置种子,以固定时间步长确定性地将模拟推进到秒,渲染一次后停止循环。
?t=NNhtml
<script>
let rng = mulberry32(1234); // fixed seed → reproducible
const particles = spawn(() => rng());
function render() { /* draw particles to canvas / renderer.render(...) */ }
const t = new URLSearchParams(location.search).get("t");
if (t !== null) {
const step = 1 / 60, end = parseFloat(t);
for (let s = 0; s < end; s += step) update(step); // fixed-step to t
render(); // one frozen frame
window.__ready = true;
} else {
let prev = performance.now();
(function loop(now){ update(Math.min((now-prev)/1000, 1/30)); prev = now;
render(); requestAnimationFrame(loop); })(prev);
}
</script>验证流程——渲染→冻结→截图→检查: 在三个时刻打开文件——开始、中间、稳定(、、;对于爆发效果,t≈0时生成、t≈0.5时扩散、t≈1.5时稳定)——分别截图,检查保真度(符合需求)和异常情况:空白画布=解析/初始化错误(检查控制台)、粒子逃出画布(缺少限制/包裹逻辑)、NaN位置(所有粒子消失)、所有粒子聚集在原点(RNG未正确连接)。对于GPU点,WebGL需要GPU上下文;Playwright/Chromium可在无头模式下提供swiftshader上下文。
?t=0?t=<mid>?t=<end>bash
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/particles.html?t=1.0" frame-mid.png完成前检查:
- Canvas能渲染粒子——无空白、无控制台/WebGL错误、无CDN 404。
- 能冻结可重现的帧(带种子的RNG+固定时间步长→相同N→相同像素)。
?t=N - 在开始/中间/稳定时刻截图——符合需求,无逃出/NaN/聚集在原点的粒子。
- 若嵌入单页应用(SPA),需无内存泄漏(取消rAF循环;对于GPU场景,释放geometry/material/renderer)。
- 遵循——在装饰性动效场景中减少粒子数量或使用静态场。
prefers-reduced-motion
Quick reference
快速参考
| Effect | Recipe |
|---|---|
| Confetti | burst + gravity + drag + rotating rects |
| Snow | continuous top spawn + slow fall + sine sway |
| Smoke | continuous + upward + grow size + fade alpha |
| Sparks | short-life burst + additive + fast fade |
| Flow field | noise angle → velocity, evolve with time |
| Curl noise | curl of noise potential (divergence-free) |
| Constellation | spatial grid, link within radius, fade by distance |
| 1000s+ | Three.js |
| 效果 | 实现方案 |
|---|---|
| 彩屑 | 爆发式发射 + 重力 + 阻力 + 旋转矩形 |
| 雪花 | 顶部持续生成 + 缓慢下落 + 正弦摇摆 |
| 烟雾 | 持续生成 + 向上运动 + 尺寸增大 + 透明度衰减 |
| 火花 | 短生命周期爆发式发射 + 加法混合 + 快速衰减 |
| 流场 | 噪声角度→速度,随时间演化 |
| Curl噪声 | 噪声势场的旋度(无散度) |
| 星座 | 空间网格,半径内连接,随距离衰减 |
| 数千级粒子 | Three.js |
Reference files
参考文件
- — Complete canvas confetti, snow, and smoke systems; mouse attraction/repulsion; full simplex flow-field and curl-noise field with a rendered streaming look; the spatial-grid connected-dot background end to end; and a GPU
references/particle-recipes.mdsystem with per-particle life/seed attributes, additive glow, and respawn in the shader.Points
- —— 完整的Canvas彩屑、雪花、烟雾系统;鼠标吸引/排斥;完整的simplex流场和curl-noise场及流式渲染效果;端到端的空间网格连接点背景;以及带单粒子生命周期/种子属性、加法发光、着色器重生的GPU
references/particle-recipes.md系统。",Points