Skip to content

rate(N) for N <= 120 does not subtract the loop body's time, so animations run slower than requested #4

Description

@sspickle

rate(N) for N <= 120 sleeps a flat 1000/N ms without subtracting the time the user's loop body took, so a loop with any real work in it runs slower than the requested rate. The N > 120 branch of the same function does compensate, so the two halves of rate() disagree — and the flat half covers exactly the rates student programs use (rate(30), rate(60), rate(100)).

Where

lib/glow/WebGLRenderer.js, in async function rate(iters, callback) (~line 1723):

} else {
    if (iters <= 120) {
        dt = Math.ceil(1000/iters)
        // Return after waiting dt:
        if (callback === undefined) await sleep(dt/1000)
        else setTimeout(callback,dt)
    } else { // Do multiple iterations within 1/60th of a second:
        timer = msclock()
        N = Math.ceil(iters/desired_fps)
        enditers = msclock() + Math.ceil(1000/desired_fps)
        ...

The iters <= 120 branch waits dt = ceil(1000/iters) unconditionally. Whatever the loop body cost is simply added on top, so the achieved period is body + 1000/N rather than 1000/N.

The > 120 branch, and the N > 0 continuation above it, do the right thing already — they track enditers and wait enditers - Math.ceil(timer), i.e. the remainder of the interval after the work.

Measured

rate(60) (ideal 60 Hz), 180 iterations, headless Chromium against a Web VPython embed:

loop body achieved
empty 52.0 Hz
8 ms 38.0 Hz
20 ms 25.8 Hz

The 8 ms and 20 ms figures match what the flat model predicts (1/(0.008 + 0.0167) ≈ 40, 1/(0.020 + 0.0167) ≈ 27), so the mechanism is not in doubt.

Why it matters beyond the frame rate

Physics programs written for Matter & Interactions courses integrate with a fixed dt and rely on rate(N) to make simulated time track wall-clock time:

while True:
    rate(100)
    v = v + a*dt
    pos = pos + v*dt

With a 10 ms body, the loop achieves ~37 Hz instead of 100, so the simulation runs at roughly a third of real speed. Nothing looks broken — it is just silently slow, and the more physics a student adds, the slower their world gets. Upstream desktop VPython's RateKeeper compensates (it tracks userTime and subtracts it), so the same program behaves differently in the browser than on the desktop.

Suggested fix

Give the iters <= 120 branch the same treatment the > 120 branch already has: remember when rate() last returned and wait only the remainder of the period.

// sketch
var lastRateReturn = null
...
if (iters <= 120) {
    var period = 1000/iters
    var now = msclock()
    var remaining = (lastRateReturn === null) ? period : (lastRateReturn + period) - now
    if (remaining < 0) remaining = 0            // never negative
    if (callback === undefined) await sleep(remaining/1000)
    else setTimeout(callback, remaining)
    lastRateReturn = msclock()                  // re-anchor on the ACTUAL return
}

Two properties worth preserving explicitly:

  • Clamp at zero, don't carry debt. If a body overruns the period, wait zero and re-anchor on the actual return time. Accumulating the shortfall makes a slow loop fire a burst of zero-wait iterations trying to catch up, which is worse than running slow.
  • Always await, even at zero. An early return on the overrun path looks free but removes the only yield in the loop.

How this surfaced

We hit the same bug independently while running Web VPython under Pyodide in a Web Worker (a vpython-jupyter transport, where rate() is a Python coroutine rather than this function). Fixing it there produced 54.4 Hz and 49.6 Hz for the 8 ms and 20 ms bodies above — which is what made the browser path's numbers stand out, since they had been tracking our unfixed behaviour exactly.

Happy to open a PR against this if the sketch looks right — in particular whether lastRateReturn should reset when a program restarts, which our implementation handles by rebuilding the interpreter and may need explicit handling here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions