このページの中身

水面

背景エフェクト / 動く素材トップ

多方向の進行波から法線を求め、太陽を向いた面だけが光る水面。水・清涼・静けさ。主役に近い強さの背景がほしいとき。

試して調整する#

見た目を調整する
元コードの調整項目をすべて出す既定値は元の実装のまま11 項目

元の実装の既定値で動いています

判断#

向く場面: 水・清涼・静けさ。主役に近い強さの背景がほしいとき。

避ける場面: 文字が主役の面に、そのまま重ねること。

つまみは元の実装が持っている項目そのままです。実際に文字を重ねた状態で速さと密度を詰めます。

組み込み条件#

依存: 素のCanvas 2D。外部ライブラリは要らない。

負荷: 1画面に1つ。画面外・非表示タブ・動きを減らす設定で止まる。

再利用: コピーした単一HTMLがそのまま動く。

単一HTMLをコピーする#

下のコードだけで実物サンプルと同じ画面が動きます。調整欄を動かすと、値を反映したコードへ更新されます。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,follow">
<title>水面 実物サンプル</title>
<style>
:root {
  color-scheme: light dark;
  --ink-light:#1a2421; --mut-light:#5a6a72; --signal-light:#2a7ab8; --bg-light:#f4f8fb;
  --ink-dark:#f4f8fb; --mut-dark:#a8bac7; --signal-dark:#48b8ff; --bg-dark:#07111f;
}
* { box-sizing:border-box; }
html,body { margin:0; min-height:100%; }
body { font-family:"Hiragino Sans","Yu Gothic UI",system-ui,sans-serif; transition:background .3s,color .3s; }
html[data-demo-theme="light"], html[data-demo-theme="light"] body {
  background:var(--bg-light); color:var(--ink-light); --ink:var(--ink-light); --mut:var(--mut-light); --signal:var(--signal-light); }
html[data-demo-theme="dark"], html[data-demo-theme="dark"] body {
  background:var(--bg-dark); color:var(--ink-dark); --ink:var(--ink-dark); --mut:var(--mut-dark); --signal:var(--signal-dark); }
@media (prefers-color-scheme:light) {
  html:not([data-demo-theme]), html:not([data-demo-theme]) body {
    background:var(--bg-light); color:var(--ink-light); --ink:var(--ink-light); --mut:var(--mut-light); --signal:var(--signal-light); } }
@media (prefers-color-scheme:dark) {
  html:not([data-demo-theme]), html:not([data-demo-theme]) body {
    background:var(--bg-dark); color:var(--ink-dark); --ink:var(--ink-dark); --mut:var(--mut-dark); --signal:var(--signal-dark); } }
.sample { position:relative; min-height:100dvh; overflow:hidden; isolation:isolate; display:grid; align-items:end; }
.effect { position:absolute; inset:0; z-index:0; }
.effect canvas { display:block; width:100%; height:100%; touch-action:manipulation; }
.copy { position:relative; z-index:1; width:min(42rem,100%); padding:clamp(2rem,7vw,6rem); }
.label { margin:0 0 1rem; color:var(--signal); font:600 .72rem/1.4 ui-monospace,Consolas,monospace; letter-spacing:.16em; }
h1 { margin:0; max-width:14ch; font-size:clamp(2rem,5.5vw,4.5rem); line-height:1.05; letter-spacing:-.04em; }
.lead { margin:1.5rem 0 0; max-width:36rem; color:var(--mut); font-size:clamp(.95rem,1.4vw,1.1rem); line-height:1.85; }
.readout { position:absolute; inset:1.5rem 1.5rem auto auto; margin:0; color:var(--mut); font:500 .68rem/1.6 ui-monospace,Consolas,monospace; letter-spacing:.1em; writing-mode:vertical-rl; z-index:2; }
.theme-toggle { position:absolute; inset:1.5rem auto auto 1.5rem; z-index:2; display:flex; gap:.25rem; }
.theme-toggle button {
  min-block-size:36px; padding-inline:.75rem; font:600 .72rem/1 ui-monospace,Consolas,monospace;
  letter-spacing:.08em; border:1px solid var(--mut); background:transparent; color:var(--ink); cursor:pointer; border-radius:4px; }
.theme-toggle button[aria-pressed="true"] { background:var(--signal); color:var(--bg-dark); border-color:var(--signal); }
@media (max-width:640px) { .copy{padding:2rem 1.25rem 3rem}.readout{display:none} }
.bare .copy, .bare .readout, .bare .theme-toggle { display:none; }
</style>

</head>
<body>
<script id="effect-config" type="application/json">{"sunAzimuth":84,"sunElevation":71,"reflectionStrength":0.2,"waterAmount":0.4,"sourceCount":11,"playbackExponent":1.05,"wavelengthMin":24,"wavelengthMax":1000,"heightRms":8,"polygonRotation":0.0,"variation":2,"pulseDepth":0.42,"reflectionWidth":9.7,"edgeSoftness":0.27,"shoreStrength":0.6,"renderScale":0.17}</script>
<script>if (/[?&]bare=1/.test(location.search)) document.documentElement.classList.add('bare');
if (/[?&]tap=1/.test(location.search)) { addEventListener('load', function () {
  setTimeout(function () { [[0.5,0.5],[0.3,0.6],[0.7,0.4]].forEach(function (p, i) {
    setTimeout(function () { document.dispatchEvent(new MouseEvent('click',
      {clientX: innerWidth * p[0], clientY: innerHeight * p[1], bubbles: true})); }, i * 220);
  }); }, 300); }); }</script>
