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
This commit is contained in:
Danila Poyarkov 2026-08-05 18:54:18 +03:00
parent 85abfd5d21
commit 82ef49aa9e
3 changed files with 75 additions and 11 deletions

View file

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

View file

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

View file

@ -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<SkiaRenderer> = {
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