メタボール(リキッド)

動き回る複数の円が近づくと滑らかに融合して見えるメタボール表現。距離場のしきい値で輪郭を描き、粘性のある液体のような有機的ビジュアルを生成します。

#canvas#metaballs#generative#animation

ライブデモ

使用例(お題: カフェ MOON BREW)

この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- MOON BREW:新作ドリンクのヒーロー(とろけるメタボール背景) -->
<section class="mb-drink">
  <!-- 主役:液体のように融合するメタボール -->
  <canvas class="mb-drink__fx" id="mbMeta"></canvas>

  <!-- 前景UI:新作ドリンク告知 -->
  <div class="mb-drink__inner">
    <span class="mb-drink__tag">NEW ARRIVAL</span>
    <h1 class="mb-drink__title">とろける、<br>キャラメルラテ。</h1>
    <p class="mb-drink__lead">ミルクとカラメルがゆっくり混ざり合う、まろやかな一杯。今週末から登場します。</p>
    <div class="mb-drink__row">
      <span class="mb-drink__price">¥640</span>
      <a class="mb-drink__btn" href="#">メニューを見る</a>
    </div>
  </div>
</section>
CSS
/* MOON BREW:メタボール背景の新作ドリンクヒーロー */
:root {
  --cream: #f5ede1;
  --brown: #2b1d12;
  --amber: #c98a3b;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", system-ui, sans-serif;
  overflow: hidden;
}

