From 684c1e67fceb7869a344a5a39e673c811608db80 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sat, 25 Jul 2026 14:48:36 +0200 Subject: [PATCH] [Feature] Add root motion extraction and application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Animation/CompiledAnimationClip.swift | 26 ++ .../UntoldEngine/Animation/RootMotion.swift | 204 ++++++++++++++ Sources/UntoldEngine/ECS/Components.swift | 2 + .../Systems/AnimationSystem.swift | 61 ++++ .../AnimationRootMotionTests.swift | 265 ++++++++++++++++++ docs/API/UsingRootMotion.md | 90 ++++++ 6 files changed, 648 insertions(+) create mode 100644 Sources/UntoldEngine/Animation/RootMotion.swift create mode 100644 Tests/UntoldEngineTests/AnimationRootMotionTests.swift create mode 100644 docs/API/UsingRootMotion.md diff --git a/Sources/UntoldEngine/Animation/CompiledAnimationClip.swift b/Sources/UntoldEngine/Animation/CompiledAnimationClip.swift index 4b966fec5..713ecfb73 100644 --- a/Sources/UntoldEngine/Animation/CompiledAnimationClip.swift +++ b/Sources/UntoldEngine/Animation/CompiledAnimationClip.swift @@ -58,6 +58,13 @@ final class CompiledAnimationClip { let restRotations: [simd_quatf] let restScales: [simd_float3] + /// Root motion metadata: the skeleton's first parentless joint, and the + /// root channel's net displacement/yaw over one loop — used to correct + /// frame deltas across the loop wrap. + let rootJointIndex: Int? + let rootTranslationPerLoop: simd_float3 + let rootYawPerLoop: Float + init(clip: AnimationClip, skeleton: Skeleton) { name = clip.name jointCount = skeleton.jointPaths.count @@ -96,5 +103,24 @@ final class CompiledAnimationClip { self.restTranslations = restTranslations self.restRotations = restRotations self.restScales = restScales + + let rootIndex = skeleton.parentIndices.firstIndex(where: { $0 == nil }) + rootJointIndex = rootIndex + if let rootIndex, channels[rootIndex].animated { + let rootChannel = channels[rootIndex] + if let first = rootChannel.translationValues.first, let last = rootChannel.translationValues.last { + rootTranslationPerLoop = last - first + } else { + rootTranslationPerLoop = .zero + } + if let first = rootChannel.rotationValues.first, let last = rootChannel.rotationValues.last { + rootYawPerLoop = wrapAngle(yawTwist(last).yaw - yawTwist(first).yaw) + } else { + rootYawPerLoop = 0 + } + } else { + rootTranslationPerLoop = .zero + rootYawPerLoop = 0 + } } } diff --git a/Sources/UntoldEngine/Animation/RootMotion.swift b/Sources/UntoldEngine/Animation/RootMotion.swift new file mode 100644 index 000000000..ac4b711a6 --- /dev/null +++ b/Sources/UntoldEngine/Animation/RootMotion.swift @@ -0,0 +1,204 @@ +// +// RootMotion.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd + +// Root motion: a locomotion clip authored with its root joint traveling +// (a walk that actually moves forward) normally drags the mesh away from +// the entity transform and snaps back when the clip loops. With root motion +// enabled, the root's horizontal translation and yaw deltas are extracted +// each frame and applied to the entity transform instead, and the pose is +// grounded — the character moves through the world because its animation +// says so. Vertical root motion, pitch, and roll stay in the pose (a +// stumbling zombie still leans). +// +// Loop wrap is handled with the clip's precomputed per-loop root +// displacement and yaw: when the sampled channel time wraps, the delta is +// corrected by one full loop instead of snapping backward. +// See docs/Architecture/animationPoseLayer.md. + +/// Per-entity root motion state. +struct RootMotionState { + var isEnabled = false + + /// Entity whose transform receives the extracted deltas — the entity + /// the public API was called on (the gameplay handle). Hierarchical + /// assets keep their AnimationComponent on a skinned descendant, but + /// games move the asset root. + var anchorEntity: EntityID = .invalid + + /// Optional joint-path override; by default the skeleton's first + /// parentless joint drives root motion. + var rootJointPath: String? + var resolvedRootIndex: Int? + + /// Last frame's raw (pre-strip) root sample, for delta extraction. + var hasPreviousSample = false + var previousTranslation = simd_float3.zero + var previousYaw: Float = 0 + var previousTranslationTime: Float = 0 + var previousRotationTime: Float = 0 + + /// Forget the sample history (clip switches, enable toggles); the next + /// frame re-baselines with a zero delta. + mutating func resetHistory() { + hasPreviousSample = false + } +} + +// MARK: - Angle helpers + +/// Wraps an angle to (-π, π] so frame-to-frame yaw deltas never take the +/// long way around. +@inline(__always) +func wrapAngle(_ angle: Float) -> Float { + var wrapped = fmod(angle + .pi, 2 * .pi) + if wrapped < 0 { + wrapped += 2 * .pi + } + return wrapped - .pi +} + +/// Swing–twist decomposition about the +Y axis: returns the yaw angle and +/// the twist quaternion, with the remainder (`q * twist⁻¹`) carrying pitch +/// and roll. The twist is normalized to the shortest arc so yaw is always +/// in (-π, π]. +@inline(__always) +func yawTwist(_ q: simd_quatf) -> (yaw: Float, twist: simd_quatf) { + let projected = simd_float4(0, q.imag.y, 0, q.real) + let length = simd_length(projected) + guard length > 1e-8 else { + // A 180° rotation about a horizontal axis has no well-defined yaw. + return (0, simd_quatf(ix: 0, iy: 0, iz: 0, r: 1)) + } + var twist = simd_quatf(vector: projected / length) + if twist.real < 0 { + twist = simd_quatf(vector: -twist.vector) + } + return (2 * atan2(twist.imag.y, twist.real), twist) +} + +/// Grounds the root joint of a local pose: horizontal translation is zeroed +/// and yaw removed (the entity transform owns both once root motion is on); +/// vertical translation, pitch, and roll remain. +@inline(__always) +func stripRootMotion(from pose: inout PoseBuffer, rootIndex: Int) { + guard rootIndex >= 0, rootIndex < pose.jointCount else { return } + pose.translations[rootIndex].x = 0 + pose.translations[rootIndex].z = 0 + let (_, twist) = yawTwist(pose.rotations[rootIndex]) + pose.rotations[rootIndex] = simd_normalize(pose.rotations[rootIndex] * twist.inverse) +} + +// MARK: - Per-frame extraction + +/// Resolves which joint drives root motion, caching the answer until the +/// state is reconfigured. +func resolveRootMotionJointIndex( + state: inout RootMotionState, + skeleton: Skeleton, + compiledClip: CompiledAnimationClip +) -> Int? { + if let cached = state.resolvedRootIndex { + return cached + } + let index: Int? = if let path = state.rootJointPath { + skeleton.jointPaths.firstIndex(of: path) + } else { + compiledClip.rootJointIndex + } + state.resolvedRootIndex = index + return index +} + +/// Extracts this frame's root translation/yaw deltas from the raw sampled +/// pose, applies them to the entity transform (character space rotated into +/// the entity's current orientation), and grounds the pose's root joint. +/// +/// Must run on the raw sampled pose, before transition offsets are applied, +/// so the deltas come from the clip and transitions blend grounded poses. +func applyRootMotion( + entityId: EntityID, + animationComponent: AnimationComponent, + skeleton: Skeleton, + compiledClip: CompiledAnimationClip, + clipDuration: Float, + clipSpeed: Float +) { + guard animationComponent.rootMotion.isEnabled else { return } + guard let rootIndex = resolveRootMotionJointIndex( + state: &animationComponent.rootMotion, + skeleton: skeleton, + compiledClip: compiledClip + ), rootIndex < animationComponent.localPose.jointCount else { return } + + let channel = compiledClip.channels[rootIndex] + guard channel.animated else { return } + + let translation = animationComponent.localPose.translations[rootIndex] + let (yaw, _) = yawTwist(animationComponent.localPose.rotations[rootIndex]) + + // Channel-wrapped sample times, replicating the sampler's per-channel + // wrap, to detect when the clip looped between frames. + let channelTime = fmod(animationComponent.currentTime, clipDuration) * clipSpeed + let translationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.translationTimes.last) + let rotationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.rotationTimes.last) + + let motionEntity = animationComponent.rootMotion.anchorEntity == .invalid + ? entityId + : animationComponent.rootMotion.anchorEntity + + if animationComponent.rootMotion.hasPreviousSample { + var delta = translation - animationComponent.rootMotion.previousTranslation + if translationTime < animationComponent.rootMotion.previousTranslationTime { + delta += compiledClip.rootTranslationPerLoop + } + + var yawDelta = yaw - animationComponent.rootMotion.previousYaw + if rotationTime < animationComponent.rootMotion.previousRotationTime { + yawDelta += compiledClip.rootYawPerLoop + } + yawDelta = wrapAngle(yawDelta) + + let horizontal = simd_float3(delta.x, 0, delta.z) + if scene.get(component: LocalTransformComponent.self, for: motionEntity) != nil { + // LocalTransformComponent's default rotation is the zero + // quaternion (simd_quatf()), which rotates every vector to zero + // — treat it as identity so deltas survive on never-rotated + // entities. + var entityRotation = getRotationQuaternion(entityId: motionEntity) + if simd_length_squared(entityRotation.vector) < 1e-8 { + entityRotation = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) + } + if simd_length_squared(horizontal) > 0 { + translateBy(entityId: motionEntity, position: entityRotation.act(horizontal)) + } + if yawDelta != 0 { + let yawRotation = simd_quatf(angle: yawDelta, axis: simd_float3(0, 1, 0)) + rotateTo(entityId: motionEntity, rotation: simd_normalize(entityRotation * yawRotation)) + } + } + } + + animationComponent.rootMotion.previousTranslation = translation + animationComponent.rootMotion.previousYaw = yaw + animationComponent.rootMotion.previousTranslationTime = translationTime + animationComponent.rootMotion.previousRotationTime = rotationTime + animationComponent.rootMotion.hasPreviousSample = true + + stripRootMotion(from: &animationComponent.localPose, rootIndex: rootIndex) +} + +@inline(__always) +private func wrappedChannelTime(_ time: Float, lastKeyTime: Float?) -> Float { + guard let lastKeyTime, lastKeyTime > 0 else { return 0 } + return fmod(time, lastKeyTime) +} diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index c7d5753b6..6be194c9f 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -203,6 +203,7 @@ public class AnimationComponent: Component { var hasPreviousPose = false var lastSampleDeltaTime: Float = 0 var transition = PoseTransition() + var rootMotion = RootMotionState() public required init() {} @@ -218,6 +219,7 @@ public class AnimationComponent: Component { hasPreviousPose = false lastSampleDeltaTime = 0 transition = PoseTransition() + rootMotion = RootMotionState() } func getAllAnimationClips() -> [String] { diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index b6e53f079..96c30c35e 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -189,6 +189,18 @@ private func updateAnimationSystem(deltaTime: Float) { into: &animationComponent.localPose ) + // Root motion runs on the raw sampled pose, before transition + // offsets: deltas come from the clip, transitions blend grounded + // poses. + applyRootMotion( + entityId: entity, + animationComponent: animationComponent, + skeleton: skeletonComponent.skeleton, + compiledClip: compiledClip, + clipDuration: animationClip.duration, + clipSpeed: animationClip.speed + ) + // Transitions decay in real time, independent of playback speed. animationComponent.transition.apply( to: &animationComponent.localPose, @@ -326,9 +338,44 @@ public func changeAnimation(entityId: EntityID, name: String, transitionHalflife animationComponent.currentAnimation = animationClip animationComponent.currentTime = 0 animationComponent.pause = withPause + // Re-baseline root motion on the new clip; the first frame after a + // switch contributes no delta. + animationComponent.rootMotion.resetHistory() } } +/// Enables or disables root motion for the entity (or its descendants that +/// carry an `AnimationComponent`). While enabled, the root joint's +/// horizontal translation and yaw drive the entity transform instead of the +/// pose; vertical motion, pitch, and roll stay in the pose. By default the +/// skeleton's first parentless joint is the root; pass `rootJointPath` to +/// designate a different joint. +public func setRootMotionEnabled(entityId: EntityID, enabled: Bool, rootJointPath: String? = nil) { + let animationComponents = animationComponentsForEntityOrDescendants(entityId: entityId) + guard animationComponents.isEmpty == false else { + handleError(.noAnimationComponent, entityId) + return + } + + for (_, animationComponent) in animationComponents { + animationComponent.rootMotion.isEnabled = enabled + animationComponent.rootMotion.rootJointPath = rootJointPath + animationComponent.rootMotion.anchorEntity = entityId + animationComponent.rootMotion.resolvedRootIndex = nil + animationComponent.rootMotion.resetHistory() + } +} + +public func isRootMotionEnabled(entityId: EntityID) -> Bool { + let targetEntityId = resolveEntityWithAnimationComponent(entityId: entityId) ?? entityId + guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { + handleError(.noAnimationComponent, entityId) + return false + } + + return animationComponent.rootMotion.isEnabled +} + /// Captures inertialization offsets for a clip switch. Falls back to a hard /// cut (no transition) when there is nothing to blend from: no clip playing, /// no pose displayed yet, no skeleton, or a zero halflife. @@ -372,6 +419,20 @@ private func beginAnimationTransition( into: &animationComponent.transition.scratchTargetNext ) + // With root motion enabled the displayed pose is grounded, so the + // incoming clip's samples must be grounded too — otherwise the captured + // offset would reintroduce the horizontal root displacement. + if animationComponent.rootMotion.isEnabled, + let rootIndex = resolveRootMotionJointIndex( + state: &animationComponent.rootMotion, + skeleton: skeleton, + compiledClip: compiledClip + ) + { + stripRootMotion(from: &animationComponent.transition.scratchTarget, rootIndex: rootIndex) + stripRootMotion(from: &animationComponent.transition.scratchTargetNext, rootIndex: rootIndex) + } + // Copy the scratch poses out (COW, no allocation) so the mutating // begin() call does not overlap a read of the same property. let targetPose = animationComponent.transition.scratchTarget diff --git a/Tests/UntoldEngineTests/AnimationRootMotionTests.swift b/Tests/UntoldEngineTests/AnimationRootMotionTests.swift new file mode 100644 index 000000000..6370a02e1 --- /dev/null +++ b/Tests/UntoldEngineTests/AnimationRootMotionTests.swift @@ -0,0 +1,265 @@ +// +// AnimationRootMotionTests.swift +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +@MainActor +final class AnimationRootMotionTests: XCTestCase { + var entityId: EntityID! + + private let deltaTime: Float = 1.0 / 90.0 + + override func setUp() async throws { + resetEngineTestState() + + entityId = createEntity() + registerComponent(entityId: entityId, componentType: SkeletonComponent.self) + registerComponent(entityId: entityId, componentType: AnimationComponent.self) + registerComponent(entityId: entityId, componentType: RenderComponent.self) + registerComponent(entityId: entityId, componentType: ScenegraphComponent.self) + registerComponent(entityId: entityId, componentType: LocalTransformComponent.self) + registerComponent(entityId: entityId, componentType: WorldTransformComponent.self) + + let runtimeSkeleton = RuntimeSkeleton( + jointPaths: ["root", "root/hips"], + parentIndices: [nil, 0], + bindTransforms: [.identity, simd_float4x4(translation: simd_float3(0, 1, 0))], + restTransforms: [.identity, simd_float4x4(translation: simd_float3(0, 1, 0))] + ) + scene.get(component: SkeletonComponent.self, for: entityId)?.skeleton = + Skeleton(runtimeSkeleton: runtimeSkeleton) + + let animationComponent = scene.get(component: AnimationComponent.self, for: entityId)! + animationComponent.animationClips["walk"] = makeWalkClip() + animationComponent.animationClips["turn"] = makeTurnClip() + } + + override func tearDown() async throws { + destroyEntity(entityId: entityId) + } + + /// Straight walk: root travels 2 m along +Z over the 2 s loop at a + /// constant 1 m/s, bobbing at a constant height of 0.9. + private func makeWalkClip() -> AnimationClip { + let rootChannel = RuntimeAnimationChannel( + jointPath: "root", + translations: [ + .init(time: 0.0, value: simd_float3(0, 0.9, 0)), + .init(time: 1.0, value: simd_float3(0, 0.9, 1)), + .init(time: 2.0, value: simd_float3(0, 0.9, 2)), + ], + rotations: [ + .init(time: 0.0, value: SIMD4(0, 0, 0, 1)), + .init(time: 2.0, value: SIMD4(0, 0, 0, 1)), + ] + ) + return AnimationClip(runtimeClip: RuntimeAnimationClip(name: "walk", duration: 2.0, channels: [rootChannel])) + } + + /// Turn in place: root yaws 90° about +Y over the 2 s loop, no travel. + private func makeTurnClip() -> AnimationClip { + func yawKey(_ angle: Float) -> SIMD4 { + let q = simd_quatf(angle: angle, axis: simd_float3(0, 1, 0)) + return SIMD4(q.imag.x, q.imag.y, q.imag.z, q.real) + } + let rootChannel = RuntimeAnimationChannel( + jointPath: "root", + translations: [ + .init(time: 0.0, value: simd_float3(0, 0.9, 0)), + .init(time: 2.0, value: simd_float3(0, 0.9, 0)), + ], + rotations: [ + .init(time: 0.0, value: yawKey(0)), + .init(time: 1.0, value: yawKey(.pi / 4)), + .init(time: 2.0, value: yawKey(.pi / 2)), + ] + ) + return AnimationClip(runtimeClip: RuntimeAnimationClip(name: "turn", duration: 2.0, channels: [rootChannel])) + } + + private var animationComponent: AnimationComponent { + scene.get(component: AnimationComponent.self, for: entityId)! + } + + private var rootIndex: Int { + 0 + } + + private func run(frames: Int, onFrame: ((Int) -> Void)? = nil) { + for frame in 0 ..< frames { + AnimationSystem.shared.update(deltaTime) + onFrame?(frame) + } + } + + // MARK: - Default off + + func testRootMotionDisabledByDefault() { + changeAnimation(entityId: entityId, name: "walk", transitionHalflife: 0) + run(frames: 90) // 1 s in + + XCTAssertFalse(isRootMotionEnabled(entityId: entityId)) + XCTAssertEqual(getLocalPosition(entityId: entityId).z, 0, "Entity must not move with root motion off") + XCTAssertGreaterThan( + animationComponent.localPose.translations[rootIndex].z, 0.9, + "Pose root keeps its authored travel with root motion off" + ) + } + + func testEnableDisableRoundTrip() { + setRootMotionEnabled(entityId: entityId, enabled: true) + XCTAssertTrue(isRootMotionEnabled(entityId: entityId)) + setRootMotionEnabled(entityId: entityId, enabled: false) + XCTAssertFalse(isRootMotionEnabled(entityId: entityId)) + } + + // MARK: - Straight travel + + func testEntityAccumulatesClipDisplacementAcrossLoops() { + setRootMotionEnabled(entityId: entityId, enabled: true) + changeAnimation(entityId: entityId, name: "walk", transitionHalflife: 0) + + // 3 s at 1 m/s across a 2 s loop (one wrap). The first frame is the + // extraction baseline, so expected travel is total minus one frame. + run(frames: 270) + + let expected: Float = 3.0 - deltaTime + XCTAssertEqual(getLocalPosition(entityId: entityId).z, expected, accuracy: 1e-3) + XCTAssertEqual(getLocalPosition(entityId: entityId).x, 0, accuracy: 1e-5) + XCTAssertEqual(getLocalPosition(entityId: entityId).y, 0, accuracy: 1e-5, "Vertical motion stays in the pose") + } + + func testNoSnapAtLoopWrap() { + setRootMotionEnabled(entityId: entityId, enabled: true) + changeAnimation(entityId: entityId, name: "walk", transitionHalflife: 0) + + var previousZ = getLocalPosition(entityId: entityId).z + var maxStep: Float = 0 + run(frames: 270) { _ in + let z = getLocalPosition(entityId: self.entityId).z + maxStep = max(maxStep, abs(z - previousZ)) + previousZ = z + } + + // At 1 m/s a 90 Hz frame moves ~0.0111 m; a wrap snap would move ~2 m. + XCTAssertLessThan(maxStep, deltaTime * 1.5, "Loop wrap must not snap the entity") + } + + func testPoseRootIsGrounded() { + setRootMotionEnabled(entityId: entityId, enabled: true) + changeAnimation(entityId: entityId, name: "walk", transitionHalflife: 0) + run(frames: 90) + + let rootTranslation = animationComponent.localPose.translations[rootIndex] + XCTAssertEqual(rootTranslation.x, 0, accuracy: 1e-6) + XCTAssertEqual(rootTranslation.z, 0, accuracy: 1e-6, "Horizontal travel must be stripped from the pose") + XCTAssertEqual(rootTranslation.y, 0.9, accuracy: 1e-5, "Vertical offset must stay in the pose") + } + + // MARK: - Yaw + + func testEntityAccumulatesClipYaw() { + setRootMotionEnabled(entityId: entityId, enabled: true) + changeAnimation(entityId: entityId, name: "turn", transitionHalflife: 0) + + // One full 2 s loop turns 90°, minus the one-frame baseline. + run(frames: 180) + + let rotation = getRotationQuaternion(entityId: entityId) + let (yaw, _) = yawTwist(rotation) + let expected: Float = .pi / 2 - (.pi / 4) * deltaTime + XCTAssertEqual(yaw, expected, accuracy: 0.01) + } + + func testPoseRootYawIsStripped() { + setRootMotionEnabled(entityId: entityId, enabled: true) + changeAnimation(entityId: entityId, name: "turn", transitionHalflife: 0) + run(frames: 90) + + let (poseYaw, _) = yawTwist(animationComponent.localPose.rotations[rootIndex]) + XCTAssertEqual(poseYaw, 0, accuracy: 1e-4, "Yaw must be stripped from the pose root") + } + + // MARK: - Interplay with transitions and clip switches + + func testTransitionWithRootMotionStaysGrounded() { + setRootMotionEnabled(entityId: entityId, enabled: true) + changeAnimation(entityId: entityId, name: "walk", transitionHalflife: 0) + run(frames: 60) + + changeAnimation(entityId: entityId, name: "turn", transitionHalflife: 0.15) + run(frames: 30) { _ in + let rootTranslation = self.animationComponent.localPose.translations[self.rootIndex] + XCTAssertEqual(rootTranslation.x, 0, accuracy: 1e-4, "Transition must blend grounded poses") + XCTAssertEqual(rootTranslation.z, 0, accuracy: 1e-4, "Transition must blend grounded poses") + } + } + + func testClipSwitchDoesNotTeleportEntity() { + setRootMotionEnabled(entityId: entityId, enabled: true) + changeAnimation(entityId: entityId, name: "walk", transitionHalflife: 0) + run(frames: 135) // mid-clip, root at z ≈ 1.5 + + let zBeforeSwitch = getLocalPosition(entityId: entityId).z + changeAnimation(entityId: entityId, name: "turn", transitionHalflife: 0) + AnimationSystem.shared.update(deltaTime) + + let jump = abs(getLocalPosition(entityId: entityId).z - zBeforeSwitch) + XCTAssertLessThan(jump, 1e-4, "Switching clips must re-baseline, not apply a spurious delta") + } + + // MARK: - Hierarchical assets + + /// Hierarchical assets (setEntityMeshAsync) carry their + /// AnimationComponent on a skinned scenegraph child while the game + /// holds and steers the asset root. Root motion deltas must anchor to + /// the entity the public API was called on — the gameplay handle — not + /// the component's entity, or the child drifts inside the asset while + /// the root the game steers stays put. + func testHierarchicalAssetAnchorsMotionToAPIEntity() { + let root = createEntity() + registerComponent(entityId: root, componentType: LocalTransformComponent.self) + registerComponent(entityId: root, componentType: WorldTransformComponent.self) + registerComponent(entityId: root, componentType: ScenegraphComponent.self) + defer { destroyEntity(entityId: root) } + + // Reparent the fixture entity (which carries all the components) + // under the root, then call every API on the root — like a game. + setParent(childId: entityId, parentId: root) + + setRootMotionEnabled(entityId: root, enabled: true) + changeAnimation(entityId: root, name: "walk", transitionHalflife: 0) + run(frames: 90) // 1 s of the 1 m/s walk + + XCTAssertGreaterThan( + getLocalPosition(entityId: root).z, 0.5, + "Root motion must move the API entity (the gameplay handle)" + ) + XCTAssertEqual( + simd_length(getLocalPosition(entityId: entityId)), 0, accuracy: 1e-4, + "The component's child entity must not drift inside the asset" + ) + } + + // MARK: - Root joint override + + func testRootJointPathOverride() { + setRootMotionEnabled(entityId: entityId, enabled: true, rootJointPath: "root/hips") + changeAnimation(entityId: entityId, name: "walk", transitionHalflife: 0) + run(frames: 90) + + // The override points at a joint the clip does not animate, so no + // deltas are produced and the authored root travel stays in the pose. + XCTAssertEqual(getLocalPosition(entityId: entityId).z, 0, accuracy: 1e-5) + XCTAssertGreaterThan(animationComponent.localPose.translations[rootIndex].z, 0.9) + } +} diff --git a/docs/API/UsingRootMotion.md b/docs/API/UsingRootMotion.md new file mode 100644 index 000000000..127b4a2ea --- /dev/null +++ b/docs/API/UsingRootMotion.md @@ -0,0 +1,90 @@ +# Root Motion + +## Introduction + +A locomotion clip authored with its root joint traveling — a walk that +actually moves forward — normally drags the mesh away from the entity +transform and snaps back when the clip loops. **Root motion** inverts that: +the root joint's horizontal movement and turning are extracted from the +animation each frame and applied to the entity transform, and the pose is +grounded in place. The character moves through the world because its +animation says so. + +## Why Use It + +- **No foot sliding from mismatched speeds.** When code moves the entity at + one speed and the clip's stride implies another, feet skate. With root + motion, the entity moves exactly as far as the animation does. +- **No loop snap.** The travel accumulates on the entity; the clip's loop + wrap is corrected with the clip's per-loop displacement. +- **Authoring stays in the DCC tool.** Turns, lunges, and stumbles move the + character exactly as animated. + +## Step-by-Step Implementation + +Enable it per entity — by default it is off and everything behaves as +before: + +```swift +setEntityAnimations(entityId: zombie, filename: "shamble", withExtension: "untold", name: "shamble") +setRootMotionEnabled(entityId: zombie, enabled: true) +changeAnimation(entityId: zombie, name: "shamble") +``` + +The skeleton's first parentless joint drives root motion. If your rig uses +a different traveling joint, designate it by path: + +```swift +setRootMotionEnabled(entityId: zombie, enabled: true, rootJointPath: "root/hips") +``` + +Query or turn it off at any time: + +```swift +if isRootMotionEnabled(entityId: zombie) { + setRootMotionEnabled(entityId: zombie, enabled: false) +} +``` + +## What Happens Behind the Scenes + +1. Each frame, the raw sampled pose's root translation and yaw are compared + with the previous frame's. The horizontal delta (rotated into the + entity's current orientation) is applied with `translateBy`, and the yaw + delta with a rotation about the up axis. +2. When the clip loops, the wrapped-time jump is corrected with the clip's + precomputed per-loop displacement and yaw, so there is no backward snap. +3. The pose's root joint is then grounded: horizontal translation zeroed + and yaw removed. **Vertical motion, pitch, and roll stay in the pose** — + a crouch still lowers the character, a stagger still leans it. +4. Root motion runs inside the animation update, before physics and custom + systems, so steering and gameplay code see the post-root-motion + transform in the same frame. + +Inertialized transitions compose cleanly with root motion: transitions +blend grounded poses, so switching clips never teleports the character. + +## Tips and Best Practices + +- Author locomotion clips with the root traveling at the real stride; root + motion makes that speed authoritative. +- Yaw is extracted about the up (+Y) axis. Clips that turn more than 180° + in a single loop are ambiguous at the wrap — split extreme turns into + shorter clips. +- Switching clips re-baselines the extraction: the first frame after a + `changeAnimation` contributes no delta. +- Gameplay code can still move the entity (steering, knockback); root + motion adds deltas rather than overwriting the transform. +- Hierarchical assets (loaded via `setEntityMeshAsync`) keep their + `AnimationComponent` on a skinned child, but the deltas are applied to + the entity you called `setRootMotionEnabled` on — the same handle your + game steers — so the asset root moves and nothing drifts inside it. + +## Running the Feature + +1. Load a clip whose root actually travels (verify in Blender: the root + bone moves across the ground). +2. Enable root motion and play the clip in game mode. +3. The entity's transform now follows the stride — watch the entity gizmo + move with the character, and note the mesh no longer snaps back when + the clip loops.