mcpField
T
threejs-animation

threejs-animation

github.com/cloudai-xクリエイティブ・メディア3.8k21.2k 回インストール1 週間前に更新
リポジトリ

threejs-animation について

threejs-animation は、three.js r186 でのアニメーション制作を解説するリファレンススキルです。キーフレームや手続き型の動き、リグモデル、モーフターゲット、CPU・GPUによるアニメーション手法を扱います。

主な機能

  1. 01AnimationClip、Mixer、Action
  2. 02キーフレームトラックと補間
  3. 03スケルトンとモーフターゲットのアニメーション
  4. 04クリップのブレンド、フェード、加算
  5. 05WebGPUとTSLのアニメーション
  6. 06性能とリソースの解放

活用シーン

  1. 01モデルのキーフレーム動作の作成
  2. 02glTFアニメーションの再生とブレンド
  3. 03スケルトンやモーフターゲットの手続き制御
  4. 04WebGPUでの頂点アニメーションの追加

インストール

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

$npx skills add https://github.com/cloudai-x/threejs-skills --skill threejs-animation

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

詳細

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); // also keeps deltas small after the tab was hidden

function animate() {
  timer.update(); // first thing each frame: fixes getDelta()/getElapsed() for this frame
  const delta = timer.getDelta();
  const elapsed = timer.getElapsed();

  mesh.rotation.y += delta; // frame-rate-independent spin
  mesh.position.y = Math.sin(elapsed * 2) * 0.25; // bob on absolute time

  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

システムの仕組み

  • AnimationClip:キーフレームデータです。時間と値のトラックで構成され、再生状態は持ちません。
  • AnimationMixer:1 つのルートオブジェクトと、そのすべての子孫オブジェクトでクリップを再生します。
  • AnimationAction:1 つのミキサー上で、1 つのクリップの再生を制御します。

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

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

// One track per animated property. values.length must be times.length * valueSize.
const times = [0, 1, 2]; // keyframe times in seconds
const values = [0, 1, 0]; // one scalar per keyframe for a Number track

const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // the root's position.y

const clip = new THREE.AnimationClip("bounce", 2, [track]); // duration in seconds
// pass -1 as the duration to derive it from the tracks

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

// Number track (value size 1); create the material with transparent: true, or opacity is ignored
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);

// Vector track (value size 3: x, y, z per keyframe)
new THREE.VectorKeyframeTrack(".position", [0, 1, 2], [0, 0, 0, 1, 2, 0, 0, 0, 0]);

// Rotation track (value size 4): quaternion x, y, z, w per keyframe, not Euler angles
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], // identity, then q2
);

// Color track (value size 3)
new THREE.ColorKeyframeTrack(".material.color", [0, 1], [1, 0, 0, 0, 1, 0]);

// Boolean track (always discrete)
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); // default
track.setInterpolation(THREE.InterpolateSmooth); // cubic through the keyframes
track.setInterpolation(THREE.InterpolateDiscrete); // step, no in-between values

// Bezier takes absolute (time, value) control points, two numbers per keyframe per
// component: a stride-1 two-keyframe track needs four entries per array
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]);

// One mixer per animated root; it reaches the root and all descendants
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // cached: calling it again for this clip and root returns the same action
action.play();

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

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // required: advances every active action
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

// Events fire on the mixer while it updates; "finished" needs LoopOnce or finite repetitions
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(); // schedule and start
action.reset(); // rewind to time 0 and cancel fades
action.stop(); // unschedule; properties no other scheduled action animates return to their original values
action.halt(0.3); // decelerate to a stop within 0.3 s

action.time = 0.5; // scrub within the clip
action.timeScale = -1; // negative plays backward
action.paused = true; // freeze in place

action.setLoop(THREE.LoopOnce, 1); // stop (or hold) after one pass
action.setLoop(THREE.LoopRepeat, Infinity); // the default
action.setLoop(THREE.LoopPingPong, 4); // alternate direction each pass
action.clampWhenFinished = true; // hold the final pose instead of resetting
フェードとクロスフェード
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();

// Hand over from idle to walk over 0.4 s; true as the third argument also warps
// both time scales so their cycle lengths match during the fade
idle.crossFadeTo(walk, 0.4, false);

// The same hand-over from the one-sided calls (use one form, not both)
walk.reset().fadeIn(0.4).play(); // ramp weight up from 0
idle.fadeOut(0.4); // ramp weight down to 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's default material (MeshStandardMaterial) is lit: without lights the model renders black
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 is an array of parsed AnimationClips
const clips = gltf.animations;

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

// A second character: clone the skinned model, give the copy its own mixer
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // beside the first one
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // same clip, two mixers

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 is the loaded model root; find its first SkinnedMesh
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));

// Grab a bone to pose or attach to
const handBone = skeleton.bones.find((b) => b.name === "RightHand");

