Skip to content

Commit f3422f9

Browse files
committed
[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.
1 parent b2b6706 commit f3422f9

2 files changed

Lines changed: 38 additions & 1 deletion

File tree

‎Sources/UntoldEngine/Animation/RootMotion.swift‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,12 @@ func applyRootMotion(
204204
// wrap, to detect when the clip looped between frames. Non-repeating
205205
// channels clamp at their last key (like the sampler), so a one-shot
206206
// clip never fakes a wrap and never injects a per-loop correction.
207-
let channelTime = fmod(animationComponent.currentTime, clipDuration) * clipSpeed
207+
// Match ClipSampler: a one-shot's clock must not wrap at completion,
208+
// otherwise wrap detection injects an extra loop's root displacement.
209+
let repeats = compiledClip.channels.contains { $0.animated && $0.repeats }
210+
let sampleTime = repeats ? fmod(animationComponent.currentTime, clipDuration)
211+
: min(max(animationComponent.currentTime, 0), clipDuration)
212+
let channelTime = sampleTime * clipSpeed
208213
let translationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.translationTimes.last, repeats: channel.repeats)
209214
let rotationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.rotationTimes.last, repeats: channel.repeats)
210215

‎Tests/UntoldEngineTests/AnimationRootMotionTests.swift‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,38 @@ final class AnimationRootMotionTests: XCTestCase {
265265
)
266266
}
267267

268+
func testNonRepeatingClipHoldsPositionPastClipDurationWithoutLoopJump() {
269+
animationComponent.animationClips["lunge"] = makeOneShotClip()
270+
271+
setRootMotionEnabled(entityId: entityId, enabled: true)
272+
changeAnimation(entityId: entityId, name: "lunge", transitionHalflife: 0)
273+
274+
// 3 s: past the clip's own 2 s duration, not just the root channel's 1 s
275+
// last key. Before the fix, applyRootMotion's own
276+
// fmod(animationComponent.currentTime, clipDuration) wrapped unconditionally
277+
// at the 2 s mark regardless of repeatAnimation, handing wrappedChannelTime
278+
// a small post-wrap time it misread as a fresh loop and injecting a full
279+
// loop's root displacement right at that instant — distinct from
280+
// testNonRepeatingChannelDoesNotInjectLoopJump above, which only runs up to
281+
// 1.89 s and so never reaches the clip's own duration.
282+
var previousZ = getLocalPosition(entityId: entityId).z
283+
var maxStep: Float = 0
284+
run(frames: 270) { _ in
285+
let z = getLocalPosition(entityId: self.entityId).z
286+
maxStep = max(maxStep, abs(z - previousZ))
287+
previousZ = z
288+
}
289+
290+
XCTAssertEqual(
291+
getLocalPosition(entityId: entityId).z, 1.0 - deltaTime, accuracy: 1e-3,
292+
"Entity holds the authored 1 m travel; the clip's own duration elapsing must not add displacement"
293+
)
294+
XCTAssertLessThan(
295+
maxStep, deltaTime * 1.5,
296+
"currentTime crossing the clip's own duration must not be misread as a loop wrap"
297+
)
298+
}
299+
268300
// MARK: - Pitch and roll preservation
269301

270302
/// The swing–twist split must remove only yaw. A body-frame lean —

0 commit comments

Comments
 (0)