Cache effect ImageFilters and reuse layer paint

Eliminates per-frame WASM allocations for shadows and blurs:
- Reusable effectLayerPaint instead of new Paint() per effect
- ImageFilter cache keyed by params (drop shadow, blur, decal blur)
- Zero allocations during SkPicture recording for cached effects
This commit is contained in:
Danila Poyarkov 2026-03-03 10:12:02 +03:00
parent faa02d8ab0
commit 8ced3201cf
14 changed files with 353 additions and 84 deletions

1
.gitattributes vendored
View file

@ -1 +1,2 @@
tests/fixtures/*.fig filter=lfs diff=lfs merge=lfs -text
packages/core/vendor/canvaskit-webgpu/*.wasm filter=lfs diff=lfs merge=lfs -text

1
.gitignore vendored
View file

@ -14,6 +14,7 @@ dist-ssr
# Copied from node_modules at build time
public/canvaskit.wasm
public/canvaskit-webgpu/
# Editor directories and files
.vscode/*

View file

@ -16,6 +16,8 @@
{
"identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }]
}
},
"fs:allow-watch",
"fs:allow-unwatch"
]
}

View file

@ -2,19 +2,64 @@ import CanvasKitInit, { type CanvasKit } from 'canvaskit-wasm'
let instance: CanvasKit | null = null
export type GpuBackend = 'webgl' | 'webgpu'
let activeBackend: GpuBackend = 'webgl'
export function getGpuBackend(): GpuBackend {
return activeBackend
}
export interface CanvasKitOptions {
locateFile?: (file: string) => string
backend?: GpuBackend
}
function detectBackend(): GpuBackend {
if (typeof window === 'undefined') return 'webgl'
const params = new URLSearchParams(window.location.search)
if (params.get('gpu') === 'webgpu' && 'gpu' in navigator) return 'webgpu'
return 'webgl'
}
function loadCanvasKitWebGPU(): Promise<(opts?: Record<string, unknown>) => Promise<CanvasKit>> {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = '/canvaskit-webgpu/canvaskit.js'
script.onload = () => {
const init = (globalThis as Record<string, unknown>).CanvasKitInit as
(opts?: Record<string, unknown>) => Promise<CanvasKit>
if (!init) return reject(new Error('CanvasKitInit not found after loading WebGPU build'))
resolve(init)
}
script.onerror = () => reject(new Error('Failed to load canvaskit-webgpu/canvaskit.js'))
document.head.appendChild(script)
})
}
export async function getCanvasKit(options?: CanvasKitOptions): Promise<CanvasKit> {
if (instance) return instance
instance = await CanvasKitInit({
locateFile:
options?.locateFile ??
((file) => {
if (typeof window !== 'undefined') return `/${file}`
return file
})
})
return instance
const backend = options?.backend ?? detectBackend()
activeBackend = backend
const defaultLocate = (file: string) => {
if (typeof window !== 'undefined') return `/${file}`
return file
}
if (backend === 'webgpu') {
const init = await loadCanvasKitWebGPU()
instance = await init({
locateFile:
options?.locateFile ??
((file: string) => `/canvaskit-webgpu/${file}`)
})
} else {
instance = await CanvasKitInit({
locateFile: options?.locateFile ?? defaultLocate
})
}
return instance!
}

View file

@ -49,7 +49,7 @@ export { ALL_TOOLS, defineTool, toolsToAI } from './tools'
export type { ToolDef, ParamDef, ParamType } from './tools'
export { SkiaRenderer, type RenderOverlays } from './renderer'
export { computeLayout, computeAllLayouts } from './layout'
export { getCanvasKit, type CanvasKitOptions } from './canvaskit'
export { getCanvasKit, getGpuBackend, type CanvasKitOptions, type GpuBackend } from './canvaskit'
export {
loadFont,
listFamilies,

View file

@ -75,7 +75,8 @@ import type {
FontMgr,
FontWeight,
TypefaceFontProvider,
SkPicture
SkPicture,
ImageFilter
} from 'canvaskit-wasm'
export interface RenderOverlays {
@ -125,6 +126,8 @@ export class SkiaRenderer {
private auxFill: Paint
private auxStroke: Paint
private opacityPaint: Paint
private effectLayerPaint: Paint
private imageFilterCache = new Map<string, ImageFilter>()
private textFont: Font | null = null
private labelFont: Font | null = null
private sizeFont: Font | null = null
@ -216,6 +219,7 @@ export class SkiaRenderer {
this.auxStroke.setAntiAlias(true)
this.opacityPaint = new ck.Paint()
this.effectLayerPaint = new ck.Paint()
this.textFont = new ck.Font(null, DEFAULT_FONT_SIZE)
@ -961,17 +965,8 @@ export class SkiaRenderer {
const layerBlur = node.effects.find((e) => e.visible && e.type === 'LAYER_BLUR')
if (layerBlur) {
const blurPaint = new this.ck.Paint()
blurPaint.setImageFilter(
this.ck.ImageFilter.MakeBlur(
layerBlur.radius / 2,
layerBlur.radius / 2,
this.ck.TileMode.Clamp,
null
)
)
canvas.saveLayer(blurPaint)
blurPaint.delete()
this.effectLayerPaint.setImageFilter(this.getCachedBlur(layerBlur.radius / 2))
canvas.saveLayer(this.effectLayerPaint)
}
const rotation =
@ -1552,6 +1547,41 @@ export class SkiaRenderer {
)
}
private getCachedDropShadow(
dx: number,
dy: number,
sigma: number,
color: Float32Array
): ImageFilter {
const key = `ds:${dx},${dy},${sigma},${color[0]},${color[1]},${color[2]},${color[3]}`
let filter = this.imageFilterCache.get(key)
if (!filter) {
filter = this.ck.ImageFilter.MakeDropShadowOnly(dx, dy, sigma, sigma, color, null)
this.imageFilterCache.set(key, filter)
}
return filter
}
private getCachedBlur(sigma: number): ImageFilter {
const key = `blur:${sigma}`
let filter = this.imageFilterCache.get(key)
if (!filter) {
filter = this.ck.ImageFilter.MakeBlur(sigma, sigma, this.ck.TileMode.Clamp, null)
this.imageFilterCache.set(key, filter)
}
return filter
}
private getCachedDecalBlur(sigma: number): ImageFilter {
const key = `dblur:${sigma}`
let filter = this.imageFilterCache.get(key)
if (!filter) {
filter = this.ck.ImageFilter.MakeBlur(sigma, sigma, this.ck.TileMode.Decal, null)
this.imageFilterCache.set(key, filter)
}
return filter
}
private applyClippedBlur(
canvas: Canvas,
node: SceneNode,
@ -1561,14 +1591,10 @@ export class SkiaRenderer {
): void {
canvas.save()
this.clipNodeShape(canvas, node, rect, hasRadius)
const blurPaint = new this.ck.Paint()
blurPaint.setImageFilter(
this.ck.ImageFilter.MakeBlur(sigma, sigma, this.ck.TileMode.Clamp, null)
)
canvas.saveLayer(blurPaint)
this.effectLayerPaint.setImageFilter(this.getCachedBlur(sigma))
canvas.saveLayer(this.effectLayerPaint)
canvas.restore()
canvas.restore()
blurPaint.delete()
}
private clipNodeShape(canvas: Canvas, node: SceneNode, rect: Float32Array, hasRadius: boolean): void {
@ -1694,33 +1720,20 @@ export class SkiaRenderer {
)
const sigma = effect.radius / 2
const dropFilter = this.getCachedDropShadow(
effect.offset.x,
effect.offset.y,
sigma,
shadowColor
)
this.effectLayerPaint.setImageFilter(dropFilter)
if (node.type === 'TEXT') {
const dropFilter = this.ck.ImageFilter.MakeDropShadowOnly(
effect.offset.x,
effect.offset.y,
sigma,
sigma,
shadowColor,
null
)
const layerPaint = new this.ck.Paint()
layerPaint.setImageFilter(dropFilter)
canvas.saveLayer(layerPaint)
canvas.saveLayer(this.effectLayerPaint)
this.renderText(canvas, node)
canvas.restore()
layerPaint.delete()
} else {
const dropFilter = this.ck.ImageFilter.MakeDropShadowOnly(
effect.offset.x,
effect.offset.y,
sigma,
sigma,
shadowColor,
null
)
const layerPaint = new this.ck.Paint()
layerPaint.setImageFilter(dropFilter)
canvas.saveLayer(layerPaint)
canvas.saveLayer(this.effectLayerPaint)
this.auxFill.setColor(this.ck.WHITE)
this.auxFill.setImageFilter(null)
if (node.type === 'ELLIPSE') {
@ -1733,7 +1746,6 @@ export class SkiaRenderer {
canvas.drawRect(spreadRect, this.auxFill)
}
canvas.restore()
layerPaint.delete()
}
}
@ -1746,36 +1758,27 @@ export class SkiaRenderer {
if (pass === 'front' && effect.type === 'INNER_SHADOW') {
if (node.type === 'TEXT') {
const blurFilter = this.ck.ImageFilter.MakeBlur(
effect.radius,
effect.radius,
this.ck.TileMode.Decal,
null
)
const layerPaint = new this.ck.Paint()
layerPaint.setImageFilter(blurFilter)
layerPaint.setColorFilter(
this.effectLayerPaint.setImageFilter(this.getCachedDecalBlur(effect.radius))
this.effectLayerPaint.setColorFilter(
this.ck.ColorFilter.MakeBlend(
this.ck.Color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a),
this.ck.BlendMode.SrcIn
)
)
canvas.saveLayer(layerPaint)
canvas.saveLayer(this.effectLayerPaint)
canvas.save()
canvas.translate(effect.offset.x, effect.offset.y)
this.renderText(canvas, node)
canvas.restore()
canvas.restore()
layerPaint.delete()
this.effectLayerPaint.setColorFilter(null)
continue
}
const sp = effect.spread
this.auxFill.setColor(
this.ck.Color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a)
)
this.auxFill.setImageFilter(
this.ck.ImageFilter.MakeBlur(effect.radius, effect.radius, this.ck.TileMode.Decal, null)
)
this.auxFill.setImageFilter(this.getCachedDecalBlur(effect.radius))
canvas.save()
if (node.type === 'ELLIPSE') {
@ -2628,6 +2631,9 @@ export class SkiaRenderer {
this.penHandlePaint.delete()
this.penVertexFill.delete()
this.penVertexStroke.delete()
this.effectLayerPaint.delete()
for (const filter of this.imageFilterCache.values()) filter.delete()
this.imageFilterCache.clear()
this.scenePicture?.delete()
this.surface.delete()
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a337e1ca02e7eb9bb05a7fd7986fde3e0a845f95ddc06193751ef69c106918a
size 9697190

33
patches/skia-webgpu/build.sh Executable file
View file

@ -0,0 +1,33 @@
#!/bin/bash
# Build CanvasKit with Graphite+WebGPU from Skia source
# Requires: git, python3, cmake, ninja
# Output: canvaskit.js + canvaskit.wasm in out/canvaskit_wasm/
set -euo pipefail
SKIA_DIR="${SKIA_DIR:-$HOME/Development/skia}"
if [ ! -d "$SKIA_DIR" ]; then
echo "Cloning Skia..."
git clone --depth 1 https://skia.googlesource.com/skia.git "$SKIA_DIR"
fi
cd "$SKIA_DIR"
echo "Syncing dependencies..."
python3 tools/git-sync-deps
echo "Activating emsdk..."
python3 bin/activate-emsdk
echo "Applying patches..."
PATCHES_DIR="$(cd "$(dirname "$0")" && pwd)"
git apply "$PATCHES_DIR/skia.patch" 2>/dev/null || true
(cd third_party/externals/dawn && git apply "$PATCHES_DIR/dawn-tint.patch" 2>/dev/null || true)
echo "Building CanvasKit with WebGPU..."
# CPATH may contain /opt/homebrew/include which leaks host headers into emscripten
unset CPATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH
bash modules/canvaskit/compile.sh webgpu
echo "Done!"
ls -lh out/canvaskit_wasm/canvaskit.js out/canvaskit_wasm/canvaskit.wasm

View file

@ -1,15 +1,46 @@
import { useRafFn, useResizeObserver } from '@vueuse/core'
import { onMounted, onUnmounted, type Ref } from 'vue'
import { getCanvasKit } from '@/engine/canvaskit'
import { getCanvasKit, getGpuBackend } from '@/engine/canvaskit'
import { SkiaRenderer } from '@/engine/renderer'
import type { EditorStore } from '@/stores/editor'
import type { CanvasKit } from 'canvaskit-wasm'
interface WebGPUContext {
device: GPUDevice
deviceContext: unknown
}
interface CanvasKitWebGPU {
MakeGPUDeviceContext(device: GPUDevice): unknown
MakeGPUCanvasContext(ctx: unknown, canvas: HTMLCanvasElement, opts?: unknown): unknown
MakeGPUCanvasSurface(
ctx: unknown,
colorSpace?: unknown,
width?: number,
height?: number
): ReturnType<CanvasKit['MakeSurface']>
}
function asWebGPU(ck: CanvasKit): CanvasKitWebGPU {
return ck as unknown as CanvasKitWebGPU
}
async function initWebGPU(ck: CanvasKit): Promise<WebGPUContext | null> {
if (!('gpu' in navigator)) return null
const adapter = await navigator.gpu.requestAdapter()
if (!adapter) return null
const device = await adapter.requestDevice()
const deviceContext = asWebGPU(ck).MakeGPUDeviceContext?.(device)
if (!deviceContext) return null
return { device, deviceContext }
}
export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: EditorStore) {
let renderer: SkiaRenderer | null = null
let ck: CanvasKit | null = null
let gpuCtx: WebGPUContext | null = null
let destroyed = false
let dirty = true
let lastRenderVersion = -1
@ -22,6 +53,14 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
ck = await getCanvasKit()
if (destroyed) return
if (getGpuBackend() === 'webgpu') {
gpuCtx = await initWebGPU(ck)
if (!gpuCtx) {
console.warn('WebGPU init failed, reload without ?gpu=webgpu to use WebGL')
return
}
}
await new Promise((r) => requestAnimationFrame(r))
createSurface(canvas)
@ -44,15 +83,26 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
canvas.width = w * dpr
canvas.height = h * dpr
const isTest = new URLSearchParams(window.location.search).has('test')
const surface = ck.MakeWebGLCanvasSurface(
canvas,
undefined,
isTest ? { preserveDrawingBuffer: 1 } : undefined
)
if (!surface) {
console.error('Failed to create WebGL surface')
return
let surface
if (getGpuBackend() === 'webgpu' && gpuCtx) {
const gpu = asWebGPU(ck)
const canvasCtx = gpu.MakeGPUCanvasContext(gpuCtx.deviceContext, canvas)
surface = gpu.MakeGPUCanvasSurface(canvasCtx, ck.ColorSpace.SRGB, canvas.width, canvas.height)
if (!surface) {
console.error('Failed to create WebGPU surface')
return
}
} else {
const isTest = new URLSearchParams(window.location.search).has('test')
surface = ck.MakeWebGLCanvasSurface(
canvas,
undefined,
isTest ? { preserveDrawingBuffer: 1 } : undefined
)
if (!surface) {
console.error('Failed to create WebGL surface')
return
}
}
renderer = new SkiaRenderer(ck, surface)

View file

@ -1 +1,6 @@
export { getCanvasKit, type CanvasKitOptions } from '@open-pencil/core'
export {
getCanvasKit,
getGpuBackend,
type CanvasKitOptions,
type GpuBackend
} from '@open-pencil/core'

View file

@ -121,6 +121,8 @@ export function createEditorStore() {
let downloadName: string | null = null
let savedVersion = 0
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
let lastWriteTime = 0
let unwatchFile: (() => void) | null = null
let _ck: import('canvaskit-wasm').CanvasKit | null = null
let _renderer: import('@/engine/renderer').SkiaRenderer | null = null
let _textEditor: TextEditor | null = null
@ -578,6 +580,7 @@ export function createEditorStore() {
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
requestRender()
startWatchingFile()
} catch (e) {
console.error('Failed to open .fig file:', e)
}
@ -624,6 +627,7 @@ export function createEditorStore() {
.pop()
?.replace(/\.fig$/i, '') ?? 'Untitled'
await writeFile(data)
startWatchingFile()
return
}
@ -642,6 +646,7 @@ export function createEditorStore() {
filePath = null
state.documentName = handle.name.replace(/\.fig$/i, '')
await writeFile(data)
startWatchingFile()
return
} catch (e) {
if ((e as Error).name === 'AbortError') return
@ -656,6 +661,7 @@ export function createEditorStore() {
}
async function writeFile(data: Uint8Array) {
lastWriteTime = Date.now()
if (filePath && IS_TAURI) {
const { writeFile: tauriWrite } = await import('@tauri-apps/plugin-fs')
await tauriWrite(filePath, data)
@ -670,6 +676,88 @@ export function createEditorStore() {
}
}
const WATCH_DEBOUNCE_MS = 1000
async function reloadFromDisk() {
const viewport = { panX: state.panX, panY: state.panY, zoom: state.zoom }
const pageId = state.currentPageId
if (filePath && IS_TAURI) {
const { readFile: tauriRead } = await import('@tauri-apps/plugin-fs')
const bytes = await tauriRead(filePath)
const blob = new Blob([bytes])
const file = new File([blob], state.documentName + '.fig')
const imported = await readFigFile(file)
graph = imported
computeAllLayouts(graph)
} else if (fileHandle) {
const file = await fileHandle.getFile()
const imported = await readFigFile(file)
graph = imported
computeAllLayouts(graph)
} else {
return
}
undo.clear()
savedVersion = state.sceneVersion
state.selectedIds = new Set()
if (graph.getNode(pageId)) {
state.currentPageId = pageId
} else {
state.currentPageId = graph.getPages()[0]?.id ?? graph.rootId
}
state.panX = viewport.panX
state.panY = viewport.panY
state.zoom = viewport.zoom
requestRender()
}
function stopWatchingFile() {
if (unwatchFile) {
unwatchFile()
unwatchFile = null
}
}
async function startWatchingFile() {
stopWatchingFile()
if (filePath && IS_TAURI) {
const { watch: tauriWatch } = await import('@tauri-apps/plugin-fs')
const path = filePath
const unwatch = await tauriWatch(
path,
(event) => {
if (typeof event.type !== 'object' || !('modify' in event.type)) return
if (Date.now() - lastWriteTime < WATCH_DEBOUNCE_MS) return
reloadFromDisk()
},
{ delayMs: 500 }
)
unwatchFile = () => unwatch()
} else if (fileHandle) {
let lastModified = (await fileHandle.getFile()).lastModified
const interval = setInterval(async () => {
if (!fileHandle) {
clearInterval(interval)
return
}
try {
const file = await fileHandle.getFile()
if (file.lastModified > lastModified) {
lastModified = file.lastModified
if (Date.now() - lastWriteTime < WATCH_DEBOUNCE_MS) return
reloadFromDisk()
}
} catch {
clearInterval(interval)
}
}, 2000)
unwatchFile = () => clearInterval(interval)
}
}
async function renderExportImage(
nodeIds: string[],
scale: number,

View file

@ -53,7 +53,7 @@ describe('Shadow spread support', () => {
)
expect(dropShadowSection).toContain('effect.spread')
expect(dropShadowSection).toContain('makeRRectWithSpread')
expect(dropShadowSection).toContain('MakeDropShadowOnly')
expect(dropShadowSection).toContain('getCachedDropShadow')
})
test('inner shadow uses spread for cutout contraction', () => {
@ -81,7 +81,7 @@ describe('Text shadow renders on glyphs, not bounding box', () => {
)
expect(dropShadowBlock).toContain("node.type === 'TEXT'")
expect(dropShadowBlock).toContain('renderText')
expect(dropShadowBlock).toContain('MakeDropShadowOnly')
expect(dropShadowBlock).toContain('getCachedDropShadow')
})
test('inner shadow has TEXT-specific branch', () => {
@ -97,10 +97,9 @@ describe('Text shadow renders on glyphs, not bounding box', () => {
describe('Blur effects use saveLayer pattern', () => {
test('layer blur wraps node content in a blurred saveLayer', () => {
expect(rendererSource).toContain("e.type === 'LAYER_BLUR'")
// Applied at the renderNode level, not in renderEffects
const layerBlurIdx = rendererSource.indexOf("e.type === 'LAYER_BLUR'")
const layerBlurSection = rendererSource.slice(layerBlurIdx - 200, layerBlurIdx + 300)
expect(layerBlurSection).toContain('MakeBlur')
expect(layerBlurSection).toContain('getCachedBlur')
expect(layerBlurSection).toContain('saveLayer')
})
@ -114,7 +113,7 @@ describe('Blur effects use saveLayer pattern', () => {
const idx = rendererSource.indexOf('private applyClippedBlur')
const section = rendererSource.slice(idx, idx + 500)
expect(section).toContain('clipNodeShape')
expect(section).toContain('MakeBlur')
expect(section).toContain('getCachedBlur')
expect(section).toContain('saveLayer')
})
})

View file

@ -6,7 +6,7 @@ import tailwindcss from '@tailwindcss/vite'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
import Components from 'unplugin-vue-components/vite'
import { copyFileSync, existsSync } from 'fs'
import { copyFileSync, existsSync, mkdirSync } from 'fs'
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST
@ -27,6 +27,21 @@ export default defineConfig(async () => ({
if (existsSync(src) && !existsSync(dest)) {
copyFileSync(src, dest)
}
const webgpuSrc = 'packages/core/vendor/canvaskit-webgpu/canvaskit.wasm'
const webgpuDir = 'public/canvaskit-webgpu'
const webgpuDest = `${webgpuDir}/canvaskit.wasm`
if (existsSync(webgpuSrc) && !existsSync(webgpuDest)) {
mkdirSync(webgpuDir, { recursive: true })
copyFileSync(webgpuSrc, webgpuDest)
}
const webgpuJsSrc = 'packages/core/vendor/canvaskit-webgpu/canvaskit.js'
const webgpuJsDest = `${webgpuDir}/canvaskit.js`
if (existsSync(webgpuJsSrc) && !existsSync(webgpuJsDest)) {
mkdirSync(webgpuDir, { recursive: true })
copyFileSync(webgpuJsSrc, webgpuJsDest)
}
}
},
tailwindcss(),