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

キャンバス メタボール アニメーション9種 — マーチングスクエアで融合する液体の塊

メタボール アニメーションは球の輪郭を滑らかにモーフィングさせる仕事ではなく、格子点ごとのスカラー場を毎フレーム計算する仕事です。ライブラリ·WebGL なし — 閾値を跨ぐ格子をつなぐマーチングスクエアひとつでブロブが液体のように融合と分離を繰り返します。

デモ

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

フィールドひと層が九つの場面ににじむ理由

並び順の基準は項目の賑やかさではなく、スカラー場をどう読み替えるかです。01は距離減衰を格子に足すだけの最小構成で、02がその同じ格子に16通りの場合分けを載せて輪郭線を引き、03は02の境界パスをそのまま塗って粘着質の合体を作ります。04は閾値の上下を問わずフィールド値を明度として読む発光へ進み、05と06は同じ計算を毎フレーム位置に結びつける持続型(浮力・ポインター追跡)、07は閾値を5本同時に追う等高帯、08はテキストのピクセルをフィールド源に変え、09は衝撃波を一瞬だけ足して消す雨だれで締めくくります。「blender」で検索して3Dソフトの球状オブジェクトを期待した人には遠回りに見えますが、この9盤が計算しているのは同じスカラー場と閾値の関係で、3D側の表現も中身は同じ等値面の考え方です。ブラウザだけで完結する中点変位で雷の枝を割る9盤とは違い、この9盤を貫く数は格子点の値が閾値をどちら向きに跨いでいるかひとつです。

格子点が閾値をどちら向きに跨ぐかが9盤を貫く

01から09まで、すべての盤が持っている共通の計算は「格子の各点にスカラー値を書き込み、その値が閾値の上か下かを見る」ことです。マーチングスクエアはこの跨ぎ方を4隅ぶんのビットにまとめて16通りの場合分けに変換するだけの層で、02以降の輪郭・グーイ・発光・等高帯はすべてこの1層の上に別の描画方法を重ねているにすぎません。順序はこの一本の規則が点の集合から輪郭へ、輪郭から質感へと移っていく道筋として読めます。

メタボールはなぜマーチングスクエアで描くか — スカラーフィールドと閾値境界の計算

各点のスカラー値は距離減衰の総和で決まる

01のfieldAtが示すとおり、格子点のスカラー値はそこにいるすべてのボールの半径²÷距離²ぶんの減衰を足し合わせた総和です。1個のボールだけでは滑らかな山になりますが、2個が近づくと山と山が重なった場所の値が急に高くなり、これが閾値を跨いだ瞬間に橋として現れます。ボールの数だけループを回す単純な計算なので、メタボールという名前が示す「複数の球が場を通じて対話する」性質はこの足し算だけで生まれています。

4隅の閾値超過ビットが16通りの場合分けに変換される

マーチングスクエアは1マスの4隅それぞれが閾値を超えているかを1ビットずつ持たせ、4ビットを合わせた0〜15の数字ひとつでそのマスの輪郭パターンを決めます。02の場合分け表はこの16通りのうち0と15(全部下か全部上)は輪郭なし、5と10は対角の2隅だけが超過した鞍点で、中心の値を追加で見ないと分離と連結のどちらかが決まらない曖昧なケースとして扱っています。

線形補間が境界点をピクセル単位ではなく実数座標で求める

ip(a, b) = (THRESH - a) / (b - a)という1行が、格子2点の値aとbの間で閾値をちょうど跨ぐ位置を0〜1の比率として返し、lerpPtはその比率をx軸かy軸の実距離に掛けて境界点の座標にします。この補間を省いてマスの中央に固定点を置くだけだと、輪郭はマス目に沿ったギザギザの階段状になり、滑らかな液体の縁は出ません。

lighter合成が重なった場の明るさを計算する

04と08がフィールド値を明度としてglobalCompositeOperation = 'lighter'で描き重ねるのは、ピクセルの値を単純に足し合わせるこの合成モードが、複数のボールが重なった場所ほど場の値が高いという計算をそのまま画面の明るさに翻訳できるからです。塗り終えたらsource-overへ戻さないと、次に描く輪郭線やUIまで発光の影響を受けてしまいます。

