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
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 | Node3D[] | Ordered joint nodes. A joint’s index in this array is the index the per-vertex SkinningJoints attribute refers to |
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 | SkinnedMesh[] | 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.
| 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: 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):
| 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 (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 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 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 member | Meaning |
|---|---|
roots | Node3D[] - the copied scene roots |
skins | Skin[] bound to the copy’s joints |
clips | ShadeAnimationClip[] retargeted onto the copy |
nodes | Map<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:
| 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, 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:
| 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.
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.
Related
- 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,MeshSystemand model loading - Source:
shade/renderer/animation/,shade/renderer/scene/,engine/graphics3/