diff --git a/src/editor/EditorRecorder.ts b/src/editor/EditorRecorder.ts index ca685a58..258dc7ed 100644 --- a/src/editor/EditorRecorder.ts +++ b/src/editor/EditorRecorder.ts @@ -13,7 +13,7 @@ import { recordingName, type RecordingUploadOptions, } from '../core/recording/recording'; -import { renderCount, requestRender, Theme, toHex, ui } from '../ui'; +import { drawWhileHidden, renderCount, requestRender, Theme, toHex, ui } from '../ui'; import { consoleLine, type FrameSource, @@ -101,6 +101,7 @@ export class EditorRecorder { app.ticker.add(this.tick, this, UPDATE_PRIORITY.UTILITY); this.beat ??= new BackgroundBeat(app.ticker); this.beat.start(settings.fps); + drawWhileHidden(true); requestRender(); } @@ -118,6 +119,7 @@ export class EditorRecorder { const app = ui().app; app.ticker.remove(this.tick, this); this.beat?.stop(); + drawWhileHidden(false); if (this.screenResolution !== null) { app.renderer.resize(Math.round(app.screen.width), Math.round(app.screen.height), this.screenResolution); this.screenResolution = null; diff --git a/src/ui/index.ts b/src/ui/index.ts index 7f3084ca..e7140d1c 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -24,6 +24,7 @@ export { initUI, ui, type UIContext } from './UIContext'; export { afterFrames, cancelFrameWork, + drawWhileHidden, initRenderLoop, onFrame, isRendering, diff --git a/src/ui/render.ts b/src/ui/render.ts index 58402b83..9dd645e4 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -22,6 +22,7 @@ const WAKE_MS = 200; let deadline = 0; let rendered = 0; let renderMs = 0; +let drawHidden = false; /** Draw, and keep drawing for a moment. Cheap enough to call from every event. */ export function requestRender(): void { @@ -43,6 +44,16 @@ export function lastRenderTime(): number { return renderMs; } +/** + * Whether the screen is drawn while the page is hidden. It is not, by default: behind another tab + * only a recording's beat ticks the page, nobody sees the canvas, and late in a long session the + * GPU there was slow enough that drawing it cost a recording in real time most of its frames. A + * recording of the editor copies the canvas, so it keeps the drawing on. + */ +export function drawWhileHidden(on: boolean): void { + drawHidden = on; +} + /** Work put off to the next frame by `onFrame`, one entry per key. */ let frameWork = new Map void>(); @@ -114,6 +125,7 @@ export function initRenderLoop(app: Application): void { frameWork = new Map(); for (const fn of due.values()) fn(); } + if (!drawHidden && typeof document !== 'undefined' && document.hidden) return; rendered += 1; const start = performance.now(); app.render(); @@ -134,6 +146,7 @@ export function resetRenderLoop(): void { deadline = 0; rendered = 0; renderMs = 0; + drawHidden = false; frameWaiters = []; frameWork = new Map(); } diff --git a/src/viewport/PictureReader.ts b/src/viewport/PictureReader.ts index 78118cf4..30f39dd3 100644 --- a/src/viewport/PictureReader.ts +++ b/src/viewport/PictureReader.ts @@ -180,12 +180,20 @@ export class PictureReader { /** * Hands over each frame the GPU has finished, oldest first, as I420 in `layout`. The bytes are * reused for the next frame, so `take` copies what it keeps. + * + * Behind another tab, late in a long session, the GPU can take over a second to finish a read + * back; with every buffer still on its way, nearly each frame due was dropped. There the oldest is + * read at once when no buffer is free: the main thread waits for the GPU, but nobody sees it. */ collect(take: (bytes: Uint8Array, ms: number) => void): void { const gl = this.gl; + let hurry = document.hidden && this.free.length === 0; while (this.inFlight.length > 0) { const next = this.inFlight[0]!; - if (gl.clientWaitSync(next.fence, 0, 0) === gl.TIMEOUT_EXPIRED) return; + if (gl.clientWaitSync(next.fence, 0, 0) === gl.TIMEOUT_EXPIRED) { + if (!hurry) return; + hurry = false; + } gl.deleteSync(next.fence); gl.bindBuffer(gl.PIXEL_PACK_BUFFER, next.buffer); gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, this.bytes); diff --git a/tests/ui/render.test.ts b/tests/ui/render.test.ts index 87d5040a..bb063a00 100644 --- a/tests/ui/render.test.ts +++ b/tests/ui/render.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterFrames, cancelFrameWork, + drawWhileHidden, initRenderLoop, isRendering, onFrame, @@ -196,6 +197,32 @@ describe('render loop', () => { expect(ran).toBe(10); }); + it('draws nothing behind another tab, unless a recording of the editor needs the canvas', () => { + const { app, tick } = fakeApp(); + initRenderLoop(app as unknown as Application); + const page = { hidden: true }; + vi.stubGlobal('document', page); + try { + let ran = 0; + onFrame('drag', () => (ran += 1)); + tick(); + expect(ran).toBe(1); + expect(app.drawn).toBe(0); + expect(renderCount()).toBe(0); + + drawWhileHidden(true); + tick(); + expect(app.drawn).toBe(1); + + drawWhileHidden(false); + page.hidden = false; + tick(); + expect(app.drawn).toBe(2); + } finally { + vi.unstubAllGlobals(); + } + }); + it('afterFrames gives up rather than hanging when no frame ever comes', async () => { vi.useFakeTimers(); try {