mcpField
P
particle-system

particle-system

github.com/iart-aiクリエイティブ・メディア451.4k 回インストール18 週間前に更新
リポジトリ

particle-system について

particle-system は、粒子ごとの動作ルールでエフェクトを作るスキルです。2D canvas と GPU 描画を扱い、再現可能な単体 HTML の作成方法も示します。

主な機能

  1. 01力を使った粒子運動の更新
  2. 02バースト型と連続型の発生器
  3. 03フローフィールドと curl-noise の作成
  4. 04空間グリッドによる近傍粒子の接続
  5. 05Three.js Points による大量描画
  6. 06再現可能な単体 HTML の生成

活用シーン

  1. 01紙吹雪、雪、煙の作成
  2. 02星座風の背景の構築
  3. 03フローフィールドのアニメーション
  4. 04再現可能な粒子エフェクトのプレビュー

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/iart-ai/webgl-animation-skills --skill particle-system

インストール後、Claude Code で/particle-systemを入力して読み込まれたか確認します。

同梱ファイルREADME.mdreferences/particle-recipes.md

詳細

パーティクルシステム

シンプルな粒子ごとのルールで多数の小さな要素を動かし、創発的で有機的な動きを生み出します。数百個なら2Dキャンバス、数千個ならGPUのPointsを使います。

使用する場面

  • パーティクルや星座の背景、アンビエントな動き。
  • 華やかなバースト:紙吹雪、火花。天候:雪、雨。ボリューム表現:煙。
  • フローフィールド/カールノイズによる渦巻きや、データ駆動型のポイントクラウド。
  • 接続ドットネットワーク(近くのパーティクル同士を線で結ぶ)。

基本ループ:パーティクルごとに積分する

各パーティクルは状態を保持し、毎フレーム更新されます。力を加速度に累積し、速度と位置を積分して、経過時間を加算し、寿命が尽きたら再生成します。フレームレートに依存しないよう、dtでスケーリングします。

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();
  }
}

セミインプリシット・オイラー法(上記のように、先に速度を更新し、その新しい速度で位置を更新する方法)を使うことをおすすめします。パーティクルで使うばね力や抵抗力に対して安定しています。タブが停止してもシミュレーションが破綻しないよう、固定またはクランプしたdt(Math.min(dt, 1/30))を使ってください。

力

力は加速度[fx, fy]を返す関数です。リストを組み合わせて使います。

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です。ホーム位置へ向かうばね力を使うと、「元の位置に戻る」ような効果が得られます。

フローフィールド/カールノイズ(有機的な渦巻き)

ノイズフィールドをサンプリングし、パーティクルごとの速度方向を導きます。ノイズ値を角度として使います。

// `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];
  };
}

真のカールノイズは発散ゼロです(湧き出しや吸い込みがなく、流体のような動きになります)。有限差分でポテンシャルのカールを計算します。

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))、フィールドを変化させます。

放出:バーストと連続

  • バースト(紙吹雪、火花):円すい状の範囲で角度と速度をランダム化し、点から一度にN個のパーティクルを生成します。その後は重力と抵抗に任せます。再生成はせず、寿命が尽きたら削除します。
  • 連続(雪、煙):一定の割合で生成し、寿命が尽きたパーティクルは上部または発生源で再生成します。

自然な広がりになるよう、範囲内でランダム化します。angle = base + (Math.random()-0.5)*spread; speed = min + Math.random()*(max-min)。

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;
}

紙吹雪らしさを生み出すのは、丸いドットではなく、回転+平たい長方形+重力+空気抵抗です。雪らしさは、ゆっくりとした落下、穏やかな水平サイン波状の揺れ、サイズの違いによる奥行きで表現できます。

O(n²)を避けた接続ドットネットワーク

すべてのペアを単純に比較するとO(n²)になり、パーティクル数が約300を超えると処理できなくなります。均一な空間グリッドを使ってパーティクルをセルに振り分け、隣接する8つのセルのみを比較します。

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)です。各ペアは2回見つかるため、必要に応じて前方の近傍のみをチェックし、処理を半分にできます。

