ワイヤーフレームの波
背景エフェクト / 動く素材トップ
透視投影の格子が波打つ地形。点で打つ版と同じ幾何を線でつなぐ。テック・研究・設計。奥行きのある下地。
試して調整する#
判断#
向く場面: テック・研究・設計。奥行きのある下地。
避ける場面: 文字が主役の面に、そのまま重ねること。
つまみは元の実装が持っている項目そのままです。実際に文字を重ねた状態で速さと密度を詰めます。
組み込み条件#
依存: 素の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">{"AMOUNT_X":121,"AMOUNT_Y":60,"SEP":25,"TICK_STEP":0.02,"FOV":60}</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"><canvas id="hero-wave"></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 || '{}');
/* ============================================================
hero-wave.js — トップのヒーロー背景「3Dの波」
現行 自社サイト の BgParticleEffect((元の実装)
bg-effect-particles.js・Three.js 実装)の忠実移植。
グリッド・波の式・tick・色・カメラ位置は原典と同一。
Three.js は使わず、同じ透視投影(fov60°・(0,150,400)から原点を注視)を
手計算で再現し、同じ頂点を線で接続するワイヤーフレーム地形として描く。
追加した行儀: 画面外・スクロール中は停止、30fps上限、
reduced-motion は静止1枚、装飾CanvasのDPRは1上限。
============================================================ */
(function () {
'use strict';
var canvas = document.getElementById('hero-wave');
if (!canvas) return;
var ctx = canvas.getContext('2d');
if (!ctx) return;
// ── 原典と同じパラメータ ────────────────────────────────
var AMOUNT_X = (__CFG__["AMOUNT_X"] !== undefined ? __CFG__["AMOUNT_X"] : (121)); // グリッド横方向の頂点数
var AMOUNT_Y = (__CFG__["AMOUNT_Y"] !== undefined ? __CFG__["AMOUNT_Y"] : (60)); // グリッド縦方向(奥行き)の頂点数
var SEP = (__CFG__["SEP"] !== undefined ? __CFG__["SEP"] : (25)); // 頂点間隔(ワールド単位)
var COLOR = '2,93,204'; // #025DCC(AK² ブランドの青)
var TICK_STEP = (__CFG__["TICK_STEP"] !== undefined ? __CFG__["TICK_STEP"] : (0.02)); // 原典の60fps時における1フレームの時間の進み
var FOV = (__CFG__["FOV"] !== undefined ? __CFG__["FOV"] : (60)); // カメラ視野角(度)
var EYE = { x: 0, y: 150, z: 400 }; // カメラ位置(原点を注視)
// ── 描画負荷の上限 ──────────────────────────────────────
// ゆっくりした背景アニメーションなので30fpsで十分滑らかに見える。
// tickは実時間で進め、描画回数を半分にしても動く速さを変えない。
var TARGET_FPS = 30;
var FRAME_INTERVAL = 1000 / TARGET_FPS;
var TICK_PER_MS = TICK_STEP / (1000 / 60);
var MAX_DPR = 1;
var SCROLL_IDLE_DELAY = 160;
// ── カメラ基底(eye から原点を lookAt。up=(0,1,0))────────
var fl = Math.hypot(EYE.x, EYE.y, EYE.z);
var F = { x: -EYE.x / fl, y: -EYE.y / fl, z: -EYE.z / fl }; // forward
var R = { x: -F.z, y: 0, z: F.x }; // cross(F, up) を正規化
var rl = Math.hypot(R.x, R.y, R.z); R.x /= rl; R.z /= rl;
var U = { // cross(R, F)
x: R.y * F.z - R.z * F.y,
y: R.z * F.x - R.x * F.z,
z: R.x * F.y - R.y * F.x
};
var COUNT = AMOUNT_X * AMOUNT_Y;
var screenX = new Float32Array(COUNT);
var screenY = new Float32Array(COUNT);
var drawable = new Uint8Array(COUNT);
var waveX = new Float64Array(AMOUNT_X);
var waveY = new Float64Array(AMOUNT_Y);
var waveDiagonal = new Float64Array(AMOUNT_X + AMOUNT_Y - 1);
var W = 0, H = 0, DPR = 1, FOCAL = 300, tick = 0, wireStroke = null;
var wireOverscanSide = 200;
var wireOverscanBottom = 200;
var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var running = false, raf = 0, visible = true, pageVisible = !document.hidden;
var scrolling = false, scrollResumeTimer = 0, resizeTimer = 0, lastDrawAt = 0;
function resize() {
W = Math.max(1, canvas.clientWidth);
H = Math.max(1, canvas.clientHeight);
// 背景の細い格子線は高DPI化による見た目の差が小さい一方、DPR 2では
// 描画バッファが4倍になる。装飾CanvasはDPR 1を上限にして負荷を抑える。
DPR = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
// 垂直 fov 60°: 焦点距離 = (H/2) / tan(30°)。three.js の投影と同じ
FOCAL = (H / 2) / Math.tan((FOV / 2) * Math.PI / 180);
// 近景の格子線をcanvasの左右・下端まで自然に到達させるため、画面外にも投影を続ける。
// 実canvasを広げると焦点距離と構図まで変わるため、描画バッファは従来サイズのまま。
// 左右は最低200px(投影間隔に合わせて高さの35%)、下は最低200px(高さの30%)
// だけ論理的に延長し、Canvas API自身のクリッピングではみ出した分を切る。
wireOverscanSide = Math.max(200, H * 0.35);
wireOverscanBottom = Math.max(200, H * 0.3);
// 上方(遠景)は細く淡く、下方(近景)は少し濃くする。
// 頂点座標と透視投影はパーティクル版から一切変えない。
wireStroke = ctx.createLinearGradient(0, 0, 0, H);
wireStroke.addColorStop(0, 'rgba(' + COLOR + ',0.07)');
wireStroke.addColorStop(0.48, 'rgba(' + COLOR + ',0.19)');
wireStroke.addColorStop(1, 'rgba(' + COLOR + ',0.42)');
}
function projectTerrain() {
var halfX = (AMOUNT_X * SEP) / 2;
var halfY = (AMOUNT_Y * SEP) / 2;
// 3本の波はそれぞれ ix / iy / ix+iy だけで決まる。同じsinを頂点ごとに
// 何度も計算せず、各軸について1回だけ求めて全7,260頂点で共有する。
for (var wx = 0; wx < AMOUNT_X; wx++) {
waveX[wx] = Math.sin((wx + tick) * 0.3) * 30;
}
for (var wy = 0; wy < AMOUNT_Y; wy++) {
waveY[wy] = Math.sin((wy + tick) * 0.5) * 30;
}
for (var wd = 0; wd < waveDiagonal.length; wd++) {
waveDiagonal[wd] = Math.sin((wd + tick * 1.5) * 0.2) * 10;
}
for (var ix = 0; ix < AMOUNT_X; ix++) {
var px = ix * SEP - halfX;
for (var iy = 0; iy < AMOUNT_Y; iy++) {
var index = ix * AMOUNT_Y + iy;
var pz = iy * SEP - halfY;
// 原典と同じ波の式(複数サイン波の重ね合わせ)
var py = waveX[ix] + waveY[iy] + waveDiagonal[ix + iy];
// カメラ空間へ(eye からの相対ベクトルを基底に射影)
var dx = px - EYE.x, dy = py - EYE.y, dz = pz - EYE.z;
var zc = dx * F.x + dy * F.y + dz * F.z; // 前方距離
if (zc <= 1) {
drawable[index] = 0;
continue;
}
var xc = dx * R.x + dy * R.y + dz * R.z;
var yc = dx * U.x + dy * U.y + dz * U.z;
var sx = W / 2 + FOCAL * (xc / zc);
var sy = H / 2 - FOCAL * (yc / zc);
screenX[index] = sx;
screenY[index] = sy;
drawable[index] = (
sx >= -wireOverscanSide && sx <= W + wireOverscanSide &&
sy >= -8 && sy <= H + wireOverscanBottom
) ? 1 : 0;
}
}
}
function addWireStrip(start, step, length) {
var penDown = false;
for (var i = 0, index = start; i < length; i++, index += step) {
if (!drawable[index]) {
penDown = false;
continue;
}
if (penDown) {
ctx.lineTo(screenX[index], screenY[index]);
} else {
ctx.moveTo(screenX[index], screenY[index]);
penDown = true;
}
}
}
function drawWireframe() {
ctx.beginPath();
// 横方向の等高線。index は ix * AMOUNT_Y + iy のため、step は AMOUNT_Y。
for (var iy = 0; iy < AMOUNT_Y; iy++) {
addWireStrip(iy, AMOUNT_Y, AMOUNT_X);
}
// 奥行き方向の線。交差させることで立体的な地形のメッシュとして見せる。
for (var ix = 0; ix < AMOUNT_X; ix++) {
addWireStrip(ix * AMOUNT_Y, 1, AMOUNT_Y);
}
ctx.strokeStyle = wireStroke || 'rgba(' + COLOR + ',0.24)';
ctx.lineWidth = 0.85;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
function draw() {
ctx.clearRect(0, 0, W, H);
projectTerrain();
drawWireframe();
}
function frame(now) {
raf = 0;
if (!running || !visible || !pageVisible || scrolling) return;
if (canvas.clientWidth !== W || canvas.clientHeight !== H) resize();
// 初回はすぐ描き、以後は30fpsを上限にする。間引いた時間ぶんもtickへ
// 反映するため、従来の60fps描画と波の移動速度は変わらない。
if (!lastDrawAt) {
lastDrawAt = now;
draw();
} else {
var elapsed = now - lastDrawAt;
if (elapsed >= FRAME_INTERVAL - 1) {
// タブ復帰などの長い空白で波が一気に飛ばないよう上限を設ける。
tick += Math.min(elapsed, 100) * TICK_PER_MS;
lastDrawAt = now;
draw();
}
}
raf = requestAnimationFrame(frame);
}
function start() {
if (!running && visible && pageVisible && !scrolling) {
running = true;
lastDrawAt = 0;
if (!raf) raf = requestAnimationFrame(frame);
}
}
function stop() {
running = false;
lastDrawAt = 0;
if (raf) { cancelAnimationFrame(raf); raf = 0; }
}
function scheduleResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(function () {
resizeTimer = 0;
// モバイルのアドレスバー伸縮など、スクロール中の連続resizeでは
// Canvasを再確保しない。停止後のframeが実寸差を検知して更新する。
if (scrolling) return;
resize();
if (reduce) draw();
}, 100);
}
function pauseForScroll() {
scrolling = true;
stop();
if (scrollResumeTimer) clearTimeout(scrollResumeTimer);
scrollResumeTimer = window.setTimeout(function () {
scrollResumeTimer = 0;
scrolling = false;
if (visible && pageVisible) start();
}, SCROLL_IDLE_DELAY);
}
window.addEventListener('resize', scheduleResize);
window.addEventListener('load', scheduleResize);
resize();
if (reduce) { tick = 8; draw(); return; }
window.addEventListener('scroll', pauseForScroll, { passive: true });
document.addEventListener('visibilitychange', function () {
pageVisible = !document.hidden;
if (pageVisible && visible && !scrolling) start(); else stop();
});
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !scrolling && pageVisible) start(); else stop();
}, { threshold: 0.05 }).observe(canvas);
}
start();
})();
/* ---- ここまで --------------------------------------------------------- */
(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で静止する- 画面外と非表示タブで描画が止まる
- 背景を止めても文字の階層と構図が成立する
- 文字とのコントラストを実際の配色で測る
- 装飾が主要操作の当たり判定を覆わない