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.