Merge pull request #469 from open-pencil/fix-canvas-backing-limit
fix(canvas): bound retained backing allocations
This commit is contained in:
commit
a703a48926
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<SkiaRenderer> = {
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue