particle-system

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Particle System

粒子系统

Drive many small elements with simple per-particle rules to get emergent, organic motion. Use 2D canvas for hundreds, GPU
Points
for thousands.
通过简单的单粒子规则驱动大量微小元素,以产生自然涌现的有机运动。数百个粒子可使用2D Canvas实现,数千个粒子则使用GPU
Points
实现。

When 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
dt
for frame-rate independence.
js
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
dt
(
Math.min(dt, 1/30)
) so a stalled tab does not explode the simulation.
每个粒子都保存自身状态,并在每一帧更新:将力累积为加速度,积分计算速度与位置,更新生命周期,死亡后重生。通过
dt
缩放以保证帧率无关性。
js
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(先更新速度,再用新速度更新位置,如上所示)——这种方法对于粒子使用的弹簧/阻力场来说稳定性更好。使用固定或限制范围的
dt
Math.min(dt, 1/30)
),避免标签页卡顿导致模拟崩溃。

Forces

力场

A force is a function returning an acceleration
[fx, fy]
. Compose a list.
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
attract
with negative strength. Springs toward a home position give "settle back" effects.
力是返回加速度
[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];
  };
}
排斥力是强度为负值的
attract
。指向初始位置的弹簧力可实现“回弹”效果。

Flow 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 (
noise2D(x*scale, y*scale + t)
) to make the field evolve.
采样噪声场来为每个粒子推导速度方向。将噪声值用作角度:
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
BufferGeometry
and render as
Points
. Animate in the vertex shader for true GPU scale.
js
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();
AdditiveBlending
+
depthWrite: false
gives the glowing-particle look.
discard
on
gl_PointCoord
distance makes square points round. For per-particle data (life, seed), add custom attributes and read them in the shader.
对于数千个以上的粒子,将所有位置存入
BufferGeometry
并以
Points
渲染。在顶点着色器中实现动画以实现真正的GPU级规模。
js
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();
AdditiveBlending
+
depthWrite: false
可实现发光粒子效果。通过
gl_PointCoord
距离的
discard
操作将方形点转为圆形。如需单粒子数据(生命周期、种子),可添加自定义属性并在着色器中读取。

Deliver & verify (standalone HTML)

交付与验证(独立HTML文件)

Packaged helper (
scripts/
):
scripts/seek-shot.sh anim.html 0 1.5 3
freezes the
?t=N
harness and screenshots each moment;
scripts/contact-sheet.sh sheet.png frame-*.png
tiles them for one-glance review. See
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
Points
, 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.
Output contract:
  • One
    .html
    : for canvas, the simulation + 2D draw loop in one inline
    <script>
    ; for GPU points, importmap pins
    three
    to a CDN with the
    Points
    setup inline.
  • Drive the sim from one accumulated
    time
    (sum of clamped
    dt
    , or
    clock.getElapsedTime()
    /
    u_time
    for GPU). No
    Date.now()
    scattered per particle.
  • Seed the RNG — replace bare
    Math.random()
    with a seeded PRNG (e.g. mulberry32) so spawn positions, angles, and bursts reproduce frame-for-frame.
