[Feature] Add root motion extraction and application - #1135
Conversation
Locomotion clips authored with a traveling root previously dragged the mesh away from the entity transform and snapped back on loop. With root motion enabled (opt-in per entity, default off), the root joint's horizontal translation and yaw deltas are extracted from the raw sampled pose each frame and applied to the entity transform in character space via translateBy/rotateTo; the pose root is grounded (horizontal zeroed, yaw removed via swing-twist decomposition). Vertical motion, pitch, and roll stay in the pose. - Loop wrap corrected with the clip's precomputed per-loop root displacement and yaw (CompiledAnimationClip root metadata) — no backward snap when the clip wraps - setRootMotionEnabled(entityId:enabled:rootJointPath:) resolves hierarchical assets like the other animation APIs; the root defaults to the skeleton's first parentless joint, overridable by joint path - Deltas anchor to the entity the public API was called on (the gameplay handle): hierarchical assets carry the AnimationComponent on a skinned child, and applying deltas there would drift the child inside the asset while the root the game steers stays put - Runs on the raw sampled pose before transition offsets, and the transition capture grounds the incoming clip's samples, so inertialized transitions blend grounded poses and never teleport the character - Defends against LocalTransformComponent's zero-quaternion default rotation, which rotates every vector to zero Docs: docs/API/UsingRootMotion.md
| } | ||
|
|
||
| @inline(__always) | ||
| private func wrappedChannelTime(_ time: Float, lastKeyTime: Float?) -> Float { |
There was a problem hiding this comment.
This always does fmod(time, lastKeyTime) to detect a loop wrap, but the real sampler (ClipSampler.swift:105) only wraps like that when channel.repeats is true — a non-repeating channel clamps at the last key instead of cycling. If a channel doesn't repeat, this would still detect a spurious "wrap" once time passes lastKeyTime and inject a full rootTranslationPerLoop/rootYawPerLoop jump that was never authored, even though the pose itself stays correctly clamped.
Not live today — repeatAnimation is hardcoded true everywhere in the engine right now, nothing sets it false. But the non-repeating plumbing already exists in Skeleton.swift/ClipSampler.swift, so this will misfire the moment someone enables root motion on a one-shot clip (a lunge or death animation with root travel), and it'll be hard to spot since the mesh looks right and only the entity transform jumps. Can you thread channel.repeats through here so it matches ClipSampler's behavior?
There was a problem hiding this comment.
yes, I can. ill do the changes
| let (_, twist) = yawTwist(pose.rotations[rootIndex]) | ||
| pose.rotations[rootIndex] = simd_normalize(pose.rotations[rootIndex] * twist.inverse) | ||
| } | ||
|
|
There was a problem hiding this comment.
The docs (docs/API/UsingRootMotion.md:58-59) and this function's own doc comment above say pitch and roll stay in the pose — "a crouch still lowers the character, a stagger still leans it." But I don't see a test that exercises it. testPoseRootIsGrounded only checks vertical translation staying put, and testPoseRootYawIsStripped only checks yaw going to zero. Neither authors a root joint with real pitch or roll and confirms it survives the swing-twist split unchanged.
I checked the math by hand and the decomposition looks right, but this is the claim doing the most work in the docs, and it's exactly the kind of thing a sign error or axis mixup in the twist projection would silently break without anyone noticing. Can you add a test that authors a pitch/roll component on the root and asserts it comes through stripRootMotion untouched?
There was a problem hiding this comment.
Yes I can do the test.
- Wrap detection now mirrors ClipSampler exactly: a non-repeating channel clamps at its last key instead of cycling, so a one-shot clip (lunge, death) travels its authored distance and stops — the clamp window no longer reads as a loop wrap injecting the per-loop displacement/yaw. Regression test drives a non-repeating channel past its last key and asserts no jump. - New test pins the docs' pitch/roll claim: pure pitch (and pure roll) composed with yaw comes through stripRootMotion untouched, with yaw fully removed and vertical offset kept.
|
I already made the changes and the tests. |
Summary
Next link in the animation chain after inertialized transitions (#1125): root motion. A locomotion clip authored with a traveling root previously dragged the mesh away from the entity transform and snapped it back on every loop. With root motion enabled — opt-in per entity, default off, zero cost when disabled — the root joint's horizontal translation and yaw deltas are extracted from the raw sampled pose each frame and applied to the entity transform in character space (
translateBy/rotateTo), while the pose root is grounded: horizontal travel zeroed, yaw removed via swing–twist decomposition. Vertical motion, pitch, and roll stay in the pose — a crouch still lowers the character, a stagger still leans it.API
CompiledAnimationCliproot metadata, and the wrapped-time jump is corrected with them.setEntityMeshAsync) carry theAnimationComponenton a skinned child while the game steers the asset root. Deltas are applied to the entity the public API was called on, so nothing drifts inside the asset. Flat entities behave exactly as before.changeAnimationre-baselines extraction, so a clip switch contributes no spurious delta.LocalTransformComponent's zero-quaternion default rotation (which rotates every vector to zero) by treating it as identity.How-to guide:
docs/API/UsingRootMotion.md. Single commit cherry-picked onto current develop (5f61789).Testing
AnimationRootMotionTests(11 tests, all passing):Inertialization, compiled-sampler, and policy suites still pass on this base. SwiftFormat lint clean.