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

ボイド シミュレーション9選 — キャンバスで描く鳥の群れ

ボイド シミュレーションとは、近傍の個体どうしが分離・整列・結合という3つのベクトルを足し合わせ、リーダーのいない鳥の群れを描くアルゴリズムです。ライブラリ·WebGLなし、3つのベクトル規則だけで数百羽の鳥の群れを、キャンバスと素のJavaScriptだけで動かす9枚がここにあります。

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

3つの規則が九つの群れに枝分かれする理由

並びは、群れに加わる力の数の順です。01は分離・整列・結合の3ベクトルだけで回す最小の群れで、02と03は同じ3規則を鳥の羽ばたきと魚の奥行きという皮で包みます。04で4本目の逃走ベクトルが割って入り、05は同じ振る舞いのまま千羽をさばく空間ハッシュに道具を替え、06は風の流れ場、07は引力の軌道へ土台を置き換えます。08は夜の海の発光、09は目標点を文字の輪郭へ挿げ替えて群れの行先そのものを変えます。「群れ シミュレーション」で探す方は規則の講義より画面の育ちを先に見たいはずなので、この順に並べました。個体どうしが独立には動かず、Verletばねの網で形を保つ柔らかい物体の盤や格子ごとに流れる流体の盤とは軸が違い、ボイドは「この近くに誰がいるか」だけを材料にします。

群れはなぜ3規則で作るのか — 分離·整列·結合と近傍半径の計算

リーダーのいない整列

1987年のSIGGRAPHでCraig Reynoldsが発表したBoidsは、「あちらへ飛べ」と指示する個体を1匹も置かずに群れの絵を作りました。各個体は見える範囲の隣人の統計 — 平均速度・重心・近すぎる個体 — だけから次の進路を出し、群れの形はその集積として現れます。01では代表個体に3本の矢印が灯り、この計算が毎フレーム見えます。

半径が群れの密度を決める

R_NEIGH 42px(01)では隣人が密集して畑のような塊になり、52px(02)では羽の群れが横へ広がり、26px(06)では雲が薄くほぐれます。半径を広げるほど1個体が見る隣人が増え、結合の重心が遠くまで及ぶため塊が大きくなります。分離は01では半径の半分 21px に限る二段構えで、近すぎる相手だけを強く押す重み w の分数式が肌理を決めます。

重みの比が模様を分ける

同じ3規則でも W_SEP と W_ALI と W_COH の比で群れの顔が変わります。04は W_SEP 1.7 と分離を強めて裂け目を見せ、03は W_COH 0.6 と結合を弱めてゆるい学校にします。「鳥 の 群れ シミュレーション」で検索する方の求める絵に一番近いのは01と02の3規則のままの群れで、04以降は力を足して物語を乗せた発展形です。

項目コード

013規則の群れ — 分離·整列·結合だけで回す最小の群れ

個体は毎フレーム半径 R_NEIGH 42px の隣人だけを見て、離れる・向きを揃える・中心へ寄る3つのベクトルを足して動く、260羽の最小の盤です。

3規則の群れ — 分離·整列·結合だけで回す最小の群れ
新しいタブで開く

01 · 3規則の群れ — 分離·整列·結合だけで回す最小の群れ — 計測 累積面積 41.242% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRectrequestAnimationFrameMath.hypot

進路への加速を上限で切るステア

  // 進行方向へ加速を向けるステア — 目標速度から現在速度を引いて力の上限で切る
  function steer(vx, vy, tvx, tvy, out) {
    var sx = tvx - vx, sy = tvy - vy;
    var m = Math.hypot(sx, sy);
    if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
    out[0] = sx; out[1] = sy;
  }

分離だけは半径の半分に効く

  for (var j = 0; j < n; j++) {
    if (j === i) continue;
    var o = boids[j];
    var dx = o.x - b.x, dy = o.y - b.y;
    var d2 = dx * dx + dy * dy;
    if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
    var d = Math.sqrt(d2);
    cnt++;
    aliX += o.vx; aliY += o.vy;
    cx += o.x; cy += o.y;
    if (d < R_NEIGH * 0.5) {          // 分離 — 半径の半分の中でのみ、近いほど強く
      var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
      sepX -= dx / d * w; sepY -= dy / d * w;
    }
  }

