First CH Designs
PGE-06Pages

3D Object Gallery Hero (PBR, IBL, shadows)

A hero for projects that ask for real-time 3D on the web. Three specimens — polished metal, glazed ceramic and cast glass — can be swapped to show material work, yet the block ships no model files and no texture images: the shapes are generated in code with TorusKnot, Lathe (the vessel profile comes from a function) and Extrude (rounded shape with a bevel). Reflections come from a studio built in code — a white box with four emissive panels — folded into an environment map by PMREMGenerator.fromScene(), so PBR reflections read as real area lights without loading an HDRI, and the amber back panel tints the highlights on brand. Three lights are used: a shadow-casting directional key with PCF shadows, an amber point light for the rim, and a hemisphere fill. The only shadow catcher is a ShadowMaterial floor, which keeps the white page clean while grounding the object. Entrance, exit and float are driven by an AnimationMixer: keyframe tracks such as VectorKeyframeTrack('.scale') are assembled by hand, so a timeline is available even without an animated model. The specimen switcher lives in the hairline ledger below the canvas as real buttons with aria-pressed, so everything is reachable by keyboard. Adding three is the single exception to this gallery's zero-dependency rule, taken only because PBR materials, environment maps, shadows and animation are not worth hand-writing in raw WebGL; anything raw WebGL covers well (planes, shader backdrops, particles) stays dependency-free elsewhere. The measured cost of the exception (three 0.185, production build) is one extra chunk of 725,217 bytes (~708KB), 181,641 bytes (~177KB) gzipped, and it is never imported statically: a dynamic import inside useEffect splits it into its own chunk that loads only where 3D is actually shown (scaled-down previews are detected via rect.width / offsetWidth and never load it). Under prefers-reduced-motion neither the auto-rotation nor the timeline runs and a single frame is drawn; when WebGL is unavailable, the context is lost or the frame budget is missed, it falls back to a static panel.

Added:
2026-08-19
Dependencies:
three
tags
#hero #page #3d #webgl #pbr #environment-map #shadow #animation #interactive #motion #no-image #dynamic-import

Preview

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>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/three-object-gallery-hero.json