From 82ef49aa9e640d7d1064aec198076d9d3cb2d4eb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 5 Aug 2026 18:54:18 +0300 Subject: [PATCH] fix(canvas): bound retained backing allocations - Scale retained margins to a fixed device-pixel budget on large viewports - Fall back to direct scene rendering when CanvasKit rejects an offscreen surface - Cover the reported wide HiDPI geometry without coupling rendering to a WebGL context --- CHANGELOG.md | 1 + .../src/canvas/renderer/retained-backing.ts | 34 +++++++++---- .../render/canvas/retained-backing.test.ts | 51 ++++++++++++++++++- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a612a28..e1a2a7d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ ### Fixed +- Keep the canvas rendering when hiding or showing the UI on wide HiDPI viewports by bounding retained scene backing allocations and falling back to direct rendering when CanvasKit rejects an offscreen surface. - Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, CanvasKit-shaped generated text, imported text bounds, and nested instance geometry more closely. - Match Figma Plugin API vector path and network editing, including bounds, transforms, winding rules, region fills, validation, and handle mirroring. (#444) - Let AI and MCP tools create arbitrary vectors from SVG path data, validating input without leaving blank layers behind. (#440) diff --git a/packages/core/src/canvas/renderer/retained-backing.ts b/packages/core/src/canvas/renderer/retained-backing.ts index 862839ede..d0bce799a 100644 --- a/packages/core/src/canvas/renderer/retained-backing.ts +++ b/packages/core/src/canvas/renderer/retained-backing.ts @@ -10,6 +10,7 @@ import type { RenderLayer } from './pipeline' const now = typeof performance !== 'undefined' ? () => performance.now() : () => 0 const SCENE_BACKING_SCALE = 3 +const MAX_SCENE_BACKING_DEVICE_PIXELS = 16_000_000 const FRAME_BUDGET_60HZ_MS = 1000 / 60 const MIN_SCENE_BACKING_IDLE_FRAMES = 2 const MAX_SCENE_BACKING_IDLE_FRAMES = 18 @@ -153,9 +154,20 @@ function drawSceneBacking( return true } +function sceneBackingScale(r: SkiaRenderer): number { + const viewportDevicePixels = r.viewportWidth * r.viewportHeight * r.dpr * r.dpr + if (viewportDevicePixels <= 0) return 1 + return clamp( + Math.sqrt(MAX_SCENE_BACKING_DEVICE_PIXELS / viewportDevicePixels), + 1, + SCENE_BACKING_SCALE + ) +} + function sceneBackingGeometry(r: SkiaRenderer) { - const marginX = r.viewportWidth * ((SCENE_BACKING_SCALE - 1) / 2) - const marginY = r.viewportHeight * ((SCENE_BACKING_SCALE - 1) / 2) + const backingScale = sceneBackingScale(r) + const marginX = r.viewportWidth * ((backingScale - 1) / 2) + const marginY = r.viewportHeight * ((backingScale - 1) / 2) const width = Math.max(1, Math.ceil(r.viewportWidth + marginX * 2)) const height = Math.max(1, Math.ceil(r.viewportHeight + marginY * 2)) const backingPanX = r.panX + marginX @@ -175,13 +187,17 @@ function sceneBackingGeometry(r: SkiaRenderer) { } function createSceneBackingSurface(r: SkiaRenderer, width: number, height: number): Surface | null { - return r.surface.makeSurface({ - width: Math.ceil(width * r.dpr), - height: Math.ceil(height * r.dpr), - colorType: r.ck.ColorType.RGBA_8888, - alphaType: r.ck.AlphaType.Premul, - colorSpace: r.ck.ColorSpace.SRGB - }) + try { + return r.surface.makeSurface({ + width: Math.ceil(width * r.dpr), + height: Math.ceil(height * r.dpr), + colorType: r.ck.ColorType.RGBA_8888, + alphaType: r.ck.AlphaType.Premul, + colorSpace: r.ck.ColorSpace.SRGB + }) + } catch { + return null + } } function ensureSubtreePictureCacheScope( diff --git a/tests/engine/render/canvas/retained-backing.test.ts b/tests/engine/render/canvas/retained-backing.test.ts index 05c3dcadc..0d93f6cad 100644 --- a/tests/engine/render/canvas/retained-backing.test.ts +++ b/tests/engine/render/canvas/retained-backing.test.ts @@ -1,13 +1,13 @@ import { expect, mock, test } from 'bun:test' -import type { Canvas, Image as CKImage, Surface } from 'canvaskit-wasm' +import type { Canvas, Image as CKImage, ImageInfo, Surface } from 'canvaskit-wasm' import type { SceneGraph } from '@open-pencil/scene-graph' import type { SkiaRenderer } from '#core/canvas/renderer' import { renderSceneBacking } from '#core/canvas/renderer/retained-backing' -function createRenderer(surfaceFactory: () => Surface | null) { +function createRenderer(surfaceFactory: (info: ImageInfo) => Surface | null) { const renderer: Partial = { ck: { AlphaType: { Premul: 'Premul' }, @@ -88,6 +88,53 @@ test('retained scene backing falls back when CanvasKit cannot create an offscree expect(r.sceneBacking).toBeNull() }) +test('retained scene backing bounds wide HiDPI allocations without depending on GPU limits', () => { + const requests: ImageInfo[] = [] + const r = createRenderer((info) => { + requests.push(info) + return null + }) + r.viewportWidth = 2998 + r.viewportHeight = 1490 + r.dpr = 2 + + expect(renderSceneBacking(r, createCanvas(), createGraph(), 1)).toBe(false) + expect(requests).toHaveLength(1) + const request = requests[0] + expect(request).toBeDefined() + const viewportPixels = Math.ceil(r.viewportWidth * r.dpr) * Math.ceil(r.viewportHeight * r.dpr) + expect((request?.width ?? 0) * (request?.height ?? 0)).toBeLessThanOrEqual( + Math.max(16_010_000, viewportPixels) + ) + expect(request?.width).toBe(Math.ceil(r.viewportWidth * r.dpr)) +}) + +test('retained scene backing preserves the full margin when it fits the allocation budget', () => { + const requests: ImageInfo[] = [] + const r = createRenderer((info) => { + requests.push(info) + return null + }) + r.viewportWidth = 800 + r.viewportHeight = 600 + r.dpr = 1 + + renderSceneBacking(r, createCanvas(), createGraph(), 1) + + expect(requests[0]).toMatchObject({ width: 2400, height: 1800 }) +}) + +test('retained scene backing falls back when CanvasKit throws during allocation', () => { + const r = createRenderer(() => { + throw new TypeError("Cannot set properties of null (setting 'be')") + }) + const canvas = createCanvas() + + expect(() => renderSceneBacking(r, canvas, createGraph(), 1)).not.toThrow() + expect(r.sceneBacking).toBeNull() + expect(canvas.drawImageRectOptions).not.toHaveBeenCalled() +}) + test('retained scene backing filters cross-zoom previews instead of falling back to live rendering', () => { const r = createRenderer(() => null) r.zoom = 1