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.
rate(N)forN <= 120sleeps a flat1000/Nms 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. TheN > 120branch of the same function does compensate, so the two halves ofrate()disagree — and the flat half covers exactly the rates student programs use (rate(30),rate(60),rate(100)).Where
lib/glow/WebGLRenderer.js, inasync function rate(iters, callback)(~line 1723):The
iters <= 120branch waitsdt = ceil(1000/iters)unconditionally. Whatever the loop body cost is simply added on top, so the achieved period isbody + 1000/Nrather than1000/N.The
> 120branch, and theN > 0continuation above it, do the right thing already — they trackenditersand waitenditers - 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: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
dtand rely onrate(N)to make simulated time track wall-clock time: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
RateKeepercompensates (it tracksuserTimeand subtracts it), so the same program behaves differently in the browser than on the desktop.Suggested fix
Give the
iters <= 120branch the same treatment the> 120branch already has: remember whenrate()last returned and wait only the remainder of the period.Two properties worth preserving explicitly:
returnon 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-jupytertransport, whererate()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
lastRateReturnshould reset when a program restarts, which our implementation handles by rebuilding the interpreter and may need explicit handling here.