分離だけが半径の半分 21px に効く非対称が群れの肌理を作り、結合は隣人の重心へ 2.2 倍の目標速度で寄せます。重みは W_SEP 1.5 ・ W_ALI 1.0 ・ W_COH 1.0、速度は MAX_SPD 128px/s を上限にその半分を下限とし、計測は累積面積 41.242% ・強度の平均 124.8 ・動いたフレーム 23/24 です。

02鳥の群れシルエット — 翼の羽ばたき位相がずれる羽毛

130羽が夕暮れの空を横切り、個体ごとにずれた羽ばたきの位相 b.ph が遠目には羽毛の波に見える盤です。

鳥の群れシルエット — 翼の羽ばたき位相がずれる羽毛
新しいタブで開く

02 · 鳥の群れシルエット — 翼の羽ばたき位相がずれる羽毛 — 計測 累積面積 39.212% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

saverotaterequestAnimationFrame

空は毎フレーム息をする

  // 夕暮れの空 — 毎フレーム縦グラデーションを塗り直す。地平線のパステルが下に
  var breath = 0.5 + 0.5 * Math.sin(tSec * 0.9);
  var horizon = H * (0.72 + 0.05 * breath);
  var sky = ctx.createLinearGradient(0, 0, 0, H);
  sky.addColorStop(0, 'rgb(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ')');
  sky.addColorStop(Math.max(0.2, horizon / H - 0.22), 'rgb(' +
    (C_DUSK[0] + (C_GLOW[0] - C_DUSK[0]) * 0.35 | 0) + ',' +
    (C_DUSK[1] + (C_GLOW[1] - C_DUSK[1]) * 0.35 | 0) + ',' +
    (C_DUSK[2] + (C_GLOW[2] - C_DUSK[2]) * 0.35 | 0) + ')');
  sky.addColorStop(1, 'rgb(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ')');
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, W, H);

前後の翼は逆位相で羽ばたく

  var flap = Math.sin(tSec * b.flap + b.ph);   // -1..1 — 翼の羽ばたき
  var ang = Math.atan2(b.vy, b.vx);
  ctx.save();
  ctx.translate(b.x, b.y);
  ctx.rotate(ang);
  var s = b.sz;
  // 前翼 — 位相が正のとき上へ、負のとき下へ折れる
  var wy = flap * 4.2 * s;
  // 後翼 — 前翼と逆の位相(ずれた二拍子)
  var wy2 = -flap * 4.2 * s;

羽ばたきの速さ b.flap は 5.2~8.6 rad/s の個体差で、同じ波でも羽が揃わないのが群れらしさの正体です。TONE を 0 → 1 へ切り替えると墨色 #23262f の羽がライラック #b9a0ff に変わり、計測は累積面積 39.212% ・強度の平均 69.5 ・動いたフレーム 23/24 です。

03魚の群れ — 奥行きのあるスクーリング

140匹が仮想の深さ b.z を持って泳ぎ、遠い個体ほど小さく薄く描かれることで平面のキャンバスに水の層ができる盤です。

魚の群れ — 奥行きのあるスクーリング
新しいタブで開く

03 · 魚の群れ — 奥行きのあるスクーリング — 計測 累積面積 44.171% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

globalAlphascalerequestAnimationFrame

