Cache vector paths and pen paints across frames

This commit is contained in:
Danila Poyarkov 2026-03-01 22:21:20 +03:00
parent 97f4727e26
commit 80beba3348
3 changed files with 73 additions and 45 deletions

View file

@ -4,8 +4,10 @@
### Performance ### Performance
- Cache ruler paints instead of allocating per frame - 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 - Only enable `preserveDrawingBuffer` in test mode
- Hoist URL param parsing out of render loop
### Bug Fixes ### Bug Fixes

View file

@ -132,12 +132,17 @@ export class SkiaRenderer {
private fontProvider: TypefaceFontProvider | null = null private fontProvider: TypefaceFontProvider | null = null
private fontsLoaded = false private fontsLoaded = false
private imageCache = new Map<string, CKImage>() private imageCache = new Map<string, CKImage>()
private vectorPathCache = new Map<string, Path>()
private rulerBgPaint: Paint private rulerBgPaint: Paint
private rulerTickPaint: Paint private rulerTickPaint: Paint
private rulerTextPaint: Paint private rulerTextPaint: Paint
private rulerHlPaint: Paint private rulerHlPaint: Paint
private rulerBadgePaint: Paint private rulerBadgePaint: Paint
private rulerLabelPaint: Paint private rulerLabelPaint: Paint
private penPathPaint: Paint
private penHandlePaint: Paint
private penVertexFill: Paint
private penVertexStroke: Paint
panX = 0 panX = 0
panY = 0 panY = 0
@ -233,6 +238,29 @@ export class SkiaRenderer {
this.rulerLabelPaint = new ck.Paint() this.rulerLabelPaint = new ck.Paint()
this.rulerLabelPaint.setColor(ck.Color4f(1, 1, 1, 1)) this.rulerLabelPaint.setColor(ck.Color4f(1, 1, 1, 1))
this.rulerLabelPaint.setAntiAlias(true) this.rulerLabelPaint.setAntiAlias(true)
this.penPathPaint = new ck.Paint()
this.penPathPaint.setStyle(ck.PaintStyle.Stroke)
this.penPathPaint.setStrokeWidth(PEN_PATH_STROKE_WIDTH)
this.penPathPaint.setColor(this.selColor())
this.penPathPaint.setAntiAlias(true)
this.penHandlePaint = new ck.Paint()
this.penHandlePaint.setStyle(ck.PaintStyle.Stroke)
this.penHandlePaint.setStrokeWidth(1)
this.penHandlePaint.setColor(this.selColor(PARENT_OUTLINE_ALPHA))
this.penHandlePaint.setAntiAlias(true)
this.penVertexFill = new ck.Paint()
this.penVertexFill.setStyle(ck.PaintStyle.Fill)
this.penVertexFill.setColor(ck.WHITE)
this.penVertexFill.setAntiAlias(true)
this.penVertexStroke = new ck.Paint()
this.penVertexStroke.setStyle(ck.PaintStyle.Stroke)
this.penVertexStroke.setStrokeWidth(PEN_PATH_STROKE_WIDTH)
this.penVertexStroke.setColor(this.selColor())
this.penVertexStroke.setAntiAlias(true)
} }
getFontProvider(): TypefaceFontProvider | null { getFontProvider(): TypefaceFontProvider | null {
@ -934,6 +962,23 @@ export class SkiaRenderer {
canvas.restore() canvas.restore()
} }
private getVectorPath(node: SceneNode): Path | null {
if (!node.vectorNetwork) return null
const cached = this.vectorPathCache.get(node.id)
if (cached) return cached
const path = vectorNetworkToPath(this.ck, node.vectorNetwork)
this.vectorPathCache.set(node.id, path)
return path
}
invalidateVectorPath(nodeId: string): void {
const old = this.vectorPathCache.get(nodeId)
if (old) {
old.delete()
this.vectorPathCache.delete(nodeId)
}
}
private strokeNodeShape(canvas: Canvas, node: SceneNode, paint: Paint): void { private strokeNodeShape(canvas: Canvas, node: SceneNode, paint: Paint): void {
const rect = this.ck.LTRBRect(0, 0, node.width, node.height) const rect = this.ck.LTRBRect(0, 0, node.width, node.height)
@ -941,13 +986,11 @@ export class SkiaRenderer {
case 'ELLIPSE': case 'ELLIPSE':
canvas.drawOval(rect, paint) canvas.drawOval(rect, paint)
return return
case 'VECTOR': case 'VECTOR': {
if (node.vectorNetwork) { const vp = this.getVectorPath(node)
const vp = vectorNetworkToPath(this.ck, node.vectorNetwork) if (vp) canvas.drawPath(vp, paint)
canvas.drawPath(vp, paint)
vp.delete()
}
return return
}
case 'LINE': case 'LINE':
canvas.drawLine(0, 0, node.width, node.height, paint) canvas.drawLine(0, 0, node.width, node.height, paint)
return return
@ -1316,13 +1359,11 @@ export class SkiaRenderer {
hasRadius: boolean hasRadius: boolean
): void { ): void {
switch (node.type) { switch (node.type) {
case 'VECTOR': case 'VECTOR': {
if (node.vectorNetwork) { const vp = this.getVectorPath(node)
const vp = vectorNetworkToPath(this.ck, node.vectorNetwork) if (vp) canvas.drawPath(vp, this.fillPaint)
canvas.drawPath(vp, this.fillPaint)
vp.delete()
}
break break
}
case 'ELLIPSE': case 'ELLIPSE':
if (node.arcData) { if (node.arcData) {
this.drawArc(canvas, node, this.fillPaint) this.drawArc(canvas, node, this.fillPaint)
@ -1359,13 +1400,11 @@ export class SkiaRenderer {
hasRadius: boolean hasRadius: boolean
): void { ): void {
switch (node.type) { switch (node.type) {
case 'VECTOR': case 'VECTOR': {
if (node.vectorNetwork) { const vp = this.getVectorPath(node)
const vp = vectorNetworkToPath(this.ck, node.vectorNetwork) if (vp) canvas.drawPath(vp, this.strokePaint)
canvas.drawPath(vp, this.strokePaint)
vp.delete()
}
break break
}
case 'ELLIPSE': case 'ELLIPSE':
if (node.arcData) { if (node.arcData) {
this.drawArc(canvas, node, this.strokePaint) this.drawArc(canvas, node, this.strokePaint)
@ -1890,28 +1929,10 @@ export class SkiaRenderer {
if (!penState || penState.vertices.length === 0) return if (!penState || penState.vertices.length === 0) return
const { vertices, segments, dragTangent, cursorX, cursorY } = penState const { vertices, segments, dragTangent, cursorX, cursorY } = penState
const pathPaint = new this.ck.Paint() const pathPaint = this.penPathPaint
pathPaint.setStyle(this.ck.PaintStyle.Stroke) const handlePaint = this.penHandlePaint
pathPaint.setStrokeWidth(PEN_PATH_STROKE_WIDTH) const vertexFill = this.penVertexFill
pathPaint.setColor(this.selColor()) const vertexStroke = this.penVertexStroke
pathPaint.setAntiAlias(true)
const handlePaint = new this.ck.Paint()
handlePaint.setStyle(this.ck.PaintStyle.Stroke)
handlePaint.setStrokeWidth(1)
handlePaint.setColor(this.selColor(PARENT_OUTLINE_ALPHA))
handlePaint.setAntiAlias(true)
const vertexFill = new this.ck.Paint()
vertexFill.setStyle(this.ck.PaintStyle.Fill)
vertexFill.setColor(this.ck.WHITE)
vertexFill.setAntiAlias(true)
const vertexStroke = new this.ck.Paint()
vertexStroke.setStyle(this.ck.PaintStyle.Stroke)
vertexStroke.setStrokeWidth(PEN_PATH_STROKE_WIDTH)
vertexStroke.setColor(this.selColor())
vertexStroke.setAntiAlias(true)
const toScreen = (x: number, y: number) => ({ const toScreen = (x: number, y: number) => ({
x: x * this.zoom + this.panX, x: x * this.zoom + this.panX,
@ -2007,10 +2028,6 @@ export class SkiaRenderer {
canvas.drawCircle(v.x, v.y, radius, vertexStroke) canvas.drawCircle(v.x, v.y, radius, vertexStroke)
} }
pathPaint.delete()
handlePaint.delete()
vertexFill.delete()
vertexStroke.delete()
} }
// --- Remote Cursors --- // --- Remote Cursors ---
@ -2329,6 +2346,8 @@ export class SkiaRenderer {
destroy(): void { destroy(): void {
for (const img of this.imageCache.values()) img.delete() for (const img of this.imageCache.values()) img.delete()
this.imageCache.clear() this.imageCache.clear()
for (const p of this.vectorPathCache.values()) p.delete()
this.vectorPathCache.clear()
this.fillPaint.delete() this.fillPaint.delete()
this.strokePaint.delete() this.strokePaint.delete()
this.selectionPaint.delete() this.selectionPaint.delete()
@ -2347,6 +2366,10 @@ export class SkiaRenderer {
this.rulerHlPaint.delete() this.rulerHlPaint.delete()
this.rulerBadgePaint.delete() this.rulerBadgePaint.delete()
this.rulerLabelPaint.delete() this.rulerLabelPaint.delete()
this.penPathPaint.delete()
this.penHandlePaint.delete()
this.penVertexFill.delete()
this.penVertexStroke.delete()
this.surface.delete() this.surface.delete()
} }
} }

View file

@ -768,6 +768,9 @@ export function createEditorStore() {
function updateNode(id: string, changes: Partial<SceneNode>) { function updateNode(id: string, changes: Partial<SceneNode>) {
graph.updateNode(id, changes) graph.updateNode(id, changes)
if ('vectorNetwork' in changes) {
_renderer?.invalidateVectorPath(id)
}
runLayoutForNode(id) runLayoutForNode(id)
syncIfInsideComponent(id) syncIfInsideComponent(id)
requestRender() requestRender()