Three.js-Animation
Zielt auf three.js r186. Beispiele verwenden WebGLRenderer; der Abschnitt zu WebGPU und TSL verwendet WebGPURenderer.
Schnellstart
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); // hält auch Deltas klein, nachdem der Tab ausgeblendet wurde
function animate() {
timer.update(); // zuerst in jedem Frame: fixiert getDelta()/getElapsed() für diesen Frame
const delta = timer.getDelta();
const elapsed = timer.getElapsed();
mesh.rotation.y += delta; // framerate-unabhängige Drehung
mesh.position.y = Math.sin(elapsed * 2) * 0.25; // Wippen basierend auf absoluter Zeit
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
Wie das System zusammenspielt
- AnimationClip: Keyframe-Daten — Spuren von Zeiten und Werten, kein Wiedergabestatus.
- AnimationMixer: Spielt Clips auf einem Stammobjekt und allen seinen Nachkommen ab.
- AnimationAction: Wiedergabesteuerung für einen Clip auf einem Mixer.
Jeden Frame tastet mixer.update(delta) die aktiven Clips ab und schreibt die Werte in normale Objekteigenschaften. Das System läuft auf der CPU und berührt nie den Renderer, sodass es Modelle unter beiden Renderern gleichermaßen steuert.
AnimationClip und Keyframe-Tracks
// Ein Track pro animierter Eigenschaft. values.length muss times.length * valueSize sein.
const times = [0, 1, 2]; // Keyframe-Zeiten in Sekunden
const values = [0, 1, 0]; // ein Skalar pro Keyframe für einen Number-Track
const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // die position.y des Stammobjekts
const clip = new THREE.AnimationClip("bounce", 2, [track]); // Dauer in Sekunden
// Übergeben Sie -1 als Dauer, um sie aus den Tracks abzuleiten
Track-Namen folgen nodeName.objectName[objectIndex].propertyName[propertyIndex]; alle Teile außer der Eigenschaft sind optional. .position bindet die Position des Stammobjekts, .material.opacity eine Materialeigenschaft, .position[y] eine Komponente, .morphTargetInfluences[smile] ein Morph-Target, Hips.position einen benannten Nachkommen — so werden Knochen in einem geskinnten Modell adressiert. Objektnamen können material, materials, bones oder map sein.
// Number-Track (value size 1); erstellen Sie das Material mit transparent: true, sonst wird opacity ignoriert
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);
// Vector-Track (value size 3: x, y, z pro Keyframe)
new THREE.VectorKeyframeTrack(".position", [0, 1, 2], [0, 0, 0, 1, 2, 0, 0, 0, 0]);
// Rotations-Track (value size 4): Quaternion x, y, z, w pro Keyframe, keine Euler-Winkel
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], // Identität, dann q2
);
// Color-Track (value size 3)
new THREE.ColorKeyframeTrack(".material.color", [0, 1], [1, 0, 0, 0, 1, 0]);
// Boolean-Track (immer diskret)
new THREE.BooleanKeyframeTrack(".visible", [0, 1], [true, false]);
Interpolation
const track = new THREE.VectorKeyframeTrack(
".position",
[0, 1, 2],
[0, 0, 0, 1, 2, 0, 0, 0, 0],
);
track.setInterpolation(THREE.InterpolateLinear); // Standard
track.setInterpolation(THREE.InterpolateSmooth); // kubisch durch die Keyframes
track.setInterpolation(THREE.InterpolateDiscrete); // Sprung, keine Zwischenwerte
// Bezier benötigt absolute (Zeit, Wert)-Kontrollpunkte, zwei Zahlen pro Keyframe pro
// Komponente: ein Zwei-Keyframe-Track mit Schrittweite 1 benötigt vier Einträge pro 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]);
// Ein Mixer pro animiertem Stammobjekt; er erreicht das Stammobjekt und alle Nachkommen
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // zwischengespeichert: ein erneuter Aufruf für diesen Clip und dieses Stammobjekt gibt die gleiche Action zurück
action.play();
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // erforderlich: setzt jede aktive Action fort
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
// Events werden auf dem Mixer während seines Updates ausgelöst; "finished" benötigt LoopOnce oder endliche Wiederholungen
mixer.addEventListener("finished", (e) => console.log("fertig:", e.action.getClip().name));
mixer.addEventListener("loop", (e) => console.log("wiederholt:", 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(); // einplanen und starten
action.reset(); // zurückspulen auf Zeit 0 und Überblendungen abbrechen
action.stop(); // abmelden; Eigenschaften, die von keiner anderen eingeplanten Action animiert werden, kehren zu ihren ursprünglichen Werten zurück
action.halt(0.3); // innerhalb von 0,3 s zum Stillstand abbremsen
action.time = 0.5; // innerhalb des Clips suchen
action.timeScale = -1; // negativ spielt rückwärts
action.paused = true; // an Ort und Stelle einfrieren
action.setLoop(THREE.LoopOnce, 1); // nach einem Durchlauf stoppen (oder halten)
action.setLoop(THREE.LoopRepeat, Infinity); // der Standard
action.setLoop(THREE.LoopPingPong, 4); // Richtung wechselt bei jedem Durchlauf
action.clampWhenFinished = true; // Endpose halten statt zurücksetzen
Überblendungen und Crossfades
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();
// Übergang von idle zu walk über 0,4 s; true als drittes Argument verzerrt auch
// beide Zeitskalen, sodass ihre Zykluslängen während der Überblendung übereinstimmen
idle.crossFadeTo(walk, 0.4, false);
// Der gleiche Übergang über einseitige Aufrufe (verwenden Sie eine Form, nicht beide)
walk.reset().fadeIn(0.4).play(); // Gewicht von 0 hochfahren
idle.fadeOut(0.4); // Gewicht auf 0 herunterfahren
glTF-Animationen abspielen
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 Standardmaterial (MeshStandardMaterial) ist beleuchtet: Ohne Licht wird das Modell schwarz gerendert
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 ist ein Array analysierter AnimationClips
const clips = gltf.animations;
const mixer = new THREE.AnimationMixer(model);
const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
if (walkClip) mixer.clipAction(walkClip).play();
// Ein zweiter Charakter: Klonen Sie das geskinnte Modell, geben Sie der Kopie ihren eigenen Mixer
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // neben dem ersten
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // gleicher Clip, zwei Mixer
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);
Skelett-Animation
// mesh ist die geladene Modellwurzel; finden Sie das erste SkinnedMesh
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));
// Einen Knochen greifen, um ihn zu posieren oder etwas anzuhängen
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
// Das Rig visualisieren
const helper = new THREE.SkeletonHelper(mesh);
scene.add(helper);
Prozedurale Knochensteuerung läuft nach mixer.update(), oder ein Clip, der denselben Knochen animiert, überschreibt sie:
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const headBone = skinnedMesh.skeleton.bones.find((b) => b.name === "Head");
const mixer = new THREE.AnimationMixer(mesh); // Clips des Modells darauf abspielen (siehe glTF-Animationen abspielen)
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // Clips schreiben hier ihre Knochenpose
// Danach überschreiben: update() überschreibt jede Eigenschaft, die die aktiven Clips animieren
if (headBone) headBone.rotation.y = Math.sin(timer.getElapsed()) * 0.3;
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
Knochen sind Object3Ds, daher bedeutet Anhängen Parenting:
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); // Waffe folgt nun der Knochentransformation der Hand
weapon.position.set(0, 0, 0.25); // Versatz im Raum des Knochens
weapon.rotation.set(0, Math.PI / 2, 0);
}
Morph Targets
// mesh trägt Morph-Targets (geladen oder von Hand hinzugefügt, siehe unten)
const names = Object.keys(mesh.morphTargetDictionary); // Name -> Index
console.log(names, mesh.morphTargetInfluences); // ein Gewicht pro Target
// Ein Target nach Index oder nach Namen mischen; 0 = Basisform, 1 = volles Target
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;
Ein Morph-Target von Hand hinzufügen:
const geometry = mesh.geometry;
// Ein Morph-Target ist ein vollständiges Positions- (oder Normalen-) Attribut neben dem Basis-Attribut
const raised = geometry.attributes.position.array.slice(); // gleiche Vertex-Anzahl
for (let i = 1; i < raised.length; i += 3) {
raised[i] += 0.5; // y ist jede dritte Komponente: hebe jeden Vertex an
}
const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // benannte Targets bleiben von Tracks aus adressierbar
geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // baut morphTargetInfluences und das Dictionary neu auf
Morph Targets steuern:
// Prozedural, in der Render-Schleife nach timer.update():
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;
// Keyframed: Ein Number-Track steuert ein Target nach Namen — niemals ein 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();
Clips nach Gewicht mischen
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();
// Beide tasten jeden Frame ab; die Gewichte mischen ihre Posen
function setBlend(speed) {
const t = Math.min(speed / 5, 1); // 0 = idle, 5 und mehr = voller Lauf
idle.setEffectiveWeight(1 - t);
run.setEffectiveWeight(t);
}
setBlend(2.5); // halb idle, halb Lauf
Additive Animation
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]),
]);
// Basiert jeden Track auf Frame 0 neu und markiert den Clip als additiv
THREE.AnimationUtils.makeClipAdditive(breathClip);
mixer.clipAction(walkClip).play(); // normaler Basis-Clip
mixer.clipAction(breathClip).play(); // fügt seine Offsets oben auf die Basispose
Clip-Hilfsfunktionen
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"); // Namenssuche
// Behält die vorhandenen Keyframes in den Frames [0, 31) bei 30 fps (Zeiten 0 und 1); es wird nie neu abgetastet
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // unabhängige tiefe Kopie
clip.optimize(); // entfernt Keys mit doppelter Zeit und Keys, die auf nicht-glatten Tracks (Bezier-Tangenten werden nicht verdichtet) gleich beiden Nachbarn sind
clip.resetDuration(); // berechnet die Dauer aus dem längsten Track neu — sucht nicht
console.log(clip.validate()); // false bei einem unpassenden values-to-times-Verhältnis, leerem Track, unsortierten Zeiten oder NaN
Prozedurale Bewegung
const timer = new THREE.Timer();
timer.connect(document);
// Exponentieller Ansatz; ein höheres Lambda beschleunigt die Annäherung. Frame-Rate-unabhängig.
// …