Skeletons & skinning

How Shade deforms skinned meshes on the GPU - MeshSkin and AnimationClips components, the systems that register them with GPUAnimationManager, and models spawned per entity from a compiled prefab.

Skinning belongs to Shade and runs on the GPU. In the ECS a skinned model is components: a MeshSkin on each mesh entity a skeleton deforms (the joints, as entities, and their inverse-bind matrices), and an AnimationClips on the entity that plays the model’s ShadeAnimationClips. MeshSkinSystem registers every skin with a GPUAnimationManager, the animation systems register the clips they play, and the manager evaluates curves, accumulates the pose, builds skin matrices and deforms vertices in compute passes.

The GPU-side types live under @woosh/meep-engine/src/shade/renderer/animation/. The components, the systems that register them, and the prefab layer that turns a loaded model into entities live under @woosh/meep-engine/src/engine/.

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
jointsnumber[]Ordered joints. A joint’s index in this array is the index the per-vertex SkinningJoints attribute refers to. What each number is depends on where the skin lives - see below
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
meshesnumber[]The meshes that deform against this skin, addressed the same way as joints

A skin’s joints and meshes are indices, and which index depends on where the skin is. In a loader’s SceneBundle they index bundle.nodes; in a compiled Prefab they are that prefab’s node indices. A placement carries no Skin: prefab_instantiate turns each into a MeshSkin on the mesh entities it deforms, with the joints as entities.

Skin.from({ name?, joints, inverse_bind_matrices, meshes? }) validates lengths and shape; copy(other) and clone() share inverse_bind_matrices outright.

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

MeshSkin

@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/MeshSkin.js (named export). The component that makes the geometry beside it deform, and it lives on the mesh entity: the geometry’s per-vertex joint indices index joints, and an inverse bind belongs to the pair of this mesh and that joint.

MemberTypeMeaning
jointsInt32ArrayThe joints, as entities, in the order the geometry’s SkinningJoints attribute addresses them
inverse_bind_matricesFloat32Arrayjoints.length x 16, column-major, shared - every placement of a model holds the one array its prefab compiled

MeshSkin.from(joints, inverse_bind_matrices) builds one. The joints are entities like any other, so a clip, IK, a ragdoll or cloth moves them and the skin follows.

  • The geometry must draw a clone. The ShadedGeometry beside it carries ShadedGeometryFlags.Skinned; without the flag it draws the shared source and nothing deforms.
  • Replaced, never edited. A skin is registered once with the joints and matrices it has then. To change either, take the component off and put another on.
  • One skeleton, one registration. Meshes bound to the same joints with the same inverse binds - a body and its hair - share one GPU skin whether or not they share the arrays, so the skeleton’s matrices are built once a frame.
  • Not saved. The joint indices address a vertex attribute no save holds, so whatever builds the geometry builds its skin.

MeshSkinSystem (engine/graphics3/MeshSkinSystem.js) is what deforms it - see Who registers what.

The skinned primitive

A skinned mesh is an entity like any other primitive: a transforms row, a meshes row, and a ShadedGeometry naming what it draws with the Skinned flag set - plus the MeshSkin that says which joints it is weighted to.

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 - the inverse of the mesh’s world, times the joint’s world, times the joint’s inverse-bind matrix - and the rasterizer re-applies the mesh entity’s own world transform to the result, so it cancels out exactly. Which frame a skin deforms in is settled once per model, at compile time, by prefab_normalize_skin_frames - not once per placement, and not at registration.

The GPU-side skin id is a table row and lives in GPUMeshSkinningContext; nothing on the CPU stamps it onto anything.

Which bounds to believe

There is one answer, and it is the GPU’s. The deformed world box is refreshed every frame from the vertices the skinning pass just moved, and published into the scene database’s meshes row by that pass’s bounds-refresh chain.

Nothing on the CPU keeps a copy, and nothing could: a joint driven by the pose accumulator has its local offset resolved on the GPU, so the CPU never holds the pose that is on screen. GPUSceneContext.bounds reads the row back, a frame or two behind.

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: number, property: number, curves: { x?, y?, z?, w? } }, plus static from(...), copy, clone, apply(t), get curve_count and optimize(). target is addressed exactly as a skin’s joints are - a bundle node index, a prefab node index, or an entity in a placement’s AnimationClips, which is what register_clip takes.

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 (high level)register_skin(joints, inverse_bind_matrices) -> number, bind_skin_mesh(entity, skin_id), unbind_skin_mesh(entity), 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_skin_joint_count(id), get database

register_skin takes the joints as entities and their inverse binds, and each call is one hold on the skin. A second registration with the same joint entities in the same order under the same inverse binds answers with the first one’s id, and the last unregister_skin frees it. bind_skin_mesh deforms a mesh entity against an issued skin - one skin per mesh, so unbind_skin_mesh first to move it.

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 entity or a channel target has not taken its transforms row yet, the driving chain is queued and built on the next update() that sees every one of them there.

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_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 - { nodes: SceneNode[], skins: Skin[], clips: ShadeAnimationClip[], lights: {light, node}[] } (shade/renderer/loader/SceneBundle.js). That is the model as loaded, not a placement of it: records addressed by node index, with no transform state anyone could pose.

It is compiled once and spawned per placement:

import { prefab_compile } from
    "@woosh/meep-engine/src/engine/graphics3/prefab/prefab_compile.js";
