Three.js完全ガイド — 3Dグラフィックス・WebGL・アニメーション・React Three Fiber


ウェブブラウザ上でリッチな3Dグラフィックスを実現する技術として、Three.js は長年にわたって第一線で使われ続けている。WebGLの低レベルAPIを直接扱う複雑さを隠蔽しつつ、プロダクション品質の3Dシーンを短時間で構築できる強力なライブラリだ。本記事では、Three.jsの基礎から応用、そしてReact Three Fiber(R3F)によるReact統合まで、実装コードを交えながら徹底解説する。


1. Three.jsとは — WebGL抽象化と採用事例

WebGLの複雑さを解決する

WebGLはOpenGL ES 2.0をベースにしたブラウザネイティブの3D描画API。しかし生のWebGLコードは非常に冗長で、シェーダーの記述・バッファの管理・行列計算など、単純な立方体を描くだけでも数百行のコードが必要になる。

Three.jsはこの複雑さを抽象化し、直感的なオブジェクト指向APIを提供する。2010年にRicardo Cabelloによってスタートし、現在はGitHubで10万スター以上を獲得しているオープンソースライブラリだ(MITライセンス)。

主な採用事例

Three.jsは様々な場面で採用されている。

  • Apple製品ページ: MacBook・iPhone等のインタラクティブ3D展示
  • Google Earth Web: ブラウザベースの地球儀表示
  • NASAの可視化プロジェクト: 宇宙探査データの3Dビジュアライゼーション
  • ゲーム・インタラクティブ体験: ブラウザゲーム・インスタレーションアート
  • データビジュアライゼーション: 3Dグラフ・ネットワーク図
  • 製品コンフィギュレーター: 家具・自動車・ファッションの3Dプレビュー

Three.jsのエコシステム

Three.js本体
├── React Three Fiber (R3F) — Reactバインディング
├── Drei — R3F向けユーティリティコレクション
├── Rapier / Cannon.js — 物理演算エンジン統合
├── Postprocessing — エフェクトコンポーザー
├── Leva — GUI コントロールパネル
└── Zustand — 3Dシーン状態管理

2. 基本セットアップ

インストール

# npmの場合
npm install three
npm install --save-dev @types/three

# pnpmの場合
pnpm add three
pnpm add -D @types/three

最小構成のシーン

Three.jsの基本要素は Scene(シーン)Camera(カメラ)Renderer(レンダラー) の3つ。

// src/main.ts
import * as THREE from 'three';

// 1. シーンの作成
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);

// 2. カメラの作成(透視投影)
const camera = new THREE.PerspectiveCamera(
  75,                                    // 視野角(FOV)
  window.innerWidth / window.innerHeight, // アスペクト比
  0.1,                                   // ニアクリッピング面
  1000                                   // ファークリッピング面
);
camera.position.set(0, 0, 5);
camera.lookAt(0, 0, 0);

// 3. WebGLレンダラーの作成
const renderer = new THREE.WebGLRenderer({
  antialias: true,       // アンチエイリアス有効化
  alpha: false,          // 透明背景不要
  powerPreference: 'high-performance',
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.body.appendChild(renderer.domElement);

// 4. 基本的なメッシュ(立方体)の追加
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x4f9cf9 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 5. ライトの追加
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(5, 10, 5);
scene.add(directionalLight);

// 6. アニメーションループ
const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);

  const delta = clock.getDelta();

  // 毎フレームの更新
  cube.rotation.x += delta * 0.5;
  cube.rotation.y += delta * 0.8;

  renderer.render(scene, camera);
}

animate();

// 7. リサイズ対応
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

カメラの種類

// 透視投影カメラ(最も一般的)
const perspectiveCamera = new THREE.PerspectiveCamera(fov, aspect, near, far);

// 平行投影カメラ(2D風・建築・CAD向け)
const orthographicCamera = new THREE.OrthographicCamera(
  -width / 2,   // left
  width / 2,    // right
  height / 2,   // top
  -height / 2,  // bottom
  0.1,          // near
  1000          // far
);

// カメラコントロール(OrbitControls)
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;   // 慣性を持たせた滑らかな操作
controls.dampingFactor = 0.05;
controls.minDistance = 1;
controls.maxDistance = 100;
controls.maxPolarAngle = Math.PI / 2; // 地面より下を見ない

// アニメーションループ内で更新
function animate() {
  requestAnimationFrame(animate);
  controls.update(); // dampingを有効にした場合は必須
  renderer.render(scene, camera);
}

3. Geometry(ジオメトリ)

組み込みジオメトリ

// 直方体
const boxGeometry = new THREE.BoxGeometry(
  width,    // X方向のサイズ
  height,   // Y方向のサイズ
  depth,    // Z方向のサイズ
  widthSegments,  // X方向の分割数(デフォルト:1)
  heightSegments, // Y方向の分割数
  depthSegments   // Z方向の分割数
);

// 球体
const sphereGeometry = new THREE.SphereGeometry(
  radius,         // 半径
  widthSegments,  // 経度方向の分割数(最低8推奨)
  heightSegments  // 緯度方向の分割数
);

// 平面
const planeGeometry = new THREE.PlaneGeometry(
  width, height,
  widthSegments, heightSegments
);

// 円柱(円錐も作れる)
const cylinderGeometry = new THREE.CylinderGeometry(
  radiusTop,    // 上部半径
  radiusBottom, // 下部半径(0にすると円錐)
  height,
  radialSegments
);

// トーラス(ドーナツ形状)
const torusGeometry = new THREE.TorusGeometry(
  radius,          // 中心からチューブ中心までの距離
  tube,            // チューブの半径
  radialSegments,
  tubularSegments
);

// トーラスノット(複雑な結び目形状)
const torusKnotGeometry = new THREE.TorusKnotGeometry(
  radius, tube, tubularSegments, radialSegments, p, q
);

// 正二十面体(ローポリ球)
const icosahedronGeometry = new THREE.IcosahedronGeometry(radius, detail);

カスタムジオメトリ(BufferGeometry)

// 独自の頂点データからジオメトリを作成
const geometry = new THREE.BufferGeometry();

// 三角形の頂点座標(3頂点 × XYZ = 9要素)
const vertices = new Float32Array([
  -1.0, -1.0,  0.0,  // 頂点0
   1.0, -1.0,  0.0,  // 頂点1
   0.0,  1.0,  0.0,  // 頂点2
]);

