First CH Designs
BLK-65Blocks

ローポリ地形バックドロップ(夜景)

地形・道・家・木・街灯をローポリの立体として組み、セクションの背面に敷く夜景バックドロップ。3Dが主役の展示台ではなく背景装飾なので操作は持たず、スクロール進捗に合わせてカメラが道の先の村へ寄っていくだけ。村の上は飛び越えないので手前は開けた地面のまま残り、そこが見出しとCTAの置き場になる。モデルデータもテクスチャ画像も読まず、地形はバリューノイズで押し出したPlaneGeometry、家と木と街灯は種を固定した乱数で配置したInstancedMesh(地形1+道1+家1+窓1+木1+街灯1の計6ドローコールで、立体は200個を超えても増えない)。ローポリの見えはflatShadingだけで作る。夜の灯りは点光源を増やさずtoneMapped:falseの発光面(窓)で増やし、実際の光源は半球光・月光・アンバーの点光源2つに抑え、影は夜霧の中では見えないので持たない。空はCSSグラデーションのまま透過キャンバスを重ね、FogExp2の色を地平線の色に合わせて遠景を空へ溶かす。停止条件はprefers-reduced-motion(消さずに1フレームだけ描く)・画面外・タブ非表示・縮小プレビューの4つで、WebGL非対応時はCSSのシルエットへ降りる。

追加日:
2026-09-17
依存:
three
tags
#block #background #decoration #3d #webgl #low-poly #terrain #night #instancing #fog #scroll #motion #no-image #dynamic-import

プレビュー

夜のあいだも
灯りは消えない

受付が閉じたあとも、監視と自動化は動き続けています。朝いちばんに見る数字が揃っている状態まで含めて設計します

夜間の運用設計を相談する
無人での稼働
24h
異常の検知間隔
5分
手順書つき復旧
12

three.js は実寸表示の画面でのみ動的読み込み(別チャンク)。モデルデータ・テクスチャ画像は持たず、地形も建物もコードで生成しています。

"use client";

import { useEffect, useRef, useState } from "react";
// 型だけの import。tsc が消すので three のコードは1バイトも入らない(実体は下の dynamic import)。
import type * as THREE from "three";

/**
 * ローポリ地形バックドロップ(BLK-65)
 *
 * 差別化: 既存の3D標本は「1つの物体を回して質感を見せる展示台」「面1枚のシェーダ」
 * 「板を円弧に並べて選ばせるギャラリー」のいずれかで、3Dが常に主役・前景だった。
 * 本ブロックは逆で、多数のローポリ立体(地形・家・木・街灯)で"場所"そのものを組み、
 * 内容の**背面に敷く**背景装飾として使う。操作は無く、スクロール進捗に合わせて
 * カメラが村へ寄っていくだけ(村の上は飛び越えないので、手前は開けた地面=文字の置き場として
 * 空いたままになる)。飽和パターンの英字キッカーと末尾の「→」リンクは外し、
 * 見出しも「。」止めにしていない。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * 1. **ローポリの正体は `flatShading: true`**。頂点法線を捨てて面の微分から法線を作るので、
 *    なめらかなノイズ地形でも三角形の面が割れて見える。ジオメトリ側は何も加工しない。
 * 2. **空はCSSグラデーション、地形だけ透過キャンバス**。`scene.background` を持たず
 *    alpha付きで描き、`FogExp2` の色をCSS側の地平線の色に合わせると、遠景が
 *    「消える」のではなく空へ溶ける。空のピクセルをGPUで塗らないので一番安い。
 * 3. **夜の灯りは光源ではなく"発光する面"で増やす**。窓は `MeshBasicMaterial`
 *    (`toneMapped: false`)のInstancedMeshで、点光源は2つだけ。PointLightを窓の数だけ
 *    置くとフラグメント側の計算が線形に増えるが、発光面は何枚あっても同じコスト。
 * 4. **夜景では影を切る**。`shadowMap` は有効にしただけで深度パスが1回増えるのに、
 *    霧のかかった暗いシーンでは接地影がほとんど見えない。切って解像度に回す。
 * 5. **家・木・街灯は InstancedMesh で1ドローコールずつ**。地形1+道1+家1+窓1+木1+街灯1の
 *    計6ドローコールで、立体は200個を超えていても増えない。
 * 6. **配置の乱数は種を固定する**(mulberry32)。村の並びはデザインの一部であって
 *    毎回変わってよいものではない。再読込でも同じ街になる。
 * 7. **スクロール量をカメラへ直接入れない**。目標値へ指数補間
 *    (`cur += (target - cur) * (1 - exp(-dt * k))`)で寄せると、慣性スクロールの
 *    ガタつきがカメラに出ず、フレームレートが変わっても追従の速さが変わらない。
 * 8. 進捗はスクロールイベントではなく rAF の中で `getBoundingClientRect()` から読む。
 *    どうせ毎フレーム描いているので、リスナを足す理由がない。
 * 9. 停止条件は4つ(prefers-reduced-motion / 画面外 / タブ非表示 / 縮小プレビュー)。
 *    モーション低減時は**消さずに1フレームだけ描く**(背景としての絵は残す)。
 * 10. 非対応・コンテキストロスト時はCSSだけで組んだ同じ構図のシルエットへ降りる。
 *     canvasの下に常設しておき、opacityで入れ替える。
 *
 * 文言・配色・構図・形状はすべて First CH のオリジナル。
 */