// Visualize the rig
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); // play the model's clips on it (see Playing glTF Animations)

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

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // clips write their bone pose here

  // Override afterwards: update() overwrites every property the playing clips animate
  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 now follows the hand's bone transform
  weapon.position.set(0, 0, 0.25); // offset within the bone's space
  weapon.rotation.set(0, Math.PI / 2, 0);
}

モーフターゲット

// mesh carries morph targets (loaded, or added by hand below)
const names = Object.keys(mesh.morphTargetDictionary); // name -> index
console.log(names, mesh.morphTargetInfluences); // one weight per target

// Blend a target by index or by name; 0 = base shape, 1 = full target
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;

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

const geometry = mesh.geometry;

// A morph target is a full-size position (or normal) attribute beside the base one
const raised = geometry.attributes.position.array.slice(); // same vertex count
for (let i = 1; i < raised.length; i += 3) {
  raised[i] += 0.5; // y is every third component: lift every vertex
}

const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // named targets stay addressable from tracks

geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // rebuilds morphTargetInfluences and the dictionary

モーフターゲットを動かす場合:

// Procedural, inside the render loop after timer.update():
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;

// Keyframed: a Number track drives one target by name — never a String track
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();

// Both sample every frame; the weights mix their poses
function setBlend(speed) {
  const t = Math.min(speed / 5, 1); // 0 = idle, 5 and up = full run
  idle.setEffectiveWeight(1 - t);
  run.setEffectiveWeight(t);
}

setBlend(2.5); // half idle, half 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]),
]);

// Re-bases every track on its frame 0 and marks the clip additive
THREE.AnimationUtils.makeClipAdditive(breathClip);

mixer.clipAction(walkClip).play(); // normal base clip
mixer.clipAction(breathClip).play(); // adds its offsets on top of the base pose

クリップユーティリティ

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"); // name lookup
// Keeps the existing keyframes in frames [0, 31) at 30 fps (times 0 and 1); it never resamples
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // independent deep copy

clip.optimize(); // drops duplicate-time keys, and keys equal to both neighbours on any non-smooth track (Bezier tangents are not compacted)
clip.resetDuration(); // recomputes duration from the longest track — it does not seek
console.log(clip.validate()); // false for a fractional values-to-times ratio, an empty track, unsorted times or NaN

手続き型モーション

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

// Exponential approach; a higher lambda snaps faster. Frame-rate independent.
// The same shape drives positions, rotations or any scalar each frame.
let scale = 0;

function animate() {
  timer.update();
  scale = THREE.MathUtils.damp(scale, 1, 4, timer.getDelta());
  mesh.scale.setScalar(0.5 + scale * 0.5);
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

パフォーマンス

  • 同じ AnimationClip を任意の数のミキサーで使用できます。コピーする代わりに、キャラクター間で 1 つのクリップセットを共有してください。
  • mixer.update() は、表示中または表示される直前のキャラクターに対してのみ呼び出してください。更新されていないミキサーは、最後のポーズをそのまま保持します。
  • SkinnedMesh のバウンディングスフィアは、カリングまたはレイキャストで初めて必要になったときに 1 度だけ計算されます。そのため、クリップによってそのポーズから大きく移動すると、画面の端でカリングされることがあります。mixer.update() の後に mixer.getRoot().updateMatrixWorld() を呼び出してから skinnedMesh.computeBoundingSphere() を呼び出す(全頂点を走査する CPU 処理です)か、skinnedMesh.frustumCulled = false を設定してください。
// Free a mixer and its cached bindings when its model leaves for good
const mixer = new THREE.AnimationMixer(mesh);
mixer.stopAllAction();
mixer.uncacheRoot(mesh); // releases the property bindings created for the model

WebGPU と TSL

上記のすべて(ミキサー、アクション、ボーンの変換、モーフの重み)は CPU 上で計算され、WebGPURenderer でも変更なしでモデルを動かします。TSL では GPU 上でアニメーションを追加できます。time(経過秒数)と deltaTime(前のフレームからの経過秒数)は、レンダラーが毎フレーム更新する float ユニフォームです。また、oscSine、oscSquare、oscTriangle、oscSawtooth は、時間ノードから 1 秒周期の 0 から 1 までの波形を返します。デフォルトでは time を使用します(oscSine(time.mul(0.5)) の場合は 2 秒周期です)。頂点単位のモーションには、ノードマテリアルと細分化されたメッシュが必要です。

import * as THREE from "three/webgpu";
import { positionLocal, time, vec3 } from "three/tsl";

const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init(); // before any other use of the renderer
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111418); // the WebGPU canvas is transparent by default

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

