3Dオブジェクト・ギャラリーHero(PBR・環境マップ・影)
「サイトに3Dを載せたい」に応えるためのヒーロー。金属・陶磁・ガラスの3標本を切り替えて質感を見せるが、モデルデータもテクスチャ画像も一切持たず、形は TorusKnot / Lathe(回転体のプロファイルを関数で作る)/ Extrude(角丸シェイプ+ベベル押し出し)でコード生成する。映り込みは白い箱の内側に発光板を4枚置いた"撮影スタジオ"を組み、PMREMGenerator.fromScene() で畳んで scene.environment に渡す(HDRIファイルを読まずにPBRらしい面光源の映り込みが出る・アンバーの背面パネルで反射の色までブランドに寄せられる)。光源は方向光(影あり・PCF)+アンバーの点光源+半球光の3灯で、影は ShadowMaterial の床1枚だけを敷いて白背景を汚さずに接地感を出す。登場・退場・浮遊は AnimationMixer で扱う——モデルが無くても VectorKeyframeTrack('.scale') 等を手で組めばクリップになるので、rAFの中に手続きを書き散らさずに済む。標本の切り替えは canvas ではなく下の罫線台帳(button・aria-pressed)が正で、キーボードだけでも全て操作できる。3Dライブラリ(three)の採用はこのギャラリーの依存ゼロ方針に対する唯一の例外で、理由は「PBR・環境マップ・影・アニメーションを素のWebGLで書くと割に合わないため」——つまり素のWebGLで足りる表現(板ポリ・シェーダ背景・粒子)はこれまで通り依存ゼロで作り、3Dの質感実証だけをこのブロックに集約している。実測の追加コスト(three 0.185・本番ビルド)は追加チャンク 725,217バイト(約708KB)・gzip 181,641バイト(約177KB)で、静的importではなく useEffect 内の動的importで別チャンクに切り出し、実際に3Dを表示する画面でだけ読み込む(縮小プレビューでは親のscaleを rect.width÷offsetWidth で検出して読み込まない)。prefers-reduced-motion では自動回転もタイムラインも動かさず1フレームだけ描き、WebGL非対応・コンテキストロスト・低速時は静止パネルへ降りる。文言・配色・シェイプはすべてFirst CHのオリジナル。
- 追加日:
- 2026-08-19
- 依存:
- three
- tags
- #hero #page #3d #webgl #pbr #environment-map #shadow #animation #interactive #motion #no-image #dynamic-import
プレビュー
3D OBJECT STUDY
PBR / IBL / SHADOW
Real-time 3D
Depth,
on demand.
「サイトに3Dを載せたい」に、モデルデータなしで応えます。質感・光・影・動きをすべてコードで組み立てるので、案件のトーンに合わせて素材から作れます。
3D演出を相談する01 / Metal / knotmetalness 1.00 / roughness 0.16
Drag to orbitSwipe to orbit
three.js は3Dを実際に表示する画面でのみ動的読み込み(別チャンク)。縮小プレビュー・非対応環境では静止パネルを表示します。
"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
// 型だけの import。tsc が消すので three のコードは1バイトも入らない(実体は下の dynamic import)。
import type * as THREE from "three";
const montserrat = Montserrat({ weight: ["700"], subsets: ["latin"], display: "swap" });
/**
* three.js オブジェクト・ギャラリーHero(PGE-06)
*
* このギャラリーで唯一 3D ライブラリを使う標本。素の WebGL で書くと割に合わない領域
* (PBR・環境マップ・影・アニメーション)だけを担当させ、「3Dを載せたい」案件に
* コードだけで応えられることを示す。
*
* 汎用技法メモ(web-design-playbook へ還流する要点):
* 1. **重いライブラリは静的 import しない**。three はこの構成の実測で追加チャンク
* 725,217バイト(約708KB・gzip 181,641バイト=約177KB)あり、
* 静的 import すると読み込んだページ全部に乗る。`useEffect` の中で
* `await import("three")` すればバンドラが別チャンクに切り出し、
* 「3Dを実際に動かすときだけ」ネットワークに出る(サーバ側では実行されないので
* ssr:false と同じ効果になる。1ファイルで配布したいのでこの形にしている)。
* 2. **モデルデータは要らない**。TorusKnot / Lathe(回転体)/ Extrude(押し出し+ベベル)
* の3つで「金属・陶磁・ガラス」の質感実証には足りる。プロファイルを配列で持てば
* 形はコードで作れるので、glTF もテクスチャも配布物に含めなくていい。
* 3. **環境マップも画像を持たない**。白い箱の内側に発光板(emissive のみの
* MeshLambertMaterial)を数枚置いた"スタジオ"を組み、`PMREMGenerator.fromScene()`
* で畳んで `scene.environment` に渡す。金属とガラスに映り込む「面光源の形」が
* 出るのがPBRらしさの正体で、HDRI ファイルを読むより軽くて色も自由に決められる。
* 4. **AnimationMixer はモデルが無くても使える**。`VectorKeyframeTrack('.scale', …)`
* のようにトラックを手で組んで `AnimationClip` にすれば、登場・退場・浮遊を
* タイムラインとして扱える(rAF の中に手続きを書き散らさずに済む)。
* 5. 影は `ShadowMaterial` の床1枚。白基調では「接地影だけが落ちる」状態が
* もっとも自然で、床のマテリアル自体は透明なので背景の白を汚さない。
* 6. 停止条件は WebGL 標本と同じ3つ(reduced-motion / 画面外 / タブ非表示)に
* 「縮小プレビューでは起動しない」を足す。親に transform:scale が掛かっているかは
* `getBoundingClientRect().width ÷ offsetWidth` で測れる(前者は変形後、
* 後者はレイアウト幅)。一覧のカードで重いライブラリを読ませないための保険。
* 7. コンテキストロストは必ず `preventDefault()` で握り、アンマウント時は
* geometry / material / renderer を dispose して `forceContextLoss()` する。
*
* 文言・配色・構図・シェイプはすべて First CH のオリジナル。
*/
type ThreeModule = typeof import("three");
const AMBER = 0xd97706;
const INK = 0x221e19;
type Specimen = {
no: string;
ja: string;
en: string;
spec: string;
};
const SPECIMENS: Specimen[] = [
{
no: "01",
ja: "メタル・トーラスノット",
en: "Metal / knot",
spec: "metalness 1.00 / roughness 0.16",
},
{
no: "02",
ja: "セラミック・ベッセル",
en: "Ceramic / lathe",
spec: "clearcoat 1.00 / roughness 0.30",
},
{
no: "03",
ja: "アンバーガラス・プレート",
en: "Glass / extrude",
spec: "transmission 1.00 / ior 1.52",
},
];
type StudioApi = {
select: (index: number) => void;
setRunning: (running: boolean) => void;
dispose: () => void;
};
type StudioOptions = {
canvas: HTMLCanvasElement;
host: HTMLElement;
index: number;
onFirstFrame: () => void;
onFail: () => void;
};
/**
* 3つの標本を並べた"撮影スタジオ"を組む。React には触れず、返した API 経由でのみ操作する
* (毎フレームの再レンダーを避けるため、状態は全てクロージャに閉じ込める)。
*/
function createStudio(T: ThreeModule, opts: StudioOptions): StudioApi {
const { canvas, host, onFirstFrame, onFail } = opts;
const renderer = new T.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: "low-power",
});
let dpr = Math.min(window.devicePixelRatio || 1, 1.75);
renderer.setPixelRatio(dpr);
renderer.toneMapping = T.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = T.PCFShadowMap; // r185 で PCFSoft は非推奨。ぼけ幅は shadow.radius で作る
const scene = new T.Scene();
const camera = new T.PerspectiveCamera(34, 1, 0.1, 60);
camera.position.set(0, 0.42, 4.35);
camera.lookAt(0, 0, 0);
// --- 環境マップ: 画像を読まず、白い箱+発光板の"スタジオ"を畳んで作る ---------
const envScene = new T.Scene();
const envBox = new T.BoxGeometry();
envBox.deleteAttribute("uv");
const envResources: Array<{ dispose: () => void }> = [envBox];
const panel = (
intensity: number,
color: number,
x: number,
y: number,
z: number,
sx: number,
sy: number,
sz: number,
) => {
// 発光のみのマテリアル(色を黒にして emissive だけを持たせるのが定石)
const mat = new T.MeshLambertMaterial({
color: 0x000000,
emissive: color,
emissiveIntensity: intensity,
});
envResources.push(mat);
const mesh = new T.Mesh(envBox, mat);
mesh.position.set(x, y, z);
mesh.scale.set(sx, sy, sz);
envScene.add(mesh);
};
// 箱自体は「常に明るい面」でよいので unlit(MeshStandardMaterial だと envScene に光源が無く真っ黒になり、
// 金属が暗く沈む)。白いスタジオの地明かりはこの面が担う。
const roomMat = new T.MeshBasicMaterial({ color: 0xf2eee7, side: T.BackSide });
envResources.push(roomMat);
const room = new T.Mesh(envBox, roomMat);
room.scale.set(20, 14, 20);
envScene.add(room);
panel(46, 0xffffff, -3.4, 3.2, 2.6, 0.1, 3.4, 4.2); // キーの面光源(左上)
panel(20, 0xfff6ea, 4.2, 1.4, 1.2, 0.1, 3.0, 3.4); // フィル(右)
panel(30, 0xffffff, 0, 6.4, 0, 5.0, 0.1, 5.0); // トップライト
panel(16, AMBER, -0.6, -0.4, -5.6, 4.4, 2.6, 0.1); // アンバーの背面パネル(映り込みの色)
const pmrem = new T.PMREMGenerator(renderer);
const envMap = pmrem.fromScene(envScene, 0.02).texture;
scene.environment = envMap;
for (const r of envResources) r.dispose();
pmrem.dispose();
// --- ライト(複数光源)--------------------------------------------------------
const key = new T.DirectionalLight(0xffffff, 2.7);
key.position.set(2.4, 3.8, 2.6);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 1;
key.shadow.camera.far = 12;
key.shadow.camera.left = -2.6;
key.shadow.camera.right = 2.6;
key.shadow.camera.top = 2.6;
key.shadow.camera.bottom = -2.6;
key.shadow.bias = -0.0012;
key.shadow.radius = 3;
scene.add(key);
const rim = new T.PointLight(AMBER, 14, 10, 2); // アンバーのリム(輪郭に色を差す)
rim.position.set(-2.3, 0.7, -1.9);
scene.add(rim);
const fill = new T.HemisphereLight(0xffffff, 0xe6ddcd, 0.55);
scene.add(fill);
// --- 床(接地影だけを受ける透明なプレーン)------------------------------------
const groundGeo = new T.PlaneGeometry(14, 14);
const groundMat = new T.ShadowMaterial({ opacity: 0.17, color: INK });
const ground = new T.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1.22;
ground.receiveShadow = true;
scene.add(ground);
// --- 標本3種(ジオメトリはすべてコード生成)----------------------------------
const stage = new T.Group();
scene.add(stage);
const geometries: THREE.BufferGeometry[] = [];
const materials: THREE.Material[] = [];
const knotGeo = new T.TorusKnotGeometry(0.62, 0.205, 190, 28, 2, 3);
const knotMat = new T.MeshPhysicalMaterial({
color: AMBER,
metalness: 1,
roughness: 0.16,
envMapIntensity: 1.25,
});
// 回転体のプロファイル(半径をtの関数で作る=壺の輪郭をコードで持つ)
const profile: THREE.Vector2[] = [new T.Vector2(0.001, -0.92)];
for (let i = 0; i <= 28; i++) {
const t = i / 28;
const y = -0.92 + t * 1.84;
const r = 0.3 + 0.5 * Math.sin(Math.PI * Math.pow(t, 0.8)) - 0.13 * Math.pow(t, 2.6);
profile.push(new T.Vector2(Math.max(0.05, r), y));
}
const vesselGeo = new T.LatheGeometry(profile, 96);
const vesselMat = new T.MeshPhysicalMaterial({
color: 0xf6f3ee,
metalness: 0,
roughness: 0.3,
clearcoat: 1,
clearcoatRoughness: 0.09,
side: T.DoubleSide,
envMapIntensity: 1,
});
// 帯は「その高さの本体半径」より外に置かないと中に埋まって見えない。
// プロファイル配列から実際の半径を引いて決める(形を関数で持つ利点)。
const bandY = 0.62;
const bandSeat = profile.reduce((best, v) =>
Math.abs(v.y - bandY) < Math.abs(best.y - bandY) ? v : best,
);
const bandGeo = new T.TorusGeometry(bandSeat.x + 0.022, 0.02, 12, 96);
const bandMat = new T.MeshPhysicalMaterial({ color: AMBER, metalness: 1, roughness: 0.22 });
// 角丸プレート+中央の抜き。押し出しにベベルを付けると縁がハイライトを拾う
const plate = new T.Shape();
const w = 0.68;
const r0 = 0.3;
plate.moveTo(-w + r0, -w);
plate.lineTo(w - r0, -w);
plate.quadraticCurveTo(w, -w, w, -w + r0);
plate.lineTo(w, w - r0);
plate.quadraticCurveTo(w, w, w - r0, w);
plate.lineTo(-w + r0, w);
plate.quadraticCurveTo(-w, w, -w, w - r0);
plate.lineTo(-w, -w + r0);
plate.quadraticCurveTo(-w, -w, -w + r0, -w);
const hole = new T.Path();
hole.absarc(0, 0, 0.235, 0, Math.PI * 2, true);
plate.holes.push(hole);
const plateGeo = new T.ExtrudeGeometry(plate, {
depth: 0.34,
bevelEnabled: true,
bevelThickness: 0.07,
bevelSize: 0.07,
bevelSegments: 5,
curveSegments: 34,
});
plateGeo.center();
const plateMat = new T.MeshPhysicalMaterial({
color: 0xffffff,
metalness: 0,
roughness: 0.08,
transmission: 1,
ior: 1.52,
thickness: 0.8,
attenuationDistance: 1.6,
attenuationColor: new T.Color(AMBER),
clearcoat: 1,
clearcoatRoughness: 0.05,
envMapIntensity: 1.1,
});
geometries.push(knotGeo, vesselGeo, bandGeo, plateGeo, groundGeo);
materials.push(knotMat, vesselMat, bandMat, plateMat, groundMat);
const buildGroup = (build: (g: THREE.Group) => void) => {
const g = new T.Group();
build(g);
g.traverse((o) => {
const mesh = o as THREE.Mesh;
if (mesh.isMesh) {
mesh.castShadow = true;
mesh.receiveShadow = false;
}
});
g.visible = false;
g.scale.setScalar(0.001);
stage.add(g);
return g;
};
const groups: THREE.Group[] = [
buildGroup((g) => {
const m = new T.Mesh(knotGeo, knotMat);
m.rotation.set(0.3, 0.4, 0);
g.add(m);
}),
buildGroup((g) => {
const m = new T.Mesh(vesselGeo, vesselMat);
g.add(m);
const band = new T.Mesh(bandGeo, bandMat);
band.rotation.x = Math.PI / 2;
band.position.y = bandSeat.y;
g.add(band);
}),
buildGroup((g) => {
const m = new T.Mesh(plateGeo, plateMat);
m.rotation.set(-0.16, 0.42, 0.1);
g.add(m);
}),
];
// --- AnimationMixer: モデルが無くてもトラックを手で組めばタイムラインになる ----
const enterClip = new T.AnimationClip("enter", 0.66, [
new T.VectorKeyframeTrack(
".scale",
[0, 0.36, 0.66],
[0.001, 0.001, 0.001, 1.07, 1.07, 1.07, 1, 1, 1],
),
new T.NumberKeyframeTrack(".rotation[y]", [0, 0.66], [-0.85, 0]),
]);
const exitClip = new T.AnimationClip("exit", 0.3, [
new T.VectorKeyframeTrack(".scale", [0, 0.3], [1, 1, 1, 0.001, 0.001, 0.001]),
new T.NumberKeyframeTrack(".rotation[y]", [0, 0.3], [0, 0.6]),
]);
const floatTrack = new T.VectorKeyframeTrack(
".position",
[0, 1.9, 3.8],
[0, -0.045, 0, 0, 0.055, 0, 0, -0.045, 0],
);
floatTrack.setInterpolation(T.InterpolateSmooth);
const floatClip = new T.AnimationClip("float", 3.8, [floatTrack]);
const rigs = groups.map((g) => {
const mixer = new T.AnimationMixer(g);
const enter = mixer.clipAction(enterClip);
enter.setLoop(T.LoopOnce, 1);
enter.clampWhenFinished = true;
const exit = mixer.clipAction(exitClip);
exit.setLoop(T.LoopOnce, 1);
exit.clampWhenFinished = true;
const drift = mixer.clipAction(floatClip);
return { group: g, mixer, enter, exit, drift };
});
// --- 状態 ---------------------------------------------------------------------
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let motionOK = !motionQuery.matches;
let current = opts.index;
let raf = 0;
let running = false;
let firstFrame = true;
let last = performance.now();
let yaw = -0.3;
let pitch = 0.1;
let yawTarget = -0.3;
let pitchTarget = 0.1;
let dragging = false;
let pointerId: number | null = null;
let lastX = 0;
let lastY = 0;
let frames = 0;
let elapsed = 0;
let degraded = false;
const resize = () => {
const rect = host.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
const aspect = width / height;
camera.aspect = aspect;
// 縦長でも標本が切れないよう、収めたい"幅"から縦画角を逆算する
const dist = camera.position.z;
let vfov = 2 * Math.atan(1.35 / dist);
const needed = 2 * Math.atan(1.35 / (dist * aspect));
if (needed > vfov) vfov = needed;
camera.fov = Math.min(60, (vfov * 180) / Math.PI);
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const renderOnce = () => {
renderer.render(scene, camera);
if (firstFrame) {
firstFrame = false;
onFirstFrame();
}
};
const frame = (now: number) => {
raf = requestAnimationFrame(frame);
const dt = Math.min((now - last) / 1000, 0.06);
last = now;
if (!dragging) yawTarget += 0.26 * dt; // 自動回転(モーション許可時のみループが回る)
const ease = 1 - Math.pow(0.0018, dt);
yaw += (yawTarget - yaw) * ease;
pitch += (pitchTarget - pitch) * ease;
stage.rotation.y = yaw;
stage.rotation.x = pitch;
for (const rig of rigs) {
rig.mixer.update(dt);
// 退場しきった標本は描画から外す(スケール0のメッシュを回し続けない)
if (rig.group !== groups[current] && rig.group.scale.x < 0.02) rig.group.visible = false;
}
renderOnce();
// 低スペック対策: 立ち上がり10フレームを捨て、続く48フレームの平均で二段階に落とす
frames += 1;
if (frames > 10 && frames <= 58) {
elapsed += dt * 1000;
if (frames === 58 && elapsed / 48 > 32) {
if (degraded) {
onFail();
stop();
} else {
degraded = true;
dpr = Math.max(1, dpr * 0.6);
renderer.setPixelRatio(dpr);
resize();
frames = 0;
elapsed = 0;
}
}
}
};
const start = () => {
if (raf || !motionOK) return;
last = performance.now();
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (!raf) return;
cancelAnimationFrame(raf);
raf = 0;
};
const select = (index: number) => {
if (index < 0 || index >= groups.length) return;
const next = rigs[index];
if (motionOK) {
for (const rig of rigs) {
if (rig.group === next.group) continue;
if (!rig.group.visible) continue;
rig.enter.stop();
rig.drift.stop();
rig.exit.reset().play();
}
next.exit.stop();
next.group.visible = true;
next.enter.reset().play();
next.drift.reset().play();
} else {
// モーション低減時はタイムラインを使わず、最終状態を直接置いて1枚だけ描く
for (const rig of rigs) {
rig.mixer.stopAllAction();
const on = rig.group === next.group;
rig.group.visible = on;
rig.group.scale.setScalar(on ? 1 : 0.001);
rig.group.position.set(0, 0, 0);
rig.group.rotation.set(0, 0, 0);
}
}
current = index;
if (!motionOK) renderOnce();
};
const applyMotion = () => {
if (motionOK) {
start();
} else {
stop();
select(current);
}
};
const onMotionChange = () => {
motionOK = !motionQuery.matches;
if (running) applyMotion();
};
motionQuery.addEventListener("change", onMotionChange);
// --- ポインタ操作(ユーザー起因の回転はモーション低減時も許可=1フレームずつ描く)--
const onPointerDown = (e: PointerEvent) => {
if (pointerId !== null) return;
pointerId = e.pointerId;
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragging || e.pointerId !== pointerId) return;
yawTarget += (e.clientX - lastX) * 0.007;
pitchTarget = Math.max(-0.42, Math.min(0.42, pitchTarget + (e.clientY - lastY) * 0.005));
lastX = e.clientX;
lastY = e.clientY;
if (!motionOK) {
yaw = yawTarget;
pitch = pitchTarget;
stage.rotation.set(pitch, yaw, 0);
renderOnce();
}
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
dragging = false;
pointerId = null;
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
};
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerUp);
const onContextLost = (e: Event) => {
e.preventDefault();
stop();
onFail();
};
canvas.addEventListener("webglcontextlost", onContextLost);
const ro = new ResizeObserver(() => {
resize();
if (!motionOK || !raf) renderOnce();
});
ro.observe(host);
resize();
select(current);
if (motionOK) renderOnce();
return {
select,
setRunning: (next: boolean) => {
running = next;
if (next) applyMotion();
else stop();
},
dispose: () => {
stop();
ro.disconnect();
motionQuery.removeEventListener("change", onMotionChange);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointercancel", onPointerUp);
canvas.removeEventListener("webglcontextlost", onContextLost);
for (const rig of rigs) rig.mixer.stopAllAction();
for (const g of geometries) g.dispose();
for (const m of materials) m.dispose();
envMap.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}
export default function ThreeObjectGalleryHero() {
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const apiRef = useRef<StudioApi | 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);
const [active, setActive] = useState(0);
// 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に 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: "160px" },
);
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;
// 対応可否の判定 → 動的import → 起動、までを1本の非同期手続きにまとめる
// (effect の本体から setState を直に呼ばない)
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 = createStudio(T, {
canvas,
host,
index: 0,
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]);
useEffect(() => {
apiRef.current?.select(active);
}, [active, ready]);
const specimen = SPECIMENS[active];
return (
<section className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-3.5 sm:px-8 sm:py-4">
<p className="flex items-center gap-2 text-[11px] font-bold tracking-[0.16em] whitespace-nowrap text-gray-900">
<span className="inline-block size-1 bg-amber-600" aria-hidden="true" />
3D OBJECT STUDY
</p>
{/* 狭幅では2本並べると入らないので右は畳む(overflow-hidden で切れるのを見せない) */}
<p className="hidden font-mono text-[10px] font-bold tracking-[0.18em] whitespace-nowrap text-gray-400 uppercase sm:block">
PBR / IBL / SHADOW
</p>
</div>
<div className="grid sm:grid-cols-[1fr_1fr]">
<div className="px-5 py-9 sm:px-8 sm:py-11">
<p className="text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase">
Real-time 3D
</p>
<h2
className={`${montserrat.className} mt-4 text-[clamp(1.7rem,4.6vw,2.4rem)] leading-[1.12] font-bold tracking-tight text-gray-900`}
>
Depth,
<br />
on demand.
</h2>
<p className="mt-5 max-w-sm text-[13.5px] leading-[1.95] font-medium tracking-[0.03em] text-gray-600">
「サイトに3Dを載せたい」に、モデルデータなしで応えます。質感・光・影・動きをすべてコードで組み立てるので、案件のトーンに合わせて素材から作れます。
</p>
<a
href="#contact"
className="group mt-7 inline-flex items-center gap-2 text-[13px] font-bold tracking-[0.06em] text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative py-1">
3D演出を相談する
<span
className="absolute inset-x-0 bottom-0 h-px origin-left scale-x-100 bg-gray-900 transition-transform duration-500 ease-out group-hover:scale-x-0 motion-reduce:transition-none"
aria-hidden="true"
/>
<span
className="absolute inset-x-0 bottom-0 h-px origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-500 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none"
aria-hidden="true"
/>
</span>
<span
className="transition-transform duration-300 group-hover:translate-x-1 motion-reduce:transition-none"
aria-hidden="true"
>
→
</span>
</a>
</div>
<div
ref={stageRef}
className="relative min-h-[248px] border-t border-gray-200 bg-[radial-gradient(120%_88%_at_50%_16%,rgba(217,119,6,0.12)_0%,rgba(255,255,255,0)_60%),linear-gradient(180deg,#ffffff_0%,#faf8f4_100%)] sm:min-h-[336px] sm:border-t-0 sm:border-l"
>
{/* 静止フォールバック(WebGL非対応・コンテキストロスト・低速時はこのまま)。
canvas は alpha なので、3Dが立ち上がったら必ず消す(透過部分から透けて二重に見える)。 */}
<div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-700 ${
ready && !failed ? "opacity-0" : "opacity-100"
} motion-reduce:transition-none`}
aria-hidden="true"
>
<div className="relative size-32 sm:size-40">
<span className="absolute inset-0 rounded-full border border-gray-300" />
<span className="absolute inset-[14%] rounded-full border border-dashed border-gray-300" />
<span className="absolute inset-[30%] rounded-full bg-[radial-gradient(circle_at_32%_28%,#fbbf24_0%,#d97706_58%,#92400e_100%)]" />
<span className="absolute inset-x-0 -bottom-4 mx-auto h-2 w-20 rounded-full bg-gray-900/10 blur-[6px]" />
</div>
</div>
<canvas
ref={canvasRef}
aria-hidden="true"
className={`absolute inset-0 block size-full touch-pan-y transition-opacity duration-700 ${
ready && !failed ? "opacity-100" : "opacity-0"
} motion-reduce:transition-none`}
/>
{/* 材質パラメータは下端・操作ヒントは上端に分ける(狭い舞台では同じ辺に置くと衝突する) */}
<p className="pointer-events-none absolute inset-x-4 bottom-3.5 font-mono text-[10px] font-bold tracking-[0.16em] whitespace-nowrap text-gray-400 uppercase">
<span className="text-amber-600">{specimen.no}</span> /{" "}
{/* 狭幅は材質パラメータが1行に収まらない(台帳側に同じ値が出るので短い名前に差し替える) */}
<span className="sm:hidden">{specimen.en}</span>
<span className="hidden sm:inline">{specimen.spec}</span>
</p>
<p
className={`pointer-events-none absolute top-3.5 right-4 font-mono text-[10px] tracking-[0.14em] text-gray-400 uppercase transition-opacity duration-500 ${
ready && !failed ? "opacity-100" : "opacity-0"
} motion-reduce:transition-none`}
>
<span className="[@media(pointer:coarse)]:hidden">Drag to orbit</span>
<span className="hidden [@media(pointer:coarse)]:inline">Swipe to orbit</span>
</p>
</div>
</div>
{/* 標本台帳=そのまま切り替えスイッチ(canvasは装飾なので、内容はDOM側が正) */}
<ol className="border-t border-gray-200 px-5 sm:px-8">
{SPECIMENS.map((item, i) => {
const on = i === active;
return (
<li key={item.no} className="border-b border-gray-200 last:border-b-0">
<button
type="button"
aria-pressed={on}
onClick={() => setActive(i)}
className="group flex w-full items-baseline gap-x-4 gap-y-1 py-4 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 sm:grid sm:grid-cols-[2.25rem_1fr_7rem] sm:py-4.5"
>
<span
className={`font-mono text-[11px] font-bold tracking-[0.1em] transition-colors ${
on ? "text-amber-600" : "text-gray-400 group-hover:text-amber-600"
}`}
>
{item.no}
</span>
<span className="min-w-0">
<span className="flex items-center gap-2">
<span
className={`hidden h-px w-4 shrink-0 origin-left bg-amber-600 transition-transform duration-500 ease-out sm:inline-block motion-reduce:transition-none ${
on ? "scale-x-100" : "scale-x-0 group-hover:scale-x-100"
}`}
aria-hidden="true"
/>
<span
className={`text-[13px] font-bold tracking-[0.04em] transition-colors ${
on ? "text-gray-900" : "text-gray-500 group-hover:text-gray-900"
}`}
>
{item.ja}
</span>
</span>
<span className="mt-1 block font-mono text-[10px] tracking-[0.12em] text-gray-400 uppercase sm:hidden">
{item.spec}
</span>
</span>
<span className="hidden text-right font-mono text-[10px] tracking-[0.1em] text-gray-400 uppercase sm:block">
{item.en}
</span>
</button>
</li>
);
})}
</ol>
<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 は3Dを実際に表示する画面でのみ動的読み込み(別チャンク)。縮小プレビュー・非対応環境では静止パネルを表示します。
</p>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/three-object-gallery-hero.json