diff --git a/coworld/tools/test_browser_with_playwright.py b/coworld/tools/test_browser_with_playwright.py index 93d4ec02..07c08073 100644 --- a/coworld/tools/test_browser_with_playwright.py +++ b/coworld/tools/test_browser_with_playwright.py @@ -137,7 +137,12 @@ def check_control(base, executable, output, gpu=False): heartbeat.last = now; }, 10); window.seekTicks = new Set(); - polyworldReplay.onFrame(s => { if (s.seeking) seekTicks.add(s.tick); }); + window.seekDrawn = 0; + polyworldReplay.onFrame(s => { + if (!s.seeking) return; + seekTicks.add(s.tick); + seekDrawn += s.drawn; + }); }''') frame.evaluate('nextFrames(2)') frame.evaluate('() => { heartbeat.worst = 0; }') @@ -147,8 +152,12 @@ def check_control(base, executable, output, gpu=False): report['cold_end_seek'] = { 'seconds': round(time.monotonic() - started, 1), 'worst_timer_gap_ms': round(frame.evaluate('heartbeat.worst')), - 'progress_frames': frame.evaluate('seekTicks.size')} - assert not state['playing'], state + 'progress_frames': frame.evaluate('seekTicks.size'), + 'drawn_frames': frame.evaluate('seekDrawn')} + assert not state['playing'] and state['drawn'], state + # Seek frames mostly simulate; a few draws (four a second) show progress. + seek = report['cold_end_seek'] + assert seek['drawn_frames'] <= seek['seconds'] * 4 + 2, report assert report['cold_end_seek']['progress_frames'] >= 3, report if gpu: # Unsliced, this seek blocked the page for the whole seek. diff --git a/docs/replay-control.md b/docs/replay-control.md index 5bef0133..2b55d3de 100644 --- a/docs/replay-control.md +++ b/docs/replay-control.md @@ -21,9 +21,19 @@ Options are read from the hash, then the query, like `replay`. | `loop=0` or `loop=1` | Overrides looping. Replay builds loop by default. | | `seekSlice=MS` | Simulation time per frame while seeking. The default is 100 ms, or 33 ms with `embed=1`. | -Shorter slices keep the page responsive during long seeks but finish them later. -On an M-series Mac with a GPU, a cold seek over a 28,909-tick GotA match took -28 s at 12 ms, 17 s at 33 ms and 12 s at 100 ms. +While a seek is in flight the viewer draws at most four frames a second; the +frames between only simulate and report their tick, without waiting for the next +display refresh. Shorter slices keep the page more responsive during long seeks +and cost little seek time now that most frames are not drawn. A cold seek to the +end of a 23,642-tick GotA replay, in headless Chromium on an M-series Mac with +Metal, with a 10 ms timer in the host page: + +| Slice | Seek time | Host timer gap p90 | Cancel | +| --- | --- | --- | --- | +| 10 ms | 11.7 s | 23 ms | 54 ms | +| 20 ms | 9.8 s | 47 ms | 30 ms | +| 33 ms (embed default) | 9.0 s | 75 ms | 27 ms | +| 100 ms | 9.1 s | 224 ms | 23 ms | ## Same-origin hosts @@ -34,7 +44,7 @@ GotA that is `simulation / WorldScale`, centred on the map, with y up. | Call | Result | | --- | --- | | `ready()` | `true` once the first controlled frame has been drawn. | -| `state()` | The latest frame: `tick`, `endTick`, `playing`, `seeking`, `speed`, `loop`, `seekId`, `seekTick`, `viewProjection` (16 floats, column-major), framebuffer `width`/`height`, `cssWidth`/`cssHeight`. | +| `state()` | The latest frame: `tick`, `endTick`, `playing`, `seeking`, `speed`, `loop`, `seekId`, `seekTick`, `drawn`, `viewProjection` (16 floats, column-major), framebuffer `width`/`height`, `cssWidth`/`cssHeight`. A frame with `drawn` false only simulated during a seek: its `tick` is simulation progress, and its camera and size are those of the last drawn frame, which is still on screen. The frame that lands a seek is always drawn. | | `onFrame(fn)` | Calls `fn(state)` after every presented frame. Returns an unsubscribe function. | | `onKey(fn)` | With `embed=1`, calls `fn({event, key, code, altKey, ctrlKey, metaKey, shiftKey, repeat})` for key presses. | | `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`. | diff --git a/examples/gods_of_the_arena/graphics.nim b/examples/gods_of_the_arena/graphics.nim index 6ea06b48..be4f3865 100644 --- a/examples/gods_of_the_arena/graphics.nim +++ b/examples/gods_of_the_arena/graphics.nim @@ -22,6 +22,8 @@ when defined(takeScreenshot): const DefaultCameraDistance = 17.0'f / 1.2'f + SeekDrawSeconds = 0.25 + ## While seeking, draw at most this often so the view still moves. ProfileWarmupFrames {.intdefine.} = 0 AtlasPath = TmpRoot & "/gota.atlas.png" MossyStoneSurface = SurfaceNames.len @@ -2057,6 +2059,8 @@ proc runGraphics*() = var viewingClock: ViewingClock cameraSeekSerial = -1 + frameDrawn = true + lastDrawTime = 0.0 when ProfileWarmupFrames > 0 and ActiveTracePath.len > 0: var profileWarmup = 0 @@ -2157,331 +2161,343 @@ proc runGraphics*() = updatePlayerSpells(viewProjection) updatePlayerOrder(viewProjection) - profileBlock "drawWorld": - # The clock changes palettes while the light stays fixed for - # readable silhouettes and shadows on both sides of the map. - scene.toon.setArenaHour( - clockHour(float32(run.world.battleTick()) + renderAlpha, TickRate)) - setEnvironmentPalette(scene.toon) - - profileBlock "prepare character poses": - var count = 0 - for footman in run.world.footmen: - if not visibleInView(footman): - continue - let - model = creepModel(footman) - clip = creepClip(footman) - dead = footman.hp <= 0 or footman.state == Dying - creepEyes(footman, dead) - if count == worldCharacters.len: - worldCharacters.add WorldCharacter() - worldCharacters[count].id = footman.id - worldCharacters[count].living = not dead - worldCharacters[count].eyes = creepEyeState(footman) - scene.prepareCharacter( - worldCharacters[count].pose, - model, - unitRenderPoint(footman.id, footman.position), - unitRenderFacing(footman.id, footman.facing), - clip, - creepRenderTime(footman), - sizeFactor = creepSize(footman) - ) - inc count - for hero in run.world.heroes: - if not visibleInView(hero.team, hero.position): - continue - let - pose = heroRenderPose(hero) - dead = hero.hp <= 0 or hero.state == Dying - heroEyes[hero.class].setDead(dead) - if count == worldCharacters.len: - worldCharacters.add WorldCharacter() - worldCharacters[count].id = hero.id - worldCharacters[count].living = not dead - worldCharacters[count].eyes = addr heroEyes[hero.class] - scene.prepareCharacter( - worldCharacters[count].pose, - heroModels[hero.class], - unitRenderPoint(hero.id, hero.position), - unitRenderFacing(hero.id, hero.facing), - pose.clip, - pose.time, - sizeFactor = hero.heroSizeFactor() - ) - inc count - for god in gods: - if not visibleInView( - god.team, run.world.forts[god.team.ord].center): - continue - let - model = godModels[god.team] - clip = godRenderClips[god.team][god.godClip] - dead = run.world.forts[god.team.ord].hp <= 0 - godEyes[god.team].setDead(dead) - var animTime = god.animTime - if run.world.gameOver and - (run.world.draw or god.team != run.world.winner): - animTime = min(animTime, clipDuration(model, clip)) - if count == worldCharacters.len: - worldCharacters.add WorldCharacter() - worldCharacters[count].id = run.world.forts[god.team.ord].id - worldCharacters[count].living = not dead - worldCharacters[count].eyes = addr godEyes[god.team] - scene.prepareCharacter( - worldCharacters[count].pose, - model, - god.position, - god.facing, - clip, - animTime - ) - inc count - worldCharacterCount = count - - profileBlock "Shadow pass": - sunDepthPasses(window.size): - drawTerrainSunDepth() - for tower in run.world.buildings: - if tower.hp <= 0: + # While seeking, most frames only simulate: a draw costs about as much + # as a 33 ms seek slice, so skipping it cuts long seeks by a third. + let seekingNow = + transport.targetTick >= 0 or transport.restoreTick >= 0 + frameDrawn = not seekingNow or + epochTime() - lastDrawTime >= SeekDrawSeconds + if frameDrawn: + lastDrawTime = epochTime() + profileBlock "drawWorld": + # The clock changes palettes while the light stays fixed for + # readable silhouettes and shadows on both sides of the map. + scene.toon.setArenaHour( + clockHour(float32(run.world.battleTick()) + renderAlpha, TickRate)) + setEnvironmentPalette(scene.toon) + + profileBlock "prepare character poses": + var count = 0 + for footman in run.world.footmen: + if not visibleInView(footman): continue - towerPacks[tower.team].drawPropSunDepth( - buildingPropName(tower), - renderPoint(tower.position), - renderFacing(tower.facing), - buildingScale(tower) + let + model = creepModel(footman) + clip = creepClip(footman) + dead = footman.hp <= 0 or footman.state == Dying + creepEyes(footman, dead) + if count == worldCharacters.len: + worldCharacters.add WorldCharacter() + worldCharacters[count].id = footman.id + worldCharacters[count].living = not dead + worldCharacters[count].eyes = creepEyeState(footman) + scene.prepareCharacter( + worldCharacters[count].pose, + model, + unitRenderPoint(footman.id, footman.position), + unitRenderFacing(footman.id, footman.facing), + clip, + creepRenderTime(footman), + sizeFactor = creepSize(footman) ) - scene.sunDepthPass = true - profileBlock "Shadow characters": - drawWorldCharacters(scene, worldCharacters, worldCharacterCount) - scene.sunDepthPass = false - profileBlock "Main pass": - when not defined(emscripten): - glEnable(GL_MULTISAMPLE) - glClearColor(0.05, 0.06, 0.09, 1.0) - glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT) - scene.toon.drawBackground() - updateTerrainVision() - if showTiles and (terrainEdgeWorld != run.world or - terrainEdgeRevision != run.world.navigationRevision): - updateTerrainEdges(navigationOpen) - terrainEdgeWorld = run.world - terrainEdgeRevision = run.world.navigationRevision - drawTerrain(viewProjection, showTiles) - for tower in run.world.buildings: - if tower.hp <= 0 or not visibleInView(tower.team, tower.position): - continue - towerPacks[tower.team].drawProp( - buildingPropName(tower), - renderPoint(tower.position), - renderFacing(tower.facing), - buildingScale(tower), - viewProjection - ) - - if showOccludedCharacters: - profileBlock "Character outline pass": - occlusionOutline.beginMask(window.size) - beginCharacters(scene, window, view, projection, cameraEye) - drawWorldCharacters( - scene, - worldCharacters, - worldCharacterCount, - livingOnly = true + inc count + for hero in run.world.heroes: + if not visibleInView(hero.team, hero.position): + continue + let + pose = heroRenderPose(hero) + dead = hero.hp <= 0 or hero.state == Dying + heroEyes[hero.class].setDead(dead) + if count == worldCharacters.len: + worldCharacters.add WorldCharacter() + worldCharacters[count].id = hero.id + worldCharacters[count].living = not dead + worldCharacters[count].eyes = addr heroEyes[hero.class] + scene.prepareCharacter( + worldCharacters[count].pose, + heroModels[hero.class], + unitRenderPoint(hero.id, hero.position), + unitRenderFacing(hero.id, hero.facing), + pose.clip, + pose.time, + sizeFactor = hero.heroSizeFactor() ) - finishCharacters(scene) - # Only opaque scenery is in the window depth buffer here. - occlusionOutline.drawOutline(OccludedOutlineColor) - - profileBlock "Main characters": - beginCharacters(scene, window, view, projection, cameraEye) - drawWorldCharacters(scene, worldCharacters, worldCharacterCount) - finishCharacters(scene) - - # The arena's lakes, each with its own reflection. The water mirrors the sky, - # terrain, towers and characters; particles, spells and markers stay out of it. - if lakes.len > 0: - captureScene(window.size) - for lake in lakes: - if lake.layer notin 0 ..< waterLayerRanges.len or - waterLayerRanges[lake.layer].len == 0: - continue - let reflected = lake.params.reflecting() - if reflected: - profileBlock "Water mirror pass": + inc count + for god in gods: + if not visibleInView( + god.team, run.world.forts[god.team.ord].center): + continue let - mirrored = reflectedCamera( - cameraEye, - view, - projection, - waterLayerSurfaces[lake.layer] + lake.params.lift - ) - mirroredViewProjection = mirrored.projection * mirrored.view - beginReflection(window.size) - drawWaterSky( - mirroredViewProjection, - mirrored.eye, - scene.toon.skyColor, - scene.toon.horizonColor, - scene.toon.groundColor + model = godModels[god.team] + clip = godRenderClips[god.team][god.godClip] + dead = run.world.forts[god.team.ord].hp <= 0 + godEyes[god.team].setDead(dead) + var animTime = god.animTime + if run.world.gameOver and + (run.world.draw or god.team != run.world.winner): + animTime = min(animTime, clipDuration(model, clip)) + if count == worldCharacters.len: + worldCharacters.add WorldCharacter() + worldCharacters[count].id = run.world.forts[god.team.ord].id + worldCharacters[count].living = not dead + worldCharacters[count].eyes = addr godEyes[god.team] + scene.prepareCharacter( + worldCharacters[count].pose, + model, + god.position, + god.facing, + clip, + animTime ) - drawTerrain(mirroredViewProjection, false) + inc count + worldCharacterCount = count + + profileBlock "Shadow pass": + sunDepthPasses(window.size): + drawTerrainSunDepth() for tower in run.world.buildings: - if tower.hp <= 0 or - not visibleInView(tower.team, tower.position): - continue - towerPacks[tower.team].drawProp( + if tower.hp <= 0: + continue + towerPacks[tower.team].drawPropSunDepth( buildingPropName(tower), renderPoint(tower.position), renderFacing(tower.facing), - buildingScale(tower), - mirroredViewProjection + buildingScale(tower) ) - profileBlock "Water mirror characters": - beginCharacters( + scene.sunDepthPass = true + profileBlock "Shadow characters": + drawWorldCharacters(scene, worldCharacters, worldCharacterCount) + scene.sunDepthPass = false + profileBlock "Main pass": + when not defined(emscripten): + glEnable(GL_MULTISAMPLE) + glClearColor(0.05, 0.06, 0.09, 1.0) + glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT) + scene.toon.drawBackground() + updateTerrainVision() + if showTiles and (terrainEdgeWorld != run.world or + terrainEdgeRevision != run.world.navigationRevision): + updateTerrainEdges(navigationOpen) + terrainEdgeWorld = run.world + terrainEdgeRevision = run.world.navigationRevision + drawTerrain(viewProjection, showTiles) + for tower in run.world.buildings: + if tower.hp <= 0 or not visibleInView(tower.team, tower.position): + continue + towerPacks[tower.team].drawProp( + buildingPropName(tower), + renderPoint(tower.position), + renderFacing(tower.facing), + buildingScale(tower), + viewProjection + ) + + if showOccludedCharacters: + profileBlock "Character outline pass": + occlusionOutline.beginMask(window.size) + beginCharacters(scene, window, view, projection, cameraEye) + drawWorldCharacters( scene, - window, - mirrored.view, - mirrored.projection, - mirrored.eye + worldCharacters, + worldCharacterCount, + livingOnly = true ) - drawWorldCharacters(scene, worldCharacters, worldCharacterCount) finishCharacters(scene) - glBindFramebuffer(GL_FRAMEBUFFER, 0) - glViewport(0, 0, window.size.x, window.size.y) - drawWaterBody( - lake, - viewProjection, - cameraEye, - window.size, - sunDirection, - scene.toon.highlightColor, - ivec4(0, 0, window.size.x, window.size.y), - reflected - ) - profileBlock "World effects": - particles.drawParticles( - viewProjection, - barCameraRight, - barCameraUp, - cameraForward - ) - spellEffects.drawSpells( - run.world, viewProjection, animationAlpha, viewMode - ) - clickMarks.drawClickMarks(viewProjection) - worldShapes.clear() - for hero in run.world.heroes: - if hero.portalEnds <= run.world.tick or hero.hp <= 0: - continue - for point in [hero.position, hero.portalDestination]: - if not visibleInView(hero.team, point): - continue - var ring: array[49, Vec3] - let - center = renderPoint(point) - radius = 0.5'f + (hero.portalEnds - run.world.tick).float32 / - PortalChannelTicks.float32 - for i in 0 .. ring.high: - let angle = i.float32 * 2 * PI.float32 / ring.high.float32 - ring[i] = center + vec3(cos(angle) * radius, 0.12'f, - sin(angle) * radius) - worldShapes.addPolyline(ring, rgbx(167, 128, 255, 255), 0.06'f) - if playerMode() and armedItem >= 0: - let hero = run.world.heroById(playerHeroId()) - for tower in run.world.buildings: - if tower.kind != TowerBuilding or tower.hp <= 0 or - tower.team != hero.team: + # Only opaque scenery is in the window depth buffer here. + occlusionOutline.drawOutline(OccludedOutlineColor) + + profileBlock "Main characters": + beginCharacters(scene, window, view, projection, cameraEye) + drawWorldCharacters(scene, worldCharacters, worldCharacterCount) + finishCharacters(scene) + + # The arena's lakes, each with its own reflection. The water mirrors the sky, + # terrain, towers and characters; particles, spells and markers stay out of it. + if lakes.len > 0: + captureScene(window.size) + for lake in lakes: + if lake.layer notin 0 ..< waterLayerRanges.len or + waterLayerRanges[lake.layer].len == 0: continue - var ring: array[65, Vec3] - let - center = renderPoint(tower.position) - radius = TowerAttackRanges[tower.tier].float32 / WorldScale.float32 - for i in 0 .. ring.high: - let angle = i.float32 * 2 * PI.float32 / ring.high.float32 - ring[i] = center + vec3(cos(angle) * radius, 0, - sin(angle) * radius) - ring[i].y = groundHeight(ring[i].x, ring[i].z) + - groundOffset(ring[i].x, ring[i].z) + 0.12'f - worldShapes.addPolyline(ring, rgbx(167, 128, 255, 255), 0.04'f) - if showPaths: + let reflected = lake.params.reflecting() + if reflected: + profileBlock "Water mirror pass": + let + mirrored = reflectedCamera( + cameraEye, + view, + projection, + waterLayerSurfaces[lake.layer] + lake.params.lift + ) + mirroredViewProjection = mirrored.projection * mirrored.view + beginReflection(window.size) + drawWaterSky( + mirroredViewProjection, + mirrored.eye, + scene.toon.skyColor, + scene.toon.horizonColor, + scene.toon.groundColor + ) + drawTerrain(mirroredViewProjection, false) + for tower in run.world.buildings: + if tower.hp <= 0 or + not visibleInView(tower.team, tower.position): + continue + towerPacks[tower.team].drawProp( + buildingPropName(tower), + renderPoint(tower.position), + renderFacing(tower.facing), + buildingScale(tower), + mirroredViewProjection + ) + profileBlock "Water mirror characters": + beginCharacters( + scene, + window, + mirrored.view, + mirrored.projection, + mirrored.eye + ) + drawWorldCharacters(scene, worldCharacters, worldCharacterCount) + finishCharacters(scene) + glBindFramebuffer(GL_FRAMEBUFFER, 0) + glViewport(0, 0, window.size.x, window.size.y) + drawWaterBody( + lake, + viewProjection, + cameraEye, + window.size, + sunDirection, + scene.toon.highlightColor, + ivec4(0, 0, window.size.x, window.size.y), + reflected + ) + profileBlock "World effects": + particles.drawParticles( + viewProjection, + barCameraRight, + barCameraUp, + cameraForward + ) + spellEffects.drawSpells( + run.world, viewProjection, animationAlpha, viewMode + ) + clickMarks.drawClickMarks(viewProjection) + worldShapes.clear() for hero in run.world.heroes: - if hero.state == Dying or hero.hp <= 0: - continue - if hero.movePathIndex >= hero.movePath.len: + if hero.portalEnds <= run.world.tick or hero.hp <= 0: continue - let color = - if hero.team == RedTeam: - rgbx(210, 72, 64, 255) - else: - rgbx(64, 120, 220, 255) - pathPoints.setLen(0) - let now = unitRenderPoint(hero.id, hero.position) - pathPoints.add vec3(now.x, now.y + 0.2'f, now.z) - for i in hero.movePathIndex ..< hero.movePath.len: - let p = renderPoint(hero.movePath[i]) - pathPoints.add vec3(p.x, p.y + 0.2'f, p.z) - if pathPoints.len >= 2: - worldShapes.addPolyline(pathPoints, color) - worldShapes.draw(viewProjection) - if not cleanScreenshot: - drawWorldUnitBars( - worldBarRenderer, - viewProjection, - barCameraRight, - barCameraUp, - dt - ) - drawSelectedOutline(view, projection, viewProjection) - drawCreepWaypoints(viewProjection, barCameraRight, barCameraUp) - - profileBlock "ui": - if not cleanScreenshot and not embedded: - glDisable(GL_DEPTH_TEST) - glDisable(GL_CULL_FACE) - glDisable(GL_BLEND) - when not defined(emscripten): - glDisable(GL_MULTISAMPLE) - glActiveTexture(GL_TEXTURE0) - glBindTexture(GL_TEXTURE_2D, sk.atlasTextureId()) - sk.beginUi(window, window.size) - drawUi( - sk, + for point in [hero.position, hero.portalDestination]: + if not visibleInView(hero.team, point): + continue + var ring: array[49, Vec3] + let + center = renderPoint(point) + radius = 0.5'f + (hero.portalEnds - run.world.tick).float32 / + PortalChannelTicks.float32 + for i in 0 .. ring.high: + let angle = i.float32 * 2 * PI.float32 / ring.high.float32 + ring[i] = center + vec3(cos(angle) * radius, 0.12'f, + sin(angle) * radius) + worldShapes.addPolyline(ring, rgbx(167, 128, 255, 255), 0.06'f) + if playerMode() and armedItem >= 0: + let hero = run.world.heroById(playerHeroId()) + for tower in run.world.buildings: + if tower.kind != TowerBuilding or tower.hp <= 0 or + tower.team != hero.team: + continue + var ring: array[65, Vec3] + let + center = renderPoint(tower.position) + radius = TowerAttackRanges[tower.tier].float32 / WorldScale.float32 + for i in 0 .. ring.high: + let angle = i.float32 * 2 * PI.float32 / ring.high.float32 + ring[i] = center + vec3(cos(angle) * radius, 0, + sin(angle) * radius) + ring[i].y = groundHeight(ring[i].x, ring[i].z) + + groundOffset(ring[i].x, ring[i].z) + 0.12'f + worldShapes.addPolyline(ring, rgbx(167, 128, 255, 255), 0.04'f) + if showPaths: + for hero in run.world.heroes: + if hero.state == Dying or hero.hp <= 0: + continue + if hero.movePathIndex >= hero.movePath.len: + continue + let color = + if hero.team == RedTeam: + rgbx(210, 72, 64, 255) + else: + rgbx(64, 120, 220, 255) + pathPoints.setLen(0) + let now = unitRenderPoint(hero.id, hero.position) + pathPoints.add vec3(now.x, now.y + 0.2'f, now.z) + for i in hero.movePathIndex ..< hero.movePath.len: + let p = renderPoint(hero.movePath[i]) + pathPoints.add vec3(p.x, p.y + 0.2'f, p.z) + if pathPoints.len >= 2: + worldShapes.addPolyline(pathPoints, color) + worldShapes.draw(viewProjection) + if not cleanScreenshot: + drawWorldUnitBars( + worldBarRenderer, + viewProjection, + barCameraRight, + barCameraUp, + dt + ) + drawSelectedOutline(view, projection, viewProjection) + drawCreepWaypoints(viewProjection, barCameraRight, barCameraUp) + + profileBlock "ui": + if not cleanScreenshot and not embedded: + glDisable(GL_DEPTH_TEST) + glDisable(GL_CULL_FACE) + glDisable(GL_BLEND) + when not defined(emscripten): + glDisable(GL_MULTISAMPLE) + glActiveTexture(GL_TEXTURE0) + glBindTexture(GL_TEXTURE_2D, sk.atlasTextureId()) + sk.beginUi(window, window.size) + drawUi( + sk, + window, + transport, + cameraTarget, + cameraDistance, + viewMode, + primaryId, + selectedIds, + followSelection, + actionCam, + focusPlayerHero + ) + sk.endUi() + drawStatsOverlay(sk, window) + when defined(takeScreenshot) and ActiveTracePath.len == 0: + captureScreenshot( window, - transport, - cameraTarget, - cameraDistance, - viewMode, - primaryId, - selectedIds, - followSelection, - actionCam, - focusPlayerHero + screenshotFrame, + 30, + screenshotPath ) - sk.endUi() - drawStatsOverlay(sk, window) - when defined(takeScreenshot) and ActiveTracePath.len == 0: - captureScreenshot( - window, - screenshotFrame, - 30, - screenshotPath - ) profileBlock "present": - window.presentFrame(framePaceHz) + if frameDrawn: + window.presentFrame(framePaceHz) reportDirectorFrame( actionCam, transport, cameraDistance, int32(run.hashCheck.mismatches) ) reportReplayFrame(run.world.tick, int32(run.hashCheck.mismatches)) - publishReplayControlFrame(viewProjection, window.size) + publishReplayControlFrame(viewProjection, window.size, frameDrawn) if noteProfileFrame(): when not defined(emscripten): window.closeRequested = true while not window.closeRequested: pollEvents() - waitForDisplay() + # Undrawn seek frames only yield, so the seek resumes without waiting + # for the next display refresh. + if frameDrawn: + waitForDisplay() if not run.replayMode: saveRecording() diff --git a/src/polyworld/replaycontrols.nim b/src/polyworld/replaycontrols.nim index 80021c3d..fb1691b9 100644 --- a/src/polyworld/replaycontrols.nim +++ b/src/polyworld/replaycontrols.nim @@ -146,13 +146,20 @@ proc unprojectToGround*( point.y = y true -proc publishReplayControlFrame*(viewProjection: Mat4, viewport: IVec2) = +proc publishReplayControlFrame*( + viewProjection: Mat4, + viewport: IVec2, + drawn = true +) = ## Records the presented camera and reports the frame to the page. - ## Call it after presenting, next to `reportReplayFrame`. + ## Call it after presenting, next to `reportReplayFrame`. A frame that only + ## simulated (`drawn` false) reports its tick but keeps the last drawn + ## camera, so projection and picking still match the pixels on screen. if not installed(): return - control.viewProjection = viewProjection - control.viewport = viewport.vec2 + if drawn: + control.viewProjection = viewProjection + control.viewport = viewport.vec2 when defined(emscripten): let matrix = cast[ptr UncheckedArray[float32]](control.viewProjection.addr) @@ -163,8 +170,8 @@ proc publishReplayControlFrame*(viewProjection: Mat4, viewport: IVec2) = seeking = transport.targetTick >= 0 or transport.restoreTick >= 0 speed = transport.speed looping = transport.repeating - width = viewport.x - height = viewport.y + width = control.viewport.x.int32 + height = control.viewport.y.int32 current = transport.seekSerial == control.seekSerial seekId = if current: control.seekId else: 0'i32 seekTick = if current: control.seekTick else: -1'i32 @@ -175,10 +182,10 @@ proc publishReplayControlFrame*(viewProjection: Mat4, viewport: IVec2) = Module.polyworldControlFrame({ tick: $1, endTick: $2, playing: !!$3, seeking: !!$4, speed: $5, loop: !!$6, width: $7, height: $8, seekId: $9, seekTick: $10, - viewProjection: viewProjection + drawn: !!$11, viewProjection: viewProjection }); }, `matrix`, `tick`, `endTick`, `playing`, `seeking`, `speed`, `looping`, - `width`, `height`, `seekId`, `seekTick`); + `width`, `height`, `seekId`, `seekTick`, `drawn`); """.} proc polyworld_replay_ready*(): int32 {.replayExport.} = diff --git a/tests/test_replaycontrols.nim b/tests/test_replaycontrols.nim index efba7749..d06dc4c3 100644 --- a/tests/test_replaycontrols.nim +++ b/tests/test_replaycontrols.nim @@ -66,4 +66,24 @@ block: polyworld_replay_camera(9, 0, 0, 0, 0) doAssert camera.mode == FixedCamera +echo "Testing frames that only simulate keep the drawn camera" +block: + let + viewport = ivec2(1280, 576) + drawn = + perspective(45.0'f32, 1280 / 576, 0.1'f32, 1000.0'f32) * + lookAt(vec3(0, 30, 20), vec3(0, 0, 0), vec3(0, 1, 0)) + moved = + perspective(45.0'f32, 1280 / 576, 0.1'f32, 1000.0'f32) * + lookAt(vec3(10, 30, 20), vec3(10, 0, 0), vec3(0, 1, 0)) + publishReplayControlFrame(drawn, viewport) + doAssert polyworld_replay_project(0, 0, 0) == 1 + let x = polyworld_replay_result(0) + publishReplayControlFrame(moved, ivec2(640, 288), drawn = false) + doAssert polyworld_replay_project(0, 0, 0) == 1 + doAssert polyworld_replay_result(0) == x + publishReplayControlFrame(moved, viewport) + doAssert polyworld_replay_project(0, 0, 0) == 1 + doAssert polyworld_replay_result(0) < x + echo "Replay control tests passed"