| name | threejs-animation |
| description | Three.js animation - keyframe animation, skeletal animation, morph targets, animation mixing. Use when animating objects, playing GLTF animations, creating procedural motion, or blending animations. In HULLBREAKER this is reference-only for the sanctioned moving pieces (camera ratchet curves, hinged plates/vent covers, traps and enemies) — the Meridian's own anatomy is static by operator verdict (docs/decisions.md entry 3) and none of the mixer/skeletal/morph material below may be applied to it. |
HULLBREAKER guardrails (read before using anything below)
The single fact that governs this whole skill: docs/decisions.md entry 3
(2026-07-30, CP3 verdict) is law and is never re-litigated. It states that
"the creature's anatomy is monumental and static during a transition — RIG
and the camera are what move. The next stretch of world already exists and is
revealed ... never assembled, slammed, or articulated into place." Its
addendum narrows the rule precisely: "the creature's own body never assembles,
but things the ship builds (traps, emplacements, later defenders) may."
CLAUDE.md restates it as the Static-anatomy render rule and enumerates the
only things allowed to move: "doors, access plates, vent covers, shutters,
traps, and Crown mechanisms."
So: an AnimationMixer, a skeleton, or a morph target driving world anatomy
(hull, scutes, ribs, facets, joints, the limb, the tower's faces, the Crown's
mass) is a direct violation of a recorded operator verdict, not a style
disagreement. Do not do it, do not prototype it behind a flag, do not do it
"just to see." If you believe a case needs it, stop and escalate: it requires a
new operator decision recorded in docs/decisions.md before any code lands.
Where motion legitimately lives in this repo (verified paths)
| Sanctioned motion | Curve (pure, deterministic) | Renderer that samples it |
|---|
| Corner-ritual camera ratchet (two-snap detent yaw) | src/pure/waves.js — easeOutBack(u, s), cornerYawDeltaDeg(tMs, cfg), cornerScrollVel(tMs, cfg) | src/render/camera.js |
| Transform-ritual camera yaw + scroll resume | src/pure/transform.js — transformYawDeltaDeg, transformScrollVel | src/render/camera.js |
| The tagged plate beat: hinged access plate, blown vent cover, pressure vapor | src/pure/transform.js — transformPanelState, transformCoverAjar, transformVapor | src/render/transform.js, section /* covers that move */ |
| The G1 limb: static bake, no motion at all | src/pure/limb.js — limbBakePlan(CONFIG, groundH) | src/render/limb.js (?g1=1) |
| Zip-assembly choreography (retired from the world, kept extractable for traps/emplacements per entry 3's addendum) | src/pure/waves.js — zipperOffset(tMs, colIdx, cfg) | src/render/level.js zipperColumn, driven from src/sim/wavegate.js |
src/render/limb.js says it in its own header: "THE LIMB NEVER MOVES. Every box
here is placed once from the pure bake plan (../pure/limb.js) ... there is no
per-frame hook, no ritual hook, and no build hook in this module, by
construction." src/render/transform.js says the complementary thing: "An
opening in the anatomy pre-exists; its cover is the one piece of the body
allowed to move, and it never leaves the world."
Extend these, do not reinvent them. The repo already solved "chunky,
weighted, two-detent motion" — it is easeOutBack in src/pure/waves.js,
sampled by the camera. A new beat should be a new closed-form function of tMs
next to those, asserted in tools/pathcheck.mjs, not a new AnimationClip.
The guards that will catch you
- Layer purity —
tools/pathcheck.mjs (grep it for const banned =; the
file churns, so search rather than trust a line number) runs guardLayer('pure', …)
and guardLayer('sim', …) with
const banned = /\b(THREE|document|window|renderer|scene|addEventListener|requestAnimationFrame|innerWidth|innerHeight|devicePixelRatio|performance)\b/;
over comment-stripped source, and rejects any import that crosses layers
(pure may import only ../config.js or ./x.js; sim adds ../mode.js and
../pure/x.js). Every THREE.* symbol in this document — Clock,
AnimationMixer, AnimationClip, KeyframeTrack, Quaternion — is
therefore illegal in src/pure/ and src/sim/. So is performance.now().
Only src/render/, src/ui/, and src/main.js may touch THREE.
- The anti-animation guard on the limb —
tools/pathcheck.mjs, section
"G1: the limb read of the tower" (grep for cannot be animated cannot assemble), reads the source files and asserts:
!/\bgameMs\b|\btMs\b|\bdt\b|Math\.random/ over src/pure/limb.js —
"src/pure/limb.js takes no time or randomness argument: a body that cannot
be animated cannot assemble (CP3 ruling)";
!/installView|view\./ over src/render/limb.js — "src/render/limb.js
installs no view hook at all: no per-frame, ritual or build callback can
move the limb."
Adding a mixer, a clock, or a bridge hook to either file fails the headless
gate immediately. Run node tools/pathcheck.mjs; it must exit 0.
- Determinism — randomness only via
src/pure/rng.js (mulberry32); no
Math.random, Date.now, or performance.now in src/pure/ or src/sim/.
Note honestly: the generic layer regex catches performance but not
Math.random/Date.now outside the limb-specific guard above — the rule
still binds you where pathcheck cannot see it. The gameplay clock is
in , advanced by from
; in exists so a run reproduces
frame-for-frame. A mixer accumulating its own wall-clock time silently breaks
that contract for anything gameplay-visible.
Status of the three.js animation system in this repo, today
Nothing in src/ uses AnimationMixer, AnimationClip, any KeyframeTrack,
SkinnedMesh, Skeleton, morph targets, or GLTFLoader — verified by grep
across src/, tools/, and index.html. All shipped motion is closed-form
f(tMs) → value in src/pure/, sampled by the renderer. Introducing the mixer
system at all is therefore an architecture change, not a local edit: propose it
as a task, name what it buys, and expect the static-anatomy rule to constrain
where it may point. Sanctioned targets if it is ever adopted: RIG, enemies,
traps/emplacements, and Crown mechanisms — never the body you are climbing.
Reading the rest of this document
The body below is the upstream cloudai-x/threejs-skills reference, preserved
for its API detail. Inline // HULLBREAKER: comments mark the places where the
example as written would break a rule here. One upstream import specifier was
corrected for this repo's import map (noted at the site). Treat every example
as API documentation, not as a recommended pattern for this codebase.
Three.js Animation
Quick Start
import * as THREE from "three";
const clock = new THREE.Clock();
function animate() {
const delta = clock.getDelta();
const elapsed = clock.getElapsedTime();
mesh.rotation.y += delta;
mesh.position.y = Math.sin(elapsed) * 0.5;
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
Animation System Overview
Three.js animation system has three main components:
- AnimationClip - Container for keyframe data
- AnimationMixer - Plays animations on a root object
- AnimationAction - Controls playback of a clip
AnimationClip
Stores keyframe animation data.
const times = [0, 1, 2];
const values = [0, 1, 0];
const track = new THREE.NumberKeyframeTrack(
".position[y]",
times,
values,
);
const clip = new THREE.AnimationClip("bounce", 2, [track]);
KeyframeTrack Types
new THREE.NumberKeyframeTrack(".opacity", times, [1, 0]);
new THREE.NumberKeyframeTrack(".material.opacity", times, [1, 0]);
new THREE.VectorKeyframeTrack(".position", times, [
0,
0,
0,
1,
2,
0,
0,
0,
0,
]);
const q1 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0));
const q2 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, Math.PI, ));
.(
,
[, ],
[q1., q1., q1., q1., q2., q2., q2., q2.],
);
.(, times, [
,
,
,
,
,
,
,
,
,
]);
.(, [, , ], [, , ]);
.(
,
[, ],
[, ],
);
Interpolation Modes
const track = new THREE.VectorKeyframeTrack(".position", times, values);
track.setInterpolation(THREE.InterpolateLinear);
track.setInterpolation(THREE.InterpolateSmooth);
track.setInterpolation(THREE.InterpolateDiscrete);
AnimationMixer
Plays animations on an object and its descendants.
const mixer = new THREE.AnimationMixer(model);
const action = mixer.clipAction(clip);
action.play();
function animate() {
const delta = clock.getDelta();
mixer.update(delta);
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
Mixer Events
mixer.addEventListener("finished", (e) => {
console.log("Animation finished:", e.action.getClip().name);
});
mixer.addEventListener("loop", (e) => {
console.log("Animation looped:", e.action.getClip().name);
});
AnimationAction
Controls playback of an animation clip.
const action = mixer.clipAction(clip);
action.play();
action.stop();
action.reset();
action.halt(fadeOutDuration);
action.isRunning();
action.isScheduled();
action.time = 0.5;
action.timeScale = 1;
action.paused = false;
action.weight = 1;
action.setEffectiveWeight(1);
action.loop = THREE.LoopRepeat;
action.loop = THREE.LoopOnce;
action.loop = THREE.LoopPingPong;
action.repetitions = 3;
action.clampWhenFinished = true;
action.blendMode = .;
action. = .;
Fade In/Out
action.reset().fadeIn(0.5).play();
action.fadeOut(0.5);
const action1 = mixer.clipAction(clip1);
const action2 = mixer.clipAction(clip2);
action1.play();
action1.crossFadeTo(action2, 0.5, true);
action2.play();
Loading GLTF Animations
Most common source of skeletal animations.
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
const loader = new GLTFLoader();
loader.load("model.glb", (gltf) => {
const model = gltf.scene;
scene.add(model);
const mixer = new THREE.AnimationMixer(model);
const clips = gltf.animations;
console.log(
"Available animations:",
clips.map((c) => c.name),
);
if (clips.length > 0) {
const action = mixer.clipAction(clips[0]);
action.play();
}
const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
if (walkClip) {
mixer.(walkClip).();
}
. = mixer;
});
() {
delta = clock.();
(.) ..(delta);
(animate);
renderer.(scene, camera);
}
Skeletal Animation
Skeleton and Bones
const skinnedMesh = model.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
skeleton.bones.forEach((bone) => {
console.log(bone.name, bone.position, bone.rotation);
});
const headBone = skeleton.bones.find((b) => b.name === "Head");
if (headBone) headBone.rotation.y = Math.PI / 4;
const helper = new THREE.SkeletonHelper(model);
scene.add(helper);
Programmatic Bone Animation
function animate() {
const time = clock.getElapsedTime();
const headBone = skeleton.bones.find((b) => b.name === "Head");
if (headBone) {
headBone.rotation.y = Math.sin(time) * 0.3;
}
mixer.update(clock.getDelta());
}
Bone Attachments
const weapon = new THREE.Mesh(weaponGeometry, weaponMaterial);
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
if (handBone) handBone.add(weapon);
weapon.position.set(0, 0, 0.5);
weapon.rotation.set(0, Math.PI / 2, 0);
Morph Targets
Blend between different mesh shapes.
const geometry = mesh.geometry;
console.log("Morph attributes:", Object.keys(geometry.morphAttributes));
mesh.morphTargetInfluences;
mesh.morphTargetDictionary;
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["smile"];
mesh.morphTargetInfluences[smileIndex] = 1;
Animating Morph Targets
function animate() {
const t = clock.getElapsedTime();
mesh.morphTargetInfluences[0] = (Math.sin(t) + 1) / 2;
}
const track = new THREE.NumberKeyframeTrack(
".morphTargetInfluences[smile]",
[0, 0.5, 1],
[0, 1, 0],
);
const clip = new THREE.AnimationClip("smile", 1, [track]);
mixer.clipAction(clip).play();
Animation Blending
Mix multiple animations together.
const idleAction = mixer.clipAction(idleClip);
const walkAction = mixer.clipAction(walkClip);
const runAction = mixer.clipAction(runClip);
idleAction.play();
walkAction.play();
runAction.play();
idleAction.setEffectiveWeight(1);
walkAction.setEffectiveWeight(0);
runAction.setEffectiveWeight(0);
function updateAnimations(speed) {
if (speed < 0.1) {
idleAction.setEffectiveWeight(1);
walkAction.setEffectiveWeight(0);
runAction.setEffectiveWeight(0);
} else if (speed < 5) {
const t = speed / 5;
idleAction.setEffectiveWeight(1 - t);
walkAction.setEffectiveWeight(t);
runAction.setEffectiveWeight(0);
} else {
const t = Math.min((speed - 5) / 5, 1);
idleAction.();
walkAction.( - t);
runAction.(t);
}
}
Additive Blending
const baseAction = mixer.clipAction(baseClip);
baseAction.play();
const additiveAction = mixer.clipAction(additiveClip);
additiveAction.blendMode = THREE.AdditiveAnimationBlendMode;
additiveAction.play();
THREE.AnimationUtils.makeClipAdditive(additiveClip);
Animation Utilities
import * as THREE from "three";
const clip = THREE.AnimationClip.findByName(clips, "Walk");
const subclip = THREE.AnimationUtils.subclip(clip, "subclip", 0, 30, 30);
THREE.AnimationUtils.makeClipAdditive(clip);
THREE.AnimationUtils.makeClipAdditive(clip, 0, referenceClip);
const clone = clip.clone();
clip.duration;
clip.optimize();
clip.resetDuration();
Procedural Animation Patterns
Smooth Damping
const target = new THREE.Vector3();
const current = new THREE.Vector3();
const velocity = new THREE.Vector3();
function smoothDamp(current, target, velocity, smoothTime, deltaTime) {
const omega = 2 / smoothTime;
const x = omega * deltaTime;
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
const change = current.clone().sub(target);
const temp = velocity
.clone()
.add(change.clone().multiplyScalar(omega))
.multiplyScalar(deltaTime);
velocity.sub(temp.clone().multiplyScalar(omega)).multiplyScalar(exp);
return target.clone().add(change.add(temp).multiplyScalar(exp));
}
function animate() {
current.copy(smoothDamp(current, target, velocity, , delta));
mesh..(current);
}
Spring Physics
class Spring {
constructor(stiffness = 100, damping = 10) {
this.stiffness = stiffness;
this.damping = damping;
this.position = 0;
this.velocity = 0;
this.target = 0;
}
update(dt) {
const force = -this.stiffness * (this.position - this.target);
const dampingForce = -this.damping * this.velocity;
this.velocity += (force + dampingForce) * dt;
this.position += this.velocity * dt;
return this.position;
}
}
const spring = new Spring(100, 10);
spring.target = 1;
function animate() {
mesh.. = spring.(delta);
}
Oscillation
function animate() {
const t = clock.getElapsedTime();
mesh.position.y = Math.sin(t * 2) * 0.5;
mesh.position.y = Math.abs(Math.sin(t * 3)) * 2;
mesh.position.x = Math.cos(t) * 2;
mesh.position.z = Math.sin(t) * 2;
mesh.position.x = Math.sin(t) * 2;
mesh.position.z = Math.sin(t * 2) * 1;
}
Performance Tips
- Share clips: Same AnimationClip can be used on multiple mixers
- Optimize clips: Call
clip.optimize() to remove redundant keyframes
- Disable when off-screen: Stop mixer updates for invisible objects
- Use LOD for animations: Simpler rigs for distant characters
- Limit active mixers: Each mixer.update() has a cost
mesh.onBeforeRender = () => {
action.paused = false;
};
mesh.onAfterRender = () => {
if (!isInFrustum(mesh)) {
action.paused = true;
}
};
const clipCache = new Map();
function getClip(name) {
if (!clipCache.has(name)) {
clipCache.set(name, loadClip(name));
}
return clipCache.get(name);
}
See Also
threejs-loaders - Loading animated GLTF models
threejs-fundamentals - Clock and animation loop
threejs-shaders - Vertex animation in shaders