項目別コード

項目ごとに、そのブロブの挙動を左右する関数を実際に動いているデモのソースからそのままコピーして載せています。書き直した疑似コードではなく本物の一部なので、値を変えて自分のプロジェクトへ移すところから始められます。

01フィールドひと層 — 閾値を超えるピクセルを数える最初の一枚

ヒーローセクションの背景やローディング画面の一枚絵に使う、各ボール中心からの距離減衰をルックアップテーブルで格子に足し合わせる最小構成の盤です — 実測では累積面積33.374%・強度平均95.3で、24フレーム中23フレームに動きがありました。

フィールドひと層 — 閾値を超えるピクセルを数える最初のメタボール
新しいタブで開く

01 · フィールドひと層 — 閾値を超えるピクセルを数える最初のメタボール — 計測 累積面積 33.374% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDataputImageDatarequestAnimationFrame

距離減衰をルックアップテーブルで引いて格子に足す

function fieldAt(px, py, t) {
    var sum = 0, closest = -1, closestD = 1e9;
    for (var i = 0; i < balls.length; i++) {
      var p = ballPos(balls[i], t);
      var dx = px - p[0], dy = py - p[1];
      var dist = Math.sqrt(dx * dx + dy * dy);
      // 減衰 = ルックアップテーブル(putImageDataで焼いたスプライトから読んだ値)をボール半径でスケール
      // スプライト半径(64)で減衰が1になるため、scaled = dist * 64 / rad がその距離でちょうど境界になる
      var scaled = dist * (SPR_R / balls[i].rad);
      var v = scaled >= SPR_R ? 0 : lut[Math.min(SPR_R - 1, Math.round(scaled))];
      sum += v;
      if (dist < closestD) { closestD = dist; closest = i; }
    }
    return [sum, closest];
  }

距離をそのまま計算せずスプライト半径64で焼いたルックアップテーブルを引く理由は、Math.sqrtと減衰式を毎ピクセル評価する代わりに配列参照ひとつへ落とすためで、scaledがボール半径radでスケールされているので大小さまざまなボールが同じテーブルを共有できます。

02マーチングスクエア — 16ケースの表で境界線をつなぐ輪郭

SVGフィルタなしで輪郭線だけを引きたいアイコンやバナー装飾に使う、格子4隅の閾値超過ビットから16通りの場合分けを引いて境界点を線形補間で求める盤です — 実測では累積面積34.129%・強度平均141.2で、24フレーム中23フレームに動きがありました。

マーチングスクエア — 16ケースの表で境界線をつなぐ輪郭
新しいタブで開く

02 · マーチングスクエア — 16ケースの表で境界線をつなぐ輪郭 — 計測 累積面積 34.129% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Path2DquadraticCurveTo

4隅の閾値超過ビットが16通りの場合分けを選ぶ

function lerpPt(gx, gy, edge, cw, ch) {
    var vTL = grid[gy * (COLS + 1) + gx];
    var vTR = grid[gy * (COLS + 1) + gx + 1];
    var vBR = grid[(gy + 1) * (COLS + 1) + gx + 1];
    var vBL = grid[(gy + 1) * (COLS + 1) + gx];
    var x0 = gx * cw, y0 = gy * ch;
    function ip(a, b) { return (THRESH - a) / (b - a); }
    switch (edge) {
      case EDGES.T: return [x0 + cw * ip(vTL, vTR), y0];
      case EDGES.B: return [x0 + cw * ip(vBL, vBR), y0 + ch];
      case EDGES.L: return [x0, y0 + ch * ip(vTL, vBL)];
      case EDGES.R: return [x0 + cw, y0 + ch * ip(vTR, vBR)];
    }
    return [x0, y0];
  }

ip(a, b) = (THRESH - a) / (b - a)が格子2点の間で閾値を跨ぐ位置を0〜1の比率で求める式で、lerpPtはswitchが選んだ辺(上下左右)ごとにこの補間をx軸かy軸のどちらかへ適用するだけなので、境界点はピクセル単位ではなく実数座標で滑らかに求まります。