// 法線ベクトル(ライティング計算用)
const normals = new Float32Array([
  0, 0, 1,
  0, 0, 1,
  0, 0, 1,
]);

// UV座標(テクスチャマッピング用)
const uvs = new Float32Array([
  0.0, 0.0,
  1.0, 0.0,
  0.5, 1.0,
]);

geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));

// インデックスバッファで頂点を再利用(メモリ節約)
const indices = new Uint16Array([0, 1, 2]);
geometry.setIndex(new THREE.BufferAttribute(indices, 1));

// 法線の自動計算(手動設定しない場合)
geometry.computeVertexNormals();

// バウンディングボックス・スフィアの計算(フラスタムカリング用)
geometry.computeBoundingBox();
geometry.computeBoundingSphere();

const mesh = new THREE.Mesh(
  geometry,
  new THREE.MeshStandardMaterial({ color: 0xff6b6b, side: THREE.DoubleSide })
);
scene.add(mesh);

手続き的なジオメトリ生成

// ハイトマップから地形を生成する例
function createTerrain(width: number, height: number, resolution: number) {
  const geometry = new THREE.PlaneGeometry(width, height, resolution, resolution);
  const positions = geometry.attributes.position;

  // Simplex Noiseなどで高さを設定
  for (let i = 0; i < positions.count; i++) {
    const x = positions.getX(i);
    const z = positions.getZ(i);
    // ノイズ関数で高さを計算(ここでは簡略化)
    const y = Math.sin(x * 0.5) * Math.cos(z * 0.5) * 2;
    positions.setY(i, y);
  }

  geometry.computeVertexNormals();
  return geometry;
}

4. Material(マテリアル)

基本マテリアル

// ライティング非依存(シェーダー不使用)— 最も軽量
const basicMaterial = new THREE.MeshBasicMaterial({
  color: 0xff0000,
  wireframe: false,
  transparent: false,
  opacity: 1.0,
  side: THREE.FrontSide, // FrontSide / BackSide / DoubleSide
});

// ランバートシェーディング(拡散反射のみ)— 中間的な品質
const lambertMaterial = new THREE.MeshLambertMaterial({
  color: 0x00ff00,
  emissive: 0x111111, // 自己発光色
});

// フォンシェーディング(鏡面反射あり)
const phongMaterial = new THREE.MeshPhongMaterial({
  color: 0x0000ff,
  specular: 0xffffff,  // 鏡面反射色
  shininess: 100,       // 鏡面反射の鋭さ
});

// PBR(物理ベースレンダリング)— 最高品質・最重量
const standardMaterial = new THREE.MeshStandardMaterial({
  color: 0xffffff,
  metalness: 0.5,  // 金属度(0: 非金属, 1: 金属)
  roughness: 0.3,  // 粗さ(0: 鏡面, 1: 完全拡散)
  envMapIntensity: 1.0,
  normalMap: normalTexture,
  roughnessMap: roughnessTexture,
  metalnessMap: metalnessTexture,
  aoMap: aoTexture,           // アンビエントオクルージョン
  aoMapIntensity: 1.0,
  displacementMap: heightMap, // 変位マップ
  displacementScale: 0.1,
});

// より高性能なPBRマテリアル
const physicalMaterial = new THREE.MeshPhysicalMaterial({
  ...standardMaterial,
  clearcoat: 1.0,         // クリアコート(車のペイント風)
  clearcoatRoughness: 0.1,
  transmission: 0.9,      // 透過(ガラス風)
  thickness: 0.5,
  ior: 1.5,               // 屈折率
  iridescence: 1.0,       // 虹彩(シャボン玉風)
});

ShaderMaterial(カスタムシェーダー)

// GLSL シェーダーを直接記述
const shaderMaterial = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0.0 },
    uColor: { value: new THREE.Color(0x4f9cf9) },
    uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
  },
  vertexShader: /* glsl */ `
    uniform float uTime;
    varying vec2 vUv;
    varying vec3 vPosition;

    void main() {
      vUv = uv;
      vPosition = position;

      // 頂点を波打たせる
      vec3 pos = position;
      pos.y += sin(pos.x * 2.0 + uTime) * 0.2;
      pos.y += cos(pos.z * 2.0 + uTime * 0.5) * 0.2;

      gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
    }
  `,
  fragmentShader: /* glsl */ `
    uniform float uTime;
    uniform vec3 uColor;
    varying vec2 vUv;
    varying vec3 vPosition;

    void main() {
      // 時間変化するグラデーション
      vec3 color = uColor;
      color.r += sin(vUv.x * 10.0 + uTime) * 0.2;
      color.b += cos(vUv.y * 10.0 + uTime * 0.7) * 0.2;

      // フレネル効果(エッジを光らせる)
      float fresnel = pow(1.0 - dot(normalize(vPosition), vec3(0.0, 0.0, 1.0)), 3.0);
      color += fresnel * 0.5;

      gl_FragColor = vec4(color, 1.0);
    }
  `,
  side: THREE.DoubleSide,
});

// アニメーションループ内でuniformを更新
const clock = new THREE.Clock();
function animate() {
  requestAnimationFrame(animate);
  shaderMaterial.uniforms.uTime.value = clock.getElapsedTime();
  renderer.render(scene, camera);
}

5. Light(ライト)

ライトの種類と使い方

// アンビエントライト(環境光)— 全方向から均等に照らす
const ambientLight = new THREE.AmbientLight(
  0xffffff, // 色
  0.5       // 強度
);
scene.add(ambientLight);

// 平行光源(太陽光)— 無限遠からの平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(10, 20, 10);
directionalLight.target.position.set(0, 0, 0);
scene.add(directionalLight);
scene.add(directionalLight.target);

// シャドウ設定
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;  // シャドウマップ解像度
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 100;
directionalLight.shadow.camera.left = -20;
directionalLight.shadow.camera.right = 20;
directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.bias = -0.0001;        // シャドウアクネ対策
directionalLight.shadow.normalBias = 0.02;

// ポイントライト(点光源)— 電球・炎
const pointLight = new THREE.PointLight(
  0xff6600, // オレンジ色
  2.0,      // 強度
  20.0,     // 光の届く最大距離
  2.0       // 減衰係数
);
pointLight.position.set(0, 5, 0);
pointLight.castShadow = true;
scene.add(pointLight);

