炎
背景エフェクト / 動く素材トップ
上へ加速しながら細くなり、先がちぎれて消える炎。熱・焙煎・鍛造。暗い地で強い気配を出したいとき。
試して調整する#
判断#
向く場面: 熱・焙煎・鍛造。暗い地で強い気配を出したいとき。
避ける場面: 文字が主役の面に、そのまま重ねること。
つまみは元の実装が持っている項目そのままです。実際に文字を重ねた状態で速さと密度を詰めます。
組み込み条件#
依存: 素の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を対象ページのヒーロー背景へ組み込み、文字と主要ボタンを前面に残してください」と添えます。
組み込み時の確認#
prefers-reduced-motion: reduceで静止する- 画面外と非表示タブで描画が止まる
- 背景を止めても文字の階層と構図が成立する
- 文字とのコントラストを実際の配色で測る
- 装飾が主要操作の当たり判定を覆わない