type ThreeModule = typeof import("three");

const AMBER = 0xd97706;
const WINDOW_WARM = 0xffb257;
const WINDOW_PALE = 0xffe2b8;
/** FogExp2 の色 = CSS側の地平線の色。ここがズレると遠景に境目が出る。 */
const FOG = 0x101728;

const Z_START = 28;
const Z_END = -3;

/* ------------------------------------------------------------------ *
 * 乱数とノイズ(依存ゼロ・種固定)
 * ------------------------------------------------------------------ */

function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function hash2(x: number, z: number) {
  const n = Math.sin(x * 127.1 + z * 311.7) * 43758.5453123;
  return n - Math.floor(n);
}

/** 格子の値をsmoothstepで補間する素直なバリューノイズ。simplexまでは要らない。 */
function valueNoise(x: number, z: number) {
  const xi = Math.floor(x);
  const zi = Math.floor(z);
  const xf = x - xi;
  const zf = z - zi;
  const u = xf * xf * (3 - 2 * xf);
  const v = zf * zf * (3 - 2 * zf);
  const a = hash2(xi, zi);
  const b = hash2(xi + 1, zi);
  const c = hash2(xi, zi + 1);
  const d = hash2(xi + 1, zi + 1);
  return (a * (1 - u) + b * u) * (1 - v) + (c * (1 - u) + d * u) * v;
}

function fbm(x: number, z: number) {
  let sum = 0;
  let amp = 0.5;
  let freq = 1;
  for (let i = 0; i < 4; i += 1) {
    sum += amp * valueNoise(x * freq, z * freq);
    freq *= 2.03;
    amp *= 0.5;
  }
  return sum;
}

/**
 * 地形の高さ。中央(|x| < 4.6)は谷として平らに保ち、そこへ村を置く。
 * 外側ほど ridge が立ち上がり、稜線が視界の左右を閉じる。
 */
function groundY(x: number, z: number) {
  const ridge = Math.min(1, Math.max(0, Math.abs(x) - 4.6) / 12) ** 1.7;
  const hills = (fbm(x * 0.055 + 11, z * 0.055 + 7) - 0.5) * 2;
  const grain = (fbm(x * 0.15, z * 0.15) - 0.5) * 0.9;
  return hills * 7.6 * ridge + grain * (0.32 + ridge * 0.9);
}

/* ------------------------------------------------------------------ *
 * シーン構築(React には触れない。返した API 経由でのみ操作する)
 * ------------------------------------------------------------------ */

type BackdropApi = {
  setRunning: (running: boolean) => void;
  dispose: () => void;
};

type BackdropOptions = {
  canvas: HTMLCanvasElement;
  host: HTMLElement;
  onFirstFrame: () => void;
  onFail: () => void;
};

