diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab1f11df..13d71e1cf 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. - Scale proportion-constrained `.fig` instance geometry through fixed wrapper layers so imported logos and icons retain their intended size. - 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) diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index 8b7b3947d..96b32ccf2 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -122,6 +122,7 @@ export class SkiaRenderer { } | null = null sceneBackingPreviewUntil = 0 sceneBackingNeedsCrispRender = false + sceneBackingAllocationFailed = false sceneBackingBuild: { surface: Surface graph: SceneGraph @@ -451,6 +452,7 @@ export class SkiaRenderer { replaceSurface(surface: Surface): void { this.surface.delete() this.surface = surface + this.sceneBackingAllocationFailed = false this.invalidateScenePicture() } diff --git a/packages/core/src/canvas/renderer/retained-backing.ts b/packages/core/src/canvas/renderer/retained-backing.ts index 862839ede..14aba8825 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,24 @@ function sceneBackingGeometry(r: SkiaRenderer) { } function createSceneBackingSurface(r: SkiaRenderer, width: number, height: number): Surface | null { - return r.surface.makeSurface({ + if (r.sceneBackingAllocationFailed) return null + const info = { 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(info) + } catch (error) { + r.sceneBackingAllocationFailed = true + console.warn( + `Disabling retained scene backing after CanvasKit failed to allocate ${info.width}×${info.height}`, + error + ) + return null + } } function ensureSubtreePictureCacheScope( @@ -436,6 +459,7 @@ export function renderSceneBacking( graph: SceneGraph, sceneVersion: number ): boolean { + if (r.sceneBackingAllocationFailed) return false const positionPreviewVersion = graph.positionPreviewVersion const allowStaleZoom = now() < r.sceneBackingPreviewUntil const hasCoverage = backingCoverageContainsLiveViewport( diff --git a/tests/engine/render/canvas/retained-backing.test.ts b/tests/engine/render/canvas/retained-backing.test.ts index 05c3dcadc..597a9dc4d 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 { expect, mock, spyOn, 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' }, @@ -39,6 +39,7 @@ function createRenderer(surfaceFactory: () => Surface | null) { pageId: 'page', sceneBacking: null, sceneBackingBuild: null, + sceneBackingAllocationFailed: false, sceneBackingNeedsCrispRender: false, sceneBackingPreviewUntil: 0, sceneBackingAverageRecordMs: 40, @@ -88,6 +89,65 @@ 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 reports a throwing allocation and disables further attempts', () => { + const error = new TypeError("Cannot set properties of null (setting 'be')") + const r = createRenderer(() => { + throw error + }) + const canvas = createCanvas() + const warn = spyOn(console, 'warn').mockImplementation(() => undefined) + + expect(() => renderSceneBacking(r, canvas, createGraph(), 1)).not.toThrow() + expect(renderSceneBacking(r, canvas, createGraph(), 1)).toBe(false) + + expect(r.sceneBackingAllocationFailed).toBe(true) + expect(r.surface.makeSurface).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + 'Disabling retained scene backing after CanvasKit failed to allocate 300×300', + error + ) + expect(r.sceneBacking).toBeNull() + expect(canvas.drawImageRectOptions).not.toHaveBeenCalled() + warn.mockRestore() +}) + test('retained scene backing filters cross-zoom previews instead of falling back to live rendering', () => { const r = createRenderer(() => null) r.zoom = 1