03グーイ融合 — SVGフィルタなしで本物のパスが滲む

ボタンのホバーやローディングドットの合体演出に使う、02で得た境界点をそのままパスとして塗りつぶす盤です — 実測では累積面積43.647%・強度平均43.9で、24フレーム中23フレームに動きがありました。

グーイ融合 — SVGフィルタなしで本物のパスが滲む
新しいタブで開く

03 · グーイ融合 — SVGフィルタなしで本物のパスが滲む — 計測 累積面積 43.647% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Path2DrequestAnimationFramesetAttribute

各セルの中心に最も近いボールを探して色を決める

function nearestBall(cx, cy, t) {
    var best = 0, bd = 1e9;
    for (var i = 0; i < balls.length; i++) {
      var p = ballPos(balls[i], t);
      var d = (p[0] - cx) * (p[0] - cx) + (p[1] - cy) * (p[1] - cy);
      if (d < bd) { bd = d; best = i; }
    }
    return best;
  }

距離の平方根を取らず(p[0]-cx)²+(p[1]-cy)²のまま比較しているのは大小関係だけが必要でMath.sqrtが不要だからで、nearestBallが返すインデックスをそのセルの塗り色に使うことで、隣接する2つのボールの間でも色の遷移が境界パスの単位で切り替わります。

04光のにじみ — フィールド値を明度で読むグロウブルーム

プロフィールカードのホバー効果やCTAボタンの発光装飾に使う、閾値の上下を問わずフィールド値を明度としてlighterで重ねる盤です — 実測では累積面積36.467%・強度平均19.8で、24フレーム中23フレームに動きがありました。

光のにじみ — フィールド値を明度で読むグロウブルーム
新しいタブで開く

04 · 光のにじみ — フィールド値を明度で読むグロウブルーム — 計測 累積面積 36.467% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createRadialGradientglobalCompositeOperationrequestAnimationFrame

周囲のボールとの接近度が内部の明るさを決める

function falloff(u) {
    var q = u * GLOW;
    return 1 / (1 + q * q);
  }

  // 周囲のボールとの接近度(0..1) — 近づくほど内部が明るくなる
  function heat(b) {
    var s = 0;
    for (var j = 0; j < balls.length; j++) {
      var o = balls[j];
      if (o === b) continue;
      var d = Math.hypot(o.x - b.x, o.y - b.y);
      var lim = (o.r + b.r) * 1.5;
      if (d < lim) s += 1 - d / lim;
    }
    return Math.min(1, s);
  }

falloffが1 / (1 + q * q)という滑らかに減衰する曲線を返す一方、heatは自分以外のボールとの距離が(o.r + b.r) * 1.5より近いときだけ接近度を足し合わせるので、複数のボールに同時に近づくほど内部が明るくなる発光の強さが自然に積み上がります。

05ラバランプ — 浮力と温度差で浮かぶ塊

ダッシュボードの背景やアプリのアイドル画面に使う、加熱で浮き冷却で沈む温度サイクルをボールごとに与える盤です — 実測では累積面積53.512%・強度平均21.4で、24フレーム中23フレームに動きがありました。

ラバランプ — 浮力と温度差で浮かぶ塊
新しいタブで開く

05 · ラバランプ — 浮力と温度差で浮かぶ塊 — 計測 累積面積 53.512% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramequadraticCurveToPath2D

温度サイクルが底と天井で押し合って浮力を作る

