Animation

Skeletons & skinning

How Shade deforms skinned meshes on the GPU - skins, clips and channels as plain CPU data, registered with GPUAnimationManager and instanced per entity.

Skinning belongs to Shade and runs on the GPU. A skinned model is three kinds of plain CPU data - a Skin (the joint nodes and their inverse-bind matrices), one or more SkinnedMeshes, and ShadeAnimationClips whose channels target those joints - registered with a GPUAnimationManager, which evaluates curves, accumulates the pose, builds skin matrices and deforms vertices in compute passes.

The types live under @woosh/meep-engine/src/shade/renderer/animation/ and @woosh/meep-engine/src/shade/renderer/scene/; the ECS-side helpers that place one model per entity live under @woosh/meep-engine/src/engine/graphics3/.

The per-frame chain

advance clip timeevaluate curves, add into the pose accumulatorresolve the accumulator into node TRScompose the transform hierarchybuild skin matricesdeform the per-instance vertex clone

GPUSceneContext runs all of it - it calls GPUAnimationManager.update() to flush pending uploads and GPUAnimationManager.tick(cmd_ctx, dt) to encode the passes. Application code never drives those directly; it registers data and sets playback state.

Skin

@woosh/meep-engine/src/shade/renderer/animation/Skin.js (named export). Pure data - it knows nothing about the GPU.

MemberTypeMeaning
namestringOptional, empty by default
jointsNode3D[]Ordered joint nodes. A joint’s index in this array is the index the per-vertex SkinningJoints attribute refers to
inverse_bind_matricesFloat32ArrayJoint-count x 16, flat, column-major. Joint j occupies [j*16, j*16+16). glTF already stores them this way, so loaders copy straight in
meshesSkinnedMesh[]The meshes that deform against this skin. register_skin binds exactly these - it does not walk the scene looking for users

Skin.from({ name?, joints, inverse_bind_matrices, meshes? }) validates lengths and shape; copy(other) and clone() share the joint and mesh references and share inverse_bind_matrices outright, so retargeting a cloned skin means replacing those arrays yourself - or letting instantiate_scene_bundle do it.

Several meshes can share one skin: a character’s body and its hair, both bound to the same joints.

SkinnedMesh

@woosh/meep-engine/src/shade/renderer/scene/SkinnedMesh.js (named export), extends Mesh.

On the CPU it points at the unmodified source MeshletGeometry, exactly like any other instance of that mesh. The GPU allocates a per-instance vertex clone inside the meshlet buffers, registers it as its own geometry id, and a compute pass writes the deformed result into the clone each frame - every other instance still points at the source and is untouched.

Skin matrices follow the glTF formula inverse(meshNode.world) x jointNode.world x inverseBind, and the rasterizer re-applies mesh.transform_global to the result, so the mesh node’s own transform cancels out exactly.

SkinnedMesh#skin is the CPU-side Skin this mesh deforms against, or null. It means “whose formula places these vertices”, not “is deformation running”: register_skin writes it and nothing clears it, because a mesh whose skinning pass has been unbound still holds vertices the skin put there. Assigning it refreshes nothing - bounds keep describing the previous basis until updateMatrices() or updateBoundsBasic() runs.

The GPU-side skin id is a table row and lives in GPUMeshSkinningContext; SkinnedMesh does not expose it.

Which bounds to believe

Mesh#bounding_box on a SkinnedMesh is the rest pose. Joints driven by the pose accumulator are under TransformAuthority.GPU and their transform_global is never advanced on the CPU, so the CPU can only see the pose it seeded. That box is the right size and in the right place, and it is what seeds the scene database’s meshes row.

The deformed world box - refreshed every frame from the vertices the skinning pass just moved - is that database row, published by the skinning pass’s bounds-refresh chain. Read it there when the silhouette is what matters.

Clips, channels and properties

ShadeAnimationClip (shade/renderer/animation/ShadeAnimationClip.js, named export) is a bundle of channels sharing one clock. The Shade prefix is there because the engine already exports an unrelated AnimationClip used by the graph and clip-list drivers.

MemberNotes
nameOptional
channelsShadeAnimationChannel[]; order does not matter
get start_time / get end_timeEarliest and latest keyframe times across every channel’s curves; used as the clip’s time_start / time_end at registration
get durationend_time - start_time
static from({ name?, channels })Builder
copy(other) / clone()Channels are deep-cloned; the curves inside them are shared
apply(t)CPU stepping helper - mutates the targets
optimize()See Optimising a clip

ShadeAnimationChannel (same directory, named export) is one binding: { target: Node3D, property: number, curves: { x?, y?, z?, w? } }, plus static from(...), copy, clone, apply(t), get curve_count and optimize().

property comes from Node3DProperty (shade/renderer/object_property/Node3DProperty.js, named export):

ConstantValueCurve slotsMasking
Translation0x, y, zPer-axis; an absent slot leaves that axis alone
Rotation1x, y, z, wNone - the quaternion is written whole and normalised. Supply all four
Scale2x, y, zPer-axis. For a uniform pulse, point all three slots at one curve

Rotation needs the normalise because cubic-Hermite interpolation of four scalar curves drifts off the unit 4-sphere; apply and the GPU apply shader both do it.

GPUAnimationManager

shade/renderer/animation/GPUAnimationManager.js, named export. new GPUAnimationManager(device, label, scene_context). You do not normally construct one - GPUSceneContext owns it, and graphics.scene_context(scene).animation_manager is where it comes from.

LevelMethods
Registration (CPU data classes)register_skin(skin) -> number, register_clip(clip) -> number, unregister_skin(id), unregister_clip(id)
Registration (low level)add_curve(curve), add_track({x?,y?,z?,w?}), add_skin({ joints: [{ node, inverse_bind }] }), add_clip({ time?, time_start?, time_end?, bindings: [{ track, instance, property }] })
Playback statestart(id), stop(id), set_time(id, t), set_playback_rate(id, rate), set_playback_weight(id, w), set_flags(id, bits) (OR), clear_flags(id, bits) (AND-NOT)
Per frameupdate(cmd_ctx), tick(cmd_ctx, dt), dispatch_skin_matrix_prep(cmd_ctx), ensure_pose_accumulator_capacity(row_count) - all driven by the scene context
Buffers and lookupsget skin_matrices_buffer, get prev_skin_matrices_buffer, get skin_matrix_count, get pose_accumulator_buffer, get_skin(id), get_skin_matrix_offset(id), get database

AnimationClipFlags (same directory, named export) is { Playing: 1 << 0, Loop: 1 << 1 } and applies to a GPU clip record. Do not confuse it with either ECS-side AnimationClipFlag: graphics/ecs/animation/animator/AnimationClipFlag.js is { Repeat: 1 } and belongs to graphs, ecs/animation/AnimationClipFlag.js is { ClampWhenFinished: 1 } and belongs to the Animation component. Three similar names, three different enums.

Registration is eventually consistent

register_skin and register_clip return an id synchronously, and every state mutator and the matching unregister_* accept it immediately. If a joint’s or a channel target’s Node3D is not in a scene yet, the driving chain is queued and built on the next update() that sees all of them resolved.

Until then the registration is inert, not wrong: the table row holds a placeholder head pointer, so no deformation runs and the apply pass produces no contributions. That is what makes registration callable the moment an instance exists rather than at some later moment when the scene is known to be built.

Curves and tracks are interned synchronously - they are pure data with no scene dependency.

Do not register clips nobody plays

The pose accumulator marks a joint dirty for every bound clip that writes to it, whatever that clip’s weight. So a bound clip at weight zero resolves the joints only it drives to zero rather than leaving them at rest, and a model whose every clip was registered renders the sum of all of them.

Register only what a driver actually plays, and re-register when that changes. A clip no driver claims is never bound and so moves nothing. register_instance_clips takes the set of clips for exactly this reason.

Skin matrix buffers ping-pong

skin_matrices_buffer holds this frame’s matrices; prev_skin_matrices_buffer holds the previous frame’s, which the skinning pass reads to produce position_prev for the velocity pass. The two swap roles every frame, and either can be reallocated on grow.

