mcpField
T
threejs-animation

threejs-animation

github.com/iart-aiデザイン・UI451.4k 回インストール18 週間前に更新
リポジトリ

threejs-animation について

Three.js でウェブ向けのリアルタイム3Dモーションを構築するスキルです。シーンアニメーション、カメラ移動、GLTF クリップ、インスタンシング、スクロール連動、React Three Fiber を扱い、性能と GPU リソースの解放も解説します。

主な機能

  1. 01フレームレートに依存しないアニメーション
  2. 02GLTF クリップの再生とクロスフェード
  3. 03減衰・スクロール連動カメラ移動
  4. 04多数オブジェクトのインスタンシング
  5. 05GPU リソースの解放とリーク確認

活用シーン

  1. 013D ヒーローセクションのアニメーション
  2. 02GLTF クリップの再生とブレンド
  3. 03スクロール駆動のカメラ移動
  4. 04SPA での GPU リソース管理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/iart-ai/webgl-animation-skills --skill threejs-animation

インストール後、Claude Code で/threejs-animationを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

README.md
references/
r3f-and-perf.md
resource-disposal.md

詳細

Three.js モーション(3D/WebGL)

ウェブ向けにリアルタイムの3Dモーションを構築します。アニメーションシーン、カメラ移動、GLTF の再生、インスタンシング、スクロール連動の3D、React Three Fiber に対応します。滑らかな60fpsと確実なリソース解放を目指します。

使用する場面

  • 3Dのヒーローセクション、製品ビューアー、操作可能な背景やスクロール連動の3D背景。
  • GLTF アニメーションクリップの再生とブレンド。
  • 多数のオブジェクト(パーティクルフィールド、タイル、森林など)の効率的なレンダリング。
  • カメラのフライスルーや、スクロール駆動のカメラ移動。

最小限のシーンとレンダーループ

import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));   // cap DPR — critical for retina perf
document.body.appendChild(renderer.domElement);

const mesh = new THREE.Mesh(
  new THREE.IcosahedronGeometry(1, 0),
  new THREE.MeshStandardMaterial({ color: 0x44aaff, flatShading: true })
);
scene.add(mesh, new THREE.DirectionalLight(0xffffff, 2).translateZ(5),
          new THREE.AmbientLight(0xffffff, 0.4));

const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
  const dt = clock.getDelta();
  mesh.rotation.y += dt * 0.5;                            // frame-rate independent
  renderer.render(scene, camera);
});
addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

clock.getDelta()を使うと、モーションをフレームレートに依存させずに(速度にdtを掛けて)更新できます。生のrequestAnimationFrameではなくsetAnimationLoopを使ってください(WebXRで動作し、タブが非アクティブになると一時停止します)。

GLTF アニメーションクリップ

モデルを読み込み、AnimationMixerを通じてクリップを再生し、毎フレーム、デルタ時間を使ってミキサーを更新します。

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
let mixer, actions = {};
new GLTFLoader().load('model.glb', (gltf) => {
  scene.add(gltf.scene);
  mixer = new THREE.AnimationMixer(gltf.scene);
  gltf.animations.forEach((clip) => { actions[clip.name] = mixer.clipAction(clip); });
  actions['Idle']?.play();
});
// In the loop: if (mixer) mixer.update(dt);

2つのクリップを滑らかにクロスフェードします:

function crossfade(fromName, toName, dur = 0.4) {
  const from = actions[fromName], to = actions[toName];
  to.reset().setEffectiveWeight(1).play();
  from.crossFadeTo(to, dur, false);                       // warping=false: linear weight ramp
}

ワンショットのクリップ(ジャンプなど)では、action.setLoop(THREE.LoopOnce); action.clampWhenFinished = true;を設定し、mixer.addEventListener('finished', ...)をリッスンします。

カメラの補間とスクロール連動モーション

滑らかなカメラ追従には、ターゲットに向けた指数補間を使います。フレームレートに応じて正確に減衰させる方法:

const target = new THREE.Vector3(0, 0, 5);
function dampVec(current, goal, lambda, dt) {
  current.lerp(goal, 1 - Math.exp(-lambda * dt));         // lambda ~ 3..8
}
// loop: dampVec(camera.position, target, 5, dt); camera.lookAt(0, 0, 0);

スクロール連動カメラでは、scrollY / maxScroll(0→1)を、パス上のカメラ位置に対応付けます。スクロール時に目標位置を更新し、減衰で滑らかに移動させます。

addEventListener('scroll', () => {
  const p = scrollY / (document.body.scrollHeight - innerHeight);
  target.set(0, p * -10, 5 - p * 3);                      // move down and in
});

曲線状のフライスルーには、THREE.CatmullRomCurve3とcurve.getPointAt(p)を使います。

多数のオブジェクトにインスタンシングを使う

何千もの同一メッシュを1回のドローコールで描画します。インスタンスごとの行列(必要に応じて色も)を設定します。

const count = 5000;
const geo = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const mat = new THREE.MeshStandardMaterial();
const inst = new THREE.InstancedMesh(geo, mat, count);
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
  dummy.position.set((Math.random()-0.5)*20, (Math.random()-0.5)*20, (Math.random()-0.5)*20);
  dummy.updateMatrix();
  inst.setMatrixAt(i, dummy.matrix);
}
inst.instanceMatrix.needsUpdate = true;
scene.add(inst);
// Animate: update dummy per instance, setMatrixAt, then needsUpdate = true each frame.

毎フレーム更新する場合は、inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage)を設定します。インスタンスごとの色は、inst.setColorAt(i, color)を呼び出してからinst.instanceColor.needsUpdate = trueを設定します。

React Three Fiber(R3F)

useFrameでアニメーションを駆動します。stateとdeltaが渡されます。

import { Canvas, useFrame } from '@react-three/fiber';
import { useRef } from 'react';
function Spinner() {
  const ref = useRef();
  useFrame((state, delta) => {
    ref.current.rotation.y += delta * 0.5;
    ref.current.position.y = Math.sin(state.clock.elapsedTime) * 0.3;
  });
  return <mesh ref={ref}><icosahedronGeometry /><meshStandardMaterial /></mesh>;
}
export default () => (
  <Canvas dpr={[1, 2]} camera={{ position: [0, 0, 5] }}>
    <ambientLight intensity={0.4} /><directionalLight position={[5, 5, 5]} />
    <Spinner />
  </Canvas>
);

dpr={[1, 2]}でピクセル比の上限を設定します。useGLTF(drei)でモデルを読み込み、useAnimationsでクリップをアニメーションさせます。スプリングには@react-spring/three、スクロールにはdrei のScrollControlsとuseScrollを使います。

パフォーマンスとクリーンアップ(重要)

  • ピクセル比の上限を設定します:Math.min(devicePixelRatio, 2)。これは Retina/モバイルで最も効果の大きい改善策です。
  • ジオメトリとマテリアルを再利用します。静的ジオメトリはBufferGeometryUtils.mergeGeometriesで結合します。
  • 多数のリアルタイムライトより、マットキャップやベイク済みのライティングを優先します。影を落とすライトはどれも高コストです。
  • マテリアルや発光のモーション(発光の明滅、変位など)は、多数のジオメトリを動かすより見栄えがよく、低コストな場合がよくあります。

クリーンアップと GPU メモリ(破棄)

GPU リソース(ジオメトリ、マテリアル、テクスチャ、レンダーターゲット)は JS ヒープではなく、GPU 上に存在します。ガベージコレクターは JS オブジェクトを解放しますが、アップロードされた WebGL バッファーやテクスチャは解放できません。明示的に .dispose() を呼び出さないと、コンテキストが失われるまでマウントのたびに VRAM が増え続けます(黒いキャンバス、WebGL: CONTEXT_LOST_WEBGL)。そのため、マウント/アンマウントを繰り返すと renderer.info.memory が増加し続けます。