遠い順に並べて手前で上書きする

  // 魚 — 深さ z の順に描く(遠いものから → 手前が上に重なる)
  var order = boids.slice();
  order.sort(function (p, q) { return q.z - p.z; });
  for (var k = 0; k < order.length; k++) {
    var b = order[k];
    var depth = 1 - b.z;                     // 1 = 手前、0 = 奥
    var sc = (0.45 + 0.75 * depth) * b.sz;   // 遠いほど小さく
    var alpha = 0.35 + 0.6 * depth;          // 遠いほど薄く — 層の核
    var ang = Math.atan2(b.vy, b.vx);
    // 尾の揺れ — 進行軸と垂直に sin
    var tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
    ctx.save();
    ctx.translate(b.x, b.y);
    ctx.rotate(ang);
    ctx.globalAlpha = alpha;

手前の層に光を足す

  // 上からの光 — 半径も濃さも息をする(0.8 rad/s)
  var breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
  var lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
  var la = 0.16 + 0.05 * breathe;
  // 一番手前の層(z が最も小さい5匹)にライラックのまぶしさを足す
  var near = boids.slice().sort(function (p, q) { return p.z - q.z; });
  for (var m = 0; m < Math.min(5, near.length); m++) {
    var f2 = near[m];

重みは W_SEP 1.6 ・ W_ALI 1.1 ・ W_COH 0.6 で、この結合を 1.2 まで上げる変種が下の表にあります。計測は累積面積 44.171% ・強度の平均 96.1 ・動いたフレーム 23/24 です。

04捕食者の登場 — 逃走ベクトルが群れを割る

160羽の群れに捕食者が入り、逃げる個体だけが急旋回して裂け目が走る様子を FLEE_R 90px の逃げ圏で見せる盤です。

捕食者の登場 — 逃走ベクトルが群れを割る
新しいタブで開く

04 · 捕食者の登場 — 逃走ベクトルが群れを割る — 計測 累積面積 40.232% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermovefillRectrequestAnimationFrame

トーラスの最短距離で隣人を測る

  // トーラスの最短距離(ラップされた画面で近い側へ)
  var dx = o.x - b.x, dy = o.y - b.y;
  if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
  if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
  var d = Math.hypot(dx, dy);
  if (d < PERCEP && d > 0.0001) {
    if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
    ax += o.vx; ay += o.vy; aliN++;
    cx += dx; cy += dy; cohN++;
  }

逃走は距離の二乗で立つ

  // 逃走 — 捕食者の半径に入れば、近いほど急に強く押される
  var pdx = b.x - predPx, pdy = b.y - predPy;
  if (pdx > W / 2) pdx -= W; else if (pdx < -W / 2) pdx += W;
  if (pdy > H / 2) pdy -= H; else if (pdy < -H / 2) pdy += H;
  var pd = Math.hypot(pdx, pdy);
  if (pd < FLEE_R && pd > 0.0001) {
    var t = (FLEE_R - pd) / FLEE_R;              // 0..1 — 近いほど 1
    var mag = t * t * MAX_FRC * 3.2 * FLEE_GAIN;
    fx += (pdx / pd) * mag; fy += (pdy / pd) * mag;
  }

逃げの力は通常の3規則(W_SEP 1.7 ・ W_ALI 1.0 ・ W_COH 0.9)に割り込む4本目で、矢印キーで捕食者を動かせます。計測は累積面積 40.232% ・強度の平均 114.6 ・動いたフレーム 23/24 です。

05千羽の計算 — 空間ハッシュで近傍を探す

全数比較では1000羽で約100万回の距離判定が毎フレーム必要になるのを、48px の升に個体を分けた Map の空間ハッシュで隣接9升だけ調べる盤です。

千羽の計算 — 空間ハッシュで近傍を探す
新しいタブで開く

05 · 千羽の計算 — 空間ハッシュで近傍を探す — 計測 累積面積 48.21% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MapfillRectrequestAnimationFrame

毎フレーム建て直す升と鍵

  // 空間ハッシュ — 個体が毎フレーム移るので毎フレーム建て直す
  var grid = new Map();
  function cellKey(cx, cy) { return cx + ',' + cy; }
  function buildGrid() {
    grid.clear();
    for (var i = 0; i < birds.length; i++) {
      var b = birds[i];
      var cx = Math.floor(b.x / CELL), cy = Math.floor(b.y / CELL);
      var k = cellKey(cx, cy);
      var arr = grid.get(k);
      if (!arr) { arr = []; grid.set(k, arr); }
      arr.push(i);
    }
  }

自分の升と隣の8升だけを見る

  var gcx = Math.floor(b.x / CELL), gcy = Math.floor(b.y / CELL);
  // 自分の升 + 隣接8升(3x3)だけ調べる — この盤の心臓部
  for (var oy = -1; oy <= 1; oy++) {
    for (var ox = -1; ox <= 1; ox++) {
      var arr = grid.get(cellKey(gcx + ox, gcy + oy));
      if (!arr) continue;
      for (var a = 0; a < arr.length; a++) {
        var j = arr[a];
        if (j === i) continue;
        checksThisFrame++;

バーの checksThisFrame に判定回数が生で出るので、N_BIRD を 1000 → 2000 へ倍にしても増え方が線形にとどまる様子が読めます。計測は累積面積 48.21% ・強度の平均 71.3 ・動いたフレーム 23/24 です。

06マミュレーション — 流線を残す黒い雲

400羽が黒い雲の形に呼吸し、残像 TRAIL 0.14 の覆いの上に風で押された軌跡が流線として積もる盤です。

マミュレーション — 流線を残す黒い雲
新しいタブで開く

06 · マミュレーション — 流線を残す黒い雲 — 計測 累積面積 51.635% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

globalCompositeOperationcreateLinearGradientrequestAnimationFrame

発散ゼロの回転風を作る

  // カールノイズの風 — ポテンシャル psi の回転 (u, v) = (dpsi/dy, -dpsi/dx)
  // psi は正弦波3層の重ね合わせ(方向も周波数も違う3つ) — ライブラリなしで作る流れ場
  function windAt(x, y, tSec) {
    var s = 0.004;                    // 空間周波数の基準
    var e = 1.5;                      // 有限差分の間隔(px)
    var dpsiDx = (psiAt(x + e, y, tSec) - psiAt(x - e, y, tSec)) / (2 * e);
    var dpsiDy = (psiAt(x, y + e, tSec) - psiAt(x, y - e, tSec)) / (2 * e);
    var mag = 46 * WIND;              // 回転場 — 発散 0(空気が溜まらず抜けもしない)
    return [dpsiDy * mag, -dpsiDx * mag];
  }

残像の上に毎フレーム薄く巻く

  // 残像の覆い — 初フレームは不透明、以降は半透明 0.14 で軌跡を残す
  ctx.globalCompositeOperation = 'source-over';
  ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
  ctx.fillRect(0, 0, W, H);
  // 地平線の暖かい帯 — 毎フレーム薄く描き直して残像の上に乗る
  var hy = H * 0.82;
  var hb = ctx.createLinearGradient(0, hy - 10, 0, hy + 8);

3規則は W_SEP 1.5 ・ W_ALI 1.0 ・ W_COH 0.8 と控えめで、WIND を 1.0 → 2.0 へ上げると雲の渦が深まります。9枚の中で最も高い累積面積 51.635% ・強度の平均 58.2 ・動いたフレーム 23/24 を記録した盤です。

07餌を中心に — 引力の周りを回る軌道

220個の個体が餌を中心とした引力と接線の渦で回り続け、個体ごとの目標半径 270 × orbBias が画面全域に同心の軌道を広げる盤です。

餌を中心に — 引力の周りを回る軌道
新しいタブで開く

07 · 餌を中心に — 引力の周りを回る軌道 — 計測 累積面積 31.175% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createRadialGradientfillRectrequestAnimationFrame

引力は中心ほど強く渦は外まで届く

  var orbR = 270 * p.orbBias;                      // 個体ごとの目標軌道半径
  var decay = Math.exp(-(d * d) / (orbR * orbR));  // ガウス減衰 — 中心だけ強く引き外は自由軌道
  var pull = -ATTRACT * pullMul * decay;
  var tx = -ny, ty = nx;                           // 接線(進路と直交)
  var swirlR = 300 * p.orbBias;
  var swirl = SWIRL * spinMul * (0.58 + 0.42 * Math.exp(-(d * d) / (swirlR * swirlR)));
  var ff = flockForce(i2, cx, cy);
  var ax = nx * pull + tx * swirl + ff[0];
  var ay = ny * pull + ty * swirl + ff[1];
  var damp = Math.pow(0.94, dt * 60);
  p.vx *= damp; p.vy *= damp;

群れの力は隣人の統計だけ

  function flockForce(idx, cx0, cy0) {
    var p = boids[idx];
    var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
    for (var j = 0; j < N; j++) {
      if (j === idx) continue;
      var q = boids[j];
      var dx = q.x - p.x, dy = q.y - p.y;
      if (dx > cx0) dx -= cx0 * 2; else if (dx < -cx0) dx += cx0 * 2;
      if (dy > cy0) dy -= cy0 * 2; else if (dy < -cy0) dy += cy0 * 2;
      var d2 = dx * dx + dy * dy;
      if (d2 > NEI_R2) continue;
      var d = Math.sqrt(d2) || 0.0001;
      if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
      aliX += q.vx; aliY += q.vy;
      cohX += dx; cohY += dy;
      cnt++;
    }
    var fx = sepX * W_SEP, fy = sepY * W_SEP;
    if (cnt > 0) {
      fx += (aliX / cnt - p.vx) * (W_ALI / 10); fy += (aliY / cnt - p.vy) * (W_ALI / 10);
      fx += (cohX / cnt) * (W_COH / 40); fy += (cohY / cnt) * (W_COH / 40);
    }
    return [fx, fy];
  }

ATTRACT 26 ・ SWIRL 40 ・ MAX_SPEED 1.6(速さの単位 SPEED_UNIT 140px/s)で回り、ポインタの横軸が渦の強さ spinMul を、縦軸が引きの強さ pullMul を 0.5~1.6 倍へ動かします。計測は累積面積 31.175% ・強度の平均 84.0 ・動いたフレーム 23/24 です。

08発光プランクトン — 夜の海で光る群れ

180個の光が夜の海で漂い、近づくポインタから逃げて、発光は lighter 合成のスプライト1枚を drawImage で並べる盤です。

発光プランクトン — 夜の海で光る群れ
新しいタブで開く

08 · 発光プランクトン — 夜の海で光る群れ — 計測 累積面積 32.674% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

globalCompositeOperationcreateRadialGradientrequestAnimationFrame

グローは白い丸1枚に焼いてある

  // 発光スプライト(オフスクリーン1枚) — drawImage で並べて費用を下げる
  var spriteSize = Math.max(8, Math.round(GLOW_R * 6));
  var sprite = document.createElement('canvas');
  sprite.width = spriteSize; sprite.height = spriteSize;
  var sctx = sprite.getContext('2d');
  var r = spriteSize / 2;
  var g = sctx.createRadialGradient(r, r, 0, r, r, r);
  g.addColorStop(0, 'rgba(255,255,255,1)');
  g.addColorStop(0.35, 'rgba(255,255,255,0.55)');
  g.addColorStop(1, 'rgba(255,255,255,0)');
  sctx.fillStyle = g;
  sctx.fillRect(0, 0, spriteSize, spriteSize);

光は脈打ち体は静かに浮かぶ

  // ゆらぎ — 位相ごとの sin で横に揺れ、少し上へ浮く癖
  var driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
  var driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3;
  var pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
  var dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
  ctx.globalAlpha = 0.55 + 0.35 * pulse;

逃げは FLEE_R 46px で (1 - d/46) × 120 の力、16% の個体は hue フラグでライラックに染まり GLOW_R 7px の球が重なって光ります。計測は累積面積 32.674% ・強度の平均 17.5(9枚で最も静か)・動いたフレーム 23/24 です。

09文字を成す群れ — ポインタを避けて集まる文章

太字の BOIDS の輪郭に個体が目標点を持って集まり、ポインタが近づくと一度乱れてまた文字を組み直す盤です。

文字を成す群れ — ポインタを避けて集まる文章
新しいタブで開く

09 · 文字を成す群れ — ポインタを避けて集まる文章 — 計測 累積面積 31.423% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillTextpointermoverequestAnimationFrame

輪郭は fillText の明るい画素から採る

  // 目標点の抽出 — オフスクリーン fillText → getImageData
  var FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
  var sampleGap = 4;   // 画素のサンプリング間隔
  octx.font = FONT_STACK;
  octx.textAlign = 'center';
  octx.textBaseline = 'middle';
  var word = 'BOIDS';
  octx.fillText(word, off.width / 2, off.height / 2);
  var img = octx.getImageData(0, 0, off.width, off.height);
  for (var yy = 0; yy < oh; yy += sampleGap) {
    for (var xx = 0; xx < ow; xx += sampleGap) {
      if (d[(yy * ow + xx) * 4] > 128) {
        targets.push({ x: xx / ow, y: yy / oh });   // 正規化座標で貯める
      }
    }
  }

ばねで戻り斥力で散る

  // 目標へのばね + 群れ(弱) + 逃走
  var dx = p.tx * W - p.x, dy = p.ty * H - p.y;
  var ax = dx * SPRING, ay = dy * SPRING;
  var ff = flockForce(i);
  ax += ff[0]; ay += ff[1];
  var ds = Math.hypot(dxs, dys) || 0.0001;
  if (ds < FLEE_R) {
    var fm = (1 - ds / FLEE_R) * 460;
    ax += (dxs / ds) * fm; ay += (dys / ds) * fm;
  }

個体は N=320 から目標点の数へ刈り込まれ、SPRING 6.0 のばねで輪郭に戻り FLEE_R 120px では (1 - d/120) × 460 の斥力を受けます。ポインタがなければ 6.2秒周期の仮想ポインタが文を横切り、計測は累積面積 31.423% ・強度の平均 50.2 ・動いたフレーム 23/24 です。

群れシミュレーションを実際に使う画面

ロゴやブランドの一文が散らばってからまた集まるイントロには、09の文字を成す群れがそのまま合います。ポインターを避けてから文章へ戻る動きが、ヘッダー領域の視線をつかみます。カードのホバーやローディングのオーバーレイのような暗い背景には、光だけを残す08の発光プランクトンが重くならずに馴染みます。

背景のスクロールセクションには、流線を残して大きく回る06のマミュレーションや、位相がずれて静かに繰り返す02の鳥の群れシルエットが合い、どちらもテキストに重ならない余白だけに置きます。

近傍半径を広げすぎると群れがここで壊れる落とし穴

結合を強くし過ぎると群れが震える

01の結合は隣人の重心へ 2.2 倍の目標速度で寄せます。この係数と W_COH を同時に強めると重心へ向かう加速が分離を上回り、個体は重心を行き過ぎて引き返す振動に落ち、塊が細かく震えます。結合を上げるときは分離も一緒に上げるか、目標速度の係数を落とすのが定石です。

有限差分の間隔で風が壊れる

06の windAt は間隔 e=1.5px の差分で回転を計ります。e を 0.5px などへ詰めると差分が画素の粒より細かくなって速度が跳ね、風がガタつきます。逆に 10px 以上へ広げると3つの正弦の山が平均されて渣が消え、平板な流れに変わります。

升が知覚半径より小さいと隣人が漏れる

05の3×3探索は CELL 48px ≥ PERCEP 30px という条件の上で正しいです。CELL を知覚半径より小さくすると枠の外に知覚圏がはみ出し、隣の升の仲間との分離だけが起きなくなって群れが縦に裂けます。直すには探索を5×5へ広げるか、CELL ≥ PERCEP を守ります。

目標点より個体が多いときの余り

09では目標点が N=320 より少なくなる幅があるため、count = Math.min(N, targets.length) で個体数を刈り込み、targets[i % targets.length] の循環で余りを処理します。このどちらかを外すと未定義の目標を引いた個体が NaN に沈み、文字が二度と組み上がりません。9つの群れのバニラ版とReact版は記事の下のパスワード付きzipに全部入っていて、解錠のパスワードはnhgr9myqです。

近傍数ひとつで変わる3つのバリエーションとアクセシビリティ対応

値1つを変えた3つの変種

名前 変える値 一言の感想
個体数 01 N_B 260 → 640 雲が濃くなる代償にフレームが重くなる
結合の強さ 03 W_COH 0.6 → 1.2 塊は締まるが泳ぎが鈍る
色 $flock-ink → $flock-lilac 墨色の羽がライラックの波に変わる

動きを減らした設定では1枚を返す

9枚すべての盤は prefers-reduced-motion: reduce の検出でアニメーションのループに入らず、時刻を固定した1枚だけを描きます。01・02・03は 1.6秒、07は 1.2秒、08は 1.0秒、09は 1.5秒の時刻を採り、04は逃走が始まった直後、05は千羽が整った瞬間、06は風が軌跡を描いた後の静止画です。描くコードは動いているときと同じなので品質は変わりません。

矢印キーで動かせる5枚

9枚すべての盤に role="application" と aria-label と tabindex="0" が付いており、Tab で盤に入れます。矢印キーで動くのは04(捕食者)・05(風の信号)・07(横軸で渦・縦軸で引き)・08(逃げの中心)・09(文字を掻き乱す)の5枚です。

群れシミュレーションを組み込む前によくある質問

鳥 の 群れ アルゴリズムを書くのに必要な数学はどの程度ですか?

必要なのは sin と atan2 と hypot だけです。向きは atan2、羽や尾の位相は sin、距離は hypot で間に合い、微分も行列も登場しません。唯一ひねりの利いた式は01の分離の重み w の分数で、近いほど効く1/d に似た形を四則で書いているだけです。

個体数はどこまで増やせますか?

全数比較では n(n-1) 回の判定が毎フレーム必要で、1000羽でおよそ100万回(組にして約50万)になります。05の空間ハッシュに替えれば隣接9升だけを見るので増え方が線形にとどまり、変種の 2000羽 までそのまま回せます。先に当たる壁は計算より canvas への描画の費用です。

9つの盤を自分のページに貼っても良いですか?

良いです。記事の下のzipには9つの群れのバニラ版とReact版が入っていて、貼ればそのまま動きます。色は各盤のscss冒頭のトークン変数で入れ替えられ、N_B や W_COH といった定数を1つ書き換えるだけで個体数・塊の固さ・風の強さも変わります。

さらに読む

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

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