Skip to content
Open
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
20 changes: 19 additions & 1 deletion coworld/tools/test_browser_with_playwright.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,25 @@ def check_control(base, executable, output, gpu=False):
frame.evaluate(replay + '.pause()')
assert state['playing'] and state['speed'] == 2 and state['tick'] > first, state
report['ticks_per_second_at_2x'] = (state['tick'] - first) / 2
print('control: speed passed', flush=True)
# Fast speeds deliver what they report: frames slower than ticks
# carry a larger backlog instead of dropping time.
for speed in (4, 16):
assert frame.evaluate(f'{replay}.setSpeed({speed})') == speed
frame.evaluate(replay + '.play()')
frame.evaluate('nextFrames(5)')
first = frame.evaluate(replay + '.state().tick')
started = time.monotonic()
page.wait_for_timeout(3000)
state = frame.evaluate(replay + '.state()')
elapsed = time.monotonic() - started
frame.evaluate(replay + '.pause()')
achieved = (state['tick'] - first) / elapsed / 24
report[f'achieved_speed_at_{speed}x'] = round(achieved, 2)
assert state['playing'] and state['tick'] > first, state
if gpu:
assert achieved >= speed * 0.85, report
frame.evaluate(replay + '.setSpeed(2)')
print('control: speed passed', {k: v for k, v in report.items() if 'speed' in k}, flush=True)

# A frozen camera holds still while playing; projection round-trips.
frame.evaluate(replay + ".camera({mode: 'fixed', x: 0, z: 0, distance: 40})")
Expand Down
5 changes: 3 additions & 2 deletions docs/replay-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ GotA that is `simulation / WorldScale`, centred on the map, with y up.
| `seek(tick, seekId = 0)` | Starts an exact seek and keeps the play state. Frames report `seekId` and `seekTick` until another seek replaces it; the seek has landed when `seekId` matches, `seeking` is false and `tick === seekTick`. |
| `cancelSeek()` | Stops a seek, including a pending backward restore, on the last simulated tick. |
| `play()`, `pause()` | Play rewinds first when already at the end. |
| `setSpeed(x)` | Applies the fastest of 1, 2, 4 or 16 at or below `x` and returns it. |
| `setSpeed(x)` | Applies the fastest of 1, 2, 4 or 16 at or below `x` and returns it. Playback runs at that speed while the machine keeps up: slow frames simulate several ticks each, within the seek slice, and playback drops time only after falling a quarter second of wall time behind. |
| `setLoop(on)`, `setSeekSlice(ms)` | As the URL options. |
| `camera({mode, ...})` | `director`; `fixed` with `x`, `z` and optional `distance`; `follow` with unit `id`; `freeze` holds the current view. Every mode except `director` stops the automatic camera. |
| `freezeCamera()` | Same as `camera({mode: 'freeze'})`. |
Expand Down Expand Up @@ -98,6 +98,7 @@ transport changes and the exports natively. The browser test
(`coworld/tools/test_browser_with_playwright.py`, or `--control-only` for just
this part) checks a cold seek to the last tick for progress, exact seeks, cancel,
pause, speed, frozen cameras, projection round trips, picking, the postMessage
bridge and embed input handling. Page responsiveness during the seek is asserted
bridge and embed input handling. With `--gpu`, 4× and 16× playback must reach
85% of the requested speed. Page responsiveness during the seek is asserted
only with `--gpu` (hardware rendering through Metal): SwiftShader takes seconds
per frame late in a match, so there the timer gaps are only recorded.
13 changes: 9 additions & 4 deletions src/polyworld/player.nim
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const
TransportHeight* = 80.0'f32
PlayerSpeeds* = [1'i32, 2, 4, 16]
CatchUpSeconds* = 0.1
PlaybackLagSeconds* = 0.25
## Wall-clock time realtime playback may fall behind before it drops
## time. Scaling the simulated backlog by speed lets fast speeds keep up
## when frames are slower than ticks.
IconSize = 64.0'f32
IconGap = 8.0'f32
GroupGap = 24.0'f32
Expand Down Expand Up @@ -203,16 +207,16 @@ proc startFrame*(player: var Player, dt: float32, tickRate: int32) =
return
player.accumulator = min(
player.accumulator + dt * player.speed.float32,
0.25'f32
PlaybackLagSeconds * player.speed.float32
)

proc shouldTick*(
player: var Player,
frameStart: float64
): bool =
## Returns whether one more simulation tick belongs in this frame.
## Seeks yield after `catchUpSeconds` so the page stays responsive, and
## paused seeks stop exactly on their target.
## Seeks and realtime playback yield after `catchUpSeconds` so the page
## stays responsive, and paused seeks stop exactly on their target.
if player.reachedEnd:
if player.repeating:
player.seekTo(0, automatic = true)
Expand All @@ -228,7 +232,8 @@ proc shouldTick*(
if not player.playing:
return false
let step = 1.0'f32 / max(player.tickRate, 1).float32
if player.accumulator >= step:
if player.accumulator >= step and
epochTime() - frameStart < player.catchUpSeconds:
player.accumulator -= step
return true
false
Expand Down
31 changes: 29 additions & 2 deletions tests/test_player.nim
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,36 @@ block:
p.startFrame(0.02, 24)
doAssert p.takeRestore() == -1
doAssert p.accumulator > 0
doAssert not p.shouldTick(0)
doAssert not p.shouldTick(epochTime())
p.startFrame(0.03, 24)
doAssert p.shouldTick(0)
doAssert p.shouldTick(epochTime())

echo "Testing fast playback keeps up when frames are slow"
block:
var p = initPlayer(live = false, durationTicks = 10000, speed = 16)
p.sync(0, 10000, false)
# Ten frames a second at 16x owe 1.6 s, 38 ticks, each frame.
p.startFrame(0.1, 24)
var ticks = 0
while p.shouldTick(epochTime()):
inc ticks
doAssert ticks == 38
# The backlog stops at a quarter second of wall time.
for i in 0 ..< 10:
p.startFrame(0.1, 24)
doAssert p.accumulator == PlaybackLagSeconds * 16
p.setSpeed(0)
p.startFrame(0.1, 24)
doAssert p.accumulator == PlaybackLagSeconds

echo "Testing realtime playback yields after the frame budget"
block:
var p = initPlayer(live = false, durationTicks = 10000, speed = 16)
p.sync(0, 10000, false)
p.startFrame(0.1, 24)
let owed = p.accumulator
doAssert not p.shouldTick(epochTime() - p.catchUpSeconds - 0.01)
doAssert p.accumulator == owed

echo "Testing play/pause stops catch-up"
block:
Expand Down