// スポットライト — 舞台照明
const spotLight = new THREE.SpotLight(
  0xffffff,
  2.0,      // 強度
  30,       // 距離
  Math.PI / 6, // 角度(コーン半角)
  0.3,      // penumbra(エッジのぼかし 0-1)
  2.0       // 減衰
);
spotLight.position.set(5, 10, 5);
spotLight.castShadow = true;
spotLight.shadow.mapSize.set(1024, 1024);
scene.add(spotLight);

// 半球ライト(空と地面からの環境光)
const hemisphereLight = new THREE.HemisphereLight(
  0x87ceeb, // 空の色
  0x8b7355, // 地面の色
  0.5       // 強度
);
scene.add(hemisphereLight);

// RectAreaLight(面光源)— スタジオのソフトボックス
import { RectAreaLightUniformsLib } from 'three/examples/jsm/lights/RectAreaLightUniformsLib.js';
import { RectAreaLightHelper } from 'three/examples/jsm/helpers/RectAreaLightHelper.js';

RectAreaLightUniformsLib.init();

const rectAreaLight = new THREE.RectAreaLight(0xffffff, 5, 4, 4);
rectAreaLight.position.set(-5, 5, 0);
rectAreaLight.lookAt(0, 0, 0);
scene.add(rectAreaLight);

// シャドウを受け取るオブジェクトの設定
mesh.castShadow = true;
mesh.receiveShadow = true;

// デバッグ用ヘルパー
const directionalLightHelper = new THREE.DirectionalLightHelper(directionalLight, 2);
scene.add(directionalLightHelper);

const shadowCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera);
scene.add(shadowCameraHelper);

6. Texture(テクスチャ)

基本的なテクスチャ読み込み

const textureLoader = new THREE.TextureLoader();

// 単一テクスチャの読み込み
const texture = textureLoader.load(
  '/textures/wood_diffuse.jpg',
  // onLoad コールバック
  (texture) => {
    console.log('テクスチャ読み込み完了', texture);
  },
  // onProgress
  undefined,
  // onError
  (error) => {
    console.error('テクスチャ読み込みエラー', error);
  }
);

// テクスチャのパラメータ設定
texture.wrapS = THREE.RepeatWrapping;  // 水平方向の繰り返し
texture.wrapT = THREE.RepeatWrapping;  // 垂直方向の繰り返し
texture.repeat.set(4, 4);             // 4x4で繰り返し
texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); // 異方性フィルタリング

// LoadingManagerで複数テクスチャを一括管理
const loadingManager = new THREE.LoadingManager();
loadingManager.onStart = (url, itemsLoaded, itemsTotal) => {
  console.log(`読み込み開始: ${url} (${itemsLoaded}/${itemsTotal})`);
};
loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
  const progress = (itemsLoaded / itemsTotal) * 100;
  updateProgressBar(progress);
};
loadingManager.onLoad = () => {
  console.log('全アセット読み込み完了');
  startScene();
};
loadingManager.onError = (url) => {
  console.error(`読み込みエラー: ${url}`);
};

const managedLoader = new THREE.TextureLoader(loadingManager);

PBRテクスチャセットの適用

// PBR テクスチャセット(Metal/Roughness ワークフロー)
const material = new THREE.MeshStandardMaterial();

// TextureLoader でまとめて読み込み
const [colorMap, normalMap, roughnessMap, metalnessMap, aoMap] = await Promise.all([
  textureLoader.loadAsync('/textures/metal_color.jpg'),
  textureLoader.loadAsync('/textures/metal_normal.jpg'),
  textureLoader.loadAsync('/textures/metal_roughness.jpg'),
  textureLoader.loadAsync('/textures/metal_metalness.jpg'),
  textureLoader.loadAsync('/textures/metal_ao.jpg'),
]);

// 各マップを設定
material.map = colorMap;
material.normalMap = normalMap;
material.normalScale.set(1, 1);
material.roughnessMap = roughnessMap;
material.metalnessMap = metalnessMap;
material.aoMap = aoMap;
material.aoMapIntensity = 1.0;

// 環境マップ(IBL — Image Based Lighting)
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
import { PMREMGenerator } from 'three';

const rgbeLoader = new RGBELoader();
const pmremGenerator = new PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();

rgbeLoader.load('/hdr/studio_small_08_1k.hdr', (hdrTexture) => {
  const envMap = pmremGenerator.fromEquirectangular(hdrTexture).texture;

  scene.environment = envMap;  // 全マテリアルに適用
  scene.background = envMap;   // 背景としても使用

  hdrTexture.dispose();
  pmremGenerator.dispose();
});

キャンバスで動的テクスチャを生成

// Canvas2Dで動的テクスチャ生成
function createDynamicTexture(text: string): THREE.CanvasTexture {
  const canvas = document.createElement('canvas');
  canvas.width = 512;
  canvas.height = 512;
  const ctx = canvas.getContext('2d')!;

  // 背景
  ctx.fillStyle = '#1a1a2e';
  ctx.fillRect(0, 0, 512, 512);

  // テキスト
  ctx.fillStyle = '#4f9cf9';
  ctx.font = 'bold 64px sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(text, 256, 256);

  const texture = new THREE.CanvasTexture(canvas);
  return texture;
}

// VideoTextureで動画をテクスチャとして使用
const video = document.createElement('video');
video.src = '/videos/demo.mp4';
video.loop = true;
video.muted = true;
video.play();

const videoTexture = new THREE.VideoTexture(video);
videoTexture.minFilter = THREE.LinearFilter;
videoTexture.magFilter = THREE.LinearFilter;

7. Animation(アニメーション)

AnimationMixerとKeyframeTrack

// GLTFモデルに含まれるアニメーションの再生
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

const loader = new GLTFLoader();
let mixer: THREE.AnimationMixer;