.mb-drink {
  position: relative;
  height: 400px;
  overflow: hidden;
  background: linear-gradient(160deg, #3a2716, #241710);
}

/* 主役:とろける液体のキャンバス(全面) */
.mb-drink__fx {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 1;
}

.mb-drink__inner {
  position: relative;
  z-index: 2;
  padding: 40px 32px;
  max-width: 400px;
  color: var(--cream);
  pointer-events: none;
}
.mb-drink__inner a { pointer-events: auto; }

.mb-drink__tag {
  display: inline-block;
  font-size: 10px;
  letter-spacing: 0.3em;
  color: #ffce97;
  font-weight: 700;
}
.mb-drink__title {
  margin: 13px 0 14px;
  font-size: 32px;
  line-height: 1.4;
  font-weight: 700;
  font-family: "Hiragino Mincho ProN", "Yu Mincho", serif;
  text-shadow: 0 2px 12px rgba(0,0,0,0.3);
}
.mb-drink__lead {
  margin: 0 0 24px;
  font-size: 13px;
  line-height: 1.85;
  color: rgba(245,237,225,0.85);
  max-width: 330px;
}
.mb-drink__row { display: flex; align-items: center; gap: 18px; }
.mb-drink__price {
  font-size: 26px;
  font-weight: 800;
  color: #ffce97;
}
.mb-drink__btn {
  display: inline-block;
  padding: 11px 24px;
  border-radius: 999px;
  background: var(--amber);
  color: #fff;
  font-size: 13px;
  font-weight: 700;
  text-decoration: none;
  box-shadow: 0 10px 24px rgba(201,138,59,0.45);
  transition: transform 0.2s ease;
}
.mb-drink__btn:hover { transform: translateY(-2px); }

@media (prefers-reduced-motion: reduce) {
  .mb-drink__btn { transition: none; }
}
JavaScript
// MOON BREW:メタボール(ミルク×カラメルが融合する液体表現)
(() => {
  const canvas = document.getElementById('mbMeta');
  if (!canvas) return; // null安全
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  let w = 0, h = 0, raf = 0, running = true;
  // 距離場計算は低解像度のオフスクリーンで行い負荷を抑える
  let bw = 0, bh = 0, img = null, buf = null;
  const STEP = 4; // 低解像度の倍率
  const off = document.createElement('canvas');
  const octx = off.getContext('2d');
  const balls = [];

  function resize() {
    const r = canvas.getBoundingClientRect();
    w = Math.max(1, Math.floor(r.width));
    h = Math.max(1, Math.floor(r.height));
    canvas.width = w;
    canvas.height = h;
    bw = Math.max(1, Math.ceil(w / STEP));
    bh = Math.max(1, Math.ceil(h / STEP));
    off.width = bw;
    off.height = bh;
    img = octx ? octx.createImageData(bw, bh) : null;
    buf = img ? new Uint32Array(img.data.buffer) : null;
  }

  function makeBalls() {
    balls.length = 0;
    const count = 6;
    for (let i = 0; i < count; i++) {
      balls.push({
        x: Math.random() * w,
        y: Math.random() * h,
        vx: (Math.random() - 0.5) * 1.6,
        vy: (Math.random() - 0.5) * 1.6,
        r: 26 + Math.random() * 22
      });
    }
  }

  resize();
  makeBalls();

  // 距離場のしきい値で色を決める(カラメル→ミルク)
  function colorFor(sum) {
    if (sum > 1.6) return 0xffe9d6f3; // 明るいミルク (ABGR)
    if (sum > 1.0) return 0xff5fa0c9; // amber寄り
    if (sum > 0.72) return 0xff2b4d72; // 縁取り
    return 0x00000000;                 // 透明
  }

  function step() {
    // ボールの移動と壁反射
    for (const b of balls) {
      b.x += b.vx;
      b.y += b.vy;
      if (b.x < b.r || b.x > w - b.r) b.vx *= -1;
      if (b.y < b.r || b.y > h - b.r) b.vy *= -1;
    }

    // 低解像度で距離場を評価
    if (buf && octx) {
      for (let y = 0; y < bh; y++) {
        const py = y * STEP;
        for (let x = 0; x < bw; x++) {
          const px = x * STEP;
          let sum = 0;
          for (let i = 0; i < balls.length; i++) {
            const b = balls[i];
            const dx = px - b.x;
            const dy = py - b.y;
            sum += (b.r * b.r) / (dx * dx + dy * dy + 1);
          }
          buf[y * bw + x] = colorFor(sum);
        }
      }
      octx.putImageData(img, 0, 0);
    }

    // オフスクリーンを拡大描画(スムージングでとろみを表現)
    ctx.clearRect(0, 0, w, h);
    ctx.imageSmoothingEnabled = true;
    ctx.drawImage(off, 0, 0, bw, bh, 0, 0, w, h);

    raf = requestAnimationFrame(step);
  }

  function start() {
    if (running) return;
    running = true;
    raf = requestAnimationFrame(step);
  }
  function stop() {
    running = false;
    cancelAnimationFrame(raf);
  }

  window.addEventListener('resize', () => { resize(); makeBalls(); });
  document.addEventListener('visibilitychange', () => {
    document.hidden ? stop() : start();
  });

  running = false;
  start();
})();

コード

HTML
<!-- メタボール(リキッド)デモ -->
<div class="stage">
  <canvas id="metaCanvas"></canvas>
  <div class="hint">液体のように融合するメタボール</div>
</div>
CSS
/* メタボールのステージ */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
.stage {
  position: relative;
  width: 100%;
  height: 360px;
  overflow: hidden;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: radial-gradient(900px 500px at 50% 40%, #0b1d2e, #04070d 85%);
}
#metaCanvas {
  display: block;
  width: 100%;
  height: 100%;
}
/* 下部の説明ラベル */
.hint {
  position: absolute;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  color: rgba(150, 220, 255, .5);
  font-size: 12px;
  letter-spacing: .08em;
  pointer-events: none;
  user-select: none;
}
JavaScript
// メタボール(リキッド)デモ — 距離場のしきい値で円を融合表示
(() => {
  const canvas = document.getElementById('metaCanvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 表示解像度とは別に、低解像度バッファで距離場を計算(軽量化)
  const SCALE = 4;               // 1ピクセル=実画面4px相当
  let w = 0, h = 0;              // CSSピクセルサイズ
  let gw = 0, gh = 0;           // 計算グリッドサイズ
  let buffer = null;            // ImageData
  let rafId = 0, running = false;
  const THRESHOLD = 1.0;        // この値を超えた領域を液体とみなす

  // メタボール定義(位置・速度・半径相当の強さ)
  const balls = [];
  function makeBalls() {
    const count = 6;
    balls.length = 0;
    for (let i = 0; i < count; i++) {
      balls.push({
        x: Math.random() * w,
        y: Math.random() * h,
        vx: (Math.random() * 2 - 1) * 1.1,
        vy: (Math.random() * 2 - 1) * 1.1,
        r: Math.random() * 28 + 34,
        hue: 180 + i * 28
      });
    }
  }

  function resize() {
    const rect = canvas.getBoundingClientRect();
    w = Math.max(1, Math.round(rect.width));
    h = Math.max(1, Math.round(rect.height));
    canvas.width = w;
    canvas.height = h;
    gw = Math.max(1, Math.ceil(w / SCALE));
    gh = Math.max(1, Math.ceil(h / SCALE));
    buffer = ctx.createImageData(gw, gh);
  }

  // 距離場を評価して低解像度バッファに着色
  function render() {
    if (!buffer) return;
    const data = buffer.data;
    for (let gy = 0; gy < gh; gy++) {
      const py = gy * SCALE;
      for (let gx = 0; gx < gw; gx++) {
        const px = gx * SCALE;
        let sum = 0, hueAcc = 0;
        // 各ボールの寄与(r^2 / 距離^2 を加算)
        for (let i = 0; i < balls.length; i++) {
          const b = balls[i];
          const dx = px - b.x;
          const dy = py - b.y;
          const d2 = dx * dx + dy * dy + 1;
          const f = (b.r * b.r) / d2;
          sum += f;
          hueAcc += f * b.hue;
        }
        const idx = (gy * gw + gx) * 4;
        if (sum >= THRESHOLD) {
          const hue = hueAcc / sum;
          // しきい値付近をやわらかく発光させる
          const edge = Math.min(1, (sum - THRESHOLD) * 1.3);
          const rgb = hslToRgb(hue / 360, 0.85, 0.45 + edge * 0.18);
          data[idx] = rgb[0];
          data[idx + 1] = rgb[1];
          data[idx + 2] = rgb[2];
          data[idx + 3] = Math.round(120 + edge * 135);
        } else {
          data[idx + 3] = 0;
        }
      }
    }
    // 低解像度バッファを全面に拡大描画(補間で滑らかに融合)
    ctx.clearRect(0, 0, w, h);
    putScaled();
  }

  // ImageData をスケール拡大して描画
  let tmp = null, tmpCtx = null;
  function putScaled() {
    if (!tmp) {
      tmp = document.createElement('canvas');
      tmpCtx = tmp.getContext('2d');
    }
    if (tmp.width !== gw || tmp.height !== gh) {
      tmp.width = gw; tmp.height = gh;
    }
    tmpCtx.putImageData(buffer, 0, 0);
    ctx.imageSmoothingEnabled = true;
    ctx.drawImage(tmp, 0, 0, gw, gh, 0, 0, w, h);
  }

  // HSL→RGB 変換(0..1入力, 0..255出力)
  function hslToRgb(hh, s, l) {
    let r, g, b;
    if (s === 0) { r = g = b = l; }
    else {
      const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
      const p = 2 * l - q;
      r = hue2rgb(p, q, hh + 1 / 3);
      g = hue2rgb(p, q, hh);
      b = hue2rgb(p, q, hh - 1 / 3);
    }
    return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
  }
  function hue2rgb(p, q, t) {
    if (t < 0) t += 1;
    if (t > 1) t -= 1;
    if (t < 1 / 6) return p + (q - p) * 6 * t;
    if (t < 1 / 2) return q;
    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
    return p;
  }

  // ボールを動かして壁で反射
  function update() {
    for (const b of balls) {
      b.x += b.vx;
      b.y += b.vy;
      if (b.x < b.r * 0.4 || b.x > w - b.r * 0.4) b.vx *= -1;
      if (b.y < b.r * 0.4 || b.y > h - b.r * 0.4) b.vy *= -1;
      b.x = Math.max(b.r * 0.4, Math.min(w - b.r * 0.4, b.x));
      b.y = Math.max(b.r * 0.4, Math.min(h - b.r * 0.4, b.y));
    }
  }

  function step() {
    if (!running) return;
    if (!reduced) update();
    render();
    rafId = requestAnimationFrame(step);
  }

  // rAFの二重起動を防いで開始
  function start() {
    if (running) return;
    running = true;
    rafId = requestAnimationFrame(step);
  }
  function stop() {
    running = false;
    if (rafId) cancelAnimationFrame(rafId);
    rafId = 0;
  }

  resize();
  makeBalls();
  window.addEventListener('resize', () => { resize(); });

  // タブ非表示で停止、復帰で再開
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) stop(); else start();
  });

  // reduced環境では1フレームだけ描いて静止
  if (reduced) { render(); } else { start(); }
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「メタボール(リキッド)」の効果を追加してください。

# 追加してほしい効果
メタボール(リキッド)(Canvas エフェクト)
動き回る複数の円が近づくと滑らかに融合して見えるメタボール表現。距離場のしきい値で輪郭を描き、粘性のある液体のような有機的ビジュアルを生成します。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- メタボール(リキッド)デモ -->
<div class="stage">
  <canvas id="metaCanvas"></canvas>
  <div class="hint">液体のように融合するメタボール</div>
</div>

【CSS】
/* メタボールのステージ */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
.stage {
  position: relative;
  width: 100%;
  height: 360px;
  overflow: hidden;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: radial-gradient(900px 500px at 50% 40%, #0b1d2e, #04070d 85%);
}
#metaCanvas {
  display: block;
  width: 100%;
  height: 100%;
}
/* 下部の説明ラベル */
.hint {
  position: absolute;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  color: rgba(150, 220, 255, .5);
  font-size: 12px;
  letter-spacing: .08em;
  pointer-events: none;
  user-select: none;
}

【JavaScript】
// メタボール(リキッド)デモ — 距離場のしきい値で円を融合表示
(() => {
  const canvas = document.getElementById('metaCanvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 表示解像度とは別に、低解像度バッファで距離場を計算(軽量化)
  const SCALE = 4;               // 1ピクセル=実画面4px相当
  let w = 0, h = 0;              // CSSピクセルサイズ
  let gw = 0, gh = 0;           // 計算グリッドサイズ
  let buffer = null;            // ImageData
  let rafId = 0, running = false;
  const THRESHOLD = 1.0;        // この値を超えた領域を液体とみなす

  // メタボール定義(位置・速度・半径相当の強さ)
  const balls = [];
  function makeBalls() {
    const count = 6;
    balls.length = 0;
    for (let i = 0; i < count; i++) {
      balls.push({
        x: Math.random() * w,
        y: Math.random() * h,
        vx: (Math.random() * 2 - 1) * 1.1,
        vy: (Math.random() * 2 - 1) * 1.1,
        r: Math.random() * 28 + 34,
        hue: 180 + i * 28
      });
    }
  }

  function resize() {
    const rect = canvas.getBoundingClientRect();
    w = Math.max(1, Math.round(rect.width));
    h = Math.max(1, Math.round(rect.height));
    canvas.width = w;
    canvas.height = h;
    gw = Math.max(1, Math.ceil(w / SCALE));
    gh = Math.max(1, Math.ceil(h / SCALE));
    buffer = ctx.createImageData(gw, gh);
  }

  // 距離場を評価して低解像度バッファに着色
  function render() {
    if (!buffer) return;
    const data = buffer.data;
    for (let gy = 0; gy < gh; gy++) {
      const py = gy * SCALE;
      for (let gx = 0; gx < gw; gx++) {
        const px = gx * SCALE;
        let sum = 0, hueAcc = 0;
        // 各ボールの寄与(r^2 / 距離^2 を加算)
        for (let i = 0; i < balls.length; i++) {
          const b = balls[i];
          const dx = px - b.x;
          const dy = py - b.y;
          const d2 = dx * dx + dy * dy + 1;
          const f = (b.r * b.r) / d2;
          sum += f;
          hueAcc += f * b.hue;
        }
        const idx = (gy * gw + gx) * 4;
        if (sum >= THRESHOLD) {
          const hue = hueAcc / sum;
          // しきい値付近をやわらかく発光させる
          const edge = Math.min(1, (sum - THRESHOLD) * 1.3);
          const rgb = hslToRgb(hue / 360, 0.85, 0.45 + edge * 0.18);
          data[idx] = rgb[0];
          data[idx + 1] = rgb[1];
          data[idx + 2] = rgb[2];
          data[idx + 3] = Math.round(120 + edge * 135);
        } else {
          data[idx + 3] = 0;
        }
      }
    }
    // 低解像度バッファを全面に拡大描画(補間で滑らかに融合)
    ctx.clearRect(0, 0, w, h);
    putScaled();
  }

  // ImageData をスケール拡大して描画
  let tmp = null, tmpCtx = null;
  function putScaled() {
    if (!tmp) {
      tmp = document.createElement('canvas');
      tmpCtx = tmp.getContext('2d');
    }
    if (tmp.width !== gw || tmp.height !== gh) {
      tmp.width = gw; tmp.height = gh;
    }
    tmpCtx.putImageData(buffer, 0, 0);
    ctx.imageSmoothingEnabled = true;
    ctx.drawImage(tmp, 0, 0, gw, gh, 0, 0, w, h);
  }

  // HSL→RGB 変換(0..1入力, 0..255出力)
  function hslToRgb(hh, s, l) {
    let r, g, b;
    if (s === 0) { r = g = b = l; }
    else {
      const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
      const p = 2 * l - q;
      r = hue2rgb(p, q, hh + 1 / 3);
      g = hue2rgb(p, q, hh);
      b = hue2rgb(p, q, hh - 1 / 3);
    }
    return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
  }
  function hue2rgb(p, q, t) {
    if (t < 0) t += 1;
    if (t > 1) t -= 1;
    if (t < 1 / 6) return p + (q - p) * 6 * t;
    if (t < 1 / 2) return q;
    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
    return p;
  }

  // ボールを動かして壁で反射
  function update() {
    for (const b of balls) {
      b.x += b.vx;
      b.y += b.vy;
      if (b.x < b.r * 0.4 || b.x > w - b.r * 0.4) b.vx *= -1;
      if (b.y < b.r * 0.4 || b.y > h - b.r * 0.4) b.vy *= -1;
      b.x = Math.max(b.r * 0.4, Math.min(w - b.r * 0.4, b.x));
      b.y = Math.max(b.r * 0.4, Math.min(h - b.r * 0.4, b.y));
    }
  }

  function step() {
    if (!running) return;
    if (!reduced) update();
    render();
    rafId = requestAnimationFrame(step);
  }

  // rAFの二重起動を防いで開始
  function start() {
    if (running) return;
    running = true;
    rafId = requestAnimationFrame(step);
  }
  function stop() {
    running = false;
    if (rafId) cancelAnimationFrame(rafId);
    rafId = 0;
  }

  resize();
  makeBalls();
  window.addEventListener('resize', () => { resize(); });

  // タブ非表示で停止、復帰で再開
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) stop(); else start();
  });

  // reduced環境では1フレームだけ描いて静止
  if (reduced) { render(); } else { start(); }
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。