GPUパーティクル:Three.jsのPoints+シェーダー

数千個以上の場合は、すべての位置をBufferGeometryに格納し、Pointsとして描画します。頂点シェーダーでアニメーションさせることで、GPUの性能を最大限に活用できます。

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すると、四角いポイントを丸くできます。寿命やシードなど、パーティクルごとのデータには、カスタム属性を追加してシェーダーで読み取ります。

配布と検証(スタンドアロン HTML)

パッケージ化されたヘルパー(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 ポイント)では、成果物はブラウザーで直接開ける 1 つの HTML ファイルです。canvas 2D をインラインで使うか、GPU の Points 用に importmap 経由で CDN の Three.js を使い、レンダーループは 1 つ、ビルド手順は不要です。1 ファイルで済むなら、1 ファイルが適切な規模です。1 ファイルでできることにバンドラーを持ち込まないでください。

出力要件:

  • .html を 1 つ:canvas の場合は、シミュレーションと 2D 描画ループを 1 つのインライン <script> に記述します。GPU ポイントの場合は、importmap で three を CDN に固定し、Points のセットアップをインラインで記述します。
  • シミュレーションは、蓄積した time 1 つで駆動します(クランプした dt の合計、または GPU 用の clock.getElapsedTime()/u_time)。パーティクルごとに Date.now() を呼び出さないでください。
  • RNG にシードを設定します — 何も設定していない Math.random() を、シード付き PRNG(mulberry32 など)に置き換え、スポーン位置、角度、噴出をフレームごとに再現できるようにします。

シーク/フリーズハーネス — 固定時刻まで進め、スクリーンショット用にフレームを 1 つ描画します。 ?t=N はシードを再設定し、固定タイムステップでシミュレーションを決定的に N 秒まで進めて、1 回だけ描画してループを停止します。

<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>

検証ループ — 描画 → フリーズ → スクリーンショット → 確認: 3 つの時点(開始、中間、静止時。?t=0、?t=<mid>、?t=<end>。噴出の場合は t≈0 でスポーン/t≈0.5 で拡散/t≈1.5 で静止)で開き、それぞれスクリーンショットを撮影して、忠実度(要件に合っているか)と問題を確認します。問題の例:canvas が真っ白 = 解析/初期化エラー(コンソールを確認)、パーティクルがフレーム外に出る(クランプ/ラップ処理がない)、位置が NaN(すべて消える)、すべてのパーティクルが原点に固まる(RNG が接続されていない)。GPU ポイントには GPU コンテキストが必要です。Playwright/Chromium はヘッドレスでこれを提供します(swiftshader)。

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 の場合はジオメトリ/マテリアル/レンダラーを破棄)。
  5. prefers-reduced-motion に対応すること — 動きが装飾的な場合は、パーティクルを減らすか、静的なフィールドにします。

クイックリファレンス

エフェクトレシピ
紙吹雪噴出 + 重力 + 抵抗 + 回転する長方形
雪上端から連続スポーン + ゆっくり落下 + 正弦波状の揺れ
煙連続発生 + 上昇 + サイズ拡大 + アルファ値のフェード
火花寿命の短い噴出 + 加算合成 + 高速フェード
フローフィールドノイズの角度 → 速度、時間とともに変化
カールノイズノイズポテンシャルのカール(発散なし)
星座空間グリッド、半径内をリンクし、距離に応じてフェード
1000 個以上Three.js Points + ShaderMaterial、頂点シェーダーでアニメーション

参考ファイル

  • references/particle-recipes.md — canvas での紙吹雪、雪、煙のシステム一式、マウスによる引力/斥力、レンダリングされた流れるような見た目の完全なシンプレックスフローフィールドとカールノイズフィールド、空間グリッドで接続されたドット背景の実装一式、さらにパーティクルごとの寿命/シード属性、加算発光、シェーダー内での再スポーンを備えた GPU Points システム。

コメント