<script>(function(){var m=location.hash.match(/[#&]cfg=([^&]*)/);if(!m)return;
try{JSON.parse(decodeURIComponent(m[1]));document.getElementById('effect-config').textContent=decodeURIComponent(m[1]);}catch(e){}})();</script>
<script>(function(){
  var root=document.documentElement, cfgNode=document.getElementById('effect-config');
  var themeKeys=[];
  function syncTheme(t){
    root.setAttribute('data-demo-theme',t);
    document.querySelectorAll('.theme-toggle button').forEach(function(b){
      b.setAttribute('aria-pressed',b.dataset.theme===t?'true':'false');});
    if(!themeKeys.length)return;
    try{var cfg=JSON.parse(cfgNode.textContent||'{}');
      themeKeys.forEach(function(k){cfg[k]=t==='dark'?0:1;});
      cfgNode.textContent=JSON.stringify(cfg);}catch(e){}
  }
  var fixed="light";
  if(fixed){syncTheme(fixed);}else{var q=location.search.match(/[?&]theme=(light|dark)/); if(q) syncTheme(q[1]);}
  addEventListener('DOMContentLoaded',function(){
    document.querySelectorAll('.theme-toggle button').forEach(function(b){
      b.addEventListener('click',function(){syncTheme(b.dataset.theme);});});});})();</script>
<main class="sample">
  <div class="theme-toggle" aria-label="配色切替" hidden>
    <button type="button" data-theme="light" aria-pressed="false">LIGHT</button>
    <button type="button" data-theme="dark" aria-pressed="false">DARK</button>
  </div>
  <div class="effect" aria-hidden="true"><canvas id="effect-canvas" data-field="water-surface-hero"></canvas></div>
  <div class="copy">
    <p class="label">背景エフェクト</p>
    <h1>水面</h1>
    <p class="lead">多方向の進行波から法線を求め、太陽を向いた面だけが光る水面。水・清涼・静けさ。主役に近い強さの背景がほしいとき。水・清涼・静けさ。主役に近い強さの背景がほしいとき。</p>
  </div>
  <p class="readout">単一HTML・動きを減らす設定に対応</p>
</main>
<script>
/* ---- ここから下は元の実装をそのまま持ってきたもの --------------------- */
var __CFG__ = JSON.parse(document.getElementById('effect-config').textContent || '{}');
/* field.js — 生成フィールド「水面」
   一見すると動画のようだが、すべて実時間でプログラムが描いている。

   トップヒーローは、複数方向の進行波から高さと傾斜を求め、水面の
   法線が太陽を向いた場所だけを明るくする「水面反射」エンジン。
   従来の fBm と正n角形リップル波エンジンも、ページヘッダーや別案の
   「プールの底で揺らめく光の網目」用として同じファイル内に残す。

   構図の原則: 背景の役割は主役(文字)を引き立てること。
   水面反射は shoreStrength、従来エンジンは gradX で本文側の水を
   「引かせ」、白い岸をつくる。水の見せ場は文字のいない側で泳がせ、
   実表示で必要な分だけ veil も併用する。

   ※ パラメータ違いのレシピ(次回作品用に温存):
      炎 = 上方向ドリフト driftY + 上下勾配 gradY + 速い timeScale
      絹 = warp を 2.4 以上に上げる(ドメインワープが布のドレープになる) */
'use strict';

(() => {
  function hexToRgb(hex) {
    const value = hex.replace('#', '');
    return [
      parseInt(value.slice(0, 2), 16),
      parseInt(value.slice(2, 4), 16),
      parseInt(value.slice(4, 6), 16),
    ];
  }

  function smoothStep(value) {
    return value * value * (3 - 2 * value);
  }

  /* ─── プリセット ───────────────────────────────
     従来の水面(プール)の作り方:
     - ridgeInvert: true + 高め ridge → ゼロ交差が「白い光の線」になり
       水色のセルが網目に区切られる(コースティクス)
     - warp はほぼゼロ → 上げると水ではなく絹に見える
     - timeScale はやや速く → 遅くするとスローモーション再生に見える */
  const PRESETS = {
    'water-surface-hero': {
      // _lab/water-surface-reflection-lab.html で調整したトップ用の水面。
      // 11方向 × 大中小3波の高さ場から法線を求め、指定した太陽方向を
      // 正しく向く面だけを反射させる。seedを含め、承認時の波を再現する。
      engine: 'surface-reflection',
      viewMode: 'surface',
      seed: 805445,
      playbackExponent: (__CFG__["playbackExponent"] !== undefined ? __CFG__["playbackExponent"] : (1.05)),
      sourceCount: (__CFG__["sourceCount"] !== undefined ? __CFG__["sourceCount"] : (11)),
      wavelengthMin: (__CFG__["wavelengthMin"] !== undefined ? __CFG__["wavelengthMin"] : (24)),
      wavelengthMax: (__CFG__["wavelengthMax"] !== undefined ? __CFG__["wavelengthMax"] : (1000)),
      heightRms: (__CFG__["heightRms"] !== undefined ? __CFG__["heightRms"] : (8)),
      polygonRotation: (__CFG__["polygonRotation"] !== undefined ? __CFG__["polygonRotation"] : (0)),
      variation: (__CFG__["variation"] !== undefined ? __CFG__["variation"] : (2)),
      pulseDepth: (__CFG__["pulseDepth"] !== undefined ? __CFG__["pulseDepth"] : (0.42)),
      sunAzimuth: (__CFG__["sunAzimuth"] !== undefined ? __CFG__["sunAzimuth"] : (84)),
      sunElevation: (__CFG__["sunElevation"] !== undefined ? __CFG__["sunElevation"] : (71)),
      reflectionWidth: (__CFG__["reflectionWidth"] !== undefined ? __CFG__["reflectionWidth"] : (9.7)),
      edgeSoftness: (__CFG__["edgeSoftness"] !== undefined ? __CFG__["edgeSoftness"] : (0.27)),
      reflectionStrength: (__CFG__["reflectionStrength"] !== undefined ? __CFG__["reflectionStrength"] : (0.20)),
      waterAmount: (__CFG__["waterAmount"] !== undefined ? __CFG__["waterAmount"] : (0.40)),
      shoreStrength: (__CFG__["shoreStrength"] !== undefined ? __CFG__["shoreStrength"] : (0.6)),
      renderScale: (__CFG__["renderScale"] !== undefined ? __CFG__["renderScale"] : (0.17)),
      maxPixels: 38000,
      fps: 24,
    },
    'water-hero': {
      // 「正n角形リップル波」エンジン(_lab/water-field-lab-v7.html で検証・確定した設定)。
      // 画面外に置いた複数の発生源が周回しながら進行波を送り、その干渉模様を
      // 濃淡にする。値が高い場所(山頂)ほど白く抜け、低い場所(谷)ほど水色になる。
      engine: 'ngon-ripple',
      color: '#91d3fd',     // 水の色
      alphaMin: 0.02,       // 最小不透明度
      alphaMax: 0.35,       // 最大不透明度
      fieldMin: -2.2,       // 場の下限(符号反転後の値。ラボのfieldMax=2.0に対応)
      fieldMax: 2.0,        // 場の上限(符号反転後の値。ラボのfieldMin=-2.0に対応)
      cell: 16,             // 場のグリッド間隔(8だと格子点が5倍以上に増えcompute()だけで1フレーム分を使い切っていたため16に)
      fillStep: 2,          // 塗りの描画粒度
      timeScale: 1.1,       // 時間の速さ
      amplitude: 3.0,       // 場の振れ幅
      gradX: -3.6,          // 左右の勾配。負 = 左(本文側)の水が引き、文字は素で立てる
      gradY: -0.8,          // 上下の勾配。負 = 下ほど水が深い
      sourceCount: 14,      // 発生源の数(正n角形の頂点数)
      waveNumber: 0.014,    // 波の細かさ
      waveSpeed: 14,        // 伝搬速度(基準)
      sizeJitter: 0,        // 波長・大きさの揺らぎ
      speedJitter: 0,       // 伝搬速度の揺らぎ
      radiusScale: 1.02,    // 発生源の基準距離(画面外への距離)
      radiusJitter: 0,      // 発生源の距離のゆらぎ
      orbitRadius: 135,     // 発生源の周回半径(px)
      orbitSpeed: 0.8,      // 発生源の周回speed
      harmonics: 1.9,       // 周波数レベル数(地形の複雑さ・小数可)
      harmonicLacunarity: 2,   // レベルごとの周波数倍率
      harmonicGain: 0.45,      // レベルごとの振幅減衰
      pointer: false,       // ホバーの痕跡(このサイトでは使わない)
      pointerRadius: 400,
      imprintGain: 0.06,
      imprintDecay: 0.997,
      imprintMax: 2.8,
    },
    'water-band': {
      color: '#009cff',
      alphaMin: 0.02,
      alphaMax: 0.13,
      fieldMin: -1.7,
      fieldMax: 2.1,
      cell: 16,
      fillStep: 2,
      timeScale: 0.8,
      driftX: 0.1,
      driftY: 0,
      ridge: 0.85,
      ridgeInvert: true,
      octaves: 3.3,
      lacunarity: 2.0,
      gain: 0.4,
      warp: 0.6,
      amplitude: 3.0,
      gradX: -3.0,
      gradY: -0.5,
      scaleX: [0.014, 0.003],
      scaleY: [0.020, 0.003],
      pointer: false,
      pointerRadius: 300,
      imprintGain: 0.06,
      imprintDecay: 0.997,
      imprintMax: 2.4,
    },
    'hero-silk': {
      // 絹の比較実験プリセット(水面とは別・旧fBmノイズエンジン)。
      // _lab/silk-field-lab.html で「かなり地味でシンプルな方向」に
      // 最終決定した値。ridge:0 のため ridgeInvert・warp のドレープ効果は
      // 実質無効(ridge=0だとfoldへのブレンド量が0になり、foldの計算結果
      // 自体が出力に影響しない)。gradX/gradYも0にしたため、水面版にあった
      // 「文字側だけ薄くする」構図の勾配は今回は使っていない
      // (alphaMaxが低いのでもともと主張は弱い)。
      color: '#009cff',
      alphaMin: 0.02,
      alphaMax: 0.14,
      fieldMin: -1.7,
      fieldMax: 2.1,
      cell: 23,
      fillStep: 2,
      timeScale: 2,
      driftX: 0.2,
      driftY: 0,
      ridge: 0,
      ridgeInvert: false,
      octaves: 1,
      lacunarity: 2,
      gain: 0.19,
      warp: 0,
      amplitude: 2.9,
      gradX: 0,
      gradY: 0,
      // ラボのreseed()バグにより、実際に承認いただいた見た目はスライダー表示の
      // 0.02ではなく、ページ読み込み時の初期値0.006のまま描画されていたもの
      // だった。見た目を数値に合わせるのではなく、数値を承認済みの見た目に
      // 合わせるため、ここは0.006にしてある。
      scaleX: [0.006, 0.006],
      scaleY: [0.006, 0.006],
      pointer: false,
      pointerRadius: 400,
      imprintGain: 0.06,
      imprintDecay: 0.997,
      imprintMax: 2.8,
    },
  };

  /* ─── エンジン: 正n角形リップル波 ───────────────────
     画面の縦横中央を中心とした正n角形の頂点(画面外)に発生源を置き、
     各発生源はその位置を中心に周回し続ける(円軌道なので平均位置は
     動かず、一方向への流れ=ドリフトにはならない)。各発生源は同じ
     中心から複数の周波数のさざ波(周波数レベル)を重ねて出し、これが
     地形の複雑さ(峰と谷)になる。距離減衰項は無いので遠くまで同じ
     勢いで波が届く。_lab/water-field-lab-v7.html で検証済みの設計。
     戻り値の evaluate(x,y,t) は生の値(-1〜1程度、レベル間の重み付き
     平均)を返す。値が高いほど「山頂」=呼び出し側で白く見せたい場所。 */
  function createNgonRippleField(opts, width, height) {
    const n = Math.max(3, Math.min(60, Math.round(opts.sourceCount)));
    const centerX = width / 2;
    const centerY = height / 2;
    const halfDiagonal = Math.sqrt((width / 2) ** 2 + (height / 2) ** 2);
    const baseRadius = halfDiagonal * Math.max(1.02, opts.radiusScale);
    const rotationOffset = Math.random() * Math.PI * 2;

    const sources = [];
    for (let i = 0; i < n; i += 1) {
      const angle = rotationOffset + (Math.PI * 2 * i) / n;
      const distJitter = 1 + (Math.random() * 2 - 1) * opts.radiusJitter;
      const dist = baseRadius * Math.max(0.15, distJitter);
      const nominalCx = centerX + Math.cos(angle) * dist;
      const nominalCy = centerY + Math.sin(angle) * dist;

      // 発生源自体を、決めた位置(nominal)を中心に周回させ続ける。閉じた
      // 円軌道なので平均位置はnominalから動かない。速さ・向き・位相を
      // 発生源ごとにばらけさせて同期を防ぐ。
      const orbitSpeedJit = opts.orbitSpeed * (0.6 + 0.8 * Math.random()) * (Math.random() < 0.5 ? -1 : 1);
      const orbitPhaseJit = Math.random() * Math.PI * 2;
      const orbitRadiusJit = opts.orbitRadius * (0.7 + 0.6 * Math.random());

      const kJit = 1 + (Math.random() * 2 - 1) * opts.sizeJitter;
      const speedJit = 1 + (Math.random() * 2 - 1) * opts.speedJitter;
      const weightJit = 1 + (Math.random() * 2 - 1) * opts.sizeJitter * 0.5;

      sources.push({
        nominalCx, nominalCy, orbitRadiusJit, orbitSpeedJit, orbitPhaseJit,
        k0: opts.waveNumber * kJit,
        speed: opts.waveSpeed * speedJit,
        phase0: Math.random() * Math.PI * 2,
        weight: weightJit,
      });
    }

    // harmonicsは小数可(例: 1.9なら1層目は満額、2層目は90%の重み)。
    const fullLevels = Math.max(1, Math.floor(opts.harmonics));
    const fracLevel = opts.harmonics - fullLevels;
    const octaveCount = fullLevels + (fracLevel > 0.001 ? 1 : 0);
    const levelWeights = [];
    const levelKMul = [];
    {
      let amp = 1;
      let kMul = 1;
      for (let h = 0; h < fullLevels; h += 1) {
        levelWeights.push(amp);
        levelKMul.push(kMul);
        amp *= opts.harmonicGain;
        kMul *= opts.harmonicLacunarity;
      }
      if (fracLevel > 0.001) {
        levelWeights.push(amp * fracLevel);
        levelKMul.push(kMul);
      }
    }
    const levelNorm = levelWeights.reduce((sum, w) => sum + w, 0) || 1;

    // 単純平均だと発生源が多いほど独立な波同士が打ち消し合って値が0に
    // 集まる(中心極限定理)ため、RMS(実効値)ベースで正規化し、発生源数
    // によらず典型的な振れ幅を保つ。
    const rmsNorm = Math.sqrt(sources.reduce((sum, s) => sum + s.weight * s.weight, 0)) || 1;

    // 発生源の周回位置(cos/sin)はレベルに関係なく同じなので、発生源ごとに
    // 1回だけ計算し、レベルのループはその内側で回す(波数k・角速度ωだけが
    // レベルごとに違う)。以前はレベルの数だけ周回位置を毎回再計算していて
    // 無駄が大きく、本番相当のパラメータで計測するとcompute()だけで
    // 30fpsの1フレーム分(33ms)を使い切っていた。この並べ替えだけで約4割減。
    const evaluate = (x, y, t) => {
      let sum = 0;
      for (let i = 0; i < n; i += 1) {
        const s = sources[i];
        // 発生源の「今の」位置(nominal位置まわりの周回)。tから毎回計算する
        // ので発生源は常に動き続ける。
        const orbitAngle = s.orbitPhaseJit + s.orbitSpeedJit * t;
        const cx = s.nominalCx + Math.cos(orbitAngle) * s.orbitRadiusJit;
        const cy = s.nominalCy + Math.sin(orbitAngle) * s.orbitRadiusJit;
        const dx = x - cx;
        const dy = y - cy;
        const r = Math.sqrt(dx * dx + dy * dy);

        let levelSum = 0;
        for (let level = 0; level < octaveCount; level += 1) {
          const k = s.k0 * levelKMul[level];
          const omega = k * s.speed;
          // 進行波: 位相 = k・r - ω・t + phase0(tに線形)。距離減衰項は無い。
          levelSum += levelWeights[level] * Math.sin(k * r - omega * t + s.phase0);
        }
        sum += s.weight * levelSum;
      }
      return sum / rmsNorm / levelNorm;
    };

    return { evaluate };
  }

  /* ─── エンジン: 真上から見る水面反射 ─────────────────
     正多角形の各頂点から中心へ向かう線形の進行波を重ね、その高さ場の
     解析的な勾配から水面法線を求める。色を決めるのは高さそのものでは
     なく、法線が太陽と視線のハーフベクトルを向いているかどうか。

     各方向は同じ実効強度を持ち、大・中・小の3波と、方向ごとに固有の
     長いうねり(強弱の包絡)を持つ。従って一方向だけが勝つことも、
     全方向から同時に光が当たることもない。 */
  function createSurfaceReflectionField(canvas, opts) {
    const context = canvas.getContext('2d', { alpha: false, desynchronized: true });
    if (!context) return;

    const TAU = Math.PI * 2;
    const DEG = Math.PI / 180;
    const WATER = [0, 156, 255];
    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const frameInterval = 1000 / opts.fps;
    const playbackSpeed = Math.pow(2, opts.playbackExponent);

    let cssWidth = 0;
    let cssHeight = 0;
    let renderWidth = 0;
    let renderHeight = 0;
    let imageData = null;
    let sources = [];
    let modes = [];
    let sinPhase = null;
    let cosPhase = null;
    let envelopeSin = [];
    let envelopeCos = [];
    let localStrength = null;
    let localStrengthDerivative = null;

    const randomFromSeed = (seed) => {
      let value = seed;
      return () => {
        value |= 0;
        value = value + 0x6D2B79F5 | 0;
        let t = Math.imul(value ^ value >>> 15, 1 | value);
        t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
        return ((t ^ t >>> 14) >>> 0) / 4294967296;
      };
    };

    const clampedSmoothStep = (value) => {
      const x = Math.max(0, Math.min(1, value));
      return x * x * (3 - 2 * x);
    };

    const updateGeometry = () => {
      if (!renderWidth || !modes.length) return;
      const pixelWidth = cssWidth / renderWidth;
      for (const source of sources) {
        for (const envelope of source.envelopeParts) {
          const step = envelope.kx * pixelWidth;
          envelope.stepSin = Math.sin(step);
          envelope.stepCos = Math.cos(step);
        }
      }
      for (const mode of modes) {
        const step = mode.kx * pixelWidth;
        mode.stepSin = Math.sin(step);
        mode.stepCos = Math.cos(step);
      }
    };

    const buildModes = () => {
      const random = randomFromSeed(opts.seed);
      const count = Math.max(3, Math.min(60, Math.round(opts.sourceCount)));
      const minLength = Math.max(12, Math.min(opts.wavelengthMin, opts.wavelengthMax - 10));
      const maxLength = Math.max(minLength + 10, opts.wavelengthMax);
      const logMin = Math.log(minLength);
      const logMax = Math.log(maxLength);
      const rotation = opts.polygonRotation * DEG;
      const variation = opts.variation;

      // 方向数を増やしても、各方向の大・中・小の構成と実効強度は同じ。
      // 位相と僅かな不揃いだけをシード付き乱数で方向ごとに変える。
      const bandPositions = [0.08, 0.5, 0.92];
      const bandSlopeWeights = [0.78, 1, 0.72];
      const bandPhases = bandPositions.map(() => random() * TAU);
      const bandHarmonicPhases = bandPositions.map(() => random() * TAU);
      const bandThirdHarmonicPhases = bandPositions.map(() => random() * TAU);
      const bandTempoJitters = bandPositions.map(
        () => Math.exp((random() * 2 - 1) * 0.08 * variation),
      );

      const referenceWavelength = Math.sqrt(minLength * maxLength);
      const referenceK = TAU / referenceWavelength;
      const referenceOmega = 0.73 * Math.sqrt(150 / referenceWavelength);
      const rawModes = [];
      let weightSquareSum = 0;

      sources = Array.from({ length: count }, (_, sourceIndex) => {
        const angle = rotation + sourceIndex / count * TAU;
        const directionX = -Math.cos(angle);
        const directionY = -Math.sin(angle);
        const envelopeRaw = [
          { ratio: 1.4 + random(), weight: 0.48 + random() * 0.08, speedRatio: 0.38 + random() * 0.28, phase: random() * TAU },
          { ratio: 2.7 + random() * 1.8, weight: 0.27 + random() * 0.07, speedRatio: 0.34 + random() * 0.36, phase: random() * TAU },
          { ratio: 5 + random() * 2.8, weight: 0.15 + random() * 0.06, speedRatio: 0.3 + random() * 0.44, phase: random() * TAU },
        ];
        const envelopeWeightTotal = envelopeRaw.reduce((total, part) => total + part.weight, 0);

        return {
          directionX,
          directionY,
          envelopeDepth: opts.pulseDepth * (0.85 + random() * 0.15),
          envelopeParts: envelopeRaw.map((part) => {
            const k = referenceK / part.ratio;
            return {
              k,
              kx: k * directionX,
              ky: k * directionY,
              omega: k * (referenceOmega / referenceK) * part.speedRatio,
              phase: part.phase,
              weight: part.weight / envelopeWeightTotal,
              stepSin: 0,
              stepCos: 1,
            };
          }),
        };
      });

      for (let sourceIndex = 0; sourceIndex < count; sourceIndex += 1) {
        const sourceBandWeights = bandSlopeWeights.map(
          (weight) => weight * (1 + (random() * 2 - 1) * 0.08 * variation),
        );
        const sourceBandNorm = Math.sqrt(
          sourceBandWeights.reduce((total, weight) => total + weight * weight, 0),
        );

        for (let bandIndex = 0; bandIndex < bandPositions.length; bandIndex += 1) {
          const wavelengthJitter = (random() * 2 - 1) * 0.04 * variation;
          const position = Math.min(0.985, Math.max(0.015, bandPositions[bandIndex] + wavelengthJitter));
          const wavelength = Math.exp(logMin + (logMax - logMin) * position);
          const normalizedSlopeWeight = sourceBandWeights[bandIndex] / sourceBandNorm;
          const weight = wavelength * normalizedSlopeWeight;
          weightSquareSum += weight * weight;
          rawModes.push({
            sourceIndex,
            wavelength,
            weight,
            phase: bandPhases[bandIndex] + (random() * 2 - 1) * 0.12 * variation,
            tempoJitter: bandTempoJitters[bandIndex] * Math.exp((random() * 2 - 1) * 0.06 * variation),
            harmonic: Math.min(0.3, variation * (0.04 + random() * 0.09)),
            harmonicPhase: bandHarmonicPhases[bandIndex] + (random() * 2 - 1) * 0.12 * variation,
            thirdHarmonic: Math.min(0.12, variation * (0.01 + random() * 0.035)),
            thirdHarmonicPhase: bandThirdHarmonicPhases[bandIndex] + (random() * 2 - 1) * 0.12 * variation,
          });
        }
      }

      const amplitudeScale = opts.heightRms * Math.sqrt(2 / weightSquareSum);
      modes = rawModes.map((item) => {
        const k = TAU / item.wavelength;
        const source = sources[item.sourceIndex];
        return {
          sourceIndex: item.sourceIndex,
          amplitude: item.weight * amplitudeScale,
          k,
          kx: k * source.directionX,
          ky: k * source.directionY,
          omega: 0.73 * Math.sqrt(150 / item.wavelength) * item.tempoJitter,
          phase: item.phase,
          harmonic: item.harmonic,
          harmonicSin: Math.sin(item.harmonicPhase),
          harmonicCos: Math.cos(item.harmonicPhase),
          thirdHarmonic: item.thirdHarmonic,
          thirdHarmonicSin: Math.sin(item.thirdHarmonicPhase),
          thirdHarmonicCos: Math.cos(item.thirdHarmonicPhase),
          stepSin: 0,
          stepCos: 1,
        };
      });

      sinPhase = new Float64Array(modes.length);
      cosPhase = new Float64Array(modes.length);
      envelopeSin = Array.from({ length: 3 }, () => new Float64Array(sources.length));
      envelopeCos = Array.from({ length: 3 }, () => new Float64Array(sources.length));
      localStrength = new Float64Array(sources.length);
      localStrengthDerivative = new Float64Array(sources.length);
      updateGeometry();
    };

    const layout = () => {
      const bounds = canvas.getBoundingClientRect();
      cssWidth = Math.max(1, bounds.width);
      cssHeight = Math.max(1, bounds.height);

      let width = Math.max(180, Math.round(cssWidth * opts.renderScale));
      let height = Math.max(120, Math.round(cssHeight * opts.renderScale));
      if (width * height > opts.maxPixels) {
        const factor = Math.sqrt(opts.maxPixels / (width * height));
        width = Math.max(1, Math.round(width * factor));
        height = Math.max(1, Math.round(height * factor));
      }

      if (width !== renderWidth || height !== renderHeight) {
        renderWidth = width;
        renderHeight = height;
        canvas.width = width;
        canvas.height = height;
        imageData = context.createImageData(width, height);
        context.imageSmoothingEnabled = true;
      }
      updateGeometry();
    };

    const draw = (time) => {
      if (!imageData || !modes.length) return;

      const data = imageData.data;
      const width = renderWidth;
      const height = renderHeight;
      const pixelWidth = cssWidth / width;
      const pixelHeight = cssHeight / height;

      // 真上から見る視線V=(0,0,1)と太陽方向Lの中間方向H。
      // N・Hを使うため、同じ傾斜角でも逆方角の面は反射しない。
      const azimuth = opts.sunAzimuth * DEG;
      const elevation = opts.sunElevation * DEG;
      const lightX = Math.cos(elevation) * Math.cos(azimuth);
      const lightY = Math.cos(elevation) * Math.sin(azimuth);
      const lightZ = Math.sin(elevation);
      const halfInv = 1 / Math.hypot(lightX, lightY, lightZ + 1);
      const halfX = lightX * halfInv;
      const halfY = lightY * halfInv;
      const halfZ = (lightZ + 1) * halfInv;
      const outerAngle = Math.max(0.05, opts.reflectionWidth) * DEG;
      const innerAngle = outerAngle * Math.max(0.06, 1 - opts.edgeSoftness * 0.88);
      const outerCos = Math.cos(outerAngle);
      const innerCos = Math.cos(innerAngle);
      const thresholdSpan = Math.max(1e-7, innerCos - outerCos);

      let offset = 0;
      for (let y = 0; y < height; y += 1) {
        const yCss = (y + 0.5) * pixelHeight;

        for (let modeIndex = 0; modeIndex < modes.length; modeIndex += 1) {
          const mode = modes[modeIndex];
          const phase = mode.kx * pixelWidth * 0.5 + mode.ky * yCss + mode.phase - mode.omega * time;
          sinPhase[modeIndex] = Math.sin(phase);
          cosPhase[modeIndex] = Math.cos(phase);
        }
        for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) {
          const source = sources[sourceIndex];
          for (let envelopeIndex = 0; envelopeIndex < source.envelopeParts.length; envelopeIndex += 1) {
            const envelope = source.envelopeParts[envelopeIndex];
            const phase = envelope.kx * pixelWidth * 0.5 + envelope.ky * yCss + envelope.phase - envelope.omega * time;
            envelopeSin[envelopeIndex][sourceIndex] = Math.sin(phase);
            envelopeCos[envelopeIndex][sourceIndex] = Math.cos(phase);
          }
        }

        for (let x = 0; x < width; x += 1) {
          let slopeX = 0;
          let slopeY = 0;

          for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) {
            const source = sources[sourceIndex];
            let envelopeSum = 0;
            let envelopeDerivative = 0;
            for (let envelopeIndex = 0; envelopeIndex < source.envelopeParts.length; envelopeIndex += 1) {
              const envelope = source.envelopeParts[envelopeIndex];
              const sine = envelopeSin[envelopeIndex][sourceIndex];
              const cosine = envelopeCos[envelopeIndex][sourceIndex];
              envelopeSum += envelope.weight * sine;
              envelopeDerivative += envelope.weight * envelope.k * cosine;
              envelopeSin[envelopeIndex][sourceIndex] = sine * envelope.stepCos + cosine * envelope.stepSin;
              envelopeCos[envelopeIndex][sourceIndex] = cosine * envelope.stepCos - sine * envelope.stepSin;
            }
            const envelopeSquare = envelopeSum * envelopeSum;
            const shapedEnvelope = envelopeSum * (1.5 - 0.5 * envelopeSquare);
            const shapeDerivative = 1.5 * (1 - envelopeSquare);
            localStrength[sourceIndex] = 1 + source.envelopeDepth * shapedEnvelope;
            localStrengthDerivative[sourceIndex] = source.envelopeDepth * shapeDerivative * envelopeDerivative;
          }

          for (let modeIndex = 0; modeIndex < modes.length; modeIndex += 1) {
            const mode = modes[modeIndex];
            const sine = sinPhase[modeIndex];
            const cosine = cosPhase[modeIndex];
            const strength = localStrength[mode.sourceIndex];
            const strengthDerivative = localStrengthDerivative[mode.sourceIndex];
            const doubleSin = 2 * sine * cosine;
            const doubleCos = cosine * cosine - sine * sine;
            const harmonicSin = doubleSin * mode.harmonicCos + doubleCos * mode.harmonicSin;
            const harmonicCos = doubleCos * mode.harmonicCos - doubleSin * mode.harmonicSin;
            const tripleSinBase = doubleSin * cosine + doubleCos * sine;
            const tripleCosBase = doubleCos * cosine - doubleSin * sine;
            const thirdSin = tripleSinBase * mode.thirdHarmonicCos + tripleCosBase * mode.thirdHarmonicSin;
            const thirdCos = tripleCosBase * mode.thirdHarmonicCos - tripleSinBase * mode.thirdHarmonicSin;
            const wave = sine + mode.harmonic * 0.5 * harmonicSin + mode.thirdHarmonic / 3 * thirdSin;
            const slopeWave = cosine + mode.harmonic * harmonicCos + mode.thirdHarmonic * thirdCos;
            const slopeAlongWave = mode.amplitude * (
              strength * mode.k * slopeWave + strengthDerivative * wave
            );
            const source = sources[mode.sourceIndex];
            slopeX += slopeAlongWave * source.directionX;
            slopeY += slopeAlongWave * source.directionY;
            sinPhase[modeIndex] = sine * mode.stepCos + cosine * mode.stepSin;
            cosPhase[modeIndex] = cosine * mode.stepCos - sine * mode.stepSin;
          }

          const normalInv = 1 / Math.sqrt(1 + slopeX * slopeX + slopeY * slopeY);
          const normalX = -slopeX * normalInv;
          const normalY = -slopeY * normalInv;
          const normalZ = normalInv;
          const alignment = normalX * halfX + normalY * halfY + normalZ * halfZ;
          const reflection = clampedSmoothStep((alignment - outerCos) / thresholdSpan);

          // 左(本文側)は白い岸にし、右側に水の見せ場を寄せる。
          const xNormal = x / Math.max(1, width - 1);
          const shore = (1 - opts.shoreStrength)
            + opts.shoreStrength * clampedSmoothStep((xNormal - 0.08) / 0.72);
          const slopeShade = 0.94 + Math.min(0.1, (1 - normalZ) * 0.34);
          const water = opts.waterAmount * shore * slopeShade
            * (1 - opts.reflectionStrength * reflection * 0.96);

          data[offset] = Math.max(0, Math.min(255, Math.round(255 + (WATER[0] - 255) * water)));
          data[offset + 1] = Math.max(0, Math.min(255, Math.round(255 + (WATER[1] - 255) * water)));
          data[offset + 2] = 255;
          data[offset + 3] = 255;
          offset += 4;
        }
      }

      context.putImageData(imageData, 0, 0);
    };

    let animationFrame = 0;
    let simulationTime = 0;
    let lastFrameAt = performance.now();
    let lastRenderedAt = -Infinity;
    let isVisible = true;
    let isDocumentVisible = !document.hidden;
    let resizeTimer;

    const shouldRun = () => !reduceMotion && isVisible && isDocumentVisible;

    const frame = (timestamp) => {
      animationFrame = 0;
      if (!shouldRun()) return;
      const elapsed = Math.min(0.08, Math.max(0, (timestamp - lastFrameAt) / 1000));
      lastFrameAt = timestamp;
      simulationTime += elapsed * playbackSpeed;
      if (timestamp - lastRenderedAt >= frameInterval - 1) {
        draw(simulationTime);
        lastRenderedAt = timestamp;
      }
      animationFrame = requestAnimationFrame(frame);
    };

    const start = () => {
      if (animationFrame !== 0 || !shouldRun()) return;
      lastFrameAt = performance.now();
      animationFrame = requestAnimationFrame(frame);
    };

    const stop = () => {
      if (animationFrame === 0) return;
      cancelAnimationFrame(animationFrame);
      animationFrame = 0;
    };

    const handleResize = () => {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        layout();
        draw(simulationTime);
      }, 160);
    };

    const handleVisibilityChange = () => {
      isDocumentVisible = !document.hidden;
      if (shouldRun()) start();
      else stop();
    };

    const resizeObserver = new ResizeObserver(handleResize);
    resizeObserver.observe(canvas);
    const intersectionObserver = new IntersectionObserver(
      (entries) => {
        isVisible = entries[0]?.isIntersecting ?? true;
        if (shouldRun()) start();
        else stop();
      },
      { threshold: 0 },
    );
    intersectionObserver.observe(canvas);
    document.addEventListener('visibilitychange', handleVisibilityChange);
    window.addEventListener('resize', handleResize);
    window.addEventListener('load', handleResize, { once: true });

    layout();
    buildModes();
    draw(0);
    start();
  }

  function createField(canvas, opts) {
    const context = canvas.getContext('2d');
    if (!context) return;
    const isNgonRipple = opts.engine === 'ngon-ripple';

    const [fillR, fillG, fillB] = hexToRgb(opts.color);
    const fieldRange = opts.fieldMax - opts.fieldMin;
    const radiusSquared = opts.pointerRadius * opts.pointerRadius;
    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const frameInterval = 1000 / 30;

    const pointer = { x: -9999, y: -9999, tx: -9999, ty: -9999, amp: 0, tamp: 0 };
    const fieldSeed = isNgonRipple ? null : {
      phaseX: Math.random() * Math.PI * 2,
      phaseY: Math.random() * Math.PI * 2,
      scaleX: opts.scaleX[0] + Math.random() * opts.scaleX[1],
      scaleY: opts.scaleY[0] + Math.random() * opts.scaleY[1],
    };

    let devicePixelRatio = 1;
    let width = 0;
    let height = 0;
    let cols = 0;
    let rows = 0;
    let field = null;
    let imprint = null; // ホバー痕跡(pointer: true のプリセット用)
    let fillImage = null;
    let ngonField = null; // engine: 'ngon-ripple' のときだけ使う

    const layout = () => {
      // 柔らかな低周波表現なので、描画解像度をCSSピクセル等倍に抑える。
      devicePixelRatio = 1;
      const bounds = canvas.getBoundingClientRect();
      width = Math.max(1, Math.round(bounds.width));
      height = Math.max(1, Math.round(bounds.height));
      canvas.width = Math.round(width * devicePixelRatio);
      canvas.height = Math.round(height * devicePixelRatio);
      context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
      cols = Math.ceil(width / opts.cell) + 1;
      rows = Math.ceil(height / opts.cell) + 1;
      field = new Float32Array(cols * rows);
      imprint = new Float32Array(cols * rows); // リサイズ時はグリッド寸法が変わるためリセット
      fillImage = null;
      if (isNgonRipple) {
        // 発生源の位置は画面サイズ基準(中央・画面外)なので、リサイズの
        // たびに作り直して現在の寸法に合わせる。
        ngonField = createNgonRippleField(opts, width, height);
      }
    };

    // nested-sine を 120°ずつ3方向に配置 → 3回転対称で斜めの偏りをなくす
    const baseNoise = (x, y, t) => {
      const p = x;
      const q = -0.5 * x + 0.866 * y;
      const r = -0.5 * x - 0.866 * y;
      return (
        Math.sin(p + Math.sin(q * 0.8 + t * 0.50)) +
        Math.sin(q + Math.sin(r * 0.8 + t * 0.47)) +
        Math.sin(r + Math.sin(p * 0.8 + t * 0.53))
      ) / 2;
    };

    // フラクタル(fBm)。ridgeInvert でゼロ交差を谷(白い光の線)にする
    const fractal = (x, y, t) => {
      let amplitude = 1;
      let frequency = 1;
      let sum = 0;
      let norm = 0;
      for (let octave = 0; octave < opts.octaves; octave += 1) {
        const n = baseNoise(x * frequency, y * frequency, t);
        const folded = opts.ridgeInvert
          ? 2 * Math.abs(n) - 1   // 谷折り: ゼロ交差が低く(明るく)なる
          : 1 - 2 * Math.abs(n);  // 山折り: ゼロ交差が高く(濃く)なる
        const shaped = n + (folded - n) * opts.ridge;
        sum += shaped * amplitude;
        norm += amplitude;
        amplitude *= opts.gain;
        frequency *= opts.lacunarity;
      }
      return sum / norm;
    };

    const compute = (time) => {
      const stamping = opts.pointer && pointer.tamp > 0;
      const warpStrength = isNgonRipple ? 0 : opts.warp * opts.ridge;
      for (let gridY = 0; gridY < rows; gridY += 1) {
        for (let gridX = 0; gridX < cols; gridX += 1) {
          const x = gridX * opts.cell;
          const y = gridY * opts.cell;
          // 勾配: 本文の載る側の水を引かせ、反対側で泳がせる
          const gradient =
            opts.gradX * (0.5 - x / width) +
            opts.gradY * (0.5 - y / height);

          let value;
          if (isNgonRipple) {
            // 値が高い場所(山頂)を白く見せたいが、valueToAlphaは
            // 「値が高いほど不透明(水色)」という向きに固定されているため、
            // 符号を反転してから渡す。
            value = -(opts.amplitude * ngonField.evaluate(x, y, time)) + gradient;
          } else {
            const nx = x * fieldSeed.scaleX + fieldSeed.phaseX - time * opts.driftX;
            const ny = y * fieldSeed.scaleY + fieldSeed.phaseY - time * opts.driftY;
            // 低周波のドメインワープで網目の輪郭を有機的に崩す
            const warpX = warpStrength * baseNoise(nx * 0.4 + 11.5, ny * 0.4, time * 0.2);
            const warpY = warpStrength * baseNoise(nx * 0.4, ny * 0.4 + 7.3, time * 0.2);
            value = opts.amplitude * fractal(nx + warpX, ny + warpY, time) + gradient;
          }

          // ホバー痕跡(pointer: true のプリセットのみ)
          const cellIndex = gridY * cols + gridX;
          let mark = imprint[cellIndex] * opts.imprintDecay;
          if (stamping) {
            const dx = x - pointer.x;
            const dy = y - pointer.y;
            mark += pointer.amp * opts.imprintGain * Math.exp(-(dx * dx + dy * dy) / radiusSquared);
            if (mark > opts.imprintMax) {
              mark = opts.imprintMax;
            }
          }
          imprint[cellIndex] = mark;

          field[cellIndex] = value + mark;
        }
      }
    };

    const sampleField = (x, y) => {
      const gridX = x / opts.cell;
      const gridY = y / opts.cell;
      const x0 = Math.max(0, Math.min(cols - 2, Math.floor(gridX)));
      const y0 = Math.max(0, Math.min(rows - 2, Math.floor(gridY)));
      const fx = Math.max(0, Math.min(1, gridX - x0));
      const fy = Math.max(0, Math.min(1, gridY - y0));
      const corner = y0 * cols + x0;
      const topLeft = field[corner];
      const topRight = field[corner + 1];
      const bottomLeft = field[corner + cols];
      const bottomRight = field[corner + cols + 1];
      const top = topLeft + (topRight - topLeft) * fx;
      const bottom = bottomLeft + (bottomRight - bottomLeft) * fx;
      return top + (bottom - top) * fy;
    };

    const valueToAlpha = (value) => {
      const normalized = Math.max(0, Math.min(1, (value - opts.fieldMin) / fieldRange));
      const eased = smoothStep(normalized);
      return Math.round((opts.alphaMin + eased * (opts.alphaMax - opts.alphaMin)) * 255);
    };

    const ensureFillImage = () => {
      const pixelWidth = canvas.width;
      const pixelHeight = canvas.height;
      if (fillImage && fillImage.width === pixelWidth && fillImage.height === pixelHeight) {
        return fillImage;
      }
      fillImage = context.createImageData(pixelWidth, pixelHeight);
      return fillImage;
    };

    const draw = () => {
      const image = ensureFillImage();
      const buffer = image.data;
      const pixelWidth = image.width;
      const pixelHeight = image.height;
      const invDevicePixelRatio = 1 / devicePixelRatio;
      const step = opts.fillStep;

      for (let pixelY = 0; pixelY < pixelHeight; pixelY += step) {
        const y = pixelY * invDevicePixelRatio;
        for (let pixelX = 0; pixelX < pixelWidth; pixelX += step) {
          const x = pixelX * invDevicePixelRatio;
          const alpha = valueToAlpha(sampleField(x, y));
          for (let blockY = 0; blockY < step && pixelY + blockY < pixelHeight; blockY += 1) {
            const rowStart = ((pixelY + blockY) * pixelWidth + pixelX) * 4;
            for (let blockX = 0; blockX < step && pixelX + blockX < pixelWidth; blockX += 1) {
              const index = rowStart + blockX * 4;
              buffer[index] = fillR;
              buffer[index + 1] = fillG;
              buffer[index + 2] = fillB;
              buffer[index + 3] = alpha;
            }
          }
        }
      }

      context.putImageData(image, 0, 0);
    };

    let animationFrame = 0;
    let startedAt = null;
    let lastRenderedAt = -Infinity;
    let frozenAt = null;
    let isVisible = true;
    let isDocumentVisible = !document.hidden;
    let resizeTimer;

    const shouldRun = () => isVisible && isDocumentVisible;

    const frame = (timestamp) => {
      animationFrame = 0;
      if (!shouldRun()) {
        return;
      }
      if (startedAt === null) {
        startedAt = timestamp;
      }
      if (timestamp - lastRenderedAt >= frameInterval - 1) {
        lastRenderedAt = timestamp;
        pointer.x += (pointer.tx - pointer.x) * 0.1;
        pointer.y += (pointer.ty - pointer.y) * 0.1;
        pointer.amp += (pointer.tamp - pointer.amp) * 0.06;
        compute(((timestamp - startedAt) / 1000) * opts.timeScale);
        draw();
      }
      animationFrame = requestAnimationFrame(frame);
    };

    const start = () => {
      if (animationFrame !== 0 || !shouldRun()) {
        return;
      }
      if (frozenAt !== null && startedAt !== null) {
        startedAt += performance.now() - frozenAt;
        frozenAt = null;
      }
      animationFrame = requestAnimationFrame(frame);
    };

    const stop = () => {
      if (animationFrame !== 0) {
        cancelAnimationFrame(animationFrame);
        animationFrame = 0;
      }
      if (frozenAt === null) {
        frozenAt = performance.now();
      }
    };

    const drawStatic = () => {
      layout();
      pointer.amp = 0;
      compute(0);
      draw();
    };

    const handleResize = () => {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        if (reduceMotion) {
          drawStatic();
          return;
        }
        layout();
        if (!shouldRun()) {
          // 停止中はループが描き直さないため、場を計算してから描く
          compute(0);
          draw();
        }
      }, 160);
    };

    const handleVisibilityChange = () => {
      isDocumentVisible = !document.hidden;
      if (isDocumentVisible && isVisible) {
        start();
      } else {
        stop();
      }
    };

    const handlePointerMove = (event) => {
      const bounds = canvas.getBoundingClientRect();
      if (
        event.clientX < bounds.left ||
        event.clientX > bounds.right ||
        event.clientY < bounds.top ||
        event.clientY > bounds.bottom
      ) {
        pointer.tamp = 0;
        return;
      }
      pointer.tx = event.clientX - bounds.left;
      pointer.ty = event.clientY - bounds.top;
      pointer.tamp = 1;
    };

    const resizeObserver = new ResizeObserver(handleResize);
    resizeObserver.observe(canvas);

    // 画面外・非表示タブでは止める(無駄な描画をしない)
    const intersectionObserver = new IntersectionObserver(
      (entries) => {
        isVisible = entries[0]?.isIntersecting ?? true;
        if (isVisible) {
          start();
        } else {
          stop();
        }
      },
      { threshold: 0 },
    );
    intersectionObserver.observe(canvas);

    document.addEventListener('visibilitychange', handleVisibilityChange);
    if (opts.pointer && !reduceMotion) {
      document.addEventListener('pointermove', handlePointerMove);
    }
    window.addEventListener('resize', handleResize);
    // フォント・CSS適用前に起動した場合の保険(初回描画時の寸法ずれ対策)
    window.addEventListener('load', handleResize, { once: true });

    if (reduceMotion) {
      drawStatic();
    } else {
      layout();
      compute(0);
      draw();
      start();
    }
  }

  function boot() {
    document.querySelectorAll('canvas[data-field]').forEach((canvas) => {
      const preset = PRESETS[canvas.dataset.field];
      if (preset) {
        if (preset.engine === 'surface-reflection') {
          createSurfaceReflectionField(canvas, preset);
        } else {
          createField(canvas, preset);
        }
      }
    });
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }
})();

