feat: add skeletal animation via self-contained skeletons and name-based clip binding (ADR-0064) #123

Open
akadmin wants to merge 6 commits from feat/skeletal-animation into main
Owner

Summary

  • glTF skin/joint/animation parsing (previously unsupported) with SQT poses, quaternion SLERP, and crossfade-only blending
  • Joints are self-contained array data (not ECS entities); clips bind to skeletons by joint name, not index, with Mixamo namespace normalization (mixamorig2:Hips -> Hips) and an optional per-instance joint_remap table, so animations from one Mixamo-rigged character can drive another
  • Dedicated GPU skinning pipeline: new SkinnedMeshVertex format, skinned_vertex.glsl/skinned_shadow_vertex.glsl pairing with the unmodified fragment shaders, and a new descriptor set holding a MAX_JOINTS = 128 joint-matrix palette UBO — every static draw call, pipeline, and shader is completely unaffected
  • AnimationSubsystem samples clips and writes joint poses every render frame, ticking first in the subsystem registry
  • Skeleton/SkinnedMesh get the same asset-backed rehydration treatment as Mesh (ADR-0021); AnimationPlayer (clip playback state) always serializes directly; SCENE_FORMAT_VERSION stays at 3 (purely additive fields)
  • Minimal editor Inspector UI for AnimationPlayer (clip source/index, play/loop/speed, read-only time, add/remove-row joint_remap editor)
  • ADR-0064 (Accepted) has the full design rationale, rejected alternatives, and explicit deferrals (dual-quaternion skinning, morph targets, full pose-space/bone-length retargeting, animation compression)

Test plan

  • cargo test --lib — 468/468 passing (headless coverage of glTF skin parsing, joint-hierarchy resolution, SQT/SLERP/crossfade sampling, palette computation and multiplication order, name-based binding + remap, serialization round-trips)
  • cargo build --all-targets — compiles cleanly, including SPIR-V generation for the two new shaders
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo fmt -- --check — clean
  • Manual visual smoke test (load a skinned glTF in the editor, confirm bind-pose/animated rendering and shadows look correct) — not done from this environment (no GPU available here); left for review

🤖 Generated with Claude Code

https://claude.ai/code/session_01VgUh6Fb47zBTgiUnqV32NB

## Summary - glTF skin/joint/animation parsing (previously unsupported) with SQT poses, quaternion SLERP, and crossfade-only blending - Joints are self-contained array data (not ECS entities); clips bind to skeletons by **joint name**, not index, with Mixamo namespace normalization (`mixamorig2:Hips` -> `Hips`) and an optional per-instance `joint_remap` table, so animations from one Mixamo-rigged character can drive another - Dedicated GPU skinning pipeline: new `SkinnedMeshVertex` format, `skinned_vertex.glsl`/`skinned_shadow_vertex.glsl` pairing with the *unmodified* fragment shaders, and a new descriptor set holding a `MAX_JOINTS = 128` joint-matrix palette UBO — every static draw call, pipeline, and shader is completely unaffected - `AnimationSubsystem` samples clips and writes joint poses every render frame, ticking first in the subsystem registry - `Skeleton`/`SkinnedMesh` get the same asset-backed rehydration treatment as `Mesh` (ADR-0021); `AnimationPlayer` (clip playback state) always serializes directly; `SCENE_FORMAT_VERSION` stays at 3 (purely additive fields) - Minimal editor Inspector UI for `AnimationPlayer` (clip source/index, play/loop/speed, read-only time, add/remove-row `joint_remap` editor) - ADR-0064 (Accepted) has the full design rationale, rejected alternatives, and explicit deferrals (dual-quaternion skinning, morph targets, full pose-space/bone-length retargeting, animation compression) ## Test plan - [x] `cargo test --lib` — 468/468 passing (headless coverage of glTF skin parsing, joint-hierarchy resolution, SQT/SLERP/crossfade sampling, palette computation and multiplication order, name-based binding + remap, serialization round-trips) - [x] `cargo build --all-targets` — compiles cleanly, including SPIR-V generation for the two new shaders - [x] `cargo clippy --all-targets -- -D warnings` — clean - [x] `cargo fmt -- --check` — clean - [ ] Manual visual smoke test (load a skinned glTF in the editor, confirm bind-pose/animated rendering and shadows look correct) — not done from this environment (no GPU available here); left for review 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VgUh6Fb47zBTgiUnqV32NB
feat: add skeletal animation via self-contained skeletons and name-based clip binding (ADR-0064)
All checks were successful
CI / build (pull_request) Successful in 3m53s
683910c742
Adds glTF skin/animation parsing (previously unsupported), CPU pose
sampling with crossfade blending, a dedicated GPU skinning pipeline
(new descriptor set holding a joint-matrix palette UBO, static draws
untouched), and an AnimationSubsystem ticking ahead of physics.
Clips bind to skeletons by joint name rather than index, with Mixamo
namespace normalization and an optional per-instance remap table, so
animations from one Mixamo-rigged character can drive another.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgUh6Fb47zBTgiUnqV32NB
test: add real-world Mixamo GLB smoke test for skinned glTF loading
All checks were successful
CI / build (pull_request) Successful in 10m37s
bd476d6404
Existing gltf_loader tests only cover hand-built synthetic fixtures.
This exercises load_gltf against an actual Mixamo export (52 joints,
4 clips) to confirm the joint-hierarchy invariant and clip parsing
hold on real-world data (ADR-0064).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jcaAirANchmo8iyxenAW4
fix: skinned glTF import, checkbox commits, and animated bone-length collapse (ADR-0064)
All checks were successful
CI / build (pull_request) Successful in 3m36s
578e615100
Completes ADR-0064's skinned-import gap and fixes several bugs surfaced while
testing it end-to-end for the first time with a real skinned+animated entity:

- LoadGltfCommand now spawns skinned groups (SkinnedMesh/Skeleton/
  AnimationPlayer), not just static ones; imports paused rather than
  autoplaying.
- AnimationSubsystem bootstraps SkeletonPose to the bind pose for any
  Skeleton missing one — every skinned render/animation query hard-requires
  it, so nothing ever rendered without this.
- gltf_loader's topological joint reindex wasn't applied to per-vertex
  JOINTS_0 indices, skinning vertices against the wrong joint whenever a
  file's raw joint list wasn't already parent-before-child.
- sample_channel fell back to Sqt::IDENTITY for any keyframe track a
  channel didn't populate. Real rigs animate rotation only per joint
  (translation is the fixed bone length) — 19 of the Fox sample's 24
  animated joints are rotation-only, so this collapsed nearly the whole
  skeleton's bone lengths to zero the instant a clip touched it.
- Inspector checkbox/button commits silently no-op'd: they set lost_focus
  but never gained_focus, so the "before" snapshot was never captured and
  the change reverted next frame. Fixed across AnimationPlayer, AudioSource,
  Collider, and RigidBody.
- Clip index could drift into "(unresolved)"; footer read 0 tris/0 verts
  for skinned-only scenes (only ever queried Mesh, not SkinnedMesh).

Adds the Khronos Fox sample (CC0/CC-BY-4.0) as a verified-good skinned test
asset — bind pose confirmed self-consistent with its own inverse-bind
matrices independent of this engine's loader — plus regression tests for
every fix above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jcaAirANchmo8iyxenAW4
feat: dual quaternion skinning and glTF ancestor-correct joint hierarchy (ADR-0065)
All checks were successful
CI / build (pull_request) Successful in 3m40s
2ac0d9c7e4
Replaces linear blend skinning with dual quaternion skinning to fix the
candy-wrapper collapse observed at joints with large relative rotation from
bind pose (e.g. the Mixamo hip/thigh split). Also fixes two related glTF
loader gaps surfaced while validating against real assets: bind_pose is now
derived from inverse_bind_matrices (authoritative per spec) instead of a
node's own default TRS, and a skeleton's root joint now carries
root_ancestor_transforms so animated poses are correctly prefixed by any
real, non-joint ancestor nodes above it (the common Blender/Khronos
Mesh -> Armature -> Z_UP export convention) instead of rendering rotated
into the wrong space.

Adds Khronos's CesiumMan sample as a second humanoid test asset alongside
Fox, replacing Mixamo's role for future regression testing (Mixamo's file
has its own separate, unfixable-in-engine root-rotation defect and is kept
only for its existing tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jcaAirANchmo8iyxenAW4
feat: editor-only bone debug overlay for skinned meshes
Some checks failed
CI / build (pull_request) Failing after 3m5s
bffd77b72d
Adds a "Bones" toggle to the viewport toolbar that draws every skinned
entity's joint hierarchy (parent -> child lines + a dot per joint) directly
over the offscreen texture, using the same no-extra-GPU-pass convention as
the transform gizmo. Reuses animation::joint_world_matrices (now pub) so the
overlay can never disagree with what the skinned pipeline actually renders.
Editor-only: gated behind the existing is_editing branch, so it never shows
in Play or the standalone runtime. Confirmed visually against Fox and
CesiumMan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jcaAirANchmo8iyxenAW4
feat: expose AnimationPlayer control to Lua scripts
Some checks failed
CI / build (pull_request) Failing after 3m1s
d290c70086
Adds world.animation(h), world.play_animation(h, index), and
world.crossfade_animation(h, index, duration) to the script API, following
the same scoped-function/partial-update pattern as world.light/material/
camera. The crossfade blend math and tick logic already existed in
AnimationSubsystem (clip_a/clip_b/blend_t) but had no trigger anywhere in the
engine (no Lua binding, no Inspector button, no Rust helper) -- this closes
that gap so gameplay logic (e.g. speed-based) can switch between clips like
walk and run. Moving an entity's position while an animation plays needed no
new code: it's already independent via the existing position/velocity script
API, documented in the corresponding CLAUDE.md update.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jcaAirANchmo8iyxenAW4
Some checks failed
CI / build (pull_request) Failing after 3m1s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/skeletal-animation:feat/skeletal-animation
git switch feat/skeletal-animation

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff feat/skeletal-animation
git switch feat/skeletal-animation
git rebase main
git switch main
git merge --ff-only feat/skeletal-animation
git switch feat/skeletal-animation
git rebase main
git switch main
git merge --no-ff feat/skeletal-animation
git switch main
git merge --squash feat/skeletal-animation
git switch main
git merge --ff-only feat/skeletal-animation
git switch main
git merge feat/skeletal-animation
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
akadmin/NemesisEngine!123
No description provided.