Communityコーディング&開発github.com

SHARDROOM/threejs-animation

キーフレーム、スケルトン、モーフターゲットアニメーション、プロシージャルモーション、クリップブレンドをWebGLまたはWebGPUとTSLで実現します。オブジェクトのアニメーション、glTFアニメーションの再生、ボーンのポーズ設定、モーフターゲットの駆動、クリップのミキシングやクロスフェードに使用します。

threejs-animation とは?

Three.js Animationスキルは、three.js r186をターゲットにした、WebGLおよびWebGPUレンダラー向けのアニメーションシステムを提供します。AnimationClip、AnimationMixer、AnimationActionの3つのコアコンポーネントで構成され、キーフレーム、スケルトン、モーフターゲットアニメーションをサポートします。このスキルは、CPU上で動作し、レンダラーに依存しない設計のため、WebGLRendererでもWebGPURendererでも同じコードでアニメーションを駆動できます。また、glTFアニメーションの読み込みや再生、プロシージャルモーション、クリップのブレンディングやクロスフェード、重みによるブレンディングもサポートしており、複雑なアニメーション制御が可能です。開発者はこのスキルを通じて、フレームレートに依存しないアニメーションや、ボーンアニメーションのオーバーライド、モーフターゲットのキーフレーム制御などを簡単に実装できます。

対応~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/SHARDROOM/Castle-deffence/tree/HEAD/.claude/skills/threejs-animation

Installed? Explore more コーディング&開発 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

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

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

ドキュメント

Three.js アニメーション

対象バージョン:three.js r186。例ではWebGLRendererを使用します。WebGPUおよびTSLのセクションではWebGPURendererを使用します。

クイックスタート

import * as THREE from "three";

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 0.5, 3);

const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshNormalMaterial());
scene.add(mesh);

const timer = new THREE.Timer();
timer.connect(document); // タブが非表示になった後もデルタを小さく保つ

function animate() {
  timer.update(); // 各フレームで最初に実行:このフレームのgetDelta()/getElapsed()を修正
  const delta = timer.getDelta();
  const elapsed = timer.getElapsed();

  mesh.rotation.y += delta; // フレームレートに依存しない回転
  mesh.position.y = Math.sin(elapsed * 2) * 0.25; // 絶対時間で上下動

  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

システムの構成

  • AnimationClip:キーフレームデータ — 時間と値のトラック。再生状態は持たない。
  • AnimationMixer:1つのルートオブジェクトとそのすべての子孫に対してクリップを再生する。
  • AnimationAction:1つのミキサー上の1つのクリップに対する再生制御。

各フレームで、mixer.update(delta) はアクティブなクリップをサンプリングし、値を通常のオブジェクトプロパティに書き込みます。このシステムはCPU上で動作し、レンダラーには一切影響を与えないため、どちらのレンダラーでも同じ方法でモデルを駆動できます。

AnimationClipとキーフレームトラック

// アニメーションさせるプロパティごとに1つのトラック。values.lengthは times.length * valueSize でなければならない。
const times = [0, 1, 2]; // キーフレームの時間(秒)
const values = [0, 1, 0]; // Numberトラックの場合、キーフレームごとに1つのスカラー値

const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // ルートのposition.y

const clip = new THREE.AnimationClip("bounce", 2, [track]); // 持続時間(秒)
// 持続時間に-1を渡すと、トラックから自動的に算出される

トラック名は nodeName.objectName[objectIndex].propertyName[propertyIndex] の形式に従います。プロパティ以外の部分はすべて省略可能です。.position はルートの位置、.material.opacity はマテリアルプロパティ、.position[y] は1つのコンポーネント、.morphTargetInfluences[smile] は1つのモーフターゲット、Hips.position は名前付き子孫 — これがスキンモデルでのボーンの指定方法です。オブジェクト名は material、materials、bones、map のいずれかになります。

// Numberトラック(値サイズ1)。マテリアルを transparent: true で作成しないと、opacityは無視される
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);

// Vectorトラック(値サイズ3:キーフレームごとにx, y, z)
new THREE.VectorKeyframeTrack(".position", [0, 1, 2], [0, 0, 0, 1, 2, 0, 0, 0, 0]);

// 回転トラック(値サイズ4):キーフレームごとにクォータニオン x, y, z, w。オイラー角ではない
const q2 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
new THREE.QuaternionKeyframeTrack(
  ".quaternion",
  [0, 1],
  [0, 0, 0, 1, q2.x, q2.y, q2.z, q2.w], // 単位クォータニオン、次にq2
);

// Colorトラック(値サイズ3)
new THREE.ColorKeyframeTrack(".material.color", [0, 1], [1, 0, 0, 0, 1, 0]);

// Booleanトラック(常に離散的)
new THREE.BooleanKeyframeTrack(".visible", [0, 1], [true, false]);

補間

const track = new THREE.VectorKeyframeTrack(
  ".position",
  [0, 1, 2],
  [0, 0, 0, 1, 2, 0, 0, 0, 0],
);