/* ---- ここまで --------------------------------------------------------- */
(function () {
  var reduced = matchMedia('(prefers-reduced-motion: reduce)');
  function report() {
    document.documentElement.dataset.materialActive = 'true';
    document.documentElement.dataset.materialRunning = String(!reduced.matches && !document.hidden);
  }
  addEventListener('message', function (e) {
    var d = e.data || {};
    /* 値は起動時に1度だけ読む実装なので、ここでは受け取らない。
       親は同じコードで iframe を組み直す(読み込み直しの点滅を起こさない) */
    if (d.type === 'material-active') {
      /* 元コードは自前の IntersectionObserver で止まる。
         画面から外せば、その仕組みがそのまま働く */
      var host = document.querySelector('#effect-canvas, #hero-wave');
      if (host) host.style.display = d.active === true ? '' : 'none';
      document.documentElement.dataset.materialActive = String(d.active === true);
      document.documentElement.dataset.materialRunning = String(
        d.active === true && !reduced.matches && !document.hidden);
    }
  });
  document.addEventListener('visibilitychange', report);
  reduced.addEventListener('change', report);
  report();
})();
</script>
</body>
</html>

AIエージェントへ渡す場合は、「このHTMLを対象ページのヒーロー背景へ組み込み、文字と主要ボタンを前面に残してください」と添えます。

組み込み時の確認#

関連#

この資料は、Webサイトを作るときに引くためのものです。 何のために作ったか。作っているのは AK²Lab。