loader.load('/models/character.glb', (gltf) => {
  const model = gltf.scene;
  scene.add(model);

  // AnimationMixer の作成
  mixer = new THREE.AnimationMixer(model);

  // 全アニメーションクリップを確認
  console.log('利用可能なアニメーション:', gltf.animations.map(a => a.name));

  // 特定のアニメーションを再生
  const idleAction = mixer.clipAction(
    THREE.AnimationClip.findByName(gltf.animations, 'Idle')
  );
  idleAction.play();

  // 複数アニメーションのクロスフェード
  const walkAction = mixer.clipAction(
    THREE.AnimationClip.findByName(gltf.animations, 'Walk')
  );

  // 歩行アニメーションへのトランジション
  function transitionToWalk() {
    walkAction.enabled = true;
    walkAction.setEffectiveTimeScale(1);
    walkAction.setEffectiveWeight(1);
    walkAction.play();
    idleAction.crossFadeTo(walkAction, 0.5, true); // 0.5秒でフェード
  }
});

// アニメーションループ内でMixerを更新
const clock = new THREE.Clock();
function animate() {
  requestAnimationFrame(animate);
  const delta = clock.getDelta();
  if (mixer) mixer.update(delta);
  renderer.render(scene, camera);
}

手動でKeyframeTrackを作成

// KeyframeTrackでプロパティをアニメーション
const times = [0, 1, 2, 3];
const positionValues = [
  0, 0, 0,    // t=0: 原点
  2, 0, 0,    // t=1: 右
  2, 2, 0,    // t=2: 右上
  0, 0, 0,    // t=3: 原点に戻る
];

const positionTrack = new THREE.VectorKeyframeTrack(
  'mesh.position',
  times,
  positionValues
);

const scaleValues = [
  1, 1, 1,
  1.5, 1.5, 1.5,
  0.5, 0.5, 0.5,
  1, 1, 1,
];

const scaleTrack = new THREE.VectorKeyframeTrack(
  'mesh.scale',
  times,
  scaleValues
);

// QuaternionKeyframeTrackで回転アニメーション
const quaternionValues = [
  0, 0, 0, 1,
  0, Math.sin(Math.PI / 4), 0, Math.cos(Math.PI / 4), // Y軸90度
  0, Math.sin(Math.PI / 2), 0, Math.cos(Math.PI / 2), // Y軸180度
  0, 0, 0, 1,
];

const rotationTrack = new THREE.QuaternionKeyframeTrack(
  'mesh.quaternion',
  times,
  quaternionValues
);

const clip = new THREE.AnimationClip('custom-animation', 3, [
  positionTrack,
  scaleTrack,
  rotationTrack,
]);

const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip);
action.setLoop(THREE.LoopRepeat, Infinity);
action.play();

GSAPとの統合

// GSAPをThree.jsと組み合わせる
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

// スクロール連動の3Dアニメーション
ScrollTrigger.create({
  trigger: '#scroll-container',
  start: 'top top',
  end: 'bottom bottom',
  onUpdate: (self) => {
    const progress = self.progress;

    // スクロール量に応じてカメラ移動
    camera.position.x = progress * 10 - 5;
    camera.position.y = Math.sin(progress * Math.PI) * 3;
    camera.lookAt(0, 0, 0);

    // モデルの回転
    model.rotation.y = progress * Math.PI * 2;
  }
});

// GSAP Timelineで複雑なアニメーション
const tl = gsap.timeline({ repeat: -1, yoyo: true });

tl.to(cube.position, {
  x: 3,
  duration: 1,
  ease: 'power2.inOut',
})
.to(cube.rotation, {
  y: Math.PI * 2,
  duration: 1,
  ease: 'linear',
}, '<') // 前のアニメーションと同時に開始
.to(cube.material, {
  opacity: 0.3,
  duration: 0.5,
});

8. 3Dモデル読み込み(GLTFLoader・Draco圧縮)

GLTFLoaderの基本

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';

// Draco圧縮デコーダーの設定
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/'); // CDNも使用可能
// dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');

// GLTFLoaderにDracoLoaderを紐付け
const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);

// モデルの読み込み
gltfLoader.load(
  '/models/scene.glb',
  (gltf) => {
    const model = gltf.scene;

    // シーン内の全メッシュにシャドウを設定
    model.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        child.castShadow = true;
        child.receiveShadow = true;

        // マテリアルの調整
        if (child.material instanceof THREE.MeshStandardMaterial) {
          child.material.envMapIntensity = 1.5;
        }
      }
    });

    // モデルのサイズを調整
    const box = new THREE.Box3().setFromObject(model);
    const size = box.getSize(new THREE.Vector3());
    const maxDim = Math.max(size.x, size.y, size.z);
    const scale = 2 / maxDim;
    model.scale.setScalar(scale);

    // モデルを中心に配置
    const center = box.getCenter(new THREE.Vector3());
    model.position.sub(center.multiplyScalar(scale));

    scene.add(model);
  },
  // 進捗コールバック
  (xhr) => {
    const percent = (xhr.loaded / xhr.total) * 100;
    console.log(`${percent.toFixed(1)}% 読み込み済み`);
  },
  (error) => {
    console.error('モデル読み込みエラー:', error);
  }
);

モデルメタデータのJSON管理

3Dモデルを大量に扱うプロジェクトでは、モデルのメタデータ(パス・スケール・アニメーション一覧・マテリアル設定など)をJSONで管理するのが一般的だ。

// model-registry.json の例
interface ModelMetadata {
  id: string;
  name: string;
  path: string;
  scale: number;
  position: [number, number, number];
  rotation: [number, number, number];
  animations: string[];
  tags: string[];
  compressed: boolean;
  fileSize: number; // KB
}

const modelRegistry: ModelMetadata[] = [
  {
    id: 'character-01',
    name: '主人公キャラクター',
    path: '/models/character.glb',
    scale: 1.0,
    position: [0, 0, 0],
    rotation: [0, 0, 0],
    animations: ['Idle', 'Walk', 'Run', 'Jump', 'Attack'],
    tags: ['character', 'humanoid'],
    compressed: true,
    fileSize: 2048,
  },
];

このようなJSONデータを扱う際は、スキーマのバリデーションが重要になる。DevToolBox のJSON Validatorを使うと、モデルメタデータのJSONスキーマ検証・整形・差分確認をブラウザ上で手軽に行える。特に複数人での開発時や、外部のアセットパイプラインからJSONを受け取る際に、期待するスキーマに合致しているかをリアルタイムで確認できて便利だ。


9. ポストプロセッシング

EffectComposerの設定

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';
import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass.js';
import { GlitchPass } from 'three/examples/jsm/postprocessing/GlitchPass.js';

// EffectComposer の初期化
const composer = new EffectComposer(renderer);

// 1. シーンのレンダリング(基本パス)
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

// 2. Bloom(発光エフェクト)
const bloomPass = new UnrealBloomPass(
  new THREE.Vector2(window.innerWidth, window.innerHeight),
  1.5,  // strength(強度)
  0.4,  // radius(半径)
  0.85  // threshold(閾値)
);
composer.addPass(bloomPass);

// 3. SSAO(スクリーンスペースアンビエントオクルージョン)
const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
ssaoPass.kernelRadius = 16;
ssaoPass.minDistance = 0.005;
ssaoPass.maxDistance = 0.1;
composer.addPass(ssaoPass);

// 4. FXAA(アンチエイリアス)— 最後に適用
const fxaaPass = new ShaderPass(FXAAShader);
fxaaPass.material.uniforms['resolution'].value.x = 1 / (window.innerWidth * renderer.getPixelRatio());
fxaaPass.material.uniforms['resolution'].value.y = 1 / (window.innerHeight * renderer.getPixelRatio());
composer.addPass(fxaaPass);

// アニメーションループ内ではcomposerを使ってレンダリング
function animate() {
  requestAnimationFrame(animate);
  composer.render(); // renderer.render(scene, camera) の代わり
}

// リサイズ対応
window.addEventListener('resize', () => {
  const width = window.innerWidth;
  const height = window.innerHeight;
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height);
  composer.setSize(width, height);

  fxaaPass.material.uniforms['resolution'].value.x = 1 / (width * renderer.getPixelRatio());
  fxaaPass.material.uniforms['resolution'].value.y = 1 / (height * renderer.getPixelRatio());
});

カスタムポストプロセッシングパス