function update(t, dt) {
    var floorY = H * 0.84, ceilY = H * 0.16;
    for (var i = 0; i < balls.length; i++) {
      var b = balls[i];
      // 温度サイクル — 底付近で加熱、天井付近で冷却。位相でずらして互い違いにする
      var nearBottom = Math.max(0, 1 - (b.y - floorY) / (H * 0.34));
      var nearTop = Math.max(0, 1 - (ceilY - b.y) / (H * 0.3));
      b.T += (nearBottom * b.rate * 1.5 + 0.02 - nearTop * b.rate * 1.9 - 0.012) * dt * 2.2;
      b.T = Math.max(0, Math.min(1, b.T + Math.sin(t * 0.3 + b.ph) * 0.006));
      // 浮力 — 熱いほど(軽いほど)上へ、冷たければ下へ。大きいボールほどゆっくり
      var buoy = (b.T - 0.42) * 62 / (b.r * 0.1);
      b.vy -= buoy * dt;
      // 水平方向の徘徊 — 壁のガラスにぶつかると跳ね返る
      b.vx += Math.sin(t * 0.5 + b.ph) * 7 * dt;
      b.vx *= 0.995; b.vy *= 0.988;
      b.x += b.vx * dt; b.y += b.vy * dt;
      var half = b.r * 0.7;
      if (b.x < half) { b.x = half; b.vx = Math.abs(b.vx) * 0.5; }
      if (b.x > W - half) { b.x = W - half; b.vx = -Math.abs(b.vx) * 0.5; }
      if (b.y > floorY) { b.y = floorY; b.vy = -Math.abs(b.vy) * 0.3; }
      if (b.y < ceilY) { b.y = ceilY; b.vy = Math.abs(b.vy) * 0.3; }
    }

温度b.Tを底に近いほど加熱・天井に近いほど冷却しながらnearBottomとnearTopという2つの重みで押し合う設計により、浮力buoy = (b.T - 0.42) * 62 / (b.r * 0.1)は大きいボールほど分母が大きく動きが穏やかになるので、ランプの中で大小の塊が違う速さで浮き沈みします。

06ポインタ引力 — 引かれて引き裂かれる橋

プロダクトのインタラクティブヒーローやカーソル追従演出に使う、ポインター付近のボールだけを引力で加速する盤です — 実測では累積面積32.608%・強度平均58.6で、24フレーム中23フレームに動きがありました。

ポインタ引力 — 引かれて引き裂かれる橋
新しいタブで開く

06 · ポインタ引力 — 引かれて引き裂かれる橋 — 計測 累積面積 32.608% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

addEventListenerrequestAnimationFramequadraticCurveTo

ポインター周辺だけ引力で加速しホームへ減衰で戻る

function update(t, dt) {
    // 自動モード — pointermoveがなければ円運動の巡回経路を仮想ポインターとして使う
    if (!hasPointer) {
      var a = t * 0.7;
      ptrX = W * 0.5 + Math.cos(a) * W * 0.34;
      ptrY = H * 0.5 + Math.sin(a * 1.15) * H * 0.32;
    }
    var m = 16;
    for (var i = 0; i < balls.length; i++) {
      var b = balls[i];
      var dx = ptrX - b.x, dy = ptrY - b.y;
      var d = Math.hypot(dx, dy) || 1;
      if (d < PULL_R) {
        var pull = (1 - d / PULL_R) * PULL_K;
        b.vx += (dx / d) * pull * 60 * dt;
        b.vy += (dy / d) * pull * 60 * dt;
      } else {
        // 減衰 — ホーム位置へ徐々に戻っていく
        b.vx += (b.homeX - b.x) * 0.9 * dt;
        b.vy += (b.homeY - b.y) * 0.9 * dt;
      }
      // 少し徘徊 — 静止状態を避ける
      b.vx += Math.cos(t * 0.8 + b.ph) * 3 * dt;
      b.vy += Math.sin(t * 0.9 + b.ph) * 3 * dt;
      b.vx *= 0.9; b.vy *= 0.9;

hasPointerが偽の間は円運動ptrX/ptrYが仮想ポインターとして巡回し、PULL_Rの範囲に入ったボールだけpull = (1 - d / PULL_R) * PULL_Kで加速して範囲外ではhomeX/homeYへ戻る減衰が働くため、カーソルが触れた場所だけ橋が太くなり離れると自然に手放されます。

07等高帯 — 閾値を重ねる地形図

天気アプリや気圧図ふうのデータビジュアルに使う、閾値を1本ではなく5本同時に追跡する盤です — 実測では累積面積52.869%・強度平均70.0で、24フレーム中23フレームに動きがありました。

等高帯 — 閾値を重ねる地形図
新しいタブで開く

07 · 等高帯 — 閾値を重ねる地形図 — 計測 累積面積 52.869% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDatacreateLinearGradientrequestAnimationFrame

熱源フィールドと地形ノイズを混ぜて閾値5本で帯分けする

function terrain(nx, ny, t) {
    var a = Math.sin(nx * 5.4 + t * 0.25) * Math.cos(ny * 4.1 - t * 0.19);
    var b = Math.sin((nx + ny) * 3.1 - t * 0.31);
    return (0.5 + 0.32 * a + 0.18 * b);
  }

  function classify(t) {
    var img = fieldCtx.getImageData(0, 0, LOWW, LOWH);
    var d = img.data;
    var thr = [];
    for (var k = 1; k <= BANDS; k++) thr.push(k / (BANDS + 1));
    for (var y = 0; y < LOWH; y++) {
      for (var x = 0; x < LOWW; x++) {
        var idx = (y * LOWW + x) * 4;
        var v = d[idx] / 255;                 // 熱源フィールド(0..1)
        var mix = v * 0.72 + terrain(x / LOWW, y / LOWH, t) * 0.28;
        var band = 0;
        for (var k2 = 0; k2 < thr.length; k2++) if (mix >= thr[k2]) band = k2 + 1;
        bandCtx.fillStyle = band === 0 ? C_INK : BAND_COL[band - 1];
        bandCtx.fillRect(x, y, 1, 1);
      }
    }
  }

classifyはgetImageDataで読んだ熱源フィールドの値vとterrainが返す地形ノイズを0.72:0.28で混ぜてからthr配列と比較し、閾値を1本ではなくBANDS本同時に判定することで同じスカラー場が単色の輪郭ではなく段差のある等高帯の地図になります。

08液体文字 — テキストピクセルから生まれるモーフィング

ブランドのイントロアニメーションやロゴのモーフィングに使う、文字のピクセルをそのままフィールド源に変える盤です — 実測では累積面積30.161%・強度平均89.5で、24フレーム中23フレームに動きがありました。

液体文字 — テキストピクセルから生まれるモーフィング
新しいタブで開く

08 · 液体文字 — テキストピクセルから生まれるモーフィング — 計測 累積面積 30.161% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

measureTextgetImageDataquadraticCurveTo

シード座標に呼吸と低周波の揺れを重ねてブロブを描く

function drawField(t) {
    fieldCtx.clearRect(0, 0, LOWW, LOWH);
    fieldCtx.fillStyle = '#000';
    fieldCtx.fillRect(0, 0, LOWW, LOWH);
    fieldCtx.globalCompositeOperation = 'lighter';
    var breathe = 1 + Math.sin(t * 0.7) * 0.11;   // 呼吸するように全体スケール
    var r = 6.4 + Math.sin(t * 0.5) * 0.7;
    // 文章全体がゆっくりフィールドを横切って流れる(タイポが位置を変えながら形を作り直す)
    var driftX = Math.sin(t * 0.33) * LOWW * 0.22 + Math.sin(t * 0.11) * LOWW * 0.08;
    var driftY = Math.cos(t * 0.26) * LOWH * 0.20;
    function blob(px, py, rad) {
      var g = fieldCtx.createRadialGradient(px, py, 0, px, py, rad);
      g.addColorStop(0, 'rgba(255,255,255,0.95)');
      g.addColorStop(1, 'rgba(255,255,255,0)');
      fieldCtx.fillStyle = g;
      fieldCtx.fillRect(px - rad, py - rad, rad * 2, rad * 2);
    }
    for (var i = 0; i < seeds.length; i++) {
      var s = seeds[i];
      var jx = Math.sin(t * s.sp + s.ph) * JITTER;
      var jy = Math.cos(t * s.sp * 1.2 + s.ph) * JITTER;
      var px = ((s.x + jx) / SCALE - LOWW / 2) * breathe + LOWW / 2 + driftX;
      var py = ((s.y + jy) / SCALE - LOWH / 2) * breathe + LOWH / 2 + driftY;
      blob(px, py, r);
    }

drawFieldは文字を直接動かすのではなく、あらかじめ抽出しておいたseeds座標へdriftX/driftYという低周波の揺れとbreatheという呼吸スケールを重ねてからblobを描くので、文章全体がひとつの生き物のように位置と大きさを変え続けます。

09雨の水たまり — 落下の衝撃波がボールを押し退けるシーン

窓辺の演出やアンビエントな待機画面に使う、雨粒が着水した瞬間だけ円形の衝撃波をフィールドに足して消す盤です — 実測では累積面積52.152%・強度平均119.5で、24フレーム中23フレームに動きがありました。

雨の水たまり — 落下の衝撃波がボールを押し退けるシーン
新しいタブで開く

09 · 雨の水たまり — 落下の衝撃波がボールを押し退けるシーン — 計測 累積面積 52.152% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDataputImageDatarequestAnimationFrame

着水の瞬間だけ衝撃波をキューへ登録して古いものを捨てる

function spawnDrop(t) {
    drops.push({ x: (0.12 + rand() * 0.76) * LOWW, y: -6, vy: 60 + rand() * 40 });
  }

  // ── シミュレーション ──
  function update(t, dt) {
    if (t >= nextDropAt) {
      spawnDrop(t);
      nextDropAt = t + (RAIN_MS / 1000) * (0.55 + rand() * 0.9);
    }
    // 雨粒の落下 — 水面(水たまり上端の平均)に触れると衝撃波が発生
    var surf = LOWH * 0.62;
    for (var i = drops.length - 1; i >= 0; i--) {
      var dp = drops[i];
      dp.y += dp.vy * dt;
      if (dp.y >= surf) {
        shocks.push({ x: dp.x, y: dp.y, born: t, life: 0.55, amp: SHOCK });
        drops.splice(i, 1);
      }
    }
    while (shocks.length && t - shocks[0].born > 1.2) shocks.shift();

雨粒はspawnDropで画面上端に生まれてsurf(水たまり上端)へ落ちた瞬間にshocks配列へ登録され、updateがshocks[0].bornから1.2秒経ったものを先頭から捨てていくだけの単純なキューなので、いくつもの波紋が重なっても計算量は常に一定に保たれます。

メタボールを実際に使う画面

ボタンやロゴの上でカーソルを追う強調効果が欲しいときは、06のポインタ引力が合います — 引かれて引き裂かれる橋なので、実際のヒット領域はそのままに見た目の広がりだけを伝えられます。ブランドイントロで文字が液体のように寄り集まってはほどける演出には、08の液体文字がそのまま使えます。

背景の飾りには09の雨の水たまりのように低い強度で静かに回るシーンが合い、ローディングインジケーターには01のフィールドひと層のような最も単純な形で十分です。

閾値を少しずらすと境界が裂ける落とし穴

格子を細かくすると計算量は2乗で増える

CELLの値を半分にして格子を2倍細かくすると、マスの数は縦横それぞれ2倍で合計4倍になり、01のように格子点ごとにボールの数だけループを回す盤では計算量がボール数と格子点数の掛け算でそのまま4倍に膨らみます。滑らかさを上げたくてCELLをむやみに下げる前に、07のようにgetImageDataで一度だけフィールドを読んでからCPU側で分類する構成に寄せられないかを先に確かめる順序が安全です。

5と10の鞍点を中心値なしで場合分けすると輪郭が割れる

02の場合分け表が示すとおり、ケース5と10は対角の2隅だけが閾値を超えた鞍点で、格子4点の情報だけでは2つの塊が繋がっているのか分離しているのかを機械的に決められません。中心のスカラー値を追加で調べずにどちらか一方の決まった向きへ固定すると、ボールが特定の位置関係になった瞬間だけ輪郭が不自然にねじれるバグになります。

lighter合成を戻し忘れると次の描画まで発光する

04・08のようにglobalCompositeOperationを'lighter'にした直後は、フィールドを描き終えたらすぐ'source-over'へ戻す一手間が要ります。戻し忘れたままUIのラベルや次のフレームの背景塗りつぶしを続けると、意図しない場所まで加算合成が効いてしまい、色が抜けて白飛びしたように見える不具合になります。

閾値ちょうどの境界でスカラー値がわずかに揺れるとちらつく

閾値THRESHぴったりの値でボールが静止に近い速度で動くと、格子点のスカラー値が閾値の周りをわずかに行き来するだけで輪郭線がそのマスだけ現れたり消えたりするちらつきになります。ソース一式にはこの再現手順まで含めた検証用のzipが付いていて、解凍のパスワードはnxvdqxgdです。

フィールド半径ひとつで変わる3つのバリエーションとアクセシビリティ対応

定数ひとつで変わる3つのバリエーション

名前 変える値 手触り
パステル2色の帯フィールド 07 等高帯 BANDSを5から2へ 層が減り、気圧図のような太い2色の帯だけが残る
低解像度フィールド 02 マーチングスクエア CELLを2倍に粗く 輪郭が多角形寄りになり、計算負荷が下がる
シード固定の静的ポスター 09 雨の水たまり 乱数シードを固定 毎回同じ波紋配置になり、印刷用の一枚絵として使える

いずれも冒頭付近の1つの定数を書き換えるだけで手触りが変わるので、フィールド計算や輪郭抽出のロジックには一切手を入れません。

05と06は動きを減らす設定でも事前計算した一枚を描く

prefers-reduced-motion: reduceが検知されたとき、01・02・03・04・07・08・09はstillFrame()が固定時刻のdraw()を1回だけ呼んで静止画にします。05と06はこの関数を持たず、代わりにmatchMedia('(prefers-reduced-motion: reduce)').matchesを直接調べて真ならupdate(STILL_T, 1/60)を05は90回・06は40回だけ先回りして呼んでからdraw(STILL_T)を1回実行し、is-stillクラスを付けます(05のSTILL_Tは1.2秒・06は1.4秒)。名前は違っても両方とも「アニメーションと同じ計算を一度だけ回してから止める」という同じ原則に従っています。

9盤とも役割とラベルを持つ実験パネルとしてフォーカスを受け取る

9盤ともrole="application"とtabindex="0"を持つdivがcanvasを包む実験パネルなので、タブキーで焦点を移して絵の領域に入れます。ポインター入力で場面が変わるのは06だけで、pointermoveが座標を追って橋を引き寄せ、pointerleaveで自動巡回経路に戻します。残る8盤は焦点を受け取るだけでポインターやキー入力に反応せず、動きを減らす設定の利用者には読み込みとリサイズの時点で一度だけ計算した静止フレームがそのまま保たれます(06も同じで、ホバーしてもフィールドが再計算されることはありません)。

メタボールを初めて組み込む前によくある質問

この9盤とマーチングスクエアは何が違いますか?

この計算は「距離減衰を足し合わせてスカラー場を作る」ことそのものの名前で、マーチングスクエアはそのスカラー場から輪郭線を取り出す方法の名前です。この記事の9盤はすべて同じスカラー場を持っていますが、02のように場合分け表で輪郭線を引くか、01のようにピクセルを直接塗りつぶすかで見た目の質感が変わります。

unity メタボール 2dのようにゲームエンジンで同じ表現は作れますか?

作れます。Unityの2Dシェーダーでも同じスカラー場と閾値の考え方を使いますが、この記事のデモはシェーダーやWebGLを使わずcanvasの2D APIとJavaScriptの計算だけで同じ結果を再現しているので、まずロジックをここで確認してからシェーダー言語へ移植する順番が飲み込みやすいはずです。

なぜ9盤とも強度平均の値がここまで違うのですか?

強度平均はフレーム間でピクセルがどれだけ変化したかの量なので、04のように閾値の上下を問わずlighterで滑らかに明るさを重ねる盤は変化が緩やかで数値が低く(19.8)、09のように衝撃波がフィールドの値を一瞬で大きく押し上げて消す盤は変化が鋭く数値が高くなります(119.5)。数値の高低は画面の良し悪しではなく、その盤が採用した計算の性質をそのまま映しています。

もっと読む

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

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