このページの中身

ワイヤーフレームの波

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

透視投影の格子が波打つ地形。点で打つ版と同じ幾何を線でつなぐ。テック・研究・設計。奥行きのある下地。

試して調整する#

見た目を調整する

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

判断#

向く場面: テック・研究・設計。奥行きのある下地。

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

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

組み込み条件#

依存: 素の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を対象ページのヒーロー背景へ組み込み、文字と主要ボタンを前面に残してください」と添えます。

組み込み時の確認#

関連#

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