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
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.
| Member | Type | Meaning |
|---|---|---|
name | string | Optional, empty by default |
joints | number[] | 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_matrices | Float32Array | Joint-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 |
meshes | number[] | 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.
| Member | Type | Meaning |
|---|---|---|
joints | Int32Array | The joints, as entities, in the order the geometry’s SkinningJoints attribute addresses them |
inverse_bind_matrices | Float32Array | joints.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
ShadedGeometrybeside it carriesShadedGeometryFlags.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.
| Member | Notes |
|---|---|
name | Optional |
channels | ShadeAnimationChannel[]; order does not matter |
get start_time / get end_time | Earliest and latest keyframe times across every channel’s curves; used as the clip’s time_start / time_end at registration |
get duration | end_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):
| Constant | Value | Curve slots | Masking |
|---|---|---|---|
Translation | 0 | x, y, z | Per-axis; an absent slot leaves that axis alone |
Rotation | 1 | x, y, z, w | None - the quaternion is written whole and normalised. Supply all four |
Scale | 2 | x, y, z | Per-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.
| Level | Methods |
|---|---|
| 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 state | start(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 frame | update(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 lookups | get 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 member | Meaning |
|---|---|
prefab | the model it was spawned from |
root | the entity that placed the model - node 0, which was there already |
entities | Int32Array - the entity of each node, by node index; entities[0] is root |
mesh_entities | Int32Array - 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:
| Driver | Component | System |
|---|---|---|
| State machine | AnimationGraphController | AnimationGraphSystem |
| Clip list | Animation (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:
| Property | Identity | Granularity |
|---|---|---|
Translation | (0, 0, 0) | Per axis |
Scale | (1, 1, 1) | Per axis |
Rotation | identity orientation - x, y, z all constant zero, w free | All 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/).
Related
- 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,MeshSystemand model loading - Source:
shade/renderer/animation/,engine/graphics3/prefab/,engine/graphics3/