Skip to content
The CNA BibleCNA 0.1.0-alpha.1 Edition

Chapter 41 Skinning and Animation across Two Systems

CNA contains two skeletal-animation systems that deliberately do not share a runtime graph. SkinningData plus AnimationPlayer animate an ordinary Model; SkinnedModelEXT is an Avatar-oriented model, skeleton, clip, and draw system of its own. Similar interpolation code appears in both. The duplication is documented as intentional because forcing the public types together would couple two distinct asset contracts.

Three index spaces are connected by explicit maps. Flattened selected-scene nodes become ModelBone indices. The ordinal in the glTF skin joints array is the source joint index used by JOINTS zero. BuildSkeleton topologically reorders joints and records an old-to-new map, yielding palette indices. Packed joint values are rewritten to palette indices before SkinningData and the skinned effect consume them.
Figure 41.1: Scene-node, source-joint, and palette indices are distinct. Convenient source ordering can hide a missing conversion, so the importer records and tests the maps explicitly.

41.1 The ordinary Model path

A skinned Model carries SkinningData through Model.Tag. The record contains bind-pose local matrices, inverse-bind matrices, parent indices, animation clips, and a fourth matrix array named SkeletonRootPrefix. An AnimationPlayer samples a clip into local transforms, composes worlds in topological order, then builds the skin palette used by SkinnedEffect or SkinnedPbrEffect.

The model itself does not do that last step. Model::Draw sets the ordinary IEffectMatrices world matrix for each mesh, but never installs a bone palette. The application or animation helper must call the skinned effect’s SetBoneTransforms before drawing. A model whose effect does not implement IEffectMatrices is rejected by Model::Draw.

41.2 The Avatar path

SkinnedModelEXT is explicitly not built from ModelBone, ModelMesh, or ModelMeshPart. It owns its Avatar-specific geometry and animation representation and is loaded through the legacy .skinnedmodel.json reader. Its superficially similar SampleTrack logic is a local copy, not a hidden conversion to AnimationPlayer.

Both players nevertheless enforce the same essential inputs. Bone arrays must agree in size, and every non-root parent index must precede its child; a parent index greater than or equal to the current index throws. Any negative parent value is treated as “root”, not only 1, and an animation track with an empty key list or an out-of-range bone index is silently ignored. Looping uses floor-modulo on raw ticks, so negative or over-duration times wrap consistently. Runtime translations and scales interpolate linearly, rotations spherically. Track search is linear in the number of keyframes per bone.

The size agreement is not a complete hand-construction validator. Content readers reject a negative or implausibly large bone count, but AnimationPlayer’s public constructor first casts BoneCount to size_t for its working-vector allocations and only then runs the array-size check in RecomputeTransforms. A manually supplied negative count can therefore request an enormous allocation (typically failing with a standard allocation/length exception) rather than producing the later ArgumentException. Validate custom SkinningData as non-negative before construction.

41.3 Scene indices are not palette indices

The glTF scene is flattened into ModelBone indices, while a skin’s joints array defines a separate palette. Those spaces need not have the same order and the glTF joint array need not be parent-before-child. BuildSkeleton therefore reorders joints breadth-first into topological order and records an old-to-new map. Packed JOINTS_0 values are rewritten through that map.

This yields three indices worth naming explicitly:

scene-node index

selects the model bone representing placement in the selected scene;

source-joint index

is the ordinal stored in glTF JOINTS_0; and

palette index

selects the reordered bind, inverse-bind, and animated world arrays.

Treating any two as interchangeable works only on conveniently authored fixtures. Reversed joint-order tests exist precisely to rule out that accident.

41.4 The coordinate-space failure that produced D8

A joint root may have ancestors that are not themselves listed in skin.joints. Older logic walked parents only within the joint set. It therefore dropped the armature node’s world transform from the reconstructed bind hierarchy while retaining the file’s inverse-bind matrix, which already encoded that ancestry. A uniform armature scale became an inverse scale in the final palette, collapsing the character toward the origin.

The corrected root term is

