diff --git a/CHANGELOG.md b/CHANGELOG.md index aa5ef124c..1175132a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Performance +- SkPicture scene caching — pan/zoom replays cached display list instead of re-rendering all nodes - Cache vector network paths — avoid rebuilding WASM paths every frame - Cache ruler and pen overlay paints — eliminate 10 WASM Paint allocations per frame - Only enable `preserveDrawingBuffer` in test mode diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 23e30cbda..1030c99ef 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -73,7 +73,8 @@ import type { Font, FontMgr, FontWeight, - TypefaceFontProvider + TypefaceFontProvider, + SkPicture } from 'canvaskit-wasm' export interface RenderOverlays { @@ -133,6 +134,9 @@ export class SkiaRenderer { private fontsLoaded = false private imageCache = new Map() private vectorPathCache = new Map() + private scenePicture: SkPicture | null = null + private scenePictureVersion = -1 + private scenePicturePageId: string | null = null private rulerBgPaint: Paint private rulerTickPaint: Paint private rulerTextPaint: Paint @@ -424,7 +428,12 @@ export class SkiaRenderer { this.worldViewport = prevViewport } - render(graph: SceneGraph, selectedIds: Set, overlays: RenderOverlays = {}): void { + render( + graph: SceneGraph, + selectedIds: Set, + overlays: RenderOverlays = {}, + sceneVersion = -1 + ): void { const canvas = this.surface.getCanvas() canvas.clear(this.ck.Color4f(this.pageColor.r, this.pageColor.g, this.pageColor.b, 1)) @@ -436,17 +445,52 @@ export class SkiaRenderer { h: this.viewportHeight / this.zoom } + const hasVolatileOverlays = + overlays.hoveredNodeId != null || + overlays.dropTargetId != null || + overlays.rotationPreview != null || + overlays.editingTextId != null + + const canUsePicture = + !hasVolatileOverlays && + this.scenePicture && + sceneVersion === this.scenePictureVersion && + this.pageId === this.scenePicturePageId + // Scene layer (world coordinates) canvas.save() canvas.scale(this.dpr, this.dpr) canvas.translate(this.panX, this.panY) canvas.scale(this.zoom, this.zoom) - const pageNode = graph.getNode(this.pageId ?? graph.rootId) - if (pageNode) { - for (const childId of pageNode.childIds) { - this.renderNode(canvas, graph, childId, overlays, 0, 0) + if (canUsePicture) { + canvas.drawPicture(this.scenePicture!) + } else if (hasVolatileOverlays) { + const pageNode = graph.getNode(this.pageId ?? graph.rootId) + if (pageNode) { + for (const childId of pageNode.childIds) { + this.renderNode(canvas, graph, childId, overlays, 0, 0) + } } + } else { + this.scenePicture?.delete() + const prevViewport = this.worldViewport + this.worldViewport = { x: -1e6, y: -1e6, w: 2e6, h: 2e6 } + const recorder = new this.ck.PictureRecorder() + const bounds = this.ck.LTRBRect(-1e6, -1e6, 1e6, 1e6) + const recCanvas = recorder.beginRecording(bounds) + const pageNode = graph.getNode(this.pageId ?? graph.rootId) + if (pageNode) { + for (const childId of pageNode.childIds) { + this.renderNode(recCanvas, graph, childId, {}, 0, 0) + } + } + this.scenePicture = recorder.finishRecordingAsPicture() + recorder.delete() + this.worldViewport = prevViewport + this.scenePictureVersion = sceneVersion + this.scenePicturePageId = this.pageId + canvas.drawPicture(this.scenePicture!) } canvas.restore() @@ -2370,6 +2414,7 @@ export class SkiaRenderer { this.penHandlePaint.delete() this.penVertexFill.delete() this.penVertexStroke.delete() + this.scenePicture?.delete() this.surface.delete() } } diff --git a/src/composables/use-canvas.ts b/src/composables/use-canvas.ts index 3fb1a65b0..803d65fd3 100644 --- a/src/composables/use-canvas.ts +++ b/src/composables/use-canvas.ts @@ -75,24 +75,29 @@ export function useCanvas(canvasRef: Ref, store: Edito renderer.showRulers = showRulers renderer.pageColor = store.state.pageColor renderer.pageId = store.state.currentPageId - renderer.render(store.graph, store.state.selectedIds, { - hoveredNodeId: store.state.hoveredNodeId, - editingTextId: store.state.editingTextId, - textEditor: store.textEditor, - marquee: store.state.marquee, - snapGuides: store.state.snapGuides, - rotationPreview: store.state.rotationPreview, - dropTargetId: store.state.dropTargetId, - layoutInsertIndicator: store.state.layoutInsertIndicator, - penState: store.state.penState - ? { - ...store.state.penState, - cursorX: store.state.penCursorX ?? undefined, - cursorY: store.state.penCursorY ?? undefined - } - : null, - remoteCursors: store.state.remoteCursors.length > 0 ? store.state.remoteCursors : undefined - }) + renderer.render( + store.graph, + store.state.selectedIds, + { + hoveredNodeId: store.state.hoveredNodeId, + editingTextId: store.state.editingTextId, + textEditor: store.textEditor, + marquee: store.state.marquee, + snapGuides: store.state.snapGuides, + rotationPreview: store.state.rotationPreview, + dropTargetId: store.state.dropTargetId, + layoutInsertIndicator: store.state.layoutInsertIndicator, + penState: store.state.penState + ? { + ...store.state.penState, + cursorX: store.state.penCursorX ?? undefined, + cursorY: store.state.penCursorY ?? undefined + } + : null, + remoteCursors: store.state.remoteCursors.length > 0 ? store.state.remoteCursors : undefined + }, + store.state.sceneVersion + ) } function render() {