GPU メモリを割り当てるものは破棄し、割り当てないものはそのままにします。

  • 破棄するもの:geometry.dispose()(VBO)、material.dispose()(シェーダープログラム — ただし、そのテクスチャは対象外)、texture.dispose()(マテリアルごとのマップスロット:map、normalMap、roughnessMap、envMap、…)、renderTarget.dispose()、および最終的なティアダウン時の renderer.dispose() + renderer.forceContextLoss()。
  • 破棄不要なもの:Mesh/Group/Scene(削除後は GC が回収)、Vector3/Matrix4/Color(通常の JS オブジェクト)、ライト/カメラ(シャドウマップのレンダーターゲットを除く)。
  • scene.remove(mesh) はグラフから切り離すだけで、GPU 上のリソースは何も解放しません。ジオメトリ/マテリアル/テクスチャは、明示的に破棄するまで GPU 上に残ります。

ティアダウンの走査 — 読み込んだモデルやシーン全体を破棄する際は、マテリアルの配列と、各マテリアルに設定されたすべてのテクスチャマップに対応する必要があります。

function disposeObject(root) {
  root.traverse((obj) => {
    if (obj.geometry) obj.geometry.dispose();
    if (obj.material) {
      const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
      for (const m of mats) {
        for (const key in m) {                  // dispose any texture property
          const v = m[key];
          if (v && v.isTexture) v.dispose();
        }
        m.dispose();
      }
    }
  });
  root.parent?.remove(root);
}
// SPA route teardown: renderer.setAnimationLoop(null); disposeObject(scene);
//   renderer.dispose(); renderer.forceContextLoss?.();

R3F のアンマウント時クリーンアップ — JSX で宣言的に作成されたリソースは、アンマウント時に自動で破棄されます(R3F がアタッチされたオブジェクトをたどって .dispose() を呼び出します)。リークは命令的なリソース(フック/ref/ローダー内の new THREE.X())で発生します。これらは R3F がアタッチしていないためです。useMemo で作成し、対応する useEffect のクリーンアップで破棄します。

const geometry = useMemo(() => new THREE.BufferGeometry(/* … */), [count]);
useEffect(() => () => geometry.dispose(), [geometry]);  // disposes on unmount AND dep change

複数のマウント間で再利用する共有リソースには、JSX オブジェクトに dispose={null} を設定し、アプリの終了時にのみ破棄します。renderer.info.memory(R3F では useThree().gl.info.memory)で確認します。10 回マウント/アンマウントし、geometries/textures がベースラインに戻らない場合は、何かがリークしています。

完全な破棄ユーティリティ(すべてのマップスロット、シェーダーユニフォーム、レンダーターゲット、スキニングメッシュ対応)、EffectComposer/レンダーターゲットの破棄、renderer.info を使ったリークテスト用ハーネス、R3F のクリーンアップ手順(ref、テクスチャ、GLTF キャッシュのクリア、<DisposeOnUnmount>)、および HMR の安全性については、references/resource-disposal.md を参照してください。

配布と検証(スタンドアロン HTML)

パッケージ化されたヘルパー(scripts/):scripts/seek-shot.sh anim.html 0 1.5 3 は ?t=N ハーネスで各時点の表示を固定し、スクリーンショットを撮影します。scripts/contact-sheet.sh sheet.png frame-*.png はそれらをタイル状に並べ、一目で確認できるようにします。scripts/README.md を参照してください。

自己完結型の 3D シーン(ヒーロー、WebGL 背景、マイクロシーン)では、成果物はブラウザーで直接開ける 1 つの HTML ファイルです。Three.js は importmap 経由で CDN から読み込み、レンダーループは 1 つだけ、ビルド手順は不要です。シーンには単一ファイルが適切な規模です。1 つのファイルで済むなら、バンドラーを使う必要はありません。

出力の要件:

  • .html ファイル 1 つ:importmap で three と three/addons を CDN に固定し、シーンとレンダーループを 1 つのインライン <script type="module"> に記述します。
  • すべての動きを 1 つの信頼できる情報源、つまり clock.getElapsedTime() から制御します(mixer/カメラもそこから読み取ります)。Date.now() や、オブジェクトごとの実時間は使いません。
  • インスタンスの配置やジッターなど、乱数を使う場合は固定シードを設定し、フレームを再現できるようにします。