Proot=WancestorWmeshNode1.

The first factor recovers scene ancestry above the joint set. The second cancels the transform of the node that instances the skinned mesh, because the mesh is parented to the model’s synthetic root rather than to that node. The importer does not stop at glTF’s optional skin.skeleton hint, and can obtain ancestry for a joint outside the selected scene through cgltf’s world-transform calculation.

41.4.1 Why the prefix must remain separate

It is tempting to multiply Proot into the root bind-pose local matrix. That works until an animation channel replaces that local transform. At the first sampled frame the recovered ancestry disappears again. Instead, CNA stores one prefix per root in SkeletonRootPrefix and composes

Wroot(t)=Lroot(t)Proot.

An empty prefix array means identity, preserving older CNJ skeleton sidecars. This separation is both the D8 fix and the compatibility mechanism that lets the runtime animate roots without undoing it.

The conformance fixture for the mesh-node term was chosen so the three possible results are numerically distinct: missing cancellation, cancellation exactly once, and applying the inverse twice. The test expects the middle case rather than merely checking that a vertex “moved”.

41.5 Clip extraction

For skeletal clips, CNA forms the union of translation, rotation, and scale key times for each animated bone, deduplicated at a tight tolerance. Missing channels at a time fall back to the decomposed bind pose. Linear and step samplers are evaluated directly; cubic splines use the glTF Hermite basis with the required Δt tangent scaling, and cubic quaternion results are normalized while the importer samples that union. The resulting ordinary KeyframeEXT records do not retain bone-channel interpolation modes or tangents; AnimationPlayer later uses linear/Slerp interpolation between them. A cubic channel is therefore exact at every union time but generally only a piecewise linear/Slerp approximation between those stored times. Morph tracks below are different: they retain cubic tangents for lazy Hermite evaluation.

Only channels whose target resolves through the skin’s joint map enter a skeletal clip. Rigid node TRS animation is retained separately as scene-node-indexed clips. On an unskinned model, Model.Tag carries ModelAnimationsEXT; its tracks address the same flattened scene-node indices used by ModelBone. A mixed skinned model cannot put that second carrier into the already occupied legacy Tag slot, so rigid tracks outside the retained skin palettes are explicitly counted and reported as dropped. D6 is therefore fixed for the ordinary rigid-model route without pretending that one legacy pointer can hold two unrelated carriers.

Morph-weight animation follows a different, skin-independent route. It locates a weights channel on the node instancing the mesh, preserves cubic in/out tangents unbaked, and evaluates Hermite weights lazily during playback. If one mesh is instanced by several nodes, the first matching node wins. Morph position/normal deltas are then CPU-blended into vertex bytes and the buffer is uploaded again; this is not GPU morphing.

41.6 Scaling and capacity

The converter’s unitScale applies to every translation-bearing quantity, including animation translation values and their cubic tangents. It never applies to rotation or scale channels. Runtime glTF import fixes it at 1.0, while the CLI can choose another unit basis.

Both stock skinned effects accept at most 72 palette matrices. Each packed vertex has four 8-bit joint indices and four weights. A legal glTF skin larger than those runtime limits must be split or otherwise reduced during content processing; successful container parsing does not promise it can be drawn in one part.

41.7 Practical update order

A frame using the ordinary path should perform these operations in order:

  1. 1.

    choose and sample the active clip into local bone transforms;

  2. 2.

    compose absolute skeleton transforms, including root prefixes;

  3. 3.

    form skin matrices from worlds and inverse binds;

  4. 4.

    install the palette on every skinned effect used by the model;

  5. 5.

    evaluate and upload morph weights if they changed; and

  6. 6.

    call Model::Draw on the graphics thread.

The draw scratch vector is thread_local at alpha.1, removing the earlier cross-thread scratch race. Graphics resources and the shared model graph are not thereby made generally thread-safe; keep sampling, palette installation, mutable model updates, and drawing under the application’s graphics-thread ownership rule.

Type at least three characters. Results are ranked by how often and where the words occur.