import { prefab_instantiate } from
    "@woosh/meep-engine/src/engine/graphics3/prefab/prefab_instantiate.js";

const prefab   = prefab_compile(bundle);                      // once per model
const instance = prefab_instantiate(prefab, dataset, root);   // once per placement

prefab_compile(bundle) -> Prefab reads the bundle and never writes it. Geometry, materials, inverse-bind matrices and the animation curves are referenced, not copied - none of them is per-placement state, which is what makes a prefab cheap to spawn. Two things are settled here, once per model rather than once per placement: each skin’s meshes are put into the frame that skin deforms in (prefab_normalize_skin_frames), and each clip that scales something is checked against each skin (prefab_skin_clip_scale_conflicts).

prefab_instantiate(prefab, dataset, root) -> PrefabInstance spawns the model under an entity you already have. Every node becomes an entity carrying a Transform64, a Name, a TransformAttachment naming its parent and a ParentEntity naming the root. An entity whose local offset a clip drives is granted GPUStateAuthority for TransformAttachment (engine/ecs/gpu/), because the pose accumulator resolves that offset on the GPU and a CPU that also wrote it would race the animation.

A primitive a skin deforms gets the Skinned flag and a MeshSkin naming this placement’s joint entities. The model’s clips, every channel re-addressed to this placement’s entities, go on the root as one AnimationClips - empty when the model has none - added last, once every entity they key exists. The root must not carry an AnimationClips already: one root holds one placement.

PrefabInstance memberMeaning
prefabthe model it was spawned from
rootthe entity that placed the model - node 0, which was there already
entitiesInt32Array - the entity of each node, by node index; entities[0] is root
mesh_entitiesInt32Array - the entities that draw, one per primitive, in the prefab’s primitive order
entity_of(name)the entity of the node the asset calls name, or -1. This is where a socket, an effect anchor or a projectile spawn is looked up

Nothing a model needs in order to work is only on the instance. Its skins are the MeshSkins on its mesh entities and its clips are the AnimationClips on its root, which is where a rig built in code puts both too:

import { MeshSkin } from
    "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/MeshSkin.js";
import { AnimationClips } from
    "@woosh/meep-engine/src/engine/graphics3/animation/AnimationClips.js";

const skinned = instance.mesh_entities.find(e => dataset.getComponent(e, MeshSkin) !== undefined);
const joints  = dataset.getComponent(skinned, MeshSkin).joints;          // entities
const clips   = dataset.getComponent(instance.root, AnimationClips).clips;
const dance   = clips.find(clip => clip.name === "Dance");

Instantiation registers nothing with the GPU. A placement is components, and the systems that read them register what they need.

Who registers what

The split follows ownership. A skin belongs to the mesh it deforms, so MeshSkinSystem registers every MeshSkin, binds its mesh to it and holds a row for each joint and everything above it, so a joint under a clip-driven ancestor composes on the GPU. Register it beside ShadedGeometrySystem, which draws the clone. Without it a skinned primitive draws undeformed, in its bind pose:

import { ShadedGeometrySystem } from
    "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { MeshSkinSystem } from
    "@woosh/meep-engine/src/engine/graphics3/MeshSkinSystem.js";

const scene = EngineHarness.shadeScene(engine);

await em.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
await em.addSystem(new MeshSkinSystem(engine.graphics, scene));

It does not care where a skin came from - a model MeshSystem loaded, or a mesh built in code. Registration waits for its update, never happening at link, because a spawn adds components node by node and there may be no device yet. A renderer restart voids every id the old context issued; they are forgotten rather than handed back, and every skin registers again in the new context.

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, scene) and drive an entity while it carries their component and an AnimationClips - whoever put them there. New clips are a new AnimationClips, which a driver sees as its entity leaving and coming back. 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 call register_clips(context, clips, slots) (engine/graphics3/register_clips.js), which registers the named slots of an entity’s clips and returns a ClipRegistration = { context, ids: Int32Array }, indexed by the clip’s place 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.

A registration carries the GPUSceneContext it was 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_clips, and forgets the ids otherwise rather than corrupting a stranger’s table.

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

One thing answers the bounds question, and it is measured on the GPU.

The deformed world box is computed by the skinning pass’s bounds-refresh chain - from the vertices that pass has just moved - and published into the scene database’s meshes row. There is no CPU-side bound to keep in step with it, no call that computes one, and nothing an author writes down: a skinned silhouette is wherever the pose put it, and the pose is the GPU’s.

Read it back through GPUSceneContext.bounds, which is asynchronous and a frame or two behind by construction. An outcome that has to be decided this tick cannot depend on it - for “where is this bone”, evaluate the pose instead, which is what Addressing a bone is about.

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.

A name is only how a loaded model is asked. For a joint you already hold - one of a rig built in code - call collect_entity_playbacks(playbacks, entityManager, entity) (engine/graphics3/pose/collect_entity_playbacks.js) for the entity playing its clips, then t64_evaluate_world(result, dataset, joint, playbacks) for the joint, which is all the query does once the name is resolved.

CPU skinning

IKGoal addresses its effector and target by entity ID. The CPU skinner is mesh_apply_skeletal_vertex_skinning (engine/graphics/geometry/skining/).

  • Animation graphs - the state machine that decides which clips play
  • Inverse kinematics - placing bones at entity targets 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/, engine/graphics3/prefab/, engine/graphics3/