First CH Designs
PGE-06Pages

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

Real-time 3D

Depth,
on demand.

「サイトに3Dを載せたい」に、モデルデータなしで応えます。質感・光・影・動きをすべてコードで組み立てるので、案件のトーンに合わせて素材から作れます。

3D演出を相談する

01 / Metal / knot

Drag 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