track.setInterpolation(THREE.InterpolateLinear); // デフォルト
track.setInterpolation(THREE.InterpolateSmooth); // キーフレームを通る3次スプライン補間
track.setInterpolation(THREE.InterpolateDiscrete); // ステップ補間、中間値なし

// ベジェは絶対(時間、値)の制御点を取る。1コンポーネントあたりキーフレームごとに2つの数値。
// ストライド1の2キーフレームトラックには、配列ごとに4つのエントリが必要
const morphTrack = new THREE.NumberKeyframeTrack(
  ".morphTargetInfluences[smile]",
  [0, 1],
  [0, 1],
);
morphTrack.setInterpolation(THREE.InterpolateBezier);
morphTrack.settings = { inTangents: [0, 0, 1, 1], outTangents: [0, 1, 1, 0] };

AnimationMixer

const track = new THREE.NumberKeyframeTrack(".rotation[y]", [0, 2], [0, Math.PI * 2]);
const clip = new THREE.AnimationClip("spin", 2, [track]);

// アニメーションさせるルートごとに1つのミキサー。ルートとそのすべての子孫に影響する
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // キャッシュされる:このクリップとルートで再度呼び出すと、同じアクションが返される
action.play();

const timer = new THREE.Timer();
timer.connect(document);

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // 必須:すべてのアクティブなアクションを進める
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

// イベントはミキサーが更新されている間に発生する。「finished」はLoopOnceまたは有限の繰り返しが必要
mixer.addEventListener("finished", (e) => console.log("finished:", e.action.getClip().name));
mixer.addEventListener("loop", (e) => console.log("looped:", e.action.getClip().name));

AnimationAction

const mixer = new THREE.AnimationMixer(mesh);
const clip = new THREE.AnimationClip("spin", 2, [
  new THREE.NumberKeyframeTrack(".rotation[y]", [0, 2], [0, Math.PI * 2]),
]);
const action = mixer.clipAction(clip);

action.play(); // スケジュールして開始
action.reset(); // 時間0に巻き戻し、フェードをキャンセル
action.stop(); // スケジュール解除。他のスケジュールされたアクションでアニメーションしていないプロパティは元の値に戻る
action.halt(0.3); // 0.3秒以内に減速して停止

action.time = 0.5; // クリップ内をスクラブ
action.timeScale = -1; // 負の値は逆再生
action.paused = true; // その場で停止

action.setLoop(THREE.LoopOnce, 1); // 1回のパスの後に停止(または保持)
action.setLoop(THREE.LoopRepeat, Infinity); // デフォルト
action.setLoop(THREE.LoopPingPong, 4); // パスごとに方向を交互に変える
action.clampWhenFinished = true; // リセットする代わりに最終ポーズを保持

フェードとクロスフェード

const mixer = new THREE.AnimationMixer(mesh);
const idleClip = new THREE.AnimationClip("idle", 2, [
  new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.05, 0]),
]);
const walkClip = new THREE.AnimationClip("walk", 1, [
  new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1], [0, Math.PI]),
]);

const idle = mixer.clipAction(idleClip).play();
const walk = mixer.clipAction(walkClip).play();

// 0.4秒かけてidleからwalkに引き継ぐ。3番目の引数がtrueの場合、両方のタイムスケールもワープされ、
// フェード中にサイクル長が一致する
idle.crossFadeTo(walk, 0.4, false);

// 同じ引き継ぎを片側呼び出しで行う(どちらか一方の形式を使用し、両方は使用しない)
walk.reset().fadeIn(0.4).play(); // 重みを0から徐々に上げる
idle.fadeOut(0.4); // 重みを0まで徐々に下げる

glTFアニメーションの再生

import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";

const gltf = await new GLTFLoader().loadAsync("model.glb");
const model = gltf.scene;
scene.add(model);

// GLTFLoaderのデフォルトマテリアル(MeshStandardMaterial)は光源が必要:光源がないとモデルは黒くレンダリングされる
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 5, 2);
scene.add(sun, new THREE.HemisphereLight(0xffffff, 0x444444, 1.5));

// gltf.animationsはパースされたAnimationClipの配列
const clips = gltf.animations;

const mixer = new THREE.AnimationMixer(model);
const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
if (walkClip) mixer.clipAction(walkClip).play();

// 2つ目のキャラクター:スキンモデルをクローンし、コピーに独自のミキサーを与える
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // 1つ目のモデルの隣に配置
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // 同じクリップ、2つのミキサー

const timer = new THREE.Timer();
timer.connect(document);

function animate() {
  timer.update();
  const delta = timer.getDelta();
  mixer.update(delta);
  mixer2.update(delta);
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

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

// meshは読み込まれたモデルのルート。その最初のSkinnedMeshを見つける
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));

// ポーズ設定やアタッチのためにボーンを取得
const handBone = skeleton.bones.find((b) => b.name === "RightHand");

// リグの可視化
const helper = new THREE.SkeletonHelper(mesh);
scene.add(helper);

プロシージャルなボーン制御は mixer.update() の 後 に実行します。そうしないと、同じボーンをアニメーションさせるクリップが上書きします:

const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const headBone = skinnedMesh.skeleton.bones.find((b) => b.name === "Head");
const mixer = new THREE.AnimationMixer(mesh); // モデルのクリップを再生する(「glTFアニメーションの再生」を参照)

const timer = new THREE.Timer();
timer.connect(document);

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // クリップがここでボーンポーズを書き込む

  // その後で上書き:update()は再生中のクリップがアニメーションさせるすべてのプロパティを上書きする
  if (headBone) headBone.rotation.y = Math.sin(timer.getElapsed()) * 0.3;
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

ボーンはObject3Dなので、アタッチは親子関係の設定を意味します:

const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const handBone = skinnedMesh.skeleton.bones.find((b) => b.name === "RightHand");

const weapon = new THREE.Mesh(
  new THREE.BoxGeometry(0.08, 0.08, 0.5),
  new THREE.MeshNormalMaterial(),
);
if (handBone) {
  handBone.add(weapon); // weaponは手のボーンのトランスフォームに追従する
  weapon.position.set(0, 0, 0.25); // ボーン空間内でのオフセット
  weapon.rotation.set(0, Math.PI / 2, 0);
}

モーフターゲット

// meshはモーフターゲットを保持している(読み込まれているか、手動で追加されている)
const names = Object.keys(mesh.morphTargetDictionary); // 名前 -> インデックス
console.log(names, mesh.morphTargetInfluences); // ターゲットごとに1つの重み

// インデックスまたは名前でターゲットをブレンド。0 = ベース形状、1 = 完全なターゲット
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;

モーフターゲットを手動で追加する:

const geometry = mesh.geometry;

// モーフターゲットは、ベースの位置(または法線)属性の隣に配置されるフルサイズの属性
const raised = geometry.attributes.position.array.slice(); // 同じ頂点数
for (let i = 1; i < raised.length; i += 3) {
  raised[i] += 0.5; // yは3番目ごとのコンポーネント:すべての頂点を持ち上げる
}

const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // 名前付きターゲットはトラックからアドレス指定可能

geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // morphTargetInfluencesと辞書を再構築

モーフターゲットの駆動:

// プロシージャル方式、レンダーループ内のtimer.update()の後:
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;

// キーフレーム方式:Numberトラックが名前で1つのターゲットを駆動する。Stringトラックは決して使用しない
const track = new THREE.NumberKeyframeTrack(
  ".morphTargetInfluences[raised]",
  [0, 0.5, 1],
  [0, 1, 0],
);
const clip = new THREE.AnimationClip("pulse", 1, [track]);
const mixer = new THREE.AnimationMixer(mesh);
mixer.clipAction(clip).play();

重みによるクリップのブレンディング

const mixer = new THREE.AnimationMixer(mesh);
const idleClip = new THREE.AnimationClip("idle", 2, [
  new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.05, 0]),
]);
const runClip = new THREE.AnimationClip("run", 1, [
  new THREE.NumberKeyframeTrack(".position[y]", [0, 0.5, 1], [0, 0.3, 0]),
]);

const idle = mixer.clipAction(idleClip).play();
const run = mixer.clipAction(runClip).play();

// 両方を毎フレームサンプリング。重みがポーズをミックスする
function setBlend(speed) {
  const t = Math.min(speed / 5, 1); // 0 = idle, 5以上 = 完全なrun
  idle.setEffectiveWeight(1 - t);
  run.setEffectiveWeight(t);
}

setBlend(2.5); // 半分idle、半分run

加算アニメーション

const mixer = new THREE.AnimationMixer(mesh);
const walkClip = new THREE.AnimationClip("walk", 1, [
  new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1], [0, Math.PI]),
]);
const breathClip = new THREE.AnimationClip("breath", 2, [
  new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.02, 0]),
]);

// すべてのトラックをフレーム0に再ベースし、クリップを加算としてマーク
THREE.AnimationUtils.makeClipAdditive(breathClip);

mixer.clipAction(walkClip).play(); // 通常のベースクリップ
mixer.clipAction(breathClip).play(); // ベースポーズの上にオフセットを追加

クリップユーティリティ

const clip = new THREE.AnimationClip("walk", 2, [
  new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1, 2], [0, Math.PI, Math.PI * 2]),
]);

const walk = THREE.AnimationClip.findByName([clip], "walk"); // 名前検索
// フレーム[0, 31)の既存のキーフレームを30fps(時間0と1)で保持。リサンプリングはしない
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // 独立したディープコピー

clip.optimize(); // 重複した時間のキーと、任意の非スムーズトラック上の両方の隣接キーと等しいキーを削除(ベジェ接線は圧縮されない)
clip.resetDuration(); // 最長のトラックから持続時間を再計算。シークはしない
console.log(clip.validate()); // 値対時間の比率が分数の場合、トラックが空の場合、時間がソートされていない場合、NaNの場合はfalse

プロシージャルモーション

const timer = new THREE.Timer();
timer.connect(document);

// 指数近似。ラムダが大きいほど速く収束する。フレームレートに依存しない

// ...(元の内容の続き)

関連スキル