Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion Sources/UntoldEngine/Animation/ClipSampler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion Sources/UntoldEngine/Animation/RootMotion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
72 changes: 72 additions & 0 deletions Sources/UntoldEngine/Systems/AnimationSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Float>? {
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<Float>(0, 0, 0, 1)
return SIMD3<Float>(point.x, point.y, point.z)
}
return nil
}
125 changes: 125 additions & 0 deletions Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Float>(5, 2, -3))
rotateTo(entityId: entity, rotation: simd_quatf(angle: .pi / 2, axis: SIMD3<Float>(0, 1, 0)))
scaleTo(entityId: entity, scale: SIMD3<Float>(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<Float>(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)
}
}
32 changes: 32 additions & 0 deletions Tests/UntoldEngineTests/AnimationRootMotionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
Loading