Skip to content

Fix/sprite frame timing - #2384

Open
Delfayne wants to merge 2 commits into
korlibs:mainfrom
Delfayne:fix/sprite-frame-timing
Open

Fix/sprite frame timing#2384
Delfayne wants to merge 2 commits into
korlibs:mainfrom
Delfayne:fix/sprite-frame-timing

Conversation

@Delfayne

@Delfayne Delfayne commented Aug 3, 2026

Copy link
Copy Markdown

Sprite advances frames early and discards the remainder

Project KorGE (org.korge.engine:korge)
Version 7.0.0-SNAPSHOT; identical in v6.0.0
File korge/src/commonMain/kotlin/korlibs/korge/view/Sprite.kt, nextSprite (:285)
Affects all targets - commonMain

Summary

A sprite shows each frame for about one frame interval less than spriteDisplayTime, so animations
play fast and their real speed depends on the display's refresh rate. Two independent causes, both in
nextSprite.

Reproduction

Dispatch UpdateEvent at a fixed delta and count ticks between advances - no window needed:

val sprite = Sprite(animation)
sprite.playAnimationLooped(spriteDisplayTime = 50.milliseconds)
repeat(n) { sprite.dispatch(UpdateEvent(frameTime)) }   // sprite.totalFramesPlayed
requested actual @60fps actual @144FPS
50ms 33.3ms 48.6ms
100ms 83.3ms 97.2ms
33ms 16.7ms 27.8ms

Each cell is short by roughly one frame interval of whatever display it runs on, which is why the
same spriteDisplayTime plays at a different speed on different hardware.

Cause

1. The frame's delta is counted twice (:286-287) - added to the accumulator, then added again
in the test, so the threshold fires a whole tick early:

lastAnimationFrameTime += frameTime
if (lastAnimationFrameTime + frameTime >= this.fastSpriteDisplayTime) {

2. The accumulator is reset, not decremented (:305), discarding however far past the threshold
it had gone. Every period is rounded up to a whole multiple of the frame interval with nothing to
compensate later:

lastAnimationFrameTime = 0.fastSeconds

Fix

-        if (lastAnimationFrameTime + frameTime >= this.fastSpriteDisplayTime) {
+        if (lastAnimationFrameTime >= this.fastSpriteDisplayTime) {

                 AnimationType.DURATION -> {
-                    animationRemainingDuration -= lastAnimationFrameTime
+                    animationRemainingDuration -= this.fastSpriteDisplayTime
                 }

-            lastAnimationFrameTime = 0.fastSeconds
+            lastAnimationFrameTime -= this.fastSpriteDisplayTime

DURATION has to change with it: once the remainder carries, the accumulator includes time the
previous advance already deducted. Counting it twice would end a timed animation early.

Measured after:

requested actual @60fps actual @144FPS
50ms 50.0ms 50.3ms
100ms 100.0ms 100.3ms
33ms 33.3ms 33.3ms

Notes

  • TileMap gets this right in the same package (view/tiles/TileMap.kt:407-411) - it counts the
    delta once and subtracts the frame duration. The two animation paths currently disagree.
  • Both use if rather than while, so neither can advance more than one frame per tick. Left alone
    here: changing it converts slow-down-under-lag into frame-skipping, which is a behaviour choice
    rather than a defect. I might be inclined to fix this under an engine flag, though

frameTime is added to the accumulator and again in the threshold test,
so a frame advances one tick early. At 60fps a 50ms frame shows for 33ms,
and the real rate tracks the display's refresh rate.
Resetting the accumulator discards the overshoot and rounds every period
up to a whole frame interval. Subtracting the display time keeps the mean
on target. DURATION must subtract it too, or the carried remainder gets
counted twice and a timed animation ends early.
@Delfayne

Delfayne commented Aug 9, 2026

Copy link
Copy Markdown
Author

Looks like it's blocked on the unrelated Mac stuff. I can look into that on the side if you need eyes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant