このページの中身

炎

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

上へ加速しながら細くなり、先がちぎれて消える炎。熱・焙煎・鍛造。暗い地で強い気配を出したいとき。

試して調整する#

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

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

判断#

向く場面: 熱・焙煎・鍛造。暗い地で強い気配を出したいとき。

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

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

組み込み条件#

依存: 素の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">{"RISE_SPEED":0.2,"SWAY_AMP":0.2,"FLICKER":0.16,"BREAK_GAIN":0.4,"FLAME_NARROW":1.7,"FIELD_MIN":-2,"FIELD_MAX":3.6,"FILL_ALPHA_MIN":0.005,"FILL_ALPHA_MAX":0.84,"FILL_STEP":3,"CELL":10,"TIME_SCALE":9.4,"RIDGE":0.15,"OCTAVES":3.3,"LACUNARITY":2.0,"GAIN":0.6,"WARP":0.0,"FIELD_AMPLITUDE":3.0,"GRADIENT_AMP":-8.8,"RISE_ACCEL":2.6,"RISE_SPAN":14.5,"SWAY_GROWTH":0.6,"SWAY_WAVE":18.0,"SWAY_SPEED":0.41,"SWAY_NOISE":0.35,"SWAY_COL_SCALE":0.9,"TOP_CUT":3.0,"SPREAD":0.1,"BOIL":0.35,"BOIL_TILT":0.6}</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=null;
  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="配色切替">
    <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"><div class="site-home-hero"><canvas class="site-home-field-canvas" id="effect-canvas"></canvas></div></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 || '{}');
(() => {
  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),
    ];
  }

  const SHOW_CONTOURS = false;      // 等高線を表示する/しない
  const FIELD_HEX = "#FED4B0";     // 塗りの色。空・海・プールのようなクリアな青
//  const FIELD_HEX = "#ff7744";     // 塗りの色。空・海・プールのようなクリアな青
  const CONTOUR_HEX = FIELD_HEX;   // 等高線の色(塗りと同色・透明度で溶かす)
  const FIELD_RGB = hexToRgb(FIELD_HEX);
  const CONTOUR_RGB = hexToRgb(CONTOUR_HEX);
  const STROKE_RGB = CONTOUR_RGB.join(", "); // strokeStyle 用の「R, G, B」文字列
  const [FILL_R, FILL_G, FILL_B] = FIELD_RGB; // ImageData 用に R/G/B を展開
  const STROKE_ALPHA = SHOW_CONTOURS ? 0.2 : 0; // 大きくすると:等高線が 濃く 見える
  const FIELD_MIN = (__CFG__["FIELD_MIN"] !== undefined ? __CFG__["FIELD_MIN"] : (-2));         // 大きくすると:低い値側のコントラストが変わる(やや複雑) 雲が多くなる、雑になる -1
  const FIELD_MAX = (__CFG__["FIELD_MAX"] !== undefined ? __CFG__["FIELD_MAX"] : (3.6));        // 大きくすると:高い値側のコントラストが変わる(やや複雑) 雲と空の区別がなくなる  2.6~3.6
  const FILL_ALPHA_MIN = (__CFG__["FILL_ALPHA_MIN"] !== undefined ? __CFG__["FILL_ALPHA_MIN"] : (0.005)); // 大きくすると:薄い部分が 濃く なる(濃淡がなくなる)
  const FILL_ALPHA_MAX = (__CFG__["FILL_ALPHA_MAX"] !== undefined ? __CFG__["FILL_ALPHA_MAX"] : (0.84));  // 大きくすると:濃い部分が より濃く(全体が目立つ)なる(絵画っぽくなる)↔減らすと曇る
  const FILL_STEP = (__CFG__["FILL_STEP"] !== undefined ? __CFG__["FILL_STEP"] : (3));          // 大きくすると:描画が粗くなり 軽く なる(画質はやや落ちる)
  const CELL = (__CFG__["CELL"] !== undefined ? __CFG__["CELL"] : (10));              // 大きくすると:うねりの 塊が大きく なる(粗い) 20:20~30
  const TIME_SCALE = (__CFG__["TIME_SCALE"] !== undefined ? __CFG__["TIME_SCALE"] : (9.4));       // 大きくすると:速く動く(時間が速く経過する) 0.4~0.8
  const LEVELS = [-2.4, -1.8, -1.2, -0.6, 0, 0.6, 1.2, 1.8, 2.4]; // 等高線の本数・間隔