Seek/freeze harness — advance to a fixed time, render ONE frame for screenshots.
?t=N
re-seeds, steps the sim deterministically to
N
seconds with a fixed timestep, renders once, and stops the loop.
html
<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 (
?t=0
,
?t=<mid>
,
?t=<end>
; 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.
bash
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/particles.html?t=1.0" frame-mid.png
Before you finish:
  1. Canvas renders particles — not blank, no console/WebGL errors, no CDN 404s.
  2. ?t=N
    freezes a reproducible frame (seeded RNG + fixed timestep → same N → same pixels).
  3. Screenshotted at start / mid / settle — matches the brief, no escaped/NaN/origin-bunched particles.
  4. Disposed and leak-free if embedded in an SPA (cancel the rAF loop; for GPU, dispose geometry/material/renderer).
  5. prefers-reduced-motion
    honored — fewer particles or a static field where motion is decorative.
打包工具
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
,包含一个渲染循环,无需构建步骤。单个文件是合适的交付形式;当单个文件就能完成任务时,无需使用打包工具。
输出规范:
  • 单个
    .html
    文件:对于Canvas效果,将模拟逻辑+2D绘制循环放在内联
    <script>
    中;对于GPU点效果,通过importmap将
    three
    固定到CDN,
    Points
    设置代码内联。
  • 从一个累积的
    time
    驱动模拟(累加限制后的
    dt
    ,或GPU场景使用
    clock.getElapsedTime()
    /
    u_time
    )。不要在单个粒子中分散使用
    Date.now()
  • 为随机数生成器(RNG)设置种子——将原生
    Math.random()
    替换为带种子的伪随机数生成器(如mulberry32),使生成位置、角度和爆发效果能够逐帧重现。
定位/冻结框架——跳转到固定时间,渲染一帧用于截图。
?t=N
会重新设置种子,以固定时间步长确定性地将模拟推进到
N
秒,渲染一次后停止循环。
html
<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=<mid>
?t=<end>
;对于爆发效果,t≈0时生成、t≈0.5时扩散、t≈1.5时稳定)——分别截图,检查保真度(符合需求)和异常情况:空白画布=解析/初始化错误(检查控制台)、粒子逃出画布(缺少限制/包裹逻辑)、NaN位置(所有粒子消失)、所有粒子聚集在原点(RNG未正确连接)。对于GPU点,WebGL需要GPU上下文;Playwright/Chromium可在无头模式下提供swiftshader上下文。
bash
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/particles.html?t=1.0" frame-mid.png
完成前检查:
  1. Canvas能渲染粒子——无空白、无控制台/WebGL错误、无CDN 404。
  2. ?t=N
    能冻结可重现的帧(带种子的RNG+固定时间步长→相同N→相同像素)。
  3. 在开始/中间/稳定时刻截图——符合需求,无逃出/NaN/聚集在原点的粒子。
  4. 若嵌入单页应用(SPA),需无内存泄漏(取消rAF循环;对于GPU场景,释放geometry/material/renderer)。
  5. 遵循
    prefers-reduced-motion
    ——在装饰性动效场景中减少粒子数量或使用静态场。

Quick reference

快速参考

EffectRecipe
Confettiburst + gravity + drag + rotating rects
Snowcontinuous top spawn + slow fall + sine sway
Smokecontinuous + upward + grow size + fade alpha
Sparksshort-life burst + additive + fast fade
Flow fieldnoise angle → velocity, evolve with time
Curl noisecurl of noise potential (divergence-free)
Constellationspatial grid, link within radius, fade by distance
1000s+Three.js
Points
+ ShaderMaterial, animate in vertex shader
效果实现方案
彩屑爆发式发射 + 重力 + 阻力 + 旋转矩形
雪花顶部持续生成 + 缓慢下落 + 正弦摇摆
烟雾持续生成 + 向上运动 + 尺寸增大 + 透明度衰减
火花短生命周期爆发式发射 + 加法混合 + 快速衰减
流场噪声角度→速度,随时间演化
Curl噪声噪声势场的旋度(无散度)
星座空间网格,半径内连接,随距离衰减
数千级粒子Three.js
Points
+ ShaderMaterial,顶点着色器中实现动画

Reference files

参考文件

  • references/particle-recipes.md
    — 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
    Points
    system with per-particle life/seed attributes, additive glow, and respawn in the shader.
  • references/particle-recipes.md
    —— 完整的Canvas彩屑、雪花、烟雾系统;鼠标吸引/排斥;完整的simplex流场和curl-noise场及流式渲染效果;端到端的空间网格连接点背景;以及带单粒子生命周期/种子属性、加法发光、着色器重生的GPU
    Points
    系统。",