CommunityProgramación y desarrollogithub.com

SHARDROOM/threejs-animation

Animación por fotogramas clave, esquelética y de morph targets, movimiento procedimental y mezcla de clips, en WebGL o con WebGPU y TSL. Úsalo para animar objetos, reproducir animaciones glTF, posar huesos, controlar morph targets o mezclar y difuminar clips.

¿Qué es threejs-animation?

Esta skill proporciona una guía completa y práctica para el sistema de animación de three.js (versión r186), cubriendo desde los fundamentos de fotogramas clave (clips, pistas, mezcladores y acciones) hasta técnicas avanzadas como la animación esquelética, morph targets, mezcla de clips y animación aditiva. El usuario aprende a crear animaciones independientes de la tasa de fotogramas usando la clase Timer, a reproducir animaciones importadas de modelos glTF, a posar huesos individualmente y a controlar morph targets tanto de forma procedimental como mediante pistas de animación. También se explican conceptos clave como la interpolación lineal, cúbica, discreta y Bezier, así como las transiciones suaves (crossfade) entre animaciones. La skill es compatible tanto con WebGLRenderer como con WebGPURenderer, y el sistema de animación funciona en la CPU, lo que lo hace independiente del motor de renderizado.

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

Installed? Explore more Programación y desarrollo skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Animación en Three.js

Apunta a three.js r186. Los ejemplos usan WebGLRenderer; la sección WebGPU y TSL usa WebGPURenderer.

Inicio Rápido

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); // también mantiene los deltas pequeños después de que la pestaña estuviera oculta

function animate() {
  timer.update(); // primero cada fotograma: corrige getDelta()/getElapsed() para este fotograma
  const delta = timer.getDelta();
  const elapsed = timer.getElapsed();

  mesh.rotation.y += delta; // giro independiente de la tasa de fotogramas
  mesh.position.y = Math.sin(elapsed * 2) * 0.25; // balanceo en tiempo absoluto

  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Cómo se Integra el Sistema

  • AnimationClip: datos de fotogramas clave: pistas de tiempos y valores, sin estado de reproducción.
  • AnimationMixer: reproduce clips en un objeto raíz y todos sus descendientes.
  • AnimationAction: control de reproducción para un clip en un mezclador.

Cada fotograma, mixer.update(delta) muestrea los clips activos y escribe los valores en las propiedades ordinarias del objeto. El sistema funciona en la CPU y nunca toca el renderizador, por lo que maneja modelos de la misma manera bajo cualquier renderizador.

AnimationClip y Pistas de Fotogramas Clave

// Una pista por propiedad animada. values.length debe ser times.length * valueSize.
const times = [0, 1, 2]; // tiempos de los fotogramas clave en segundos
const values = [0, 1, 0]; // un escalar por fotograma clave para una pista Number

const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // la posición.y de la raíz

const clip = new THREE.AnimationClip("bounce", 2, [track]); // duración en segundos
// pasa -1 como duración para derivarla de las pistas

Los nombres de las pistas siguen nombreNodo.nombreObjeto[índiceObjeto].nombrePropiedad[índicePropiedad]; todas las partes excepto la propiedad son opcionales. .position vincula la posición de la raíz, .material.opacity una propiedad del material, .position[y] un componente, .morphTargetInfluences[smile] un morph target, Hips.position un descendiente nombrado — así es como se abordan los huesos en un modelo esqueletizado. Los nombres de objeto pueden ser material, materials, bones o map.

// Pista Number (tamaño de valor 1); crea el material con transparent: true, o se ignora la opacidad
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);

// Pista Vector (tamaño de valor 3: x, y, z por fotograma clave)
new THREE.VectorKeyframeTrack(".position", [0, 1, 2], [0, 0, 0, 1, 2, 0, 0, 0, 0]);

// Pista de rotación (tamaño de valor 4): cuaternión x, y, z, w por fotograma clave, no ángulos de 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], // identidad, luego q2
);

// Pista Color (tamaño de valor 3)
new THREE.ColorKeyframeTrack(".material.color", [0, 1], [1, 0, 0, 0, 1, 0]);

// Pista Boolean (siempre discreta)
new THREE.BooleanKeyframeTrack(".visible", [0, 1], [true, false]);

Interpolación

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

track.setInterpolation(THREE.InterpolateLinear); // por defecto
track.setInterpolation(THREE.InterpolateSmooth); // cúbica a través de los fotogramas clave
track.setInterpolation(THREE.InterpolateDiscrete); // escalón, sin valores intermedios

// Bezier toma puntos de control absolutos (tiempo, valor), dos números por fotograma clave por
// componente: una pista de dos fotogramas clave con stride-1 necesita cuatro entradas por 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]);

// Un mezclador por raíz animada; alcanza la raíz y todos los descendientes
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // almacenado en caché: llamarlo de nuevo para este clip y raíz devuelve la misma acción
action.play();

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

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // requerido: avanza cada acción activa
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

