GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

パーティクル アニメーション canvas 9種 — ライブラリなしの作り方

パーティクル アニメーション canvas とは、小さな粒の一つひとつに位置・速度・寿命を持たせ、一枚の canvas の上で動かす映像のことです。九種類のエンジンをライブラリなし、canvas タグひとつで作りました。下のグリッドで今動いています。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

順番は人気順ではなくエンジンが組み上がる順番です。まず粒を入れるプールが生まれ(01)、撒く場所が動き(02)、風や重力のような力が生まれ(03)、粒同士がぶつかり(04)、つながり(05)、カーソルの跡が残り(06)、色が流れ(07)、文字が粒にほぐれ(08)、最後に絵まで粒で描き直されます(09)。前の四つはエンジンの骨格、後の五つはその骨格で作る画面です。CSS だけで粒を撒く CSS パーティクル背景 と、ばね・重力を扱う 物理インタラクション が、この記事の隣人です。

01パーティクルプールと寿命

読み込みが終わっても動き続けるヒーロー背景用の部品です。あらかじめ用意した 220 個の粒は、寿命を終えると枠を空け、同じ枠で新しい設定の粒として生まれ変わります。

パーティクルプールと寿命
新しいタブで開く

01 · パーティクルプールと寿命 — 計測 累積面積 30.194% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getContextglobalAlphaarc

死んだ枠は捨てずに空けておく

