Low-Poly Terrain Backdrop (Night)
A night-scene backdrop assembled from low-poly solids — displaced terrain, a road, houses, conifers and street lamps — that sits behind a section instead of starring in front of it. There is nothing to operate: the camera simply approaches the village down the road as the block scrolls through the viewport, never flying over it, so the near ground stays open for the heading and the call to action. No model files and no textures: the terrain is a PlaneGeometry displaced by value noise, and the houses, trees and lamps are seeded InstancedMeshes, so 200+ solids still cost only six draw calls. The low-poly look comes from flatShading alone. Lit windows are emissive faces (toneMapped: false) rather than lights, keeping the scene to one hemisphere light, one moonlight and two amber point lights, with shadows dropped entirely since they are invisible in night fog. The sky is a CSS gradient behind a transparent canvas, with the FogExp2 colour matched to the horizon so distant terrain dissolves into it. It stops on prefers-reduced-motion (still rendering a single frame), offscreen, hidden tab and scaled previews, and falls back to a CSS silhouette without WebGL.
- Added:
- 2026-09-17
- Dependencies:
- three
- tags
- #block #background #decoration #3d #webgl #low-poly #terrain #night #instancing #fog #scroll #motion #no-image #dynamic-import
Preview
- 無人での稼働
- 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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/lowpoly-terrain-backdrop.json