//  const LEVELS = [-2.2, -1.8, -1.44, -1.12, -0.84, -0.6, -0.4, -0.24, -0.12, -0.04, 0, 0.04, 0.12, 0.24, 0.4, 0.6, 0.84, 1.12, 1.44, 1.8, 2.2]; // 等高線の本数・間隔
//  const LEVELS = [-2.2, -1.44, -0.84, -0.4, -0.12, 0, 0.12, 0.4, 0.84, 1.44, 2.2]; // 等高線の本数・間隔
  const FIELD_RANGE = FIELD_MAX - FIELD_MIN;

  // ─── 地形シェイプ:円形のうねり ⇄ 山脈の尾根と谷 ───
  const RIDGE = (__CFG__["RIDGE"] !== undefined ? __CFG__["RIDGE"] : (0.15));          // 0: なめらかな円形のうねり / 1: 山脈の尾根と谷。間で度合いを調整 見た目が複雑化  0.05~0.25
  const OCTAVES = (__CFG__["OCTAVES"] !== undefined ? __CFG__["OCTAVES"] : (3.3));           // フラクタルの重ね合わせ数。多いほど尾根の細部が増える(やや重く なる) 多様化・細かくなる↔安っぽく見える 3~4
  const LACUNARITY = (__CFG__["LACUNARITY"] !== undefined ? __CFG__["LACUNARITY"] : (2.0));      // オクターブごとの周波数倍率。大きいほど細かい起伏が乗る 2.0ほぼ固定
  const GAIN = (__CFG__["GAIN"] !== undefined ? __CFG__["GAIN"] : (0.6));            // オクターブごとの振幅減衰。大きいほど細部が強く 残る 0.4ほぼ固定
  const WARP = (__CFG__["WARP"] !== undefined ? __CFG__["WARP"] : (0.0));            // ドメインワープの最大強さ。大きいほど尾根が 曲がりくねる(RIDGE に比例して効く) 0.7~4.7
  const FIELD_AMPLITUDE = (__CFG__["FIELD_AMPLITUDE"] !== undefined ? __CFG__["FIELD_AMPLITUDE"] : (3.0)); // 大きくすると濃淡がはっきり、絵画っぽくなる。フィールド値の振れ幅。等高線(LEVELS)と塗りの濃淡量に対応  2~4
  
  const GRADIENT_AMP = (__CFG__["GRADIENT_AMP"] !== undefined ? __CFG__["GRADIENT_AMP"] : (-8.8));  // 上下の晴れ勾配。正の値=上が濃く下が晴れ/負の値=上が晴れ下が濃い(現在は負=上が晴れ・下が濃い)。0 で勾配なし -6.8:-6.6~-4.8

  // ─── 炎:上昇・加速・横揺れ ───────────────────────────────
  // 高さは h で表す。h = 0 が下端(火の根元)、h = 1 が上端(先端)。
  //
  // 【形】炎は縦に細長い。横方向だけノイズを縮めて引くことで、丸い塊ではなく舌になる。
  const FLAME_NARROW = (__CFG__["FLAME_NARROW"] !== undefined ? __CFG__["FLAME_NARROW"] : (1.7)); // 大きくすると:炎が縦に細長くなる(1 で等方=雲のような丸い塊になる) 1.0~2.6

  // 【年齢(age)座標】炎は浮力で上ほど速くなる。上昇速度を v(h) = 1 + RISE_ACCEL*h と置くと、
  // 根元から高さ h まで昇るのにかかる時間=その場所のガスの「年齢」は
  //     age(h) = ln(1 + RISE_ACCEL*h) / RISE_ACCEL
  // で閉じた形に求まる。ノイズの縦座標に y ではなくこの age を使うことで、
  //   ・上ほど速く流れる(加速)
  //   ・上ほど模様が縦に引き伸ばされる(=舌になる)
  // が同時に成立する。y をそのまま使うと全高で同じ速さ・同じ大きさになり、雲が流れるだけになる。
  const RISE_ACCEL = (__CFG__["RISE_ACCEL"] !== undefined ? __CFG__["RISE_ACCEL"] : (2.6));   // 大きくすると:上ほど速くなり、炎が縦に長く引き伸ばされて舌になる(0 で等速=旧来の流れ) 1.5~4.5
  const RISE_SPAN = (__CFG__["RISE_SPAN"] !== undefined ? __CFG__["RISE_SPAN"] : (14.5));   // 大きくすると:縦に並ぶ模様の数が増える(=炎ひとつひとつが小さくなる) 8~16
  const RISE_SPEED = (__CFG__["RISE_SPEED"] !== undefined ? __CFG__["RISE_SPEED"] : (0.20));  // 大きくすると:炎が速く昇る(旧 DRIFT_SPEED の置き換え。負値で下降) 0.15~0.5

  // 【横揺れ】揺れの位相に age を使うのが肝。y を使うと「その高さではいつも同じ曲がり方」に
  // なってしまい、曲がりが上へ伝わらない。age を使うと、根元で生まれた曲がりが炎に乗って昇る。
  const SWAY_AMP = (__CFG__["SWAY_AMP"] !== undefined ? __CFG__["SWAY_AMP"] : (0.2));       // 大きくすると:先端の左右の振れ幅が 大きく なる 0.2~1.2
  const SWAY_GROWTH = (__CFG__["SWAY_GROWTH"] !== undefined ? __CFG__["SWAY_GROWTH"] : (0.6));    // 大きくすると:根元がより動かなくなり先端だけが暴れる(小さいと布のように全体がなびく) 1.0~2.4
  const SWAY_WAVE = (__CFG__["SWAY_WAVE"] !== undefined ? __CFG__["SWAY_WAVE"] : (18.0));     // 大きくすると:縦方向のくねりの回数が 増える(S字が細かくなる) 8~24
  const SWAY_SPEED = (__CFG__["SWAY_SPEED"] !== undefined ? __CFG__["SWAY_SPEED"] : (0.41));    // 揺れが上へ伝わる速さ。RISE_SPEED / RISE_SPAN * SWAY_WAVE(=0.41)でガスと同期し「炎自身がうねる」。
                              // それより小さくすると流れに対し揺れが遅れ、旗のように はためく 0.2~0.7
  const SWAY_NOISE = (__CFG__["SWAY_NOISE"] !== undefined ? __CFG__["SWAY_NOISE"] : (0.35));    // 大きくすると:揺れが不規則になる(0 でなめらかな正弦のうねり) 0~0.7
  const SWAY_COL_SCALE = (__CFG__["SWAY_COL_SCALE"] !== undefined ? __CFG__["SWAY_COL_SCALE"] : (0.9)); // 大きくすると:左右に並ぶ炎の本数が 増える(=1本あたりが細くなる) 0.4~1.8

  // 【消え方】上端で一様に薄まると煙になる。上ほどコントラストを上げて島に割り、ちぎれさせる。
  const BREAK_GAIN = (__CFG__["BREAK_GAIN"] !== undefined ? __CFG__["BREAK_GAIN"] : (0.4));   // 大きくすると:先端がちぎれて粒になって消える(0 で従来の一様フェード=煙) 0~1.6
  const TOP_CUT = (__CFG__["TOP_CUT"] !== undefined ? __CFG__["TOP_CUT"] : (3.0));      // 大きくすると:上端で炎が早く途切れる(炎の高さが 低く なる) 0~2.5
  const SPREAD = (__CFG__["SPREAD"] !== undefined ? __CFG__["SPREAD"] : (0.1));       // 大きくすると:上に行くほど左右に広がる(円錐状の火柱になる) 0~0.8
  const FLICKER = (__CFG__["FLICKER"] !== undefined ? __CFG__["FLICKER"] : (0.16));     // 大きくすると:全体が明滅する(焚き火のちらつき)。強すぎると安っぽい 0~0.15
  const BOIL = (__CFG__["BOIL"] !== undefined ? __CFG__["BOIL"] : (0.35));        // ノイズ自体が沸き立つ速さ。大きいと模様がその場で変わりすぎ、昇っている感じが薄れる 0.15~0.8
  const BOIL_TILT = (__CFG__["BOIL_TILT"] !== undefined ? __CFG__["BOIL_TILT"] : (0.6));    // 大きくすると:細かい渦ほど速く沸き、大きな形は崩れず残る(乱流らしさ) 0~1.0

  // オクターブごとの沸き立ち倍率。小さい渦ほど短命、という乱流の性質を再現する。
  const OCTAVE_BOIL = [];
  for (let octave = 0, frequency = 1; octave < Math.ceil(OCTAVES); octave += 1, frequency *= LACUNARITY) {
    OCTAVE_BOIL.push(Math.pow(frequency, BOIL_TILT));
  }

  let cleanup = null;

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

  function boot() {
    cleanup?.();
    cleanup = null;

    const hero = document.querySelector(".site-home-hero");
    if (!(hero instanceof HTMLElement)) {
      return;
    }

    const canvas = hero.querySelector(".site-home-field-canvas");
    if (!(canvas instanceof HTMLCanvasElement)) {
      return;
    }

    const context = canvas.getContext("2d");
    if (!context) {
      return;
    }

    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const fieldSeed = {
      phaseX: Math.random() * Math.PI * 2,
      phaseY: Math.random() * Math.PI * 2,
      // 横の周波数。縦は年齢座標(RISE_SPAN / RISE_ACCEL)が受け持つので scaleY は持たない
      scaleX: 0.0100 + Math.random() * 0.003,
    };

    let devicePixelRatio = 1;
    let width = 0;
    let height = 0;
    let cols = 0;
    let rows = 0;
    let field = null;
    let colPhase = null;
    let fillImage = null;

    const layout = () => {
      devicePixelRatio = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
      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 / CELL) + 1;
      rows = Math.ceil(height / CELL) + 1;
      field = new Float32Array(cols * rows);
      colPhase = new Float32Array(cols);
      fillImage = null;
    };

    // nested-sine を 120°ずつ3方向に配置 → 3回転対称で斜め偏りなし。
    // 旧: 2方向だと等値線が45°方向に揃って斜め伸びが出ていた。
    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)。オクターブを重ね、各層で円形(なめらか)と尾根(折り返し)を RIDGE で補間。
    const fractal = (x, y, t) => {
      let amplitude = 1;
      let frequency = 1;
      let sum = 0;
      let norm = 0;
      for (let octave = 0; octave < OCTAVES; octave += 1) {
        // 時間を OCTAVE_BOIL 倍して渡す=細かい層ほど速く沸き、大きな形は崩れず残る
        const n = baseNoise(x * frequency, y * frequency, t * OCTAVE_BOIL[octave]);
        const ridged = 1 - 2 * Math.abs(n); // ゼロ交差に沿って鋭い尾根(折り目)ができる
        const shaped = n + (ridged - n) * RIDGE; // RIDGE=0→円形 / RIDGE=1→尾根
        sum += shaped * amplitude;
        norm += amplitude;
        amplitude *= GAIN;
        frequency *= LACUNARITY;
      }
      return sum / norm;
    };

    const compute = (time) => {
      const warpStrength = WARP * RIDGE; // 円形側ではワープを弱め、山脈側で曲げを強める
      const invHeight = 1 / height;
      const halfWidth = width * 0.5;
      const boilTime = time * BOIL;
      // 焚き火のちらつき。非整数比の2波を重ね、周期を感じさせない
      const flicker = 1 + FLICKER * (Math.sin(time * 0.75) * 0.6 + Math.sin(time * 1.21 + 1.3) * 0.4);

      // 列ごとに揺れの位相をずらす。これが無いと全幅が一斉に左右へ振れ、画面がパンして見える
      for (let gridX = 0; gridX < cols; gridX += 1) {
        const nx = gridX * CELL * fieldSeed.scaleX + fieldSeed.phaseX;
        colPhase[gridX] = baseNoise(nx * SWAY_COL_SCALE, 5.7, time * 0.05) * Math.PI;
      }

      for (let gridY = 0; gridY < rows; gridY += 1) {
        const y = gridY * CELL;
        const h = Math.max(0, Math.min(1, 1 - y * invHeight)); // 0=根元(下端) / 1=先端(上端)
        // 年齢座標。上ほど速い流れを積分した値で、縦伸びと加速はどちらもここから出る
        const age = RISE_ACCEL > 1e-4 ? Math.log(1 + RISE_ACCEL * h) / RISE_ACCEL : h;
        const ny = age * RISE_SPAN - time * RISE_SPEED + fieldSeed.phaseY;
        // 根元は動かさず、先端ほど大きく振る(層流→乱流)
        const swayAmplitude = SWAY_AMP * Math.pow(h, SWAY_GROWTH);
        // 位相が age で進む=曲がりが炎に乗って上へ伝わる。第2波は波数も周波数も 1.9 倍なので同じ速さで昇る
        const swayPhase = age * SWAY_WAVE - time * SWAY_SPEED;
        // 上ほど高コントラスト=閾値付近で島に割れ、先端がちぎれる
        const amplitude = FIELD_AMPLITUDE * (1 + BREAK_GAIN * h) * flicker;
        // 上下の晴れ勾配(上=薄い / 下=濃い)に、上端を早く断ち切る三乗の追い引きを足す
        const gradient = GRADIENT_AMP * (0.5 - y * invHeight) - TOP_CUT * h * h * h;
        const invSpread = 1 / (1 + SPREAD * h); // 上ほど左右に広がる(円錐)

        for (let gridX = 0; gridX < cols; gridX += 1) {
          const x = gridX * CELL;
          const nx = (halfWidth + (x - halfWidth) * invSpread) * fieldSeed.scaleX + fieldSeed.phaseX;
          const phase = colPhase[gridX];
          const sway =
            Math.sin(swayPhase + phase) +
            Math.sin(swayPhase * 1.9 + phase * 1.7) * 0.45 +
            baseNoise(nx * 0.5, ny * 0.35, time * 0.15) * SWAY_NOISE;
          // 残したドメインワープも横だけ。縦の周波数を落とし「縦に長い曲がり」にする
          const warpX = warpStrength * baseNoise(nx * 0.9 + 11.5, ny * 0.25, time * 0.2);

          // 横だけ FLAME_NARROW 倍して引く=模様が縦長になる(揺れ幅は px 換算のまま変わらない)
          field[gridY * cols + gridX] =
            amplitude * fractal((nx + sway * swayAmplitude + warpX) * FLAME_NARROW, ny, boilTime) + gradient;
        }
      }
    };

    const sampleField = (x, y) => {
      const gridX = x / CELL;
      const gridY = y / 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 - FIELD_MIN) / FIELD_RANGE));
      const eased = smoothStep(normalized);
      return Math.round((FILL_ALPHA_MIN + eased * (FILL_ALPHA_MAX - FILL_ALPHA_MIN)) * 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 drawFill = () => {
      const image = ensureFillImage();
      const buffer = image.data;
      const pixelWidth = image.width;
      const pixelHeight = image.height;
      const invDevicePixelRatio = 1 / devicePixelRatio;
      const step = FILL_STEP;

      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] = FILL_R;
              buffer[index + 1] = FILL_G;
              buffer[index + 2] = FILL_B;
              buffer[index + 3] = alpha;
            }
          }
        }
      }

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

    const drawContours = () => {
      context.lineWidth = 1.1;
      context.lineJoin = "round";
      context.lineCap = "round";
      context.strokeStyle = `rgba(${STROKE_RGB}, ${STROKE_ALPHA})`;
      context.beginPath();

      for (let levelIndex = 0; levelIndex < LEVELS.length; levelIndex += 1) {
        const threshold = LEVELS[levelIndex];
        for (let gridY = 0; gridY < rows - 1; gridY += 1) {
          for (let gridX = 0; gridX < cols - 1; gridX += 1) {
            const corner = gridY * cols + gridX;
            const topLeft = field[corner];
            const topRight = field[corner + 1];
            const bottomRight = field[corner + cols + 1];
            const bottomLeft = field[corner + cols];
            const insideTopLeft = topLeft > threshold;
            const insideTopRight = topRight > threshold;
            const insideBottomRight = bottomRight > threshold;
            const insideBottomLeft = bottomLeft > threshold;
            const code =
              (insideTopLeft ? 8 : 0) |
              (insideTopRight ? 4 : 0) |
              (insideBottomRight ? 2 : 0) |
              (insideBottomLeft ? 1 : 0);
            if (code === 0 || code === 15) {
              continue;
            }

            const x0 = gridX * CELL;
            const y0 = gridY * CELL;
            const x1 = x0 + CELL;
            const y1 = y0 + CELL;
            const points = [];
            if (insideTopLeft !== insideTopRight) {
              points.push(x0 + ((threshold - topLeft) / (topRight - topLeft)) * CELL, y0);
            }
            if (insideTopRight !== insideBottomRight) {
              points.push(x1, y0 + ((threshold - topRight) / (bottomRight - topRight)) * CELL);
            }
            if (insideBottomLeft !== insideBottomRight) {
              points.push(
                x0 + ((threshold - bottomLeft) / (bottomRight - bottomLeft)) * CELL,
                y1,
              );
            }
            if (insideTopLeft !== insideBottomLeft) {
              points.push(x0, y0 + ((threshold - topLeft) / (bottomLeft - topLeft)) * CELL);
            }

            if (points.length === 4) {
              context.moveTo(points[0], points[1]);
              context.lineTo(points[2], points[3]);
            } else if (points.length === 8) {
              if (code === 5) {
                context.moveTo(points[0], points[1]);
                context.lineTo(points[2], points[3]);
                context.moveTo(points[4], points[5]);
                context.lineTo(points[6], points[7]);
              } else {
                context.moveTo(points[0], points[1]);
                context.lineTo(points[6], points[7]);
                context.moveTo(points[2], points[3]);
                context.lineTo(points[4], points[5]);
              }
            }
          }
        }
      }

      context.stroke();
    };

    const draw = () => {
      context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
      context.clearRect(0, 0, width, height);
      drawFill();
      if (SHOW_CONTOURS) {
        context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
        drawContours();
      }
    };

    let animationFrame = 0;
    let startedAt = null;
    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;
      }
      compute(((timestamp - startedAt) / 1000) * TIME_SCALE);
      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();
      compute(0);
      draw();
    };

    const handleResize = () => {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        if (reduceMotion) {
          drawStatic();
          return;
        }
        layout();
        if (!shouldRun()) {
          draw();
        }
      }, 160);
    };

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

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

    const intersectionObserver = new IntersectionObserver(
      (entries) => {
        isVisible = entries[0]?.isIntersecting ?? true;
        if (isVisible) {
          start();
        } else {
          stop();
        }
      },
      { threshold: 0 },
    );
    intersectionObserver.observe(canvas);

    document.addEventListener("visibilitychange", handleVisibilityChange);
    window.addEventListener("resize", handleResize);

    cleanup = () => {
      stop();
      clearTimeout(resizeTimer);
      document.removeEventListener("visibilitychange", handleVisibilityChange);
      window.removeEventListener("resize", handleResize);
      resizeObserver.disconnect();
      intersectionObserver.disconnect();
    };

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

  window.__siteHomeFieldBoot = boot;
  window.__siteHomeFieldShutdown = () => {
    cleanup?.();
    cleanup = null;
  };

  // ── 実案件 用の自動起動 ──────────────────────────
  // 実案件 本体では React コンポーネントが window.__siteHomeFieldBoot() を
  // 呼んで起動するが、このサイトは静的構成なので、ここで DOM 準備後に自分で起動する。
  // ※ 元は 実案件 の fBm 雲エンジンと同一だったが、炎にするため compute() を書き換えてある
  //   (年齢座標での上昇・加速/横方向のみの揺れ/先端のちぎれ)。調整は冒頭の const で行う。
  //   パラメータの試行は _lab/fire-field-lab.html(スライダー付き)が早い。
  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。