function createBackdrop(T: ThreeModule, opts: BackdropOptions): BackdropApi {
  const { canvas, host, onFirstFrame, onFail } = opts;

  const renderer = new T.WebGLRenderer({
    canvas,
    antialias: true,
    alpha: true,
    powerPreference: "low-power",
  });
  // 背景なので精細さより滑らかさ。Retinaでも1.5倍で頭打ちにする。
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
  renderer.toneMapping = T.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.15;
  // 影は持たない(技法メモ4)

  const scene = new T.Scene();
  scene.fog = new T.FogExp2(FOG, 0.0215);

  const camera = new T.PerspectiveCamera(42, 1, 0.1, 160);
  camera.position.set(0, groundY(0, Z_START) + 6.4, Z_START);

  const geometries: THREE.BufferGeometry[] = [];
  const materials: THREE.Material[] = [];

  /* --- 地形 ------------------------------------------------------- */
  const terrainGeo = new T.PlaneGeometry(74, 132, 62, 104);
  terrainGeo.rotateX(-Math.PI / 2);
  const pos = terrainGeo.attributes.position;
  for (let i = 0; i < pos.count; i += 1) {
    pos.setY(i, groundY(pos.getX(i), pos.getZ(i)));
  }
  pos.needsUpdate = true;
  terrainGeo.computeVertexNormals();
  geometries.push(terrainGeo);

  const terrainMat = new T.MeshStandardMaterial({
    color: 0x2b3757,
    roughness: 1,
    metalness: 0,
    flatShading: true, // ← ローポリの正体(技法メモ1)
  });
  materials.push(terrainMat);
  scene.add(new T.Mesh(terrainGeo, terrainMat));

  // 道: 谷に沿った細長い面を地形より 0.06 だけ持ち上げて敷く。街灯の列と揃うことで、
  // ただの平地が「村を貫く道」として読めるようになる(+1ドローコール)。
  const roadGeo = new T.PlaneGeometry(3.4, 132, 2, 104);
  roadGeo.rotateX(-Math.PI / 2);
  const roadPos = roadGeo.attributes.position;
  for (let i = 0; i < roadPos.count; i += 1) {
    roadPos.setY(i, groundY(roadPos.getX(i), roadPos.getZ(i)) + 0.06);
  }
  roadPos.needsUpdate = true;
  roadGeo.computeVertexNormals();
  geometries.push(roadGeo);
  const roadMat = new T.MeshStandardMaterial({
    color: 0x36425f,
    roughness: 0.82,
    metalness: 0,
    flatShading: true,
  });
  materials.push(roadMat);
  scene.add(new T.Mesh(roadGeo, roadMat));

  /* --- 村(家・窓・木・街灯)--------------------------------------- */
  const rand = mulberry32(20260917); // 種固定(技法メモ6)
  const dummy = new T.Object3D();
  const color = new T.Color();

  // 家: 立方体を足元基準にしておくと、スケールがそのまま棟高になる
  const houseGeo = new T.BoxGeometry(1, 1, 1);
  houseGeo.translate(0, 0.5, 0);
  geometries.push(houseGeo);
  const houseMat = new T.MeshStandardMaterial({
    color: 0x1b2333,
    roughness: 0.92,
    metalness: 0.04,
    flatShading: true,
  });
  materials.push(houseMat);

  const HOUSES = 62;
  const houses = new T.InstancedMesh(houseGeo, houseMat, HOUSES);
  houses.frustumCulled = false;

  const windowGeo = new T.PlaneGeometry(1, 1);
  geometries.push(windowGeo);
  // 発光する面。光源ではないので何枚でも同じコスト(技法メモ3)
  const windowMat = new T.MeshBasicMaterial({ toneMapped: false });
  materials.push(windowMat);
  const WINDOWS = HOUSES * 3;
  const windows = new T.InstancedMesh(windowGeo, windowMat, WINDOWS);
  windows.frustumCulled = false;

  let w = 0;
  for (let i = 0; i < HOUSES; i += 1) {
    const side = i % 2 === 0 ? 1 : -1;
    const x = side * (2.4 + rand() * 5.2);
    const z = Z_END - 5 - rand() * 76;
    const y = groundY(x, z);
    const width = 1.05 + rand() * 1.15;
    const depth = 1.15 + rand() * 1.3;
    const height = 1.1 + rand() * rand() * 3.8; // 二乗で低層を多く、高層をまれに
    const yaw = (rand() - 0.5) * 0.24;

    dummy.position.set(x, y - 0.05, z);
    dummy.rotation.set(0, yaw, 0);
    dummy.scale.set(width, height, depth);
    dummy.updateMatrix();
    houses.setMatrixAt(i, dummy.matrix);

    // 窓はカメラが向かう側(+Z面)と、谷の道に面した側の2面へ散らす
    const rows = 1 + Math.floor(rand() * 2.4);
    for (let r = 0; r < rows && w < WINDOWS; r += 1) {
      const toRoad = r > 0 && rand() > 0.45;
      const ry = 0.32 + (r + rand() * 0.6) * (height / (rows + 0.8));
      if (ry > height - 0.16) continue;
      const wy = y - 0.05 + ry;
      const offZ = depth / 2 + 0.012;
      const offX = width / 2 + 0.012;
      dummy.position.set(
        x + (toRoad ? -side * offX * Math.cos(yaw) : offZ * Math.sin(yaw)),
        wy,
        z + (toRoad ? side * offX * Math.sin(yaw) : offZ * Math.cos(yaw)),
      );
      dummy.rotation.set(0, toRoad ? yaw - side * (Math.PI / 2) : yaw, 0);
      const ww = Math.min(width * 0.52, 0.5);
      dummy.scale.set(ww, 0.2 + rand() * 0.16, 1);
      dummy.updateMatrix();
      windows.setMatrixAt(w, dummy.matrix);
      // 消灯している窓も混ぜないと「全戸在宅」に見えて嘘くさくなる
      const lit = rand();
      color.set(lit > 0.82 ? WINDOW_PALE : lit > 0.16 ? WINDOW_WARM : 0x2b3244);
      windows.setColorAt(w, color);
      w += 1;
    }
  }
  houses.instanceMatrix.needsUpdate = true;
  windows.count = w;
  windows.instanceMatrix.needsUpdate = true;
  if (windows.instanceColor) windows.instanceColor.needsUpdate = true;
  scene.add(houses, windows);

  // 木: 5角錐の円錐。針葉樹はこれだけで通る
  const treeGeo = new T.ConeGeometry(0.46, 1.7, 5, 1);
  treeGeo.translate(0, 0.85, 0);
  geometries.push(treeGeo);
  const treeMat = new T.MeshStandardMaterial({
    color: 0x141d2a,
    roughness: 1,
    metalness: 0,
    flatShading: true,
  });
  materials.push(treeMat);
  const TREES = 124;
  const trees = new T.InstancedMesh(treeGeo, treeMat, TREES);
  trees.frustumCulled = false;
  for (let i = 0; i < TREES; i += 1) {
    const side = i % 2 === 0 ? 1 : -1;
    const x = side * (7.4 + rand() * 17);
    const z = Z_START + 4 - rand() * 108;
    const s = 0.75 + rand() * 1.5;
    dummy.position.set(x, groundY(x, z) - 0.1, z);
    dummy.rotation.set(0, rand() * Math.PI, 0);
    dummy.scale.set(s, s * (0.85 + rand() * 0.7), s);
    dummy.updateMatrix();
    trees.setMatrixAt(i, dummy.matrix);
  }
  trees.instanceMatrix.needsUpdate = true;
  scene.add(trees);

  // 街灯: 発光する小さな箱を道の両脇へ等間隔に置くと、谷が「道」として読める
  const lampGeo = new T.BoxGeometry(0.16, 0.16, 0.16);
  geometries.push(lampGeo);
  const lampMat = new T.MeshBasicMaterial({ color: AMBER, toneMapped: false });
  materials.push(lampMat);
  const LAMPS = 32;
  const lamps = new T.InstancedMesh(lampGeo, lampMat, LAMPS);
  lamps.frustumCulled = false;
  for (let i = 0; i < LAMPS; i += 1) {
    const side = i % 2 === 0 ? 1 : -1;
    const z = Z_END - 1 - i * 2.6;
    const x = side * 2.1;
    dummy.position.set(x, groundY(x, z) + 1.5, z);
    dummy.rotation.set(0, 0, 0);
    dummy.scale.set(1, 1, 1);
    dummy.updateMatrix();
    lamps.setMatrixAt(i, dummy.matrix);
  }
  lamps.instanceMatrix.needsUpdate = true;
  scene.add(lamps);

  /* --- 光 ---------------------------------------------------------- */
  const hemi = new T.HemisphereLight(0x42528a, 0x0a0f1a, 1.7);
  scene.add(hemi);
  const moon = new T.DirectionalLight(0xaabffa, 1.35);
  moon.position.set(-11, 13, 5);
  scene.add(moon);
  // 点光源は2つまで。村の手前と奥に置き、地形へアンバーを落とす
  const glowNear = new T.PointLight(AMBER, 52, 34, 2.1);
  glowNear.position.set(0.4, 2.8, Z_END - 16);
  const glowFar = new T.PointLight(AMBER, 44, 40, 2.1);
  glowFar.position.set(-0.6, 3.4, Z_END - 44);
  scene.add(glowNear, glowFar);

  /* --- ループ ------------------------------------------------------ */
  let raf = 0;
  let running = false;
  let last = 0;
  let camZ = Z_START;
  let firstFrame = true;

  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let motionOK = !motionQuery.matches;

  /** 進捗 = ホストが画面を通過した割合。スクロールリスナは持たない(技法メモ8) */
  const readProgress = () => {
    const rect = host.getBoundingClientRect();
    const vh = window.innerHeight || 1;
    return Math.min(1, Math.max(0, (vh - rect.top) / (vh + rect.height)));
  };

  const place = (z: number, t: number) => {
    const sway = Math.sin(t * 0.21) * 0.85;
    const bob = Math.sin(t * 0.47) * 0.14;
    camera.position.set(sway, groundY(0, z) + 6.4 + bob, z);
    // やや見下ろす角度。低いと家の間に埋もれ、強く見下ろすと屋根しか見えない
    camera.lookAt(sway * 0.3, camera.position.y - 4.1, z - 28);
  };

  const draw = () => {
    renderer.render(scene, camera);
    if (firstFrame) {
      firstFrame = false;
      onFirstFrame();
    }
  };

  const renderStill = () => {
    camZ = Z_START + (Z_END - Z_START) * 0.5;
    place(camZ, 0);
    draw();
  };

  const tick = (now: number) => {
    raf = requestAnimationFrame(tick);
    const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016);
    last = now;
    const target = Z_START + (Z_END - Z_START) * readProgress();
    // 指数補間。フレームレートが変わっても追従の速さが変わらない(技法メモ7)
    camZ += (target - camZ) * (1 - Math.exp(-dt * 2.6));
    place(camZ, now / 1000);
    draw();
  };

  const start = () => {
    if (raf) return;
    last = 0;
    raf = requestAnimationFrame(tick);
  };
  const stop = () => {
    if (!raf) return;
    cancelAnimationFrame(raf);
    raf = 0;
  };

  const applyMotion = () => {
    if (!running) {
      stop();
      return;
    }
    if (motionOK) start();
    else {
      stop();
      renderStill(); // 消さずに静止画として残す(技法メモ9)
    }
  };

  const onMotionChange = () => {
    motionOK = !motionQuery.matches;
    applyMotion();
  };
  motionQuery.addEventListener("change", onMotionChange);

  const resize = () => {
    const width = Math.max(1, host.clientWidth);
    const height = Math.max(1, host.clientHeight);
    renderer.setSize(width, height, false);
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
  };

  const onContextLost = (e: Event) => {
    e.preventDefault();
    stop();
    onFail();
  };
  canvas.addEventListener("webglcontextlost", onContextLost);

  const ro = new ResizeObserver(() => {
    resize();
    if (!raf) renderStill();
  });
  ro.observe(host);

  resize();
  renderStill();

  return {
    setRunning: (next: boolean) => {
      running = next;
      applyMotion();
    },
    dispose: () => {
      stop();
      ro.disconnect();
      motionQuery.removeEventListener("change", onMotionChange);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      for (const g of geometries) g.dispose();
      for (const m of materials) m.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
    },
  };
}