Re-fetch both every frame. Caching either reference across frames reads the wrong buffer. The same applies to pose_accumulator_buffer, which is invalidated when ensure_pose_accumulator_capacity grows it.

Per-entity instancing

A loader hands back a SceneBundle - { scenes: Node3D[], skins: Skin[], clips: ShadeAnimationClip[] } (shade/renderer/loader/SceneBundle.js). That is a template, not a placement: its skins name Node3Ds and its clip channels target Node3Ds, so two entities showing one model cannot share it - the second would drive the first one’s joints.

import { instantiate_scene_bundle, SceneBundleInstance } from
    "@woosh/meep-engine/src/engine/graphics3/instantiate_scene_bundle.js";
import {
    register_instance_animation, unregister_instance_animation,
    register_instance_clips, unregister_instance_clips,
    InstanceAnimationRegistration, InstanceClipRegistration, CLIP_NOT_REGISTERED
} from "@woosh/meep-engine/src/engine/graphics3/register_instance_animation.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";

const context = engine.graphics.scene_context(EngineHarness.shadeScene(engine));

const instance = instantiate_scene_bundle(bundle);

const skins = register_instance_animation(context, instance);
const clips = register_instance_clips(context, instance, [0, 3]);

instantiate_scene_bundle(bundle) -> SceneBundleInstance copies the node tree and retargets the skins and clips onto the copy. Geometry, materials, inverse-bind matrices and the animation curves are all shared - the copy is per-node, not per-byte. Every node a clip drives is set to TransformAuthority.GPU, because the pose accumulator writes those rows and a CPU that also wrote them would race the animation. Nothing in the bundle is modified.

SceneBundleInstance memberMeaning
rootsNode3D[] - the copied scene roots
skinsSkin[] bound to the copy’s joints
clipsShadeAnimationClip[] retargeted onto the copy
nodesMap<string, Node3D> - every node by the name the asset gave it. This is where a socket, an effect anchor or a projectile spawn is looked up. A name authored twice keeps the first node the copy reached

register_instance_animation(context, instance) registers the skins and returns an InstanceAnimationRegistration = { context, skins: number[] }, positional against instance.skins.

register_instance_clips(context, instance, slots) registers the named clips and returns an InstanceClipRegistration = { context, ids: Int32Array }, indexed by instance.clips with CLIP_NOT_REGISTERED wherever this driver does not play that clip. Duplicate slots are ignored - two graph states naming one clip is one GPU row, and registering it twice would be two rows the accumulator adds together.

Both registrations carry the GPUSceneContext they were issued by, and that is the point of the field: an id may only be handed back to the manager that issued it. A renderer restart builds a new context and voids every id the old one gave out, so a holder compares with === before calling unregister_instance_animation / unregister_instance_clips, and forgets the ids otherwise rather than corrupting a stranger’s table.

Who registers what

The split follows ownership. A skin belongs to the model - it is how the mesh deforms, and every placement needs it whatever is playing - so MeshSystem registers it when it places the model. A clip belongs to whatever plays it, so the animation drivers register clips:

DriverComponentSystem
State machineAnimationGraphControllerAnimationGraphSystem
Clip listAnimation (engine/ecs/animation/Animation.js)AnimationSystem (engine/graphics3/AnimationSystem.js), which plays it through ClipListPlayer

Both take (graphics, meshes) and depend on SGMesh. In the clip-list path the per-clip weights are shares of one pose - each clip’s weight over the list’s total - which is why a walk clip at weight 1000 against an idle at 1 means “the walk”.

Both systems also expose write_pose_playbacks(target, entity) -> boolean, which appends PosePlayback entries ({ clip, time, weight }) for whatever the entity is playing. That is the input to the CPU pose evaluator.

Bounds

Three separate things answer the bounds question, at three different costs.

compute_skin_world_bounds(skin, mesh_box, mesh_sphere, out_box, out_sphere) (shade/renderer/animation/compute_skin_world_bounds.js, named export) is the union over joints of joint.global x inverse_bind applied to the mesh-space bounds. mesh_box is 6 floats, mesh_sphere is 4.

