From b2b67060b855dd649461ffd18b78b92ecaba30b7 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 6 Oct 2026 08:52:22 -0700 Subject: [PATCH 1/3] [Patch] Add animation playback helpers for synchronized gameplay Adds restartAnimation, getAnimationPlaybackTime, and sampleAnimationJointPosition so callers can replay a one-shot clip from time zero, read its current playback time, and sample a named joint in world space at an arbitrary time without changing the displayed pose. ClipSampler.sample() also unconditionally wrapped time via fmod, even for clips whose channels were flagged non-repeating, defeating the per-channel hold-last-pose logic and causing a completed one-shot clip to jump back to its initial pose. It now clamps to [0, duration] when no animated channel repeats, and only wraps for repeating clips. restartAnimation preserves warmed compiled-pose caches unless the clip's looping configuration actually changes. Fixes #1314. --- .../UntoldEngine/Animation/ClipSampler.swift | 3 +- .../Systems/AnimationSystem.swift | 65 +++++++++++ .../AnimationCompiledSamplerTests.swift | 101 ++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/Sources/UntoldEngine/Animation/ClipSampler.swift b/Sources/UntoldEngine/Animation/ClipSampler.swift index 93ad2b00d..1ee2e6ee7 100644 --- a/Sources/UntoldEngine/Animation/ClipSampler.swift +++ b/Sources/UntoldEngine/Animation/ClipSampler.swift @@ -44,7 +44,8 @@ struct ClipSampler { bind(clip) pose.resize(jointCount: clip.jointCount) - let channelTime = fmod(time, duration) * speed + let repeats = clip.channels.contains { $0.animated && $0.repeats } + let channelTime = (repeats ? fmod(time, duration) : min(max(time, 0), duration)) * speed for index in 0 ..< clip.jointCount { let channel = clip.channels[index] diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index 593d87c15..3f8e9f96b 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -1027,3 +1027,68 @@ public func removeAnimationClip(entityId: EntityID, animationClip: String) { animationComponent.removeAnimationClip(animationClip: animationClip) } } + +/// Restarts a named clip from time zero, even when it is already selected. +/// Call on the engine update thread. Applies to every animated descendant. +/// No-op (reports `.noAnimationClip`) if `entityId` and its animated descendants have no clip +/// registered under `name`. +public func restartAnimation(entityId: EntityID, name: String, repeats: Bool = false, withPause: Bool = false) { + let components = animationComponentsContainingClip(entityId: entityId, name: name) + guard !components.isEmpty else { + handleError(.noAnimationClip, name, entityId) + return + } + for (_, component, clip) in components { + // Restarting playback does not change compiled channels. Invalidate only + // this clip when its looping setting changes; preserve warmed caches. + if clip.jointAnimation.values.contains(where: { $0.repeatAnimation != repeats }) { + for path in clip.jointAnimation.keys { + clip.jointAnimation[path]?.repeatAnimation = repeats + } + component.compiledClips.removeValue(forKey: ObjectIdentifier(clip)) + } + component.transition.cancel() + component.currentAnimation = clip + component.currentTime = 0 + component.pause = withPause + component.rootMotion.resetHistory() + } +} + +/// Playback time of the first animated part of a root or split character. +/// Returns nil if no part has a selected clip. Read on the engine update thread. +public func getAnimationPlaybackTime(entityId: EntityID) -> Float? { + animationComponentsForEntityOrDescendants(entityId: entityId) + .first(where: { $0.1.currentAnimation != nil })?.1.currentTime +} + +/// Samples a named joint at a clip time without changing the displayed pose. +/// Returns world space. Call on the engine update thread after mesh loading. +/// Returns nil if `name` has no registered clip, `jointName` is not found in the skeleton, or the +/// target entity is missing its skeleton/world-transform components. +public func sampleAnimationJointPosition(entityId: EntityID, name: String, jointName: String, time: Float) -> SIMD3? { + for (target, component, clip) in animationComponentsContainingClip(entityId: entityId, name: name) { + guard let skeleton = scene.get(component: SkeletonComponent.self, for: target)?.skeleton, + let joint = skeleton.jointPaths.firstIndex(where: { + $0.split(separator: "/").last?.split(separator: ":").last.map(String.init) == jointName + }), let world = scene.get(component: WorldTransformComponent.self, for: target) else { continue } + let compiled = component.compiledClip(for: clip, skeleton: skeleton) + var sampler = ClipSampler() + var pose = PoseBuffer() + sampler.sample(compiled, time: time, duration: clip.duration, speed: clip.speed, into: &pose) + var models = [simd_float4x4](repeating: matrix_identity_float4x4, count: pose.jointCount) + for index in 0 ..< pose.jointCount { + let local = simd_float4x4(translation: pose.translations[index]) + * simd_float4x4(pose.rotations[index]) + * simd_float4x4(scale: compiled.restScales[index]) + if let parent = skeleton.parentIndices[index] { + models[index] = models[parent] * local + } else { + models[index] = local + } + } + let point = world.space * models[joint] * SIMD4(0, 0, 0, 1) + return SIMD3(point.x, point.y, point.z) + } + return nil +} diff --git a/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift b/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift index 8b7f7fa77..3d3696903 100644 --- a/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift +++ b/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift @@ -452,3 +452,104 @@ final class AnimationCompiledSamplerTests: XCTestCase { } } } + +extension AnimationCompiledSamplerTests { + func testNonRepeatingClipHoldsFinalPoseAfterItsDuration() { + let skeleton = makeSkeleton() + let clip = makeWalkClip() + for path in clip.jointAnimation.keys { + clip.jointAnimation[path]?.repeatAnimation = false + } + let compiled = CompiledAnimationClip(clip: clip, skeleton: skeleton) + var sampler = ClipSampler() + var pose = PoseBuffer() + sampler.sample(compiled, time: 2.1, duration: clip.duration, speed: 1, into: &pose) + XCTAssertEqual(pose.translations[0].z, 2, accuracy: 0.0001) + sampler.sample(compiled, time: 4.1, duration: clip.duration, speed: 1, into: &pose) + XCTAssertEqual(pose.translations[0].z, 2, accuracy: 0.0001) + } + + func testRestartSameClipResetsTimeAndRearmsPlayback() { + let entity = createEntity() + defer { destroyEntity(entityId: entity) } + registerComponent(entityId: entity, componentType: AnimationComponent.self) + guard let component = scene.get(component: AnimationComponent.self, for: entity) else { + XCTFail("Missing animation component"); return + } + let clip = makeWalkClip() + component.animationClips["strike"] = clip + component.currentAnimation = clip + component.currentTime = 0.8 + component.pause = true + restartAnimation(entityId: entity, name: "strike") + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) + XCTAssertFalse(isAnimationComponentPaused(entityId: entity)) + XCTAssertTrue(clip.jointAnimation.values.allSatisfy { !$0.repeatAnimation }) + component.currentTime = 0.7 + restartAnimation(entityId: entity, name: "strike", withPause: true) + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) + XCTAssertTrue(isAnimationComponentPaused(entityId: entity)) + } +} + +extension AnimationCompiledSamplerTests { + func testContactJointSamplingUsesWorldTransformWithoutChangingPlayback() { + let entity = createEntity() + defer { destroyEntity(entityId: entity) } + registerComponent(entityId: entity, componentType: SkeletonComponent.self) + registerComponent(entityId: entity, componentType: AnimationComponent.self) + guard let skeletonComponent = scene.get(component: SkeletonComponent.self, for: entity), + let component = scene.get(component: AnimationComponent.self, for: entity) + else { + XCTFail("Missing components"); return + } + skeletonComponent.skeleton = makeSkeleton() + let clip = makeWalkClip() + component.animationClips["strike"] = clip + component.currentAnimation = clip + component.currentTime = 0.2 + component.pause = true + translateTo(entityId: entity, position: SIMD3(5, 2, -3)) + rotateTo(entityId: entity, rotation: simd_quatf(angle: .pi / 2, axis: SIMD3(0, 1, 0))) + scaleTo(entityId: entity, scale: SIMD3(repeating: 2)) + let reference = makeSkeleton() + reference.updateWorldPose(at: 0.5, animationClip: clip) + guard let world = scene.get(component: WorldTransformComponent.self, for: entity), + let sampled = sampleAnimationJointPosition(entityId: entity, name: "strike", jointName: "leg", time: 0.5) + else { + XCTFail("Missing sampled joint"); return + } + let expected = world.space * reference.currentPose[3] * reference.bindTransform[3] * SIMD4(0, 0, 0, 1) + XCTAssertEqual(sampled.x, expected.x, accuracy: 0.0001) + XCTAssertEqual(sampled.y, expected.y, accuracy: 0.0001) + XCTAssertEqual(sampled.z, expected.z, accuracy: 0.0001) + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0.2) + XCTAssertTrue(isAnimationComponentPaused(entityId: entity)) + XCTAssertNil(sampleAnimationJointPosition(entityId: entity, name: "strike", jointName: "missing", time: 0.5)) + } +} + +extension AnimationCompiledSamplerTests { + func testRestartPreservesCompiledClipUntilLoopingChanges() { + let entity = createEntity() + defer { destroyEntity(entityId: entity) } + registerComponent(entityId: entity, componentType: AnimationComponent.self) + guard let component = scene.get(component: AnimationComponent.self, for: entity) else { + XCTFail("Missing component"); return + } + let clip = makeWalkClip() + component.animationClips["strike"] = clip + let skeleton = makeSkeleton() + _ = component.compiledClip(for: clip, skeleton: skeleton) + let key = ObjectIdentifier(clip) + restartAnimation(entityId: entity, name: "strike", repeats: true) + XCTAssertEqual(component.compiledClips.count, 1) + XCTAssertNotNil(component.compiledClips[key]) + restartAnimation(entityId: entity, name: "strike", repeats: false) + XCTAssertNil(component.compiledClips[key]) + _ = component.compiledClip(for: clip, skeleton: skeleton) + restartAnimation(entityId: entity, name: "strike", repeats: false) + XCTAssertNotNil(component.compiledClips[key]) + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) + } +} From f3422f9d26094d07ec00bd3151a78fed17faa1a0 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 6 Oct 2026 17:02:29 -0700 Subject: [PATCH 2/3] [Bugfix] Stop root motion from wrapping at a one-shot clip's duration applyRootMotion computed its own channelTime via fmod(currentTime, clipDuration), unconditionally, regardless of whether the clip's channels repeat. Once currentTime passed a non-repeating clip's duration, this wrapped back near zero, which wrappedChannelTime then misread as a fresh loop and injected a full loop's root displacement at that instant -- a visible snap right as a one-shot animation (e.g. a kick) finished, even though ClipSampler already holds the displayed pose correctly past that point. Match ClipSampler: clamp to [0, duration] when no animated channel repeats, only wrap when at least one does. --- .../UntoldEngine/Animation/RootMotion.swift | 7 +++- .../AnimationRootMotionTests.swift | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Sources/UntoldEngine/Animation/RootMotion.swift b/Sources/UntoldEngine/Animation/RootMotion.swift index d0045a51c..e96c6b998 100644 --- a/Sources/UntoldEngine/Animation/RootMotion.swift +++ b/Sources/UntoldEngine/Animation/RootMotion.swift @@ -204,7 +204,12 @@ func applyRootMotion( // wrap, to detect when the clip looped between frames. Non-repeating // channels clamp at their last key (like the sampler), so a one-shot // clip never fakes a wrap and never injects a per-loop correction. - let channelTime = fmod(animationComponent.currentTime, clipDuration) * clipSpeed + // Match ClipSampler: a one-shot's clock must not wrap at completion, + // otherwise wrap detection injects an extra loop's root displacement. + let repeats = compiledClip.channels.contains { $0.animated && $0.repeats } + let sampleTime = repeats ? fmod(animationComponent.currentTime, clipDuration) + : min(max(animationComponent.currentTime, 0), clipDuration) + let channelTime = sampleTime * clipSpeed let translationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.translationTimes.last, repeats: channel.repeats) let rotationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.rotationTimes.last, repeats: channel.repeats) diff --git a/Tests/UntoldEngineTests/AnimationRootMotionTests.swift b/Tests/UntoldEngineTests/AnimationRootMotionTests.swift index cd3865040..3613121b3 100644 --- a/Tests/UntoldEngineTests/AnimationRootMotionTests.swift +++ b/Tests/UntoldEngineTests/AnimationRootMotionTests.swift @@ -265,6 +265,38 @@ final class AnimationRootMotionTests: XCTestCase { ) } + func testNonRepeatingClipHoldsPositionPastClipDurationWithoutLoopJump() { + animationComponent.animationClips["lunge"] = makeOneShotClip() + + setRootMotionEnabled(entityId: entityId, enabled: true) + changeAnimation(entityId: entityId, name: "lunge", transitionHalflife: 0) + + // 3 s: past the clip's own 2 s duration, not just the root channel's 1 s + // last key. Before the fix, applyRootMotion's own + // fmod(animationComponent.currentTime, clipDuration) wrapped unconditionally + // at the 2 s mark regardless of repeatAnimation, handing wrappedChannelTime + // a small post-wrap time it misread as a fresh loop and injecting a full + // loop's root displacement right at that instant — distinct from + // testNonRepeatingChannelDoesNotInjectLoopJump above, which only runs up to + // 1.89 s and so never reaches the clip's own duration. + 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 + } + + XCTAssertEqual( + getLocalPosition(entityId: entityId).z, 1.0 - deltaTime, accuracy: 1e-3, + "Entity holds the authored 1 m travel; the clip's own duration elapsing must not add displacement" + ) + XCTAssertLessThan( + maxStep, deltaTime * 1.5, + "currentTime crossing the clip's own duration must not be misread as a loop wrap" + ) + } + // MARK: - Pitch and roll preservation /// The swing–twist split must remove only yaw. A body-frame lean — From dbd2c894e16c52803b4d9b5ff94c4f7579110cc6 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Wed, 7 Oct 2026 06:37:43 -0700 Subject: [PATCH 3/3] [Patch] Update animation clip sampler and root motion --- .../UntoldEngine/Animation/ClipSampler.swift | 24 +++++++++++++++-- .../UntoldEngine/Animation/RootMotion.swift | 9 +++---- .../Systems/AnimationSystem.swift | 11 ++++++-- .../AnimationCompiledSamplerTests.swift | 26 ++++++++++++++++++- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/Sources/UntoldEngine/Animation/ClipSampler.swift b/Sources/UntoldEngine/Animation/ClipSampler.swift index 1ee2e6ee7..b1cda92b7 100644 --- a/Sources/UntoldEngine/Animation/ClipSampler.swift +++ b/Sources/UntoldEngine/Animation/ClipSampler.swift @@ -10,6 +10,27 @@ import simd +/// Scales `time` by `speed` and either wraps it by `duration` (repeating +/// clips) or clamps it to `[0, duration]` (one-shot clips), so a completed +/// one-shot holds its last pose instead of jumping back to pose zero on the +/// next clip-duration boundary. Shared by `ClipSampler.sample` and +/// `applyRootMotion` so the two stay in lockstep. +/// +/// Classifies the whole clip from its first animated channel's `repeats` +/// flag. `Animation.repeatAnimation` is never set per-channel anywhere in +/// the engine — `restartAnimation` is the only mutator and it always writes +/// every joint of a clip to the same value — so every animated channel is +/// expected to agree; a debug-build assert catches the invariant breaking +/// rather than silently mis-sampling the channels that disagree. +func clipLevelChannelTime(time: Float, duration: Float, speed: Float, channels: [CompiledAnimationClip.Channel]) -> Float { + let repeats = channels.first(where: \.animated)?.repeats ?? true + assert( + channels.allSatisfy { !$0.animated || $0.repeats == repeats }, + "clipLevelChannelTime assumes every animated channel in a clip shares the same repeat flag" + ) + return (repeats ? fmod(time, duration) : min(max(time, 0), duration)) * speed +} + /// Samples a `CompiledAnimationClip` into a `PoseBuffer` without allocating. /// /// Keyframe intervals are located with a binary search seeded by a per-joint @@ -44,8 +65,7 @@ struct ClipSampler { bind(clip) pose.resize(jointCount: clip.jointCount) - let repeats = clip.channels.contains { $0.animated && $0.repeats } - let channelTime = (repeats ? fmod(time, duration) : min(max(time, 0), duration)) * speed + let channelTime = clipLevelChannelTime(time: time, duration: duration, speed: speed, channels: clip.channels) for index in 0 ..< clip.jointCount { let channel = clip.channels[index] diff --git a/Sources/UntoldEngine/Animation/RootMotion.swift b/Sources/UntoldEngine/Animation/RootMotion.swift index e96c6b998..4f66f221c 100644 --- a/Sources/UntoldEngine/Animation/RootMotion.swift +++ b/Sources/UntoldEngine/Animation/RootMotion.swift @@ -204,12 +204,9 @@ func applyRootMotion( // wrap, to detect when the clip looped between frames. Non-repeating // channels clamp at their last key (like the sampler), so a one-shot // clip never fakes a wrap and never injects a per-loop correction. - // Match ClipSampler: a one-shot's clock must not wrap at completion, - // otherwise wrap detection injects an extra loop's root displacement. - let repeats = compiledClip.channels.contains { $0.animated && $0.repeats } - let sampleTime = repeats ? fmod(animationComponent.currentTime, clipDuration) - : min(max(animationComponent.currentTime, 0), clipDuration) - let channelTime = sampleTime * clipSpeed + let channelTime = clipLevelChannelTime( + time: animationComponent.currentTime, duration: clipDuration, speed: clipSpeed, channels: compiledClip.channels + ) let translationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.translationTimes.last, repeats: channel.repeats) let rotationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.rotationTimes.last, repeats: channel.repeats) diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index 3f8e9f96b..dc759cec9 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -1032,7 +1032,14 @@ public func removeAnimationClip(entityId: EntityID, animationClip: String) { /// Call on the engine update thread. Applies to every animated descendant. /// No-op (reports `.noAnimationClip`) if `entityId` and its animated descendants have no clip /// registered under `name`. -public func restartAnimation(entityId: EntityID, name: String, repeats: Bool = false, withPause: Bool = false) { +/// +/// `repeats`, when non-nil, overwrites the clip's own looping configuration +/// for every future playback, not just this restart — `AnimationClip` +/// instances are shared by reference from `animationComponent.animationClips`, +/// so this is a persistent change to the asset, not a one-shot playback flag. +/// Pass `nil` (the default) to restart without touching the clip's current +/// looping behavior. +public func restartAnimation(entityId: EntityID, name: String, repeats: Bool? = nil, withPause: Bool = false) { let components = animationComponentsContainingClip(entityId: entityId, name: name) guard !components.isEmpty else { handleError(.noAnimationClip, name, entityId) @@ -1041,7 +1048,7 @@ public func restartAnimation(entityId: EntityID, name: String, repeats: Bool = f for (_, component, clip) in components { // Restarting playback does not change compiled channels. Invalidate only // this clip when its looping setting changes; preserve warmed caches. - if clip.jointAnimation.values.contains(where: { $0.repeatAnimation != repeats }) { + if let repeats, clip.jointAnimation.values.contains(where: { $0.repeatAnimation != repeats }) { for path in clip.jointAnimation.keys { clip.jointAnimation[path]?.repeatAnimation = repeats } diff --git a/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift b/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift index 3d3696903..f67b076ae 100644 --- a/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift +++ b/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift @@ -484,12 +484,36 @@ extension AnimationCompiledSamplerTests { restartAnimation(entityId: entity, name: "strike") XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) XCTAssertFalse(isAnimationComponentPaused(entityId: entity)) - XCTAssertTrue(clip.jointAnimation.values.allSatisfy { !$0.repeatAnimation }) + // repeats defaults to nil: a restart with no explicit looping change + // must not touch the clip's existing (default-true) repeat flags. + XCTAssertTrue(clip.jointAnimation.values.allSatisfy(\.repeatAnimation)) component.currentTime = 0.7 restartAnimation(entityId: entity, name: "strike", withPause: true) XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) XCTAssertTrue(isAnimationComponentPaused(entityId: entity)) } + + /// `AnimationClip` instances are shared by reference from + /// `animationComponent.animationClips`, so a restart that defaulted to + /// overwriting the loop flag would permanently convert a looping clip to + /// one-shot the first time a caller restarted it without passing + /// `repeats: true`. `repeats: nil` (the default) must leave it alone. + func testRestartWithoutRepeatsArgumentPreservesLoopingClip() { + let entity = createEntity() + defer { destroyEntity(entityId: entity) } + registerComponent(entityId: entity, componentType: AnimationComponent.self) + guard let component = scene.get(component: AnimationComponent.self, for: entity) else { + XCTFail("Missing animation component"); return + } + let clip = makeWalkClip() + XCTAssertTrue(clip.jointAnimation.values.allSatisfy(\.repeatAnimation)) + component.animationClips["walk"] = clip + component.currentAnimation = clip + component.currentTime = 1.3 + restartAnimation(entityId: entity, name: "walk") + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) + XCTAssertTrue(clip.jointAnimation.values.allSatisfy(\.repeatAnimation)) + } } extension AnimationCompiledSamplerTests {