シーク/固定ハーネス — スクリーンショット用に、指定した時刻のフレームを 1 枚レンダリングします。 ?t=N を指定すると、経過時間を N 秒に固定し、シーンをその時点まで進めて 1 回だけレンダリングした後、ループを停止します。これにより、再現性のある静止画が得られます。

<script type="module">
  // ... build scene, camera, renderer, clock, mixer ...
  const t = new URLSearchParams(location.search).get("t");
  function frame(elapsed) {            // pure: scene state is a function of elapsed
    mesh.rotation.y = elapsed * 0.5;
    if (mixer) { mixer.setTime(elapsed); }   // setTime is absolute, not delta
    renderer.render(scene, camera);
  }
  if (t !== null) {
    frame(parseFloat(t));             // one fixed frame, no loop
    window.__ready = true;
  } else {
    const clock = new THREE.Clock();
    renderer.setAnimationLoop(() => frame(clock.getElapsedTime()));
  }
</script>

検証ループ — レンダリング → 固定 → スクリーンショット → 確認: ファイルを 3 つの時点(開始、中間、終了。アニメーションクリップ/ループの場合は ?t=0、?t=<mid>、?t=<end>)で開き、それぞれスクリーンショットを撮影して、忠実度(要件に合っているか)と問題を確認します。問題には、黒いキャンバス=解析/初期化エラー(コンソールを確認)、ジオメトリのクリッピング/フレーム外へのはみ出し、NaN の位置(オブジェクトが消える)、モデル/テクスチャの欠落(CDN 404)があります。WebGL には GPU コンテキストが必要です。Playwright/Chromium はヘッドレスモードでこれを(swiftshader によって)提供します。

npx playwright screenshot --wait-for-timeout=600 "file://$PWD/scene.html?t=1.5" frame-mid.png

完了前に確認すること:

  1. キャンバスが実際にレンダリングされていること — 空白ではなく、コンソール/WebGL エラーも CDN 404 もない。
  2. ?t=N で再現可能なフレームが固定されること(同じ N なら同じピクセルになる。乱数にはシードを設定する)。
  3. 開始/中間/終了時点のスクリーンショットを撮影し、要件に合っていて、クリッピング/NaN/黒画面がないこと。
  4. SPA に埋め込む場合、リソースが破棄され、リークがないこと(setAnimationLoop(null) + dispose。「破棄」セクションを参照)。
  5. prefers-reduced-motion に対応していること — 該当する場合は、回転や自動再生を遅くするか停止する。

クイックリファレンス

目的API
フレームレートに依存しない動きclock.getDelta() を乗算
GLTF クリップを再生mixer.clipAction(clip).play() + mixer.update(dt)
クリップをブレンドfrom.crossFadeTo(to, dur, false)
カメラを滑らかに動かすlerp(goal, 1 - exp(-lambda*dt))
多数のオブジェクトInstancedMesh + setMatrixAt
3D 表示をスクロールに連動スクロールの 0→1 をカメラの目標値に対応付ける
DPR に上限を設定setPixelRatio(min(dpr, 2))

参考ファイル

  • references/r3f-and-perf.md — AnimationMixer のクロスフェードとワンショット処理の完全な例、useFrame のパターン(クロック、ポインター、lerp)、スクロール連動カメラ用の drei ScrollControls、カラーを使った InstancedMesh のフレームごとのアニメーション、ピクセル比率と適応型解像度、完全なアンマウント時の破棄処理、vanilla Three と R3F の両方に対応するポストプロセス(UnrealBloom、DepthOfField/ボケ)。
  • references/resource-disposal.md — GPU リソースの完全な破棄:完全な破棄ユーティリティ(すべてのマップスロット、シェーダーの uniform、レンダーターゲット、環境マップ、スキニングメッシュ)、EffectComposer/レンダーターゲットの解体、レンダラーの完全な解体、R3F のリーク事例と useMemo+クリーンアップのパターン、ref/テクスチャ/GLTF のクリーンアップ手順、<DisposeOnUnmount> ヘルパー、共有リソースと InstancedMesh の方針、renderer.info を使ったリークテスト用ハーネス、HMR の安全な処理。

コメント