SHARDROOM/threejs-animation

Animação por keyframes, esqueletal e por morph targets, movimento procedural e mistura de clipes, no WebGL ou com WebGPU e TSL. Use ao animar objetos, reproduzir animações glTF, posar ossos, controlar morph targets ou misturar e fazer crossfade de clipes.

O que é threejs-animation?

Esta skill fornece um guia completo e prático para animação em three.js, cobrindo desde conceitos fundamentais como AnimationClip, AnimationMixer e AnimationAction até técnicas avançadas como animação esqueletal, morph targets, mistura de clipes por peso e animação aditiva. É direcionada a desenvolvedores que trabalham com a versão r186 do three.js, seja com WebGLRenderer ou WebGPURenderer, e inclui exemplos de código detalhados para cada cenário. A documentação explica como o sistema de animação funciona internamente (CPU-driven, independente do renderizador), como criar e manipular tracks de keyframe com diferentes tipos de interpolação, como reproduzir animações de modelos glTF, e como controlar ossos e morph targets de forma processual ou via clipes. Também aborda utilitários como clonagem de personagens esqueletais com SkeletonUtils, otimização e validação de clipes, e boas práticas como uso do Timer para garantir animação independente de taxa de quadros. A habilidade é essencial para qualquer projeto three.js que necessite de animação complexa, oferecendo tanto exemplos de código prontos para uso quanto explicações conceituais profundas.

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

Installed? Explore more Programação e Desenvolvimento skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

Animação Three.js

Alvo: three.js r186. Os exemplos usam WebGLRenderer; a seção WebGPU e TSL usa WebGPURenderer.

Início 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); // também mantém deltas pequenos após a aba ficar oculta

function animate() {
  timer.update(); // primeira coisa a cada frame: corrige getDelta()/getElapsed() para este frame
  const delta = timer.getDelta();
  const elapsed = timer.getElapsed();

  mesh.rotation.y += delta; // rotação independente da taxa de quadros
  mesh.position.y = Math.sin(elapsed * 2) * 0.25; // balanço baseado no tempo absoluto

  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Como o Sistema se Organiza

  • AnimationClip: dados de keyframe — trilhas de tempos e valores, sem estado de reprodução.
  • AnimationMixer: reproduz clipes em um objeto raiz e todos os seus descendentes.
  • AnimationAction: controle de reprodução para um clipe em um mixer.

A cada frame, mixer.update(delta) amostra os clipes ativos e escreve os valores nas propriedades comuns dos objetos. O sistema roda na CPU e nunca toca no renderizador, portanto, ele controla modelos da mesma forma em qualquer renderizador.

AnimationClip e Trilhas de Keyframe

// Uma trilha por propriedade animada. values.length deve ser times.length * valueSize.
const times = [0, 1, 2]; // tempos dos keyframes em segundos
const values = [0, 1, 0]; // um escalar por keyframe para uma trilha Number

const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // a position.y da raiz

const clip = new THREE.AnimationClip("bounce", 2, [track]); // duração em segundos
// passe -1 como duração para derivá-la das trilhas

Os nomes das trilhas seguem nodeName.objectName[objectIndex].propertyName[propertyIndex]; todas as partes exceto a propriedade são opcionais. .position vincula a posição da raiz, .material.opacity uma propriedade de material, .position[y] um componente, .morphTargetInfluences[smile] um morph target, Hips.position um descendente nomeado — é assim que os ossos são endereçados em um modelo com skin. Os nomes de objetos podem ser material, materials, bones ou map.

// Trilha Number (value size 1); crie o material com transparent: true, senão a opacidade é ignorada
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);

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

// Trilha Rotation (value size 4): quaternion x, y, z, w por keyframe, não ângulos 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], // identidade, depois q2
);

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

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

Interpolação

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

track.setInterpolation(THREE.InterpolateLinear); // padrão
track.setInterpolation(THREE.InterpolateSmooth); // cúbico através dos keyframes
track.setInterpolation(THREE.InterpolateDiscrete); // degrau, sem valores intermediários

// Bezier usa pontos de controle absolutos (tempo, valor), dois números por keyframe por
// componente: uma trilha stride-1 de dois keyframes precisa de quatro 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]);

// Um mixer por raiz animada; ele alcança a raiz e todos os descendentes
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // cache: chamá-lo novamente para este clipe e raiz retorna a mesma ação
action.play();

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

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // obrigatório: avança cada ação ativa
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

