Animation Three.js
Cible Three.js r186. Les exemples utilisent WebGLRenderer ; la section WebGPU et TSL utilise WebGPURenderer.
Démarrage rapide
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); // maintient également les deltas petits après que l'onglet a été masqué
function animate() {
timer.update(); // en premier à chaque frame : corrige getDelta()/getElapsed() pour cette frame
const delta = timer.getDelta();
const elapsed = timer.getElapsed();
mesh.rotation.y += delta; // rotation indépendante du framerate
mesh.position.y = Math.sin(elapsed * 2) * 0.25; // oscillation sur le temps absolu
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
Comment le système s'articule
- AnimationClip : données d'images clés — pistes de temps et de valeurs, sans état de lecture.
- AnimationMixer : lit les clips sur un objet racine et tous ses descendants.
- AnimationAction : contrôle de la lecture pour un clip sur un mixeur.
Chaque frame, mixer.update(delta) échantillonne les clips actifs et écrit les valeurs dans les propriétés ordinaires des objets. Le système s'exécute sur le CPU et n'interfère jamais avec le renderer, donc il pilote les modèles de la même manière quel que soit le renderer.
AnimationClip et pistes d'images clés
// Une piste par propriété animée. values.length doit égaler times.length * valueSize.
const times = [0, 1, 2]; // temps des images clés en secondes
const values = [0, 1, 0]; // un scalaire par image clé pour une piste Number
const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // la position.y de la racine
const clip = new THREE.AnimationClip("bounce", 2, [track]); // durée en secondes
// passer -1 comme durée pour la déduire des pistes
Les noms de pistes suivent nodeName.objectName[objectIndex].propertyName[propertyIndex] ; toutes les parties sauf la propriété sont optionnelles. .position lie la position de la racine, .material.opacity une propriété de matériau, .position[y] un composant, .morphTargetInfluences[smile] une morph target, Hips.position un descendant nommé — c'est ainsi que les os sont adressés dans un modèle skinné. Les noms d'objets peuvent être material, materials, bones ou map.
// Piste Number (taille de valeur 1) ; créez le matériau avec transparent: true, sinon l'opacité est ignorée
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);
// Piste Vector (taille de valeur 3 : x, y, z par image clé)
new THREE.VectorKeyframeTrack(".position", [0, 1, 2], [0, 0, 0, 1, 2, 0, 0, 0, 0]);
// Piste Rotation (taille de valeur 4) : quaternion x, y, z, w par image clé, pas d'angles d'Euler
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é, puis q2
);
// Piste Color (taille de valeur 3)
new THREE.ColorKeyframeTrack(".material.color", [0, 1], [1, 0, 0, 0, 1, 0]);
// Piste Boolean (toujours discrète)
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); // par défaut
track.setInterpolation(THREE.InterpolateSmooth); // cubique à travers les images clés
track.setInterpolation(THREE.InterpolateDiscrete); // par paliers, pas de valeurs intermédiaires
// Bezier prend des points de contrôle absolus (temps, valeur), deux nombres par image clé par
// composant : une piste à deux images clés de stride 1 nécessite quatre entrées par tableau
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]);
// Un mixeur par racine animée ; il atteint la racine et tous les descendants
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // mis en cache : l'appeler à nouveau pour ce clip et cette racine renvoie la même action
action.play();
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // requis : avance chaque action active
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
// Les événements se déclenchent sur le mixeur lors de sa mise à jour ; "finished" nécessite LoopOnce ou des répétitions finies
mixer.addEventListener("finished", (e) => console.log("terminé :", e.action.getClip().name));
mixer.addEventListener("loop", (e) => console.log("bouclé :", 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(); // planifier et démarrer
action.reset(); // rembobiner au temps 0 et annuler les fondus
action.stop(); // désinscrire ; les propriétés qu'aucune autre action planifiée n'anime retournent à leurs valeurs originales
action.halt(0.3); // décélérer jusqu'à l'arrêt en 0,3 s
action.time = 0.5; // naviguer dans le clip
action.timeScale = -1; // négatif pour jouer en arrière
action.paused = true; // figer sur place
action.setLoop(THREE.LoopOnce, 1); // s'arrêter (ou maintenir) après un passage
action.setLoop(THREE.LoopRepeat, Infinity); // par défaut
action.setLoop(THREE.LoopPingPong, 4); // alterner la direction à chaque passage
action.clampWhenFinished = true; // maintenir la pose finale au lieu de réinitialiser
Fondus et fondus enchaînés
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();
// Passer de idle à walk en 0,4 s ; true comme troisième argument déforme également
// les deux échelles de temps pour que leurs durées de cycle correspondent pendant le fondu
idle.crossFadeTo(walk, 0.4, false);
// Le même passage avec les appels unilatéraux (utilisez une forme, pas les deux)
walk.reset().fadeIn(0.4).play(); // monter le poids de 0
idle.fadeOut(0.4); // descendre le poids à 0
Lire des animations 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);
// Le matériau par défaut de GLTFLoader (MeshStandardMaterial) est éclairé : sans lumières, le modèle rend du noir
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 est un tableau de AnimationClips parsés
const clips = gltf.animations;
const mixer = new THREE.AnimationMixer(model);
const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
if (walkClip) mixer.clipAction(walkClip).play();
// Un second personnage : cloner le modèle skinné, donner à la copie son propre mixeur
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // à côté du premier
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // même clip, deux mixeurs
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);
Animation squelettique
// mesh est la racine du modèle chargé ; trouver son premier SkinnedMesh
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));
// Attraper un os pour le poser ou y attacher
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
// Visualiser le rig
const helper = new THREE.SkeletonHelper(mesh);
scene.add(helper);
Le contrôle procédural des os s'exécute après mixer.update(), sinon un clip qui anime le même os l'écrase :
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const headBone = skinnedMesh.skeleton.bones.find((b) => b.name === "Head");
const mixer = new THREE.AnimationMixer(mesh); // lire les clips du modèle dessus (voir Lire des animations glTF)
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // les clips écrivent leur pose osseuse ici
// Surcharger ensuite : update() écrit toutes les propriétés que les clips en cours d'animation animent
if (headBone) headBone.rotation.y = Math.sin(timer.getElapsed()) * 0.3;
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
Les os sont des Object3D, donc attacher signifie parenter :
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 suit maintenant la transformation de l'os de la main
weapon.position.set(0, 0, 0.25); // décalage dans l'espace de l'os
weapon.rotation.set(0, Math.PI / 2, 0);
}
Morph Targets
// mesh porte des morph targets (chargées, ou ajoutées manuellement ci-dessous)
const names = Object.keys(mesh.morphTargetDictionary); // nom -> index
console.log(names, mesh.morphTargetInfluences); // un poids par cible
// Mélanger une cible par index ou par nom ; 0 = forme de base, 1 = cible complète
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;
Ajouter une morph target manuellement :
const geometry = mesh.geometry;
// Une morph target est un attribut de position (ou normale) de taille complète à côté de celui de base
const raised = geometry.attributes.position.array.slice(); // même nombre de sommets
for (let i = 1; i < raised.length; i += 3) {
raised[i] += 0.5; // y est le troisième composant : soulever chaque sommet
}
const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // les cibles nommées restent adressables depuis les pistes
geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // reconstruit morphTargetInfluences et le dictionnaire
Piloter les morph targets :
// Procédural, dans la boucle de rendu après timer.update() :
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;
// Par images clés : une piste Number pilote une cible par son nom — jamais une piste 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();
Mélanger des clips par poids
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();
// Les deux s'échantillonnent à chaque frame ; les poids mélangent leurs poses
function setBlend(speed) {
const t = Math.min(speed / 5, 1); // 0 = idle, 5 et au-dessus = full run
idle.setEffectiveWeight(1 - t);
run.setEffectiveWeight(t);
}
setBlend(2.5); // moitié idle, moitié run
Animation additive
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]),
]);
// Rebase chaque piste sur son image clé 0 et marque le clip comme additif
THREE.AnimationUtils.makeClipAdditive(breathClip);
mixer.clipAction(walkClip).play(); // clip de base normal
mixer.clipAction(breathClip).play(); // ajoute ses décalages par-dessus la pose de base
Utilitaires de clip
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"); // recherche par nom
// Garde les images clés existantes dans les trames [0, 31) à 30 fps (temps 0 et 1) ; ne rééchantillonne jamais
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // copie profonde indépendante
clip.optimize(); // supprime les clés à temps dupliqué, et les clés égales à leurs deux voisines sur toute piste non lisse (les tangentes Bezier ne sont pas compactées)
clip.resetDuration(); // recalcule la durée à partir de la piste la plus longue — ne cherche pas
console.log(clip.validate()); // false pour un rapport valeurs/temps fractionnaire, une piste vide, des temps non triés ou NaN
Mouvement procédural
const timer = new THREE.Timer();
timer.connect(document);
// Approche exponentielle ; un lambda plus élevé accélère l'ajustement. Indépendant du framerate.
/**
* Approche exponentielle vers une cible.
* @param {number} current - Valeur actuelle.
* @param {number} target - Valeur cible.
* @param {number} lambda - Taux d'approche.
* @param {number} delta - Delta temporel.
* @returns {number} Nouvelle valeur.
*/
function expApproach(current, target, lambda, delta) {
return current + (target - current) * (1 - Math.exp(-lambda * delta));
}
let yTarget = 0;
let yCurrent = 0;
function animate() {
timer.update();
const delta = timer.getDelta();
// Changer la cible périodiquement
if (Math.floor(timer.getElapsed()) % 2 === 0) {
yTarget = 1;
} else {
yTarget = 0;
}
yCurrent = expApproach(yCurrent, yTarget, 5, delta);
mesh.position.y = yCurrent;
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
WebGPU et TSL
import * as THREE from "three";
import { WebGPURenderer } from "three/addons/renderers/webgpu/WebGPURenderer.js";
const renderer = new WebGPURenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Le même code d'animation fonctionne sans changement
Pour l'instant, les animations par images clés, squelettiques et morph targets fonctionnent de manière identique. L'animation procédurale pilotée par Timer et les propriétés de l'objet fonctionne également sans changement. Le rendu WebGPU utilise TSL sous le capot pour les shaders, mais l'API d'animation reste la même.