SkPicture scene caching for fast pan/zoom

This commit is contained in:
Danila Poyarkov 2026-03-01 22:28:03 +03:00
parent 80beba3348
commit 9431918634
3 changed files with 75 additions and 24 deletions

View file

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

View file

@ -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<string, CKImage>()
private vectorPathCache = new Map<string, Path>()
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<string>, overlays: RenderOverlays = {}): void {
render(
graph: SceneGraph,
selectedIds: Set<string>,
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()
}
}

View file

@ -75,24 +75,29 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, 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() {