// 独自のポストプロセッシングシェーダー(ヴィネット + カラーグレーディング)
const customShader = {
  uniforms: {
    tDiffuse: { value: null },
    uTime: { value: 0 },
    uVignetteIntensity: { value: 0.5 },
    uContrast: { value: 1.1 },
    uBrightness: { value: 0.0 },
  },
  vertexShader: /* glsl */ `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: /* glsl */ `
    uniform sampler2D tDiffuse;
    uniform float uVignetteIntensity;
    uniform float uContrast;
    uniform float uBrightness;
    varying vec2 vUv;

    void main() {
      vec4 color = texture2D(tDiffuse, vUv);

      // コントラスト・ブライトネス調整
      color.rgb = (color.rgb - 0.5) * uContrast + 0.5 + uBrightness;

      // ヴィネット効果
      vec2 center = vUv - 0.5;
      float vignette = 1.0 - dot(center, center) * uVignetteIntensity * 4.0;
      color.rgb *= vignette;

      gl_FragColor = color;
    }
  `,
};

const customPass = new ShaderPass(customShader);
composer.addPass(customPass);

10. React Three Fiber(R3F)

セットアップ

npm install @react-three/fiber @react-three/drei three
npm install --save-dev @types/three

基本的なシーン構築

// src/components/Scene3D.tsx
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { useRef, useState } from 'react';
import * as THREE from 'three';

// 回転する立方体コンポーネント
function RotatingCube() {
  const meshRef = useRef<THREE.Mesh>(null!);
  const [hovered, setHovered] = useState(false);
  const [clicked, setClicked] = useState(false);

  // 毎フレーム実行(アニメーションループ)
  useFrame((state, delta) => {
    meshRef.current.rotation.x += delta * 0.5;
    meshRef.current.rotation.y += delta * 0.8;

    // ホバー時に浮き上がる
    meshRef.current.position.y = hovered
      ? Math.sin(state.clock.elapsedTime * 2) * 0.1 + 0.5
      : 0;
  });

  return (
    <mesh
      ref={meshRef}
      scale={clicked ? 1.5 : 1}
      onClick={() => setClicked(!clicked)}
      onPointerOver={(e) => {
        e.stopPropagation();
        setHovered(true);
        document.body.style.cursor = 'pointer';
      }}
      onPointerOut={() => {
        setHovered(false);
        document.body.style.cursor = 'auto';
      }}
      castShadow
      receiveShadow
    >
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial
        color={hovered ? '#ff6b6b' : '#4f9cf9'}
        metalness={0.3}
        roughness={0.4}
      />
    </mesh>
  );
}

// シーン設定コンポーネント(useThreeで状態にアクセス)
function SceneSetup() {
  const { scene, camera, gl } = useThree();

  useFrame(() => {
    // ここでThree.jsの生のAPIにアクセスできる
  });

  return null;
}

// メインのCanvasコンポーネント
export function Scene3D() {
  return (
    <Canvas
      shadows                          // シャドウ有効化
      camera={{ position: [3, 3, 5], fov: 60 }}
      gl={{
        antialias: true,
        toneMapping: THREE.ACESFilmicToneMapping,
        toneMappingExposure: 1.0,
        outputColorSpace: THREE.SRGBColorSpace,
      }}
      style={{ width: '100%', height: '600px', background: '#1a1a2e' }}
    >
      {/* 照明 */}
      <ambientLight intensity={0.5} />
      <directionalLight
        position={[10, 10, 5]}
        intensity={1}
        castShadow
        shadow-mapSize={[2048, 2048]}
        shadow-camera-far={50}
        shadow-camera-left={-10}
        shadow-camera-right={10}
        shadow-camera-top={10}
        shadow-camera-bottom={-10}
      />
      <pointLight position={[-5, 5, -5]} intensity={0.5} color="#4f9cf9" />

      {/* メッシュ */}
      <RotatingCube />

      {/* 地面 */}
      <mesh rotation-x={-Math.PI / 2} position-y={-1} receiveShadow>
        <planeGeometry args={[20, 20]} />
        <meshStandardMaterial color="#2a2a3e" roughness={0.8} />
      </mesh>

      <SceneSetup />
    </Canvas>
  );
}

useFrameの高度な使い方

// src/components/ParticleSystem.tsx
import { useFrame } from '@react-three/fiber';
import { useMemo, useRef } from 'react';
import * as THREE from 'three';

const PARTICLE_COUNT = 5000;

export function ParticleSystem() {
  const meshRef = useRef<THREE.Points>(null!);

  // メモ化でジオメトリの再生成を防ぐ
  const [positions, velocities] = useMemo(() => {
    const positions = new Float32Array(PARTICLE_COUNT * 3);
    const velocities = new Float32Array(PARTICLE_COUNT * 3);

    for (let i = 0; i < PARTICLE_COUNT; i++) {
      const i3 = i * 3;
      // 球状に分布
      const theta = Math.random() * Math.PI * 2;
      const phi = Math.acos(2 * Math.random() - 1);
      const r = Math.random() * 5;

      positions[i3]     = r * Math.sin(phi) * Math.cos(theta);
      positions[i3 + 1] = r * Math.sin(phi) * Math.sin(theta);
      positions[i3 + 2] = r * Math.cos(phi);

      // ランダムな速度
      velocities[i3]     = (Math.random() - 0.5) * 0.02;
      velocities[i3 + 1] = (Math.random() - 0.5) * 0.02;
      velocities[i3 + 2] = (Math.random() - 0.5) * 0.02;
    }

    return [positions, velocities];
  }, []);

  useFrame((state) => {
    const time = state.clock.elapsedTime;
    const pos = meshRef.current.geometry.attributes.position.array as Float32Array;

    for (let i = 0; i < PARTICLE_COUNT; i++) {
      const i3 = i * 3;
      pos[i3]     += Math.sin(time + i) * 0.001;
      pos[i3 + 1] += velocities[i3 + 1];
      pos[i3 + 2] += Math.cos(time + i) * 0.001;

      // 範囲外に出たら反対側から
      if (Math.abs(pos[i3 + 1]) > 5) velocities[i3 + 1] *= -1;
    }

    meshRef.current.geometry.attributes.position.needsUpdate = true;
    meshRef.current.rotation.y = time * 0.05;
  });

  return (
    <points ref={meshRef}>
      <bufferGeometry>
        <bufferAttribute
          attach="attributes-position"
          count={PARTICLE_COUNT}
          array={positions}
          itemSize={3}
        />
      </bufferGeometry>
      <pointsMaterial
        size={0.02}
        color="#4f9cf9"
        sizeAttenuation
        transparent
        opacity={0.8}
        blending={THREE.AdditiveBlending}
        depthWrite={false}
      />
    </points>
  );
}

11. Drei(DreiユーティリティとOrbitControls)

Dreiの主要コンポーネント

import {
  OrbitControls,
  Text,
  Html,
  useGLTF,
  Environment,
  Stars,
  Float,
  Sparkles,
  MeshReflectorMaterial,
  PerspectiveCamera,
  PresentationControls,
  useProgress,
  Loader,
  ScrollControls,
  useScroll,
  Cloud,
  Sky,
} from '@react-three/drei';
import { Suspense } from 'react';
import { useFrame } from '@react-three/fiber';

// OrbitControls — カメラコントロール
function Scene() {
  return (
    <>
      <OrbitControls
        enablePan={true}
        enableZoom={true}
        enableRotate={true}
        minDistance={2}
        maxDistance={20}
        maxPolarAngle={Math.PI / 2}
        dampingFactor={0.05}
        autoRotate
        autoRotateSpeed={1.0}
      />

      {/* 3Dテキスト */}
      <Text
        position={[0, 2, 0]}
        fontSize={0.5}
        color="#ffffff"
        anchorX="center"
        anchorY="middle"
        font="/fonts/Inter-Bold.woff"
        letterSpacing={0.02}
        lineHeight={1}
      >
        Three.js
      </Text>

      {/* HTMLオーバーレイ(3D空間内にHTMLを配置) */}
      <Html
        position={[2, 1, 0]}
        center
        occlude            // 他のオブジェクトで隠れる
        transform         // 3Dトランスフォームに追従
      >
        <div style={{
          background: 'rgba(0,0,0,0.8)',
          padding: '8px 16px',
          borderRadius: '4px',
          color: 'white',
          fontSize: '14px',
          whiteSpace: 'nowrap',
        }}>
          Interactive 3D Object
        </div>
      </Html>

      {/* 環境マップ */}
      <Environment preset="studio" background blur={0.5} />

      {/* 星空 */}
      <Stars
        radius={100}
        depth={50}
        count={5000}
        factor={4}
        saturation={0}
        fade
      />

      {/* 浮遊アニメーション */}
      <Float speed={2} rotationIntensity={0.5} floatIntensity={0.5}>
        <mesh>
          <icosahedronGeometry args={[1, 1]} />
          <meshStandardMaterial color="#4f9cf9" wireframe />
        </mesh>
      </Float>

      {/* パーティクル */}
      <Sparkles count={100} scale={5} size={2} speed={0.3} color="#4f9cf9" />

      {/* 反射床 */}
      <mesh rotation-x={-Math.PI / 2} position-y={-2}>
        <planeGeometry args={[20, 20]} />
        <MeshReflectorMaterial
          blur={[300, 100]}
          resolution={1024}
          mixBlur={1}
          mixStrength={80}
          roughness={1}
          depthScale={1.2}
          minDepthThreshold={0.4}
          maxDepthThreshold={1.4}
          color="#101010"
          metalness={0.5}
          mirror={0.5}
        />
      </mesh>
    </>
  );
}

// useGLTF — GLTFモデル読み込みフック(キャッシュ付き)
function Model({ url }: { url: string }) {
  const { scene, animations } = useGLTF(url);

  // プリロードで読み込みを事前開始
  useGLTF.preload(url);

  return <primitive object={scene} />;
}

// ローディングUI
function LoadingScreen() {
  const { progress, active } = useProgress();

  return (
    <Html center>
      {active && (
        <div style={{ color: 'white', textAlign: 'center' }}>
          <p>{progress.toFixed(0)}% 読み込み中...</p>
          <div style={{
            width: '200px',
            height: '4px',
            background: '#333',
            borderRadius: '2px',
          }}>
            <div style={{
              width: `${progress}%`,
              height: '100%',
              background: '#4f9cf9',
              borderRadius: '2px',
              transition: 'width 0.3s',
            }} />
          </div>
        </div>
      )}
    </Html>
  );
}

// スクロール連動アニメーション
function ScrollScene() {
  return (
    <ScrollControls pages={5} damping={0.1}>
      <ScrollLinkedContent />
    </ScrollControls>
  );
}

function ScrollLinkedContent() {
  const scroll = useScroll();
  const groupRef = useRef<THREE.Group>(null!);

  useFrame(() => {
    groupRef.current.rotation.y = scroll.offset * Math.PI * 2;
    groupRef.current.position.y = -scroll.offset * 10;
  });

  return (
    <group ref={groupRef}>
      {/* スクロールに追従するコンテンツ */}
    </group>
  );
}

// メインCanvas
export function DreiFeaturesDemo() {
  return (
    <Canvas shadows camera={{ position: [0, 2, 8], fov: 60 }}>
      <Suspense fallback={<LoadingScreen />}>
        <Scene />
        <Suspense fallback={null}>
          <Model url="/models/scene.glb" />
        </Suspense>
      </Suspense>
    </Canvas>
  );
}

12. パフォーマンス最適化

インスタンス化(Instancing)

// InstancedMeshで大量オブジェクトを効率的に描画
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';

const INSTANCE_COUNT = 10000;

function InstancedCubes() {
  const meshRef = useRef<THREE.InstancedMesh>(null!);

  const { matrices, colors } = useMemo(() => {
    const matrices: THREE.Matrix4[] = [];
    const colors: THREE.Color[] = [];
    const dummy = new THREE.Object3D();

    for (let i = 0; i < INSTANCE_COUNT; i++) {
      dummy.position.set(
        (Math.random() - 0.5) * 50,
        (Math.random() - 0.5) * 50,
        (Math.random() - 0.5) * 50
      );
      dummy.rotation.set(
        Math.random() * Math.PI,
        Math.random() * Math.PI,
        Math.random() * Math.PI
      );
      dummy.scale.setScalar(Math.random() * 0.5 + 0.1);
      dummy.updateMatrix();
      matrices.push(dummy.matrix.clone());

      colors.push(new THREE.Color().setHSL(Math.random(), 0.8, 0.6));
    }

    return { matrices, colors };
  }, []);

  // 初期化
  useMemo(() => {
    if (!meshRef.current) return;
    matrices.forEach((matrix, i) => {
      meshRef.current.setMatrixAt(i, matrix);
      meshRef.current.setColorAt(i, colors[i]);
    });
    meshRef.current.instanceMatrix.needsUpdate = true;
    if (meshRef.current.instanceColor) {
      meshRef.current.instanceColor.needsUpdate = true;
    }
  }, [matrices, colors]);

  useFrame((state) => {
    const time = state.clock.elapsedTime;
    const dummy = new THREE.Object3D();

    for (let i = 0; i < INSTANCE_COUNT; i++) {
      meshRef.current.getMatrixAt(i, dummy.matrix);
      dummy.matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
      dummy.rotation.y = time * 0.5 + i * 0.001;
      dummy.updateMatrix();
      meshRef.current.setMatrixAt(i, dummy.matrix);
    }

    meshRef.current.instanceMatrix.needsUpdate = true;
  });

  return (
    <instancedMesh
      ref={meshRef}
      args={[undefined, undefined, INSTANCE_COUNT]}
      castShadow
    >
      <boxGeometry args={[0.3, 0.3, 0.3]} />
      <meshStandardMaterial roughness={0.5} metalness={0.3} />
    </instancedMesh>
  );
}

LOD(Level of Detail)

// 距離に応じてジオメトリの詳細度を変える
const lod = new THREE.LOD();

// 近距離(高詳細)
const highDetailMesh = new THREE.Mesh(
  new THREE.SphereGeometry(1, 64, 64),
  material
);
lod.addLevel(highDetailMesh, 0);   // 距離0〜に使用

// 中距離(中詳細)
const medDetailMesh = new THREE.Mesh(
  new THREE.SphereGeometry(1, 16, 16),
  material
);
lod.addLevel(medDetailMesh, 10);   // 距離10〜に使用

// 遠距離(低詳細)
const lowDetailMesh = new THREE.Mesh(
  new THREE.SphereGeometry(1, 4, 4),
  material
);
lod.addLevel(lowDetailMesh, 30);   // 距離30〜に使用

scene.add(lod);

// アニメーションループ内でLODを更新
function animate() {
  requestAnimationFrame(animate);
  lod.update(camera); // カメラとの距離でLODを自動切替
  renderer.render(scene, camera);
}

フラスタムカリングとその他の最適化

// フラスタムカリング — カメラに写らないオブジェクトをスキップ
mesh.frustumCulled = true; // デフォルトはtrue(無効化する場合のみ設定)

// ジオメトリの使い回し(同じジオメトリを複数メッシュで共有)
const sharedGeometry = new THREE.BoxGeometry(1, 1, 1);
const mesh1 = new THREE.Mesh(sharedGeometry, material1);
const mesh2 = new THREE.Mesh(sharedGeometry, material2); // 同じジオメトリを参照

// マテリアルの使い回し
const sharedMaterial = new THREE.MeshStandardMaterial({ color: 0x4f9cf9 });

// メモリ解放(コンポーネントアンマウント時)
function cleanup() {
  geometry.dispose();
  material.dispose();
  texture.dispose();
  renderer.dispose();
}

// レンダラーの最適化設定
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 高DPIで過負荷防止
renderer.powerPreference = 'high-performance';

// Stats.js でパフォーマンス計測
import Stats from 'three/examples/jsm/libs/stats.module.js';
const stats = new Stats();
document.body.appendChild(stats.dom);

function animate() {
  stats.begin();
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
  stats.end();
}

// R3Fでのパフォーマンス最適化
import { Instances, Instance } from '@react-three/drei';
import { memo } from 'react';

// Memoでコンポーネントの再レンダリングを防ぐ
const OptimizedMesh = memo(function OptimizedMesh() {
  const ref = useRef<THREE.Mesh>(null!);

  useFrame((_, delta) => {
    ref.current.rotation.y += delta;
  });

  return (
    <mesh ref={ref}>
      <boxGeometry />
      <meshStandardMaterial />
    </mesh>
  );
});

// Drei の Instances コンポーネント(R3F版インスタンシング)
function OptimizedInstances() {
  return (
    <Instances limit={1000} range={1000}>
      <boxGeometry args={[0.2, 0.2, 0.2]} />
      <meshStandardMaterial />
      {Array.from({ length: 1000 }, (_, i) => (
        <Instance
          key={i}
          position={[
            (Math.random() - 0.5) * 20,
            (Math.random() - 0.5) * 20,
            (Math.random() - 0.5) * 20,
          ]}
          rotation={[Math.random(), Math.random(), Math.random()]}
          color={new THREE.Color().setHSL(Math.random(), 0.8, 0.6)}
        />
      ))}
    </Instances>
  );
}

R3Fのパフォーマンスモニタリング

import { PerformanceMonitor } from '@react-three/drei';

export function AdaptiveScene() {
  const [dpr, setDpr] = useState(1.5);

  return (
    <Canvas dpr={dpr}>
      {/* パフォーマンスモニタリング — FPSに応じてDPRを自動調整 */}
      <PerformanceMonitor
        onIncline={() => setDpr(2)}      // FPS良好→高品質
        onDecline={() => setDpr(1)}      // FPS低下→品質下げる
        flipflops={3}                    // 3回フリップフロップで確定
        factor={0.5}
        bounds={(refreshrate) => [refreshrate / 2, refreshrate - 5]}
      >
        <Scene />
      </PerformanceMonitor>
    </Canvas>
  );
}

13. デプロイ(Vercel・アセット最適化)

Vite + Vercelでのデプロイ

# Viteプロジェクトのセットアップ
npm create vite@latest my-threejs-app -- --template vanilla-ts
cd my-threejs-app
npm install three @types/three
// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // Three.jsを別チャンクに分割
        manualChunks: {
          three: ['three'],
          'three-addons': [
            'three/examples/jsm/loaders/GLTFLoader',
            'three/examples/jsm/controls/OrbitControls',
            'three/examples/jsm/postprocessing/EffectComposer',
          ],
        },
      },
    },
    // チャンクサイズ警告の閾値を調整(Three.jsは大きい)
    chunkSizeWarningLimit: 800,
  },
  // publicディレクトリの3Dアセットはそのままコピー
  publicDir: 'public',
  // 開発サーバーでCORSを許可(モデルファイル読み込みのため)
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
});

アセット最適化のベストプラクティス

# GLBモデルの最適化(gltf-transform使用)
npm install -g @gltf-transform/cli

# 最適化パイプライン
gltf-transform optimize model.glb model-optimized.glb

# Draco圧縮(ジオメトリ圧縮)
gltf-transform draco model.glb model-draco.glb

# テクスチャをWebPに変換(容量削減)
gltf-transform webp model.glb model-webp.glb --quality 80

# KTX2/Basisテクスチャ圧縮(GPU圧縮)
gltf-transform etc1s model.glb model-basis.glb  # モバイル向け
gltf-transform uastc model.glb model-uastc.glb  # デスクトップ向け

# 全最適化を一括実行
gltf-transform optimize model.glb model-final.glb \
  --texture-compress webp \
  --texture-size 1024

Vercelへのデプロイ設定

// vercel.json
{
  "headers": [
    {
      "source": "/models/(.*)\\.glb",
      "headers": [
        { "key": "Content-Type", "value": "model/gltf-binary" },
        { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
      ]
    },
    {
      "source": "/textures/(.*)",
      "headers": [
        { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
      ]
    },
    {
      "source": "/hdr/(.*)",
      "headers": [
        { "key": "Cache-Control", "value": "public, max-age=86400" }
      ]
    }
  ],
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

Next.js + R3Fのデプロイ

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  transpilePackages: ['three'],
  webpack: (config) => {
    // GLSLシェーダーファイルをインポート可能にする
    config.module.rules.push({
      test: /\.(glsl|vs|fs|vert|frag)$/,
      use: ['raw-loader', 'glslify-loader'],
    });
    return config;
  },
};

export default nextConfig;
// src/app/scene/page.tsx
import dynamic from 'next/dynamic';

// Three.jsはSSR非対応なのでクライアントサイドのみで読み込む
const Scene3D = dynamic(
  () => import('@/components/Scene3D').then((mod) => mod.Scene3D),
  {
    ssr: false,
    loading: () => (
      <div style={{
        width: '100%',
        height: '600px',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        background: '#1a1a2e',
        color: '#fff',
      }}>
        3Dシーンを読み込み中...
      </div>
    ),
  }
);

export default function ScenePage() {
  return (
    <main>
      <h1>Three.js デモ</h1>
      <Scene3D />
    </main>
  );
}

まとめ

Three.jsは、WebGLの複雑さを隠蔽しながらプロダクション品質の3Dグラフィックスをウェブ上で実現できる強力なツールだ。本記事で解説した主要なポイントを振り返ろう。

分野ポイント
基本構成Scene・Camera・Renderer の3要素。アニメーションループはrequestAnimationFrame
Geometry組み込みジオメトリとBufferGeometryカスタム実装。法線・UV・インデックスを理解する
Material用途に合わせてBasic→Standard→Physical→ShaderMaterialを選択
LightPBRにはDirectional+Ambient+Hemisphereの組み合わせ。シャドウマップは解像度に注意
TexturePBRセット(color/normal/roughness/metalness/ao)でリアルな表現。LoadingManagerで一括管理
AnimationAnimationMixerでGLTFアニメーション再生。GSAPでUI連動アニメーション
モデル読み込みGLTFLoader+DracoLoaderでファイルサイズを最小化
ポストプロセッシングEffectComposer+BloomPassでシネマティックな映像表現
React Three FiberReactのエコシステムと3Dを統合。useFrameで毎フレーム更新
DreiOrbitControls・Text・Environment等の便利なユーティリティを積極活用
パフォーマンスInstancedMesh・LOD・ジオメトリ共有・dispose()でメモリ解放
デプロイViteでコード分割。gltf-transformでアセット最適化。Vercelのキャッシュヘッダー設定

3Dウェブの世界は広大で、物理演算(Rapier)・XR/VR(WebXR)・リアルタイム協調(WebRTC)などさらなる発展領域が待っている。まずは本記事の基礎をしっかり身につけて、段階的に応用していこう。


開発中に3Dモデルのメタデータや設定JSONの検証・デバッグが必要になったときは、DevToolBox のJSON Validator・Formatter・Diff Toolが役に立つ。GLTFのエクスポート設定や、Three.jsのシーン設定をJSONで管理する際の構造確認に、ブラウザ上でサクッと使えるので開発効率が上がる。ぜひ試してみてほしい。

関連記事