Communitygithub.com

aelyraAI/threejs-animation

Three.js アニメーション - キーフレームアニメーション、スケルタルアニメーション、モーフターゲット、アニメーションミキシング。オブジェクトのアニメーション、GLTFアニメーションの再生、プロシージャルモーションの作成、アニメーションのブレンドに使用します。

threejs-animation とは?

このスキルは、Three.js のアニメーションシステムを詳細に解説する包括的なリファレンスです。主な対象は、AIエージェントを使用してThree.jsの3Dアニメーションを実装する開発者です。キーフレームアニメーション(AnimationClip)、アニメーションの再生と制御(AnimationMixer、AnimationAction)、GLTFモデルからのスケルタルアニメーションの読み込みと操作、モーフターゲットによる形状ブレンド、複数アニメーションのブレンドやクロスフェードといった高度なテクニックをカバーします。また、スムーズダンピングやスプリング物理、オシレーションなどのプロシージャルアニメーションパターンも提供し、実用的なパフォーマンス最適化のヒントも含まれています。このスキルは、Claude Code、Cursor、CodexなどのAIエージェントがThree.jsのアニメーション実装を支援する際に、信頼性の高いコードスニペットとベストプラクティスを提供します。

対応~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/aelyraAI/aelyra-skills/tree/HEAD/threejs-animation

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

Three.js アニメーション

クイックスタート

import * as THREE from "three";

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

function animate() {
  const delta = clock.getDelta();
  const elapsed = clock.getElapsedTime();

  mesh.rotation.y += delta;
  mesh.position.y = Math.sin(elapsed) * 0.5;

  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

アニメーションシステムの概要

Three.js のアニメーションシステムは、3つの主要なコンポーネントで構成されています。

  1. AnimationClip - キーフレームデータのコンテナ
  2. AnimationMixer - ルートオブジェクト上でアニメーションを再生
  3. AnimationAction - クリップの再生を制御

AnimationClip

キーフレームアニメーションデータを保存します。

// アニメーションクリップの作成
const times = [0, 1, 2]; // キーフレームの時刻(秒)
const values = [0, 1, 0]; // 各キーフレームの値

const track = new THREE.NumberKeyframeTrack(
  ".position[y]", // プロパティパス
  times,
  values,
);

const clip = new THREE.AnimationClip("bounce", 2, [track]);

KeyframeTrackの種類

// 数値トラック(単一値)
new THREE.NumberKeyframeTrack(".opacity", times, [1, 0]);
new THREE.NumberKeyframeTrack(".material.opacity", times, [1, 0]);

// ベクトルトラック(位置、スケール)
new THREE.VectorKeyframeTrack(".position", times, [
  0,
  0,
  0, // t=0
  1,
  2,
  0, // t=1
  0,
  0,
  0, // t=2
]);

// クォータニオントラック(回転)
const q1 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0));
const q2 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, Math.PI, 0));
new THREE.QuaternionKeyframeTrack(
  ".quaternion",
  [0, 1],
  [q1.x, q1.y, q1.z, q1.w, q2.x, q2.y, q2.z, q2.w],
);

// カラートラック
new THREE.ColorKeyframeTrack(".material.color", times, [
  1,
  0,
  0, // 赤
  0,
  1,
  0, // 緑
  0,
  0,
  1, // 青
]);

// ブーリアントラック
new THREE.BooleanKeyframeTrack(".visible", [0, 0.5, 1], [true, false, true]);

// 文字列トラック(モーフターゲット用)
new THREE.StringKeyframeTrack(
  ".morphTargetInfluences[smile]",
  [0, 1],
  ["0", "1"],
);

補間モード

const track = new THREE.VectorKeyframeTrack(".position", times, values);

// 補間
const track.setInterpolation(THREE.InterpolateLinear); // デフォルト
const track.setInterpolation(THREE.InterpolateSmooth); // 3次スプライン
const track.setInterpolation(THREE.InterpolateDiscrete); // ステップ関数

AnimationMixer

オブジェクトとその子孫に対してアニメーションを再生します。

const mixer = new THREE.AnimationMixer(model);

// クリップからアクションを作成
const action = mixer.clipAction(clip);
action.play();