// Eventos são disparados no mixer durante sua atualização; "finished" precisa de LoopOnce ou repetições finitas
mixer.addEventListener("finished", (e) => console.log("finalizado:", e.action.getClip().name));
mixer.addEventListener("loop", (e) => console.log("repetiu:", 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(); // agendar e iniciar
action.reset(); // rebobinar para o tempo 0 e cancelar fades
action.stop(); // desagendar; propriedades que nenhuma outra ação agendada anima retornam aos seus valores originais
action.halt(0.3); // desacelerar até parar em 0,3 s

action.time = 0.5; // avançar/voltar dentro do clipe
action.timeScale = -1; // negativo reproduz para trás
action.paused = true; // congelar no lugar

action.setLoop(THREE.LoopOnce, 1); // parar (ou segurar) após uma passagem
action.setLoop(THREE.LoopRepeat, Infinity); // o padrão
action.setLoop(THREE.LoopPingPong, 4); // alternar direção a cada passagem
action.clampWhenFinished = true; // manter a pose final em vez de resetar

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

// Transição de idle para walk em 0,4 s; true como terceiro argumento também ajusta
// ambas as escalas de tempo para que seus comprimentos de ciclo coincidam durante o fade
idle.crossFadeTo(walk, 0.4, false);

// A mesma transição a partir de chamadas unilaterais (use uma forma, não ambas)
walk.reset().fadeIn(0.4).play(); // aumentar peso de 0
idle.fadeOut(0.4); // diminuir peso para 0

Reproduzindo Animações 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);

// O material padrão do GLTFLoader (MeshStandardMaterial) é iluminado: sem luzes o modelo renderiza preto
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 é um array de AnimationClips analisados
const clips = gltf.animations;

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

// Um segundo personagem: clone o modelo com skin, dê à cópia seu próprio mixer
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // ao lado do primeiro
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // mesmo clipe, dois 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);

Animação Esqueletal

// mesh é a raiz do modelo carregado; encontre seu primeiro SkinnedMesh
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));

// Pegue um osso para posar ou anexar
const handBone = skeleton.bones.find((b) => b.name === "RightHand");

// Visualize o rig
const helper = new THREE.SkeletonHelper(mesh);
scene.add(helper);

O controle processual de ossos é executado após mixer.update(), ou um clipe que anime o mesmo osso sobrescreverá:

const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const headBone = skinnedMesh.skeleton.bones.find((b) => b.name === "Head");
const mixer = new THREE.AnimationMixer(mesh); // reproduz os clipes do modelo nele (veja Reproduzindo Animações glTF)

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

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // os clipes escrevem a pose do osso aqui

  // Sobrescrever depois: update() sobrescreve todas as propriedades que os clipes ativos animam
  if (headBone) headBone.rotation.y = Math.sin(timer.getElapsed()) * 0.3;
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Ossos são Object3Ds, então anexar significa parentear:

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 agora segue a transformação do osso da mão
  weapon.position.set(0, 0, 0.25); // offset dentro do espaço do osso
  weapon.rotation.set(0, Math.PI / 2, 0);
}

Morph Targets

// mesh carrega morph targets (carregados ou adicionados manualmente abaixo)
const names = Object.keys(mesh.morphTargetDictionary); // nome -> índice
console.log(names, mesh.morphTargetInfluences); // um peso por target

// Misture um target por índice ou por nome; 0 = forma base, 1 = target completo
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;

Adicionando um morph target manualmente:

const geometry = mesh.geometry;

// Um morph target é um atributo de posição (ou normal) de tamanho completo ao lado do base
const raised = geometry.attributes.position.array.slice(); // mesma contagem de vértices
for (let i = 1; i < raised.length; i += 3) {
  raised[i] += 0.5; // y é a cada terceira componente: levantar cada vértice
}

const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // targets nomeados permanecem endereçáveis a partir de trilhas

geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // reconstrói morphTargetInfluences e o dicionário

Controlando morph targets:

// Processual, dentro do loop de renderização após timer.update():
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;

// Keyframed: uma trilha Number controla um target pelo nome — nunca uma trilha 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();

Misturando Clipes 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 amostram a cada frame; os pesos misturam suas poses
function setBlend(speed) {
  const t = Math.min(speed / 5, 1); // 0 = idle, 5 e acima = run completo
  idle.setEffectiveWeight(1 - t);
  run.setEffectiveWeight(t);
}

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

Animação 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-baseia cada trilha em seu frame 0 e marca o clipe como aditivo
THREE.AnimationUtils.makeClipAdditive(breathClip);

mixer.clipAction(walkClip).play(); // clipe base normal
mixer.clipAction(breathClip).play(); // adiciona seus offsets sobre a pose base

Utilitários de Clipe

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"); // busca por nome
// Mantém os keyframes existentes nos frames [0, 31) a 30 fps (tempos 0 e 1); nunca reamostra
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // cópia profunda independente

clip.optimize(); // descarta chaves de tempo duplicadas e chaves iguais a ambos os vizinhos em qualquer trilha não suave (tangentes Bezier não são compactadas)
clip.resetDuration(); // recalcula a duração a partir da trilha mais longa — não busca
console.log(clip.validate()); // false para uma razão valores-tempos fracionária, trilha vazia, tempos não ordenados ou NaN

Movimento Processual

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

// Aproximação exponencial; um lambda maior captura mais rápido. Independente da taxa de quadros

…

Habilidades Relacionadas