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