// アニメーションループで更新
function animate() {
  const delta = clock.getDelta();
  mixer.update(delta); // 必須!

  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

ミキサーイベント

mixer.addEventListener("finished", (e) => {
  console.log("アニメーション終了:", e.action.getClip().name);
});

mixer.addEventListener("loop", (e) => {
  console.log("アニメーションループ:", e.action.getClip().name);
});

AnimationAction

アニメーションクリップの再生を制御します。

const action = mixer.clipAction(clip);

// 再生制御
action.play();
action.stop();
action.reset();
action.halt(fadeOutDuration);

// 再生状態
action.isRunning();
action.isScheduled();

// 時間制御
action.time = 0.5; // 現在時刻
action.timeScale = 1; // 再生速度(負の値は逆再生)
action.paused = false;

// ウェイト(ブレンド用)
action.weight = 1; // 0-1、最終ポーズへの寄与度
action.setEffectiveWeight(1);

// ループモード
action.loop = THREE.LoopRepeat; // デフォルト: 無限ループ
action.loop = THREE.LoopOnce; // 1回再生して停止
action.loop = THREE.LoopPingPong; // 前後交互に再生
action.repetitions = 3; // ループ回数(デフォルトはInfinity)

// クランプ
action.clampWhenFinished = true; // 終了時に最終フレームを保持

// ブレンド
action.blendMode = THREE.NormalAnimationBlendMode;
action.blendMode = THREE.AdditiveAnimationBlendMode;

フェードイン/アウト

// フェードイン
action.reset().fadeIn(0.5).play();

// フェードアウト
action.fadeOut(0.5);

// アニメーション間のクロスフェード
const action1 = mixer.clipAction(clip1);
const action2 = mixer.clipAction(clip2);

action1.play();

// 後で、action2にクロスフェード
action1.crossFadeTo(action2, 0.5, true);
action2.play();

GLTFアニメーションの読み込み

スケルタルアニメーションの最も一般的なソースです。

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

const loader = new GLTFLoader();
loader.load("model.glb", (gltf) => {
  const model = gltf.scene;
  scene.add(model);

  // ミキサーの作成
  const mixer = new THREE.AnimationMixer(model);

  // すべてのクリップを取得
  const clips = gltf.animations;
  console.log(
    "利用可能なアニメーション:",
    clips.map((c) => c.name),
  );

  // 最初のアニメーションを再生
  if (clips.length > 0) {
    const action = mixer.clipAction(clips[0]);
    action.play();
  }

  // 名前で特定のアニメーションを再生
  const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
  if (walkClip) {
    mixer.clipAction(walkClip).play();
  }

  // 更新ループ用にミキサーを保存
  window.mixer = mixer;
});

// アニメーションループ
function animate() {
  const delta = clock.getDelta();
  if (window.mixer) window.mixer.update(delta);

  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

スケルタルアニメーション

スケルトンとボーン

// スキンメッシュからスケルトンにアクセス
const skinnedMesh = model.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;

// ボーンにアクセス
skeleton.bones.forEach((bone) => {
  console.log(bone.name, bone.position, bone.rotation);
});

// 名前で特定のボーンを検索
const headBone = skeleton.bones.find((b) => b.name === "Head");
if (headBone) headBone.rotation.y = Math.PI / 4; // 頭を回転

// スケルトンヘルパー
const helper = new THREE.SkeletonHelper(model);
scene.add(helper);

プログラムによるボーンアニメーション

function animate() {
  const time = clock.getElapsedTime();

  // ボーンをアニメーション
  const headBone = skeleton.bones.find((b) => b.name === "Head");
  if (headBone) {
    headBone.rotation.y = Math.sin(time) * 0.3;
  }

  // クリップも再生している場合はミキサーを更新
  mixer.update(clock.getDelta());
}

ボーンへのアタッチメント

// オブジェクトをボーンにアタッチ
const weapon = new THREE.Mesh(weaponGeometry, weaponMaterial);
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
if (handBone) handBone.add(weapon);

// アタッチメントのオフセット
weapon.position.set(0, 0, 0.5);
weapon.rotation.set(0, Math.PI / 2, 0);

モーフターゲット

異なるメッシュ形状間をブレンドします。

// モーフターゲットはジオメトリに保存
const geometry = mesh.geometry;
console.log("モーフ属性:", Object.keys(geometry.morphAttributes));

// モーフターゲットの影響度にアクセス
mesh.morphTargetInfluences; // ウェイトの配列
mesh.morphTargetDictionary; // 名前 -> インデックスのマッピング

// インデックスでモーフターゲットを設定
mesh.morphTargetInfluences[0] = 0.5;

// 名前で設定
const smileIndex = mesh.morphTargetDictionary["smile"];
mesh.morphTargetInfluences[smileIndex] = 1;

モーフターゲットのアニメーション

// プロシージャル
function animate() {
  const t = clock.getElapsedTime();
  mesh.morphTargetInfluences[0] = (Math.sin(t) + 1) / 2;
}

// キーフレームアニメーションを使用
const track = new THREE.NumberKeyframeTrack(
  ".morphTargetInfluences[smile]",
  [0, 0.5, 1],
  [0, 1, 0],
);
const clip = new THREE.AnimationClip("smile", 1, [track]);
mixer.clipAction(clip).play();

アニメーションブレンド

複数のアニメーションを混ぜ合わせます。

// アクションの設定
const idleAction = mixer.clipAction(idleClip);
const walkAction = mixer.clipAction(walkClip);
const runAction = mixer.clipAction(runClip);

// 異なるウェイトですべて再生
idleAction.play();
walkAction.play();
runAction.play();

// 初期ウェイトを設定
idleAction.setEffectiveWeight(1);
walkAction.setEffectiveWeight(0);
runAction.setEffectiveWeight(0);

// 速度に基づいてブレンド
function updateAnimations(speed) {
  if (speed < 0.1) {
    idleAction.setEffectiveWeight(1);
    walkAction.setEffectiveWeight(0);
    runAction.setEffectiveWeight(0);
  } else if (speed < 5) {
    const t = speed / 5;
    idleAction.setEffectiveWeight(1 - t);
    walkAction.setEffectiveWeight(t);
    runAction.setEffectiveWeight(0);
  } else {
    const t = Math.min((speed - 5) / 5, 1);
    idleAction.setEffectiveWeight(0);
    walkAction.setEffectiveWeight(1 - t);
    runAction.setEffectiveWeight(t);
  }
}

加算ブレンド

// ベースポーズ
const baseAction = mixer.clipAction(baseClip);
baseAction.play();

// 加算レイヤー(例: 呼吸)
const additiveAction = mixer.clipAction(additiveClip);
additiveAction.blendMode = THREE.AdditiveAnimationBlendMode;
additiveAction.play();

// クリップを加算に変換
THREE.AnimationUtils.makeClipAdditive(additiveClip);

アニメーションユーティリティ

import * as THREE from "three";

// 名前でクリップを検索
const clip = THREE.AnimationClip.findByName(clips, "Walk");

// サブクリップを作成
const subclip = THREE.AnimationUtils.subclip(clip, "subclip", 0, 30, 30);

// 加算に変換
THREE.AnimationUtils.makeClipAdditive(clip);
THREE.AnimationUtils.makeClipAdditive(clip, 0, referenceClip);

// クリップをクローン
const clone = clip.clone();

// クリップの長さを取得
clip.duration;

// クリップを最適化(冗長なキーフレームを削除)
clip.optimize();

// クリップを最初のフレームにリセット
clip.resetDuration();

プロシージャルアニメーションパターン

スムーズダンピング

// スムーズフォロー/線形補間
const target = new THREE.Vector3();
const current = new THREE.Vector3();
const velocity = new THREE.Vector3();

function smoothDamp(current, target, velocity, smoothTime, deltaTime) {
  const omega = 2 / smoothTime;
  const x = omega * deltaTime;
  const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
  const change = current.clone().sub(target);
  const temp = velocity
    .clone()
    .add(change.clone().multiplyScalar(omega))
    .multiplyScalar(deltaTime);
  velocity.sub(temp.clone().multiplyScalar(omega)).multiplyScalar(exp);
  return target.clone().add(change.add(temp).multiplyScalar(exp));
}

function animate() {
  current.copy(smoothDamp(current, target, velocity, 0.3, delta));
  mesh.position.copy(current);
}

スプリング物理

class Spring {
  constructor(stiffness = 100, damping = 10) {
    this.stiffness = stiffness;
    this.damping = damping;
    this.position = 0;
    this.velocity = 0;
    this.target = 0;
  }

  update(dt) {
    const force = -this.stiffness * (this.position - this.target);
    const dampingForce = -this.damping * this.velocity;
    this.velocity += (force + dampingForce) * dt;
    this.position += this.velocity * dt;
    return this.position;
  }
}

const spring = new Spring(100, 10);
spring.target = 1;

function animate() {
  mesh.position.y = spring.update(delta);
}

オシレーション

function animate() {
  const t = clock.getElapsedTime();

  // サイン波
  mesh.position.y = Math.sin(t * 2) * 0.5;

  // バウンド
  mesh.position.y = Math.abs(Math.sin(t * 3)) * 2;

  // 円運動
  mesh.position.x = Math.cos(t) * 2;
  mesh.position.z = Math.sin(t) * 2;

  // 8の字
  mesh.position.x = Math.sin(t) * 2;
  mesh.position.z = Math.sin(t * 2) * 1;
}

パフォーマンスのヒント

  1. クリップを共有する: 同じ AnimationClip を複数のミキサーで使用できます
  2. クリップを最適化する: clip.optimize() を呼び出して冗長なキーフレームを削除
  3. 画面外では無効にする: 非表示オブジェクトのミキサー更新を停止
  4. アニメーションに LOD を使用する: 遠くのキャラクターには簡素なリグを使用
  5. アクティブなミキサーを制限する: 各 mixer.update() にはコストがかかります
// 非表示時にアニメーションを一時停止
mesh.onBeforeRender = () => {
  action.paused = false;
};

mesh.onAfterRender = () => {
  // 次のフレームで表示されるか確認
  if (!isInFrustum(mesh)) {
    action.paused = true;
  }
};

// クリップのキャッシュ
const clipCache = new Map();
function getClip(name) {
  if (!clipCache.has(name)) {
    clipCache.set(name, loadClip(name));
  }
  return clipCache.get(name);
}

関連項目

  • threejs-loaders - アニメーション付きGLTFモデルの読み込み
  • threejs-fundamentals - クロックとアニメーションループ
  • threejs-shaders - シェーダー内の頂点アニメーション

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

関連スキル