diff --git a/packages/vue/src/canvas/surface/render-loop.ts b/packages/vue/src/canvas/surface/render-loop.ts index 18bfd6065..cdad0b818 100644 --- a/packages/vue/src/canvas/surface/render-loop.ts +++ b/packages/vue/src/canvas/surface/render-loop.ts @@ -6,6 +6,46 @@ type RenderLoopOptions = { layer?: CanvasRenderLayer } +type EditorRenderScheduler = { + schedule: (callback: () => void) => void + cancel: (callback: () => void) => void +} + +const renderSchedulers = new WeakMap() + +function getRenderScheduler(editor: Editor): EditorRenderScheduler { + const existing = renderSchedulers.get(editor) + if (existing) return existing + + let frameId: number | null = null + const callbacks = new Set<() => void>() + + function flush() { + frameId = null + const pending = [...callbacks] + callbacks.clear() + for (const callback of pending) callback() + } + + const scheduler = { + schedule(callback: () => void) { + callbacks.add(callback) + if (frameId !== null) return + frameId = requestAnimationFrame(flush) + }, + cancel(callback: () => void) { + callbacks.delete(callback) + if (callbacks.size === 0 && frameId !== null) { + cancelAnimationFrame(frameId) + frameId = null + } + } + } + + renderSchedulers.set(editor, scheduler) + return scheduler +} + function shouldScheduleForRepaint(layer: CanvasRenderLayer | undefined) { return layer !== 'scene' } @@ -19,27 +59,32 @@ export function createCanvasRenderLoop( renderNow: () => void, options: RenderLoopOptions = {} ) { + const scheduler = getRenderScheduler(editor) let dirty = true - let frameId: number | null = null + let frameScheduled = false let lastRenderVersion = -1 let lastSelectedIds: Set | null = null + + function renderFrame() { + frameScheduled = false + if (editor.state.loading) { + scheduleRender() + return + } + + const versionChanged = editor.state.renderVersion !== lastRenderVersion + const selectionChanged = editor.state.selectedIds !== lastSelectedIds + if (dirty || versionChanged || selectionChanged) { + dirty = false + renderNow() + } + } + const scheduleRender = () => { dirty = true - if (frameId !== null) return - frameId = requestAnimationFrame(() => { - frameId = null - if (editor.state.loading) { - scheduleRender() - return - } - - const versionChanged = editor.state.renderVersion !== lastRenderVersion - const selectionChanged = editor.state.selectedIds !== lastSelectedIds - if (dirty || versionChanged || selectionChanged) { - dirty = false - renderNow() - } - }) + if (frameScheduled) return + frameScheduled = true + scheduler.schedule(renderFrame) } const unsubscribe = [ @@ -62,9 +107,9 @@ export function createCanvasRenderLoop( function pause() { for (const off of unsubscribe) off() - if (frameId !== null) { - cancelAnimationFrame(frameId) - frameId = null + if (frameScheduled) { + scheduler.cancel(renderFrame) + frameScheduled = false } } diff --git a/tests/engine/vue/canvas/render-loop.test.ts b/tests/engine/vue/canvas/render-loop.test.ts index 65e24a996..1f1a32edf 100644 --- a/tests/engine/vue/canvas/render-loop.test.ts +++ b/tests/engine/vue/canvas/render-loop.test.ts @@ -155,6 +155,37 @@ describe('canvas render loop', () => { } }) + test('coalesces multiple canvas surfaces into one animation frame', () => { + const scheduler = createFrameScheduler() + try { + const { editor, emit } = createEditor() + let sceneRenders = 0 + let overlayRenders = 0 + createCanvasRenderLoop( + editor, + () => { + sceneRenders++ + }, + { layer: 'scene' } + ) + createCanvasRenderLoop( + editor, + () => { + overlayRenders++ + }, + { layer: 'overlays' } + ) + + emit('viewport:changed') + expect(scheduler.pendingCount).toBe(1) + scheduler.flush() + expect(sceneRenders).toBe(1) + expect(overlayRenders).toBe(1) + } finally { + scheduler.restore() + } + }) + test('cancels pending renders when paused', () => { const scheduler = createFrameScheduler() try {