perf(vue): coalesce canvas surface frames

This commit is contained in:
Danila Poyarkov 2026-05-15 11:21:33 +03:00
parent 743f2b5a16
commit 20342d68a2
2 changed files with 95 additions and 19 deletions

View file

@ -6,6 +6,46 @@ type RenderLoopOptions = {
layer?: CanvasRenderLayer
}
type EditorRenderScheduler = {
schedule: (callback: () => void) => void
cancel: (callback: () => void) => void
}
const renderSchedulers = new WeakMap<Editor, EditorRenderScheduler>()
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<string> | 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
}
}

View file

@ -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 {