The mesh node’s own transform is deliberately absent: the skinning pass applies inverse(mesh.global) and the rasterizer re-applies mesh.global, so including it would double-apply. On an FBX-derived glTF whose root carries the file’s centimetre-to-metre scale, that mistake comes out 100x too small.

The bound is conservative - each joint’s product maps the whole geometry rather than the vertices that joint weights - and exact at the bind pose, where every joint’s product collapses to the same matrix. Pose freshness is the caller’s problem: for a GPU-driven character this is a rest-pose bound that tracks placement.

compute_skinned_mesh_clip_bounding_sphere({ positions, joint_indices, joint_weights, vertex_count, skin, clip, dt = 1/60 }, out_sphere) (same directory, named export, alongside compute_skinned_pose_bounding_sphere) steps the clip at a fixed dt, CPU-skins the bind-pose vertices at each tick, miniballs each, and unions the result. The output sphere is mesh-local. It saves and restores the joints’ bind-pose transform_local, allocates nothing per call by using module-level scratch - and is therefore not safe per-frame or concurrently. It is a scene-setup-time computation.

The per-frame deformed world box is computed on the GPU by the skinning pass’s bounds-refresh chain and published into the scene database’s meshes row. There is no API call for it; read the row.

Optimising a clip

ShadeAnimationClip.optimize() calls ShadeAnimationChannel.optimize() on every channel and drops any whose curve_count collapses to zero. Applying the clip afterwards is observationally identical, with less per-frame work and a smaller upload - each surviving channel still costs a track row and a binding row.

Identity gating is per property, and every drop is additionally gated on the target’s rest value already sitting at that identity (within an epsilon), because an absent slot leaves the rest value in place while a constant-identity curve would overwrite it:

PropertyIdentityGranularity
Translation(0, 0, 0)Per axis
Scale(1, 1, 1)Per axis
Rotationidentity orientation - x, y, z all constant zero, w freeAll four curves or none

Survivors are key-reduced with animation_curve_optimize on a clone, so a curve shared by sibling clips is never mutated underneath them. The clip itself is mutated in place - clone first if you need the original. The natural place to call it is clip-load time, right after load_gltf, for assets whose authoring tool dumped redundant bind-pose channels.

optimize() returns nothing on purpose. For before/after numbers, compare clip.channels.length and the sum of channel.curve_count yourself.

Addressing a bone

There is no Skeleton object and no SkeletonUtils. A model expands into entities, each carrying a Name and a TransformAttachment pointing at its parent, so naming a bone is naming an entity:

import { transform_attachment_find_descendant_by_name } from
    "@woosh/meep-engine/src/engine/ecs/transform-attachment/transform_attachment_find_descendant_by_name.js";

const bone = transform_attachment_find_descendant_by_name(ecd, entity, "hand.R");
// -1 when there is no such descendant - the model may simply not have arrived

To ask where that node is in world space, use query_entity_node_world_pose(result, entityManager, entity, node_name) from engine/graphics3/pose/query_entity_node_world_pose.js. It evaluates the pose from the clips the animation systems are driving, off the same clock they write to the renderer with - it never reads back from the GPU, because readback is a frame late and asynchronous, and an outcome that depended on it would depend on frame pacing. See Hierarchy.

Exports nothing reads

BoneMapping and HumanoidBoneType are exported and nothing in the engine imports them: IKConstraint.effector is an asset node name. The live CPU skinner is mesh_apply_skeletal_vertex_skinning (engine/graphics/geometry/skining/); computeSkinnedMeshVertices ships beside it and is not what the engine calls.

  • Animation graphs - the state machine that decides which clips play
  • Inverse kinematics - placing bones against terrain after the pose is evaluated
  • Hierarchy - bones as entities, and querying a socket’s world pose
  • Meshes & materials - SGMesh, MeshSystem and model loading
  • Source: shade/renderer/animation/, shade/renderer/scene/, engine/graphics3/