// Ripple a subdivided plane along its normal (local z) on the GPU; time drives it, no per-frame JS
const material = new THREE.MeshBasicNodeMaterial({ wireframe: true });
material.positionNode = positionLocal.add(
  vec3(0, 0, positionLocal.x.mul(2).sin().mul(time.mul(2).cos()).mul(0.2)),
);
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(3, 3, 32, 32), material);
mesh.rotation.x = -Math.PI / 2;
scene.add(mesh);

function animate() {
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

スキンメッシュでは、positionLocal に変位を構築してください。positionNode の実行前にスキニング、モーフターゲット、バッチ処理、インスタンシングが適用されるため、ミキサーで駆動するポーズが維持されます。positionGeometry は加工前のレストポーズ属性です。アニメーションするメッシュの変位の基準としては、決して使用しないでください。

import * as THREE from "three/webgpu";
import { color, mix, oscSine, positionLocal, vec3 } from "three/tsl";

// The pulse rides on top of the skinned pose: positionLocal already carries it
const material = new THREE.MeshBasicNodeMaterial();
material.positionNode = positionLocal.add(vec3(0, oscSine().mul(0.05), 0));

// Oscillators animate any node slot, not just positions
material.colorNode = mix(color(0x202030), color(0xffd24a), oscSine());

const skinnedMesh = scene.getObjectByProperty("type", "SkinnedMesh"); // a loaded animated model
if (skinnedMesh) skinnedMesh.material = material;

よくある間違い

  • const clock = new THREE.Clock(); → const timer = new THREE.Timer(); timer.connect(document);:Clock クラスは r183 で非推奨となり、Timer がその代替です。
  • const elapsed = clock.getElapsedTime(); const delta = clock.getDelta(); → timer.update(); const delta = timer.getDelta(); const elapsed = timer.getElapsed();:getElapsedTime() は内部で getDelta() を呼び出すため、その後の delta の読み取り値はほぼゼロになり、ミキサーが停止します。
  • requestAnimationFrame(animate); → renderer.setAnimationLoop(animate);:両方のレンダラーのドキュメントで手動ループより推奨されており、WebXR セッションの開始時にはフレームをセッションに渡します。
  • import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; → import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";:examples/jsm の指定子は標準のインポートマップでは解決されません。
  • new THREE.NumberKeyframeTrack(".material.opacity", [0, 1, 2], [1, 0]) → new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]):値の数は times.length × valueSize と一致する必要があります。比率が小数の場合、play() が戻らなくなり、それ以外の不一致でも誤った値がサンプリングされます。
  • new THREE.StringKeyframeTrack(".morphTargetInfluences[smile]", [0, 1], ["0", "1"]) → new THREE.NumberKeyframeTrack(".morphTargetInfluences[smile]", [0, 1], [0, 1]):モーフの影響度は数値であり、文字列トラックを使うと配列に「0」/「1」の文字列が書き込まれます。
  • headBone.rotation.y = Math.sin(t) * 0.3; mixer.update(delta); → mixer.update(delta); headBone.rotation.y = Math.sin(t) * 0.3;:update() は、その前に設定された値をクリップのポーズで上書きします。
  • clip.resetDuration() → action.reset():resetDuration() は最も長いトラックからクリップの長さを再計算するだけですが、reset() はアクションを時刻 0 に巻き戻し、ワンショットの再生終了後に再び有効にします。
  • material.positionNode = positionGeometry.add(offset) → material.positionNode = positionLocal.add(offset):positionGeometry は生の属性であるため、これを基に処理を構築すると、スキニング、モーフターゲット、バッチ処理、インスタンシングが失われます。
  • const uTime = uniform(0); uTime.value = timer.getElapsed(); const wave = uTime.mul(2).sin(); → const wave = time.mul(2).sin();:レンダラーは毎フレーム TSL の time ノードを更新するため、Timer から値を渡す uniform はフレームごとの JavaScript 処理として冗長です。
  • const model2 = model.clone(); → const model2 = SkeletonUtils.clone(model);:スキニングされたモデルを複製すると、コピー間でスケルトンが共有されます。各コピーにはそれぞれ独自のスケルトンが必要です。
  • import { clone } from "three/addons/utils/SkeletonUtils.js"; → import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";:名前空間を使うことで呼び出しを SkeletonUtils.clone(model) とでき、スケルトンを共有する model.clone() と混同されません。
  • new Promise((resolve, reject) => loader.load(url, resolve, undefined, reject)) → await loader.loadAsync(url):ローダーにはすでに Promise 形式が用意されており、loadAsync の第 2 引数は進捗コールバックです。

関連項目

  • threejs-fundamentals — レンダーループ、Timer、レンダラーとシーンのセットアップ
  • threejs-loaders — glTF モデルとそのアニメーションクリップの読み込み
  • threejs-shaders — TSL または ShaderMaterial を使った GPU 頂点変位

コメント