/* ------------------------------------------------------------------ *
 * 表示
 * ------------------------------------------------------------------ */

const FIGURES = [
  { value: "24h", label: "無人での稼働" },
  { value: "5分", label: "異常の検知間隔" },
  { value: "12", label: "手順書つき復旧" },
];

export default function LowpolyTerrainBackdrop() {
  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const apiRef = useRef<BackdropApi | null>(null);
  const [live, setLive] = useState(false);
  const [inView, setInView] = useState(false);
  const [pageVisible, setPageVisible] = useState(true);
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);

  // 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に scale が掛かっているので
  // rect.width(変形後)÷ offsetWidth(レイアウト幅)が 1 未満になり、three を読み込まない。
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const fullSize = el.offsetWidth > 0 ? rect.width / el.offsetWidth > 0.75 : true;
    const io = new IntersectionObserver(
      ([entry]) => {
        setInView(entry.isIntersecting);
        if (entry.isIntersecting && fullSize) setLive(true);
      },
      { rootMargin: "200px" },
    );
    io.observe(el);
    const onVisibility = () => setPageVisible(document.visibilityState === "visible");
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  // three 本体はここで初めてネットワークに出る(別チャンク・実寸表示の画面のみ)
  useEffect(() => {
    if (!live) return;
    const canvas = canvasRef.current;
    const host = stageRef.current;
    if (!canvas || !host) return;

    let cancelled = false;
    const boot = async () => {
      // 対応可否を先に確かめる(非対応なら重いライブラリを取りに行く意味がない)
      const probe = document.createElement("canvas");
      const gl =
        probe.getContext("webgl2") ?? (probe.getContext("webgl") as WebGLRenderingContext | null);
      if (!gl) {
        setFailed(true);
        return;
      }
      gl.getExtension("WEBGL_lose_context")?.loseContext();

      const T = await import("three");
      if (cancelled) return;
      apiRef.current = createBackdrop(T, {
        canvas,
        host,
        onFirstFrame: () => setReady(true),
        onFail: () => setFailed(true),
      });
    };
    boot().catch(() => {
      if (!cancelled) setFailed(true);
    });

    return () => {
      cancelled = true;
      apiRef.current?.dispose();
      apiRef.current = null;
    };
  }, [live]);

  useEffect(() => {
    apiRef.current?.setRunning(inView && pageVisible && !failed);
  }, [inView, pageVisible, failed, ready]);

  return (
    <section className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
      <div
        ref={stageRef}
        className="relative min-h-[460px] overflow-hidden bg-[radial-gradient(120%_70%_at_50%_92%,rgba(217,119,6,0.30)_0%,rgba(217,119,6,0.08)_34%,rgba(16,23,40,0)_62%),linear-gradient(180deg,#05070d_0%,#0a0f1c_44%,#101728_100%)] sm:min-h-[580px]"
      >
        {/* 星: canvasで描くより安い。スクロールしても動かないので背景側に置く */}
        <div
          aria-hidden="true"
          className="absolute inset-x-0 top-0 h-2/3 bg-[radial-gradient(1.4px_1.4px_at_12%_18%,rgba(255,255,255,0.75),transparent),radial-gradient(1.2px_1.2px_at_34%_9%,rgba(255,255,255,0.5),transparent),radial-gradient(1.6px_1.6px_at_58%_24%,rgba(255,255,255,0.65),transparent),radial-gradient(1.2px_1.2px_at_76%_12%,rgba(255,255,255,0.45),transparent),radial-gradient(1.3px_1.3px_at_88%_31%,rgba(255,255,255,0.55),transparent),radial-gradient(1.1px_1.1px_at_22%_38%,rgba(255,255,255,0.35),transparent)]"
        />

        {/* 静止フォールバック(非対応・コンテキストロスト・縮小プレビュー)。
            canvas は alpha なので、3Dが立ち上がったら必ず消す。 */}
        <div
          aria-hidden="true"
          className={`absolute inset-x-0 bottom-0 h-[58%] transition-opacity duration-1000 ${
            ready && !failed ? "opacity-0" : "opacity-100"
          } motion-reduce:transition-none`}
        >
          <div className="absolute inset-x-0 bottom-[22%] h-[62%] bg-[#1c2942] [clip-path:polygon(0_46%,9%_22%,18%_41%,27%_16%,38%_38%,47%_9%,58%_34%,68%_18%,79%_40%,88%_25%,100%_44%,100%_100%,0_100%)]" />
          <div className="absolute inset-x-0 bottom-0 h-[34%] bg-[#111b2e] [clip-path:polygon(0_58%,13%_34%,24%_52%,36%_28%,50%_48%,63%_26%,74%_50%,86%_32%,100%_54%,100%_100%,0_100%)]" />
          <div className="absolute inset-x-0 bottom-[7%] flex justify-center gap-[3px]">
            {Array.from({ length: 22 }).map((_, i) => (
              <span
                key={i}
                className="block w-[3px] bg-amber-500/70"
                style={{ height: `${3 + ((i * 7) % 11)}px`, opacity: i % 4 === 0 ? 0.35 : 0.8 }}
              />
            ))}
          </div>
        </div>

        <canvas
          ref={canvasRef}
          aria-hidden="true"
          className={`absolute inset-0 block size-full transition-opacity duration-1000 ${
            ready && !failed ? "opacity-100" : "opacity-0"
          } motion-reduce:transition-none`}
        />

        {/* 文字の下だけ暗く落として可読性を確保する(シーン全体を暗くすると灯りが死ぬ) */}
        <div
          aria-hidden="true"
          className="absolute inset-0 bg-[linear-gradient(180deg,rgba(5,7,13,0.60)_0%,rgba(5,7,13,0.30)_38%,rgba(5,7,13,0.10)_62%,rgba(5,7,13,0.62)_100%)]"
        />

        <div className="relative flex min-h-[460px] flex-col justify-between px-6 py-9 sm:min-h-[580px] sm:px-12 sm:py-14">
          <div className="max-w-xl">
            <h2 className="text-[clamp(1.75rem,5vw,2.9rem)] leading-[1.32] font-bold tracking-[0.01em] text-white">
              夜のあいだも
              <br />
              灯りは消えない
            </h2>
            <p className="mt-6 max-w-md text-[13.5px] leading-[2.05] font-medium tracking-[0.04em] text-slate-300">
              受付が閉じたあとも、監視と自動化は動き続けています。朝いちばんに見る数字が揃っている状態まで含めて設計します
            </p>
            <a
              href="#contact"
              className="group mt-9 inline-flex items-center gap-3 border border-white/25 px-6 py-3.5 text-[13px] font-bold tracking-[0.08em] text-white transition-colors duration-300 hover:border-amber-500/80 hover:bg-amber-500/10 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-500 motion-reduce:transition-none"
            >
              <span
                aria-hidden="true"
                className="size-1.5 bg-amber-500 transition-transform duration-300 group-hover:scale-150 motion-reduce:transition-none"
              />
              夜間の運用設計を相談する
            </a>
          </div>

          <dl className="mt-12 grid grid-cols-3 border-t border-white/15">
            {FIGURES.map((f) => (
              <div
                key={f.label}
                className="border-l border-white/15 px-3 pt-5 first:border-l-0 first:pl-0 sm:px-5 sm:pt-6"
              >
                <dt className="text-[10.5px] leading-[1.6] tracking-[0.1em] text-slate-400">
                  {f.label}
                </dt>
                <dd className="mt-1.5 font-mono text-[clamp(1.2rem,3.4vw,1.7rem)] leading-none font-bold text-amber-400">
                  {f.value}
                </dd>
              </div>
            ))}
          </dl>
        </div>
      </div>

      <p className="border-t border-gray-200 px-5 py-3.5 font-mono text-[10px] leading-[1.7] tracking-[0.08em] text-gray-400 sm:px-8">
        three.js は実寸表示の画面でのみ動的読み込み(別チャンク)。モデルデータ・テクスチャ画像は持たず、地形も建物もコードで生成しています。
      </p>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/lowpoly-terrain-backdrop.json