// Los eventos se disparan en el mezclador mientras se actualiza; "finished" necesita LoopOnce o repeticiones finitas
mixer.addEventListener("finished", (e) => console.log("finalizado:", e.action.getClip().name));
mixer.addEventListener("loop", (e) => console.log("repetido:", 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(); // programar e iniciar
action.reset(); // retroceder al tiempo 0 y cancelar fundidos
action.stop(); // desprogramar; las propiedades que ninguna otra acción programada anima vuelven a sus valores originales
action.halt(0.3); // desacelerar hasta detenerse en 0.3 s

action.time = 0.5; // desplazarse dentro del clip
action.timeScale = -1; // negativo reproduce hacia atrás
action.paused = true; // congelar en su lugar

action.setLoop(THREE.LoopOnce, 1); // detener (o mantener) después de una pasada
action.setLoop(THREE.LoopRepeat, Infinity); // el predeterminado
action.setLoop(THREE.LoopPingPong, 4); // alternar dirección cada pasada
action.clampWhenFinished = true; // mantener la pose final en lugar de reiniciar

Fundidos y Transiciones Suaves

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();

// Transferir de idle a walk en 0.4 s; true como tercer argumento también deforma
// ambas escalas de tiempo para que sus longitudes de ciclo coincidan durante el fundido
idle.crossFadeTo(walk, 0.4, false);

// La misma transferencia desde las llamadas unilaterales (usa una forma, no ambas)
walk.reset().fadeIn(0.4).play(); // aumentar peso desde 0
idle.fadeOut(0.4); // reducir peso hasta 0

Reproducir Animaciones 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);

// El material predeterminado del GLTFLoader (MeshStandardMaterial) necesita luz: sin luces el modelo se renderiza negro
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 es un array de AnimationClips analizados
const clips = gltf.animations;

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

// Un segundo personaje: clonar el modelo esqueletizado, dar a la copia su propio mezclador
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // al lado del primero
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // mismo clip, dos mezcladores

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);

Animación Esquelética

// mesh es la raíz del modelo cargado; encontrar su primer SkinnedMesh
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));

// Agarrar un hueso para posar o adjuntar
const handBone = skeleton.bones.find((b) => b.name === "RightHand");

// Visualizar el rig
const helper = new THREE.SkeletonHelper(mesh);
scene.add(helper);

El control procedimental de huesos se ejecuta después de mixer.update(), o un clip que anime el mismo hueso lo sobrescribe:

const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const headBone = skinnedMesh.skeleton.bones.find((b) => b.name === "Head");
const mixer = new THREE.AnimationMixer(mesh); // reproducir los clips del modelo en él (ver Reproducir Animaciones glTF)

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

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // los clips escriben su pose de hueso aquí

  // Sobrescribir después: update() sobrescribe cada propiedad que los clips activos animan
  if (headBone) headBone.rotation.y = Math.sin(timer.getElapsed()) * 0.3;
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Los huesos son Object3D, por lo que adjuntar significa establecer como hijo:

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); // el arma ahora sigue la transformación del hueso de la mano
  weapon.position.set(0, 0, 0.25); // desplazamiento dentro del espacio del hueso
  weapon.rotation.set(0, Math.PI / 2, 0);
}

Morph Targets

// mesh tiene morph targets (cargados, o añadidos manualmente más abajo)
const names = Object.keys(mesh.morphTargetDictionary); // nombre -> índice
console.log(names, mesh.morphTargetInfluences); // un peso por target

// Mezclar un target por índice o por nombre; 0 = forma base, 1 = target completo
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;

Añadir un morph target manualmente:

const geometry = mesh.geometry;

// Un morph target es un atributo de posición (o normal) de tamaño completo junto al base
const raised = geometry.attributes.position.array.slice(); // mismo número de vértices
for (let i = 1; i < raised.length; i += 3) {
  raised[i] += 0.5; // y es cada tercer componente: levantar cada vértice
}

const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // los targets nombrados siguen siendo direccionables desde las pistas

geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // reconstruye morphTargetInfluences y el diccionario

Controlar morph targets:

// Procedimental, dentro del bucle de renderizado después de timer.update():
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;

// Por fotogramas clave: una pista Number controla un target por nombre — nunca una pista 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();

Mezclar Clips por Peso

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();

// Ambos muestrean cada fotograma; los pesos mezclan sus poses
function setBlend(speed) {
  const t = Math.min(speed / 5, 1); // 0 = idle, 5 y más = run completo
  idle.setEffectiveWeight(1 - t);
  run.setEffectiveWeight(t);
}

setBlend(2.5); // mitad idle, mitad run

Animación Aditiva

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-basa cada pista en su fotograma 0 y marca el clip como aditivo
THREE.AnimationUtils.makeClipAdditive(breathClip);

mixer.clipAction(walkClip).play(); // clip base normal
mixer.clipAction(breathClip).play(); // añade sus desplazamientos sobre la pose base

Utilidades 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"); // búsqueda por nombre
// Mantiene los fotogramas clave existentes en los fotogramas [0, 31) a 30 fps (tiempos 0 y 1); nunca remuestrea
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // copia profunda independiente

clip.optimize(); // elimina claves de tiempo duplicadas y claves iguales a ambos vecinos en cualquier pista no suave (las tangentes Bezier no se compactan)
clip.resetDuration(); // recalcula la duración desde la pista más larga — no busca
console.log(clip.validate()); // false para una relación fraccionaria valores/tiempos, una pista vacía, tiempos no ordenados o NaN

Movimiento Procedimental

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

// Aproximación exponencial; un lambda más alto ajusta más rápido. Independiente de la tasa de fotogramas

func update() {
  timer.update();
  const delta = timer.getDelta();
  // ...
}

WebGPU y TSL

Para usar WebGPU y TSL, reemplaza WebGLRenderer por WebGPURenderer. El sistema de animación (mezclador, clips, acciones) permanece igual porque todo el trabajo de animación se realiza en la CPU.

import { WebGPURenderer } from "three/addons/renderers/webgpu/WebGPURenderer.js";

const renderer = new WebGPURenderer({ antialias: true });
await renderer.init(); // requerido para WebGPU

TSL (Three Shading Language) permite sombreadores personalizados con sintaxis similar a JavaScript. Ejemplo de un sombreador de vértices simple:

import { Fn, uv, sin, time, positionWorld } from "three/tsl";

const myShader = Fn(() => {
  const position = positionWorld.add(sin(time.mul(2)).mul(0.1));
  return position;
});

Recursos

Skills relacionados