function step(dt) {
  for (var i = 0; i < POOL; i++) {
    var p = pool[i];
    // 空き枠はしばらく休んでから使い回す — 配列はそのまま、この枠だけ空いている
    if (p.wait > 0) { p.wait -= dt; if (p.wait <= 0) spawn(p); continue; }
    p.age += dt;
    if (p.age >= p.life) { p.wait = 0.05 + Math.random() * 0.4; continue; }   // 死んだ枠は捨てず空けておく
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.vy += 30 * dt;                                // 浮かんだ粒を引き戻す重力

push で配列を伸ばし splice で削ると、毎フレームでメモリの確保と回収が発生し、フレーム時間が揺れます。長さを 220 に固定し、寿命が尽きた枠だけ少し空けてから上書きすれば、右上の生存数は上下してもプールの大きさは最後まで 220 のままです。

02動くエミッター

クリック時のご褒美演出や紙吹雪に使う部品です。エミッターが自分の経路を巡回しながら毎秒 90 個の粒を噴き出し、ドラッグするとノズルが指先へ移動してその場から噴き続けます。

動くエミッター
新しいタブで開く

02 · 動くエミッター — 計測 累積面積 12.007% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermovequadraticCurveTosave

ノズルは指先へ滑らかに寄る

  prevEx = ex; prevEy = ey;
  if (dragging) {
    var k = 1 - Math.exp(-14 * dt);             // ノズルが指先へ滑らかに寄る
    ex += (px - ex) * k;
    ey += (py - ey) * k;
  } else {
    var k2 = 1 - Math.exp(-6 * dt);             // 自律経路も同じように滑らかに追う
    ex += (tx - ex) * k2;
    ey += (ty - ey) * k2;
  }
  // 噴射方向 — 移動方向に合わせてなめらかに向きを変える
  var vx = (ex - prevEx) / dt, vy = (ey - prevEy) / dt;

Math.exp の指数減衰で、現在位置を目標へじわじわと近づけます。ノズルをつかむと目標が指先になり、離すと自律経路の次の地点になるだけなので、二つの状態がまったく同じコードを共有します。実測でもドラッグ目標 (297, 30) にノズルが (294, 31) まで追従し、離すと自律巡回へ戻りました。

03重力と風の場

セクション背景に流れる霧をつくる部品です。画面全体が時間とともに向きを変える風の場になっていて、ポインタを重ねた場所が渦の中心になります。

重力と風の場
新しいタブで開く

03 · 重力と風の場 — 計測 累積面積 31.642% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.sinMath.costranslate

場所ごとに違う風向き

  t += dt;
  // サインとコサインを混ぜた基準風向 — 盤全体の風がゆっくり回る
  var base = Math.sin(t * 0.2) * 1.15 + Math.cos(t * 0.34) * 0.5;
  for (var i = 0; i < N; i++) {
    var p = pool[i];
    // 場所ごとのさざ波 — 同じ瞬間でも盤の上の場所ごとに風向きが違う
    var ang = base + Math.sin(p.x * 0.012 + t * 0.6) * 0.85 + Math.cos(p.y * 0.016 - t * 0.45) * 0.6;

基準の風向きに粒の座標のサイン・コサインを重ねるので、同じ瞬間でも盤の上の場所ごとに風が違います。実測でも風向きラベルが 33 度から 41 度へゆっくり動く様子が記録され、渦の力は当初の 620 では弱く、760 へ上げて初めて半径内の 23 個がはっきり散りました。

04壁と隣接粒の跳ね返り

遊び心のあるランディングページで跳ねる粒をつくる部品です。粒は壁だけでなく粒同士でもぶつかり、向きを変えます。

壁と隣接粒の跳ね返り
新しいタブで開く

04 · 壁と隣接粒の跳ね返り — 計測 累積面積 45.357% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MapfillRectrestore

9,730 組を 625 組へ減らすセル分割

  grid.clear();
  for (var i = 0; i < N; i++) {
    var p = pool[i];
    p.cx = Math.floor(p.x / CELL);
    p.cy = Math.floor(p.y / CELL);
    var key = p.cx + ',' + p.cy;
    var arr = grid.get(key);
    if (!arr) {
      arr = [];

140 個をすべて比べると 9,730 組になります。セルの大きさを直径の 2 倍に決め、Map にセルのキーでインデックスを集め、隣のセルの候補だけを比べる空間ハッシュに切り替えると、実測の 1 フレームあたりの比較組数は 575〜653(平均 625)まで減りました。重なりは半分ずつ押し戻す補正で 1 回だけ解き、すでに離れつつある組には手を出さない — これが衝突を暴発させないこつです。

05距離でつながる線

チーム紹介や技術紹介の背景に使う部品です。浮かぶ点は近づくと間に糸が現れ、離れると消えます。

距離でつながる線
新しいタブで開く

05 · 距離でつながる線 — 計測 累積面積 27.378% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

strokeStylelineToclearRect

近いほど濃くなる糸

  var links = 0;
  ctx.lineWidth = 1;
  ctx.strokeStyle = '#fff7e6';          // 糸は低アルファのクリーム — アルファは globalAlpha で与える
  // 点と点 — 毎フレームすべての組を比べ、半径の内側だけ線を残す
  for (var i = 0; i < COUNT; i++) {
    var a = nodes[i];
    for (var j = i + 1; j < COUNT; j++) {
      var b = nodes[j];
      var dx = a.x - b.x, dy = a.y - b.y;
      var d2 = dx * dx + dy * dy;
      if (d2 >= LINK_R * LINK_R) continue;
      var fade = 1 - Math.sqrt(d2) / LINK_R;
      ctx.globalAlpha = fade * fade * 0.5;   // 近づくほど濃くなる糸

ポインタも一つのノードとして加わり、近くの点と黄色い糸でつながります。半径スライダーを既定の 90px から 130px へ上げると、実測の接続数は 500 本から 836 本へ増えます — 半径を広げた分だけ線の数が二乗で膨らむ様子を、スライダーで直接確かめられます。

06カーソルを追う残像

ポートフォリオのマウス演出に使う部品です。ポインタが通った跡に残像の粒が残り、しみ込むように消えていきます。

カーソルを追う残像
新しいタブで開く

06 · カーソルを追う残像 — 計測 累積面積 4.448% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

clientXpointermoveglobalCompositeOperation

固定長の列のいちばん古い枠を上書きする

// 一枠上書き — 次の枠は常にいちばん古い枠。配列は伸びも縮みもしない。
function writeSlot(x, y) {
  head = (head + 1) % N;
  var sl = slots[head];
  sl.on = true; sl.x = x; sl.y = y;
  sl.r = 2.2 + Math.random() * 2.4;
  sl.vx = (Math.random() - 0.5) * 16;
  sl.vy = 8 + Math.random() * 20;    // 打たれた粒はしみ込むように沈む
  sl.blue = Math.random() < 0.34;
}

90 枠のリングバッファに、ポインタがある間は毎フレーム一枠ずつ上書きします。ポインタがない間は自動デモが仮想カーソルを動かして残像を作ります。古くなった枠は大きさもアルファも小さくなってやわらかく消えるので、列を 30 枠まで縮めても末尾がぷつりと途切れることはありません。

07流れる色のパレット

音楽ページやイベントページの波に使う部品です。流れの上の粒は位置に応じた hsl の色相を受け取り、時間が経つと帯全体が横へ流れていきます。

流れる色のパレット
新しいタブで開く

07 · 流れる色のパレット — 計測 累積面積 24.006% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

hsl(fillStylebeginPath

色を計算し直さず色相に足す

    var hue = (d.x * 0.85 + drift + d.band * 24) % 360;
    var sat = 72 + (i * 37) % 24;
    var lig = 34 + (i * 53) % 21;
    var near = 0;
    if (pointer.on) {
      var ndx = d.x - pointer.x, ndy = y - pointer.y;
      near = Math.exp(-(ndx * ndx + ndy * ndy) / 2600);   // ポインタに近いほど集まる

色相を位置と時間の和で決めると、drift の値ひとつで帯全体が横へ流れます。ポインタを重ねるとガウス分布で粒が集まってその帯がわずかに膨らみ、実測の膨らみ値は場所ごとに 0.23・0.79・0.51、ポインタが離れると 0 まで沈みました。

08文字が粒にほぐれる盤

オープン告知やお知らせの文言に使う部品です。オフスクリーンのキャンバスに文字を描いて画素を読み、不透明な位置ごとに粒の home を決めます。

文字が粒にほぐれる盤
新しいタブで開く

08 · 文字が粒にほぐれる盤 — 計測 累積面積 21.127% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDatafillTextfont

アルファが 128 を超える場所が home

  // アルファが 128 を超える升目ごとに粒一つ — その場所が home になる
  var img = offCtx.getImageData(0, 0, off.width, off.height).data;
  dots = [];
  for (var y = GAP / 2; y < H; y += GAP) {
    var iy = Math.min(off.height - 1, Math.round(y)) * off.width;
    for (var x = GAP / 2; x < W; x += GAP) {
      var ix = Math.min(off.width - 1, Math.round(x));
      if (img[(iy + ix) * 4 + 3] < 128) continue;

4px 間隔の格子でアルファを調べて home を決め、粒はばねで home にとどまります。ポインタの半径内では押しのけられ、離れるとまた集まる — 実測ではポインタ周辺半径 40px の描画画素が 2,019 個から 859 個へ空き、離すと 1,649 個へ戻りました。Enter や Space で全体が一斉に跳ね上がります。

09絵を粒で描き直す

製品紹介や展示紹介のオープニングに使う部品です。オフスクリーンのキャンバスに描いた風景を格子の間隔でサンプリングし、明るい所は大きく濃い点、暗い所は小さく薄い点で描き直します。

絵を粒で描き直す
新しいタブで開く

09 · 絵を粒で描き直す — 計測 累積面積 31.312% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

drawImagegetImageDatadevicePixelRatio

ルマで大きさと濃さを決める

      // ルマ — 人の目が感じる明るさの重み。明るい所は大きく濃い点、暗い所は小さく薄い点になる
      var lum = (img[k] * 0.2126 + img[k + 1] * 0.7152 + img[k + 2] * 0.0722) / 255;
      // 暗い色には空色の気配を残し、夜空の粒らしく見せる
      var rr = Math.round(91 + (img[k] - 91) * lum);
      var gg = Math.round(140 + (img[k + 1] - 140) * lum);
      var bb = Math.round(248 + (img[k + 2] - 248) * lum);
      dots.push({
        hx: x, hy: y, x: x, y: y, vx: 0, vy: 0,
        r: 0.7 + lum * 2.0,
        alpha: 0.28 + lum * 0.72,

元の絵は外部画像を使わず、キャンバスにトークンの色で直接描いた三つの山稜と月です。各点は持ち場へ戻るばねを持っているので、押すと波打ち、離すと元の絵に戻ります — 実測ではポインタ周辺半径 40px の描画画素が 1,668 個から 329 個へ押しのけられ、離すと 1,426 個へ回復しました。

パーティクル アニメーション js はどう動いているのか?

マウス演出を使ったサイトで目に見えるのは粒ですが、実際に動いているのは毎フレームの三段階です — 力を足し、位置を積分し、消して描き直す。フレーム間の時間は requestAnimationFrame が渡す now から自分で測り、0.05 秒で頭打ちにします。dt を固定値と決め打ちすると、休んでいたタブが戻ったときに溜まった時間が一気に解放され、粒が画面の外へ飛んでいきます。

フレームを守る残りの仕掛けも、この九つの中に全部入っています。キャンバスは CSS サイズを測って devicePixelRatio を掛け、座標系は CSS ピクセルのまま使い(01)、比較する組数は空間ハッシュで 93 パーセント減らし(04)、線はまとめて引き(05)、配列は伸ばしません(01・06)。測定値もこの構造の上で取りました — 動いたフレームは 23 枠中 23 枠、累積面積は 06 の 4.4 パーセントから 04 の 45.4 パーセントまで項目ごとに違います。パーティクル アニメーション web の作り方の核心はこの三段階で、サイトで出会う派手な演出も、力の付け方が違うだけで同じ骨格です。

どこで壊れるか — 検証プローブが偽の合格を出す

この記事の落とし穴は、目で見る側ではなく測る側にありました。08・09 の操作を Playwright で実測するプローブが、最初は「ポインタを重ねても中心部の画素が 0 個にならないのに合格」を返したのです。原因を調べると、キャンバスの透明な画素が RGB (0, 0, 0) の黒として読み取られていました。空の背景が黒い明るさとして判定されるため、粒を消した場所と描いた場所の区別がつかず、判定がたまたま通ってしまっていたのです。プローブをアルファチャンネルも見るように直して初めて、08 の 2,019 個が 859 個へ減り、1,649 個へ戻る実測が取れました。キャンバスを機械で検証するときは、明るさより先にアルファを見てください。

二つ目は 09 の強度です。絵を描く点が夜空の上のクリーム系の色なので画素の差が小さく、測定強度は 16.4 と、下限 15 との差が 1.4 しかありませんでした。目にはくっきり見える月と山稜でも、機械にとっては静かな絵なのです。九つの完成したソースと測定値一式は記事の下のパスワード付き zip に入っています。zip のパスワードは z2zupqyd で、各言語ページのダウンロードボタンが同じパスワードを共有します。

3つのバリエーションとアクセシビリティ

名前 変える値 一行の感想
寿命 01 の寿命 1.2 秒 → 0.6 秒 同じプールの大きさで画面の密度が 2 倍
噴射の広さ 02 の噴射角 24 度 → 60 度 細い光線が扇形に開く
色 $subject-blue → $subject-mint 冷たい青から爽やかなミントへ

アクセシビリティは九つすべて同じ原則です。prefers-reduced-motion: reduce のユーザーには、rAF のループを CSS で隠すのではなくそもそも起動せず、それぞれの盤がいちばん読み取りやすい静止画一枚だけを描きます — 01 は粒が均等に広がった状態、08 は文字が完全に揃った状態、09 は絵が完成した状態です。どの盤も tabindex と role を持っているので、Enter・Space でやり直しや撒き直しができます。

FAQ

パーティクル アニメーション 作り方を独学で進めるなら何から始めるべきですか?

粒一つの「力を足す → 位置を積分する → 消して描く」の三段階を、この記事の 01 のプールだけで書いてみるのが最短です。派手な演出もこの三段階に力の種類が増えただけなので、骨格が分かれば九つ全部が読めるようになります。

particles.js などのライブラリを使う方が良いのではないですか?

素早い試作には向いています。ただ、ライブラリは用意されたオプションの範囲でしか動きません。この九つは力を足す順番も比較の範囲もコードとして開かれているので、ノズルをドラッグする・文字を粒にほぐすのような、オプションにない画面を直接作れます。

スマートフォンでもフレームは持ちますか?

粒の数を減らすのが定石です。プールの大きさ・半径・格子の間隔がどれも数字ひとつで決まっているので、端末に合わせて下げるだけで済みます。この記事のデモは点 64 個(05)から粒 220 個(01)まで、画素を標本化する 08・09 は数千個の水準に抑えてあり、画面幅 320px でも縦にはみ出さないことを確認しました。

さらに読む

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。