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);
// 指数近似。ラムダが大きいほど速く収束する。フレームレートに依存しない
// ...(元の内容の続き)