diff --git a/Sources/UntoldEngine/Animation/ClipSampler.swift b/Sources/UntoldEngine/Animation/ClipSampler.swift index 93ad2b00d..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,7 +65,7 @@ struct ClipSampler { bind(clip) pose.resize(jointCount: clip.jointCount) - let channelTime = fmod(time, 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 d0045a51c..4f66f221c 100644 --- a/Sources/UntoldEngine/Animation/RootMotion.swift +++ b/Sources/UntoldEngine/Animation/RootMotion.swift @@ -204,7 +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. - let channelTime = fmod(animationComponent.currentTime, clipDuration) * 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 593d87c15..dc759cec9 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -1027,3 +1027,75 @@ 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`. +/// +/// `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) + 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 let repeats, 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..f67b076ae 100644 --- a/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift +++ b/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift @@ -452,3 +452,128 @@ 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)) + // 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 { + 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) + } +} 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 —