openpencil/tests/engine/render/canvas/retained-backing.test.ts
Danila Poyarkov 8defd2c676 fix(renderer): rasterize settled scenes at viewport origin
Skia analytic coverage depends on framebuffer dimensions as well as raster origin. Replay existing retained subtree pictures into the live viewport at settlement, while preserving overscan images for navigation. Keep settlement pending until the viewport pass and report the presentation path accurately. No additional viewport cache is allocated.

Preserve exact held/released assertions and cover odd/even/fractional pans at DPR 1, 1.25, 1.5 and 2. Verify native picture and backing identity reuse; update one pixel in the existing baseline only after proving equality with direct rendering.

Validation: full check, 735 scoped unit tests, and 58 targeted browser tests pass. The targeted set still excludes the previously classified paint-field-width baseline. An additional tiled large-blur test fails identically on parent d16400b3e, with byte-identical actual PNGs. Broader release acceptance remains separate.
2026-09-15 13:25:43 +03:00

321 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, mock, spyOn, test } from 'bun:test'
import type { Canvas, Image as CKImage, ImageInfo, Surface } from 'canvaskit-wasm'
import { getAbsolutePositionFull, SceneGraph } from '@open-pencil/scene-graph'
import type { SkiaRenderer } from '#core/canvas/renderer'
import {
computeRetainedSubtreeBounds,
renderSceneBacking
} from '#core/canvas/renderer/retained-backing'
function createRenderer(surfaceFactory: (info: ImageInfo) => Surface | null) {
const renderer: Partial<SkiaRenderer> = {
ck: {
AlphaType: { Premul: 'Premul' },
ColorSpace: { SRGB: 'SRGB' },
ColorType: { RGBA_8888: 'RGBA_8888' },
Color4f: mock((r: number, g: number, b: number, a: number) => [r, g, b, a]),
LTRBRect: mock((left: number, top: number, right: number, bottom: number) => [
left,
top,
right,
bottom
]),
FilterMode: { Linear: 'Linear', Nearest: 'Nearest' },
MipmapMode: { None: 'None' }
} as SkiaRenderer['ck'],
surface: {
makeSurface: mock(surfaceFactory)
} as SkiaRenderer['surface'],
opacityPaint: {
setAlphaf: mock()
} as SkiaRenderer['opacityPaint'],
panX: 0,
panY: 0,
zoom: 1,
dpr: 1,
viewportWidth: 100,
viewportHeight: 100,
pageColor: { r: 1, g: 1, b: 1 },
pageId: 'page',
navigationPhase: 'idle',
sceneBacking: null,
sceneBackingBuild: null,
sceneBackingAllocationFailed: false,
sceneBackingNeedsCrispRender: false,
sceneBackingPreviewUntil: 0,
sceneBackingAverageRecordMs: 40,
sceneBackingAverageViewportIntervalMs: 80,
scenePictureVersion: 0,
scenePicturePositionPreviewVersion: 0,
scenePicturePageId: null,
subtreePictureCache: new Map(),
subtreePictureCachePageId: null,
subtreePictureCacheSceneVersion: 0,
subtreePictureCachePositionPreviewVersion: 0,
worldViewport: { x: 0, y: 0, w: 0, h: 0 },
renderNode: mock()
}
return renderer as SkiaRenderer
}
function createCanvas() {
const canvas: Partial<Canvas> = {
drawImageRect: mock(),
drawImageRectOptions: mock(),
save: mock(),
restore: mock(),
translate: mock(),
scale: mock()
}
return canvas as Canvas
}
function createGraph(positionPreviewVersion = 0) {
const graph: Partial<SceneGraph> = {
rootId: 'root',
positionPreviewVersion,
getNode: mock((id: string) => {
if (id === 'page') return { id: 'page', type: 'CANVAS', childIds: [] }
return null
}),
getAbsolutePosition: mock(() => ({ x: 0, y: 0 }))
}
return graph as SceneGraph
}
test('retained subtree bounds include descendants transformed by rotated ancestors', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
if (!page) throw new Error('Expected the default page')
const parent = graph.createNode('INSTANCE', page.id, {
x: 100,
y: 100,
width: 100,
height: 100,
rotation: 90
})
const child = graph.createNode('VECTOR', parent.id, {
x: 1000,
y: 0,
width: 100,
height: 10
})
const exact = getAbsolutePositionFull(child, graph)
const bounds = computeRetainedSubtreeBounds(graph, parent.id)
if (!bounds) throw new Error('Expected retained subtree bounds')
expect(bounds.minX).toBeLessThanOrEqual(exact.boundX)
expect(bounds.minY).toBeLessThanOrEqual(exact.boundY)
expect(bounds.maxX).toBeGreaterThanOrEqual(exact.boundX + exact.width)
expect(bounds.maxY).toBeGreaterThanOrEqual(exact.boundY + exact.height)
})
test('retained subtree bounds rotate directional effect overflow into world space', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
if (!page) throw new Error('Expected the default page')
const node = graph.createNode('RECTANGLE', page.id, {
x: 100,
y: 100,
width: 100,
height: 50,
rotation: 90,
effects: [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 20, y: 0 },
radius: 0,
spread: 0,
visible: true
}
]
})
const base = getAbsolutePositionFull(node, graph)
const bounds = computeRetainedSubtreeBounds(graph, node.id)
if (!bounds) throw new Error('Expected retained subtree bounds')
expect(bounds.maxY).toBeCloseTo(base.boundY + base.height + 20)
})
test('retained scene backing falls back when CanvasKit cannot create an offscreen surface', () => {
const r = createRenderer(() => null)
const canvas = createCanvas()
const graph = createGraph()
expect(renderSceneBacking(r, canvas, graph, 1)).toBe(false)
expect(r.surface.makeSurface).toHaveBeenCalled()
expect(canvas.drawImageRectOptions).not.toHaveBeenCalled()
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
r.sceneBackingPreviewUntil = Number.POSITIVE_INFINITY
r.sceneBacking = {
anchorPanX: 0,
anchorPanY: 0,
marginDeviceX: 0,
marginDeviceY: 0,
image: { delete: mock() } as CKImage,
pageId: 'page',
sceneVersion: 1,
positionPreviewVersion: 0,
panX: 0,
panY: 0,
zoom: 0.5,
width: 300,
height: 300,
dpr: 1,
worldX: 0,
worldY: 0,
worldWidth: 600,
worldHeight: 600
} as NonNullable<SkiaRenderer['sceneBacking']>
const canvas = createCanvas()
const graph = createGraph()
expect(renderSceneBacking(r, canvas, graph, 1)).toBe('backing')
expect(canvas.drawImageRectOptions).toHaveBeenCalledWith(
r.sceneBacking.image,
expect.anything(),
expect.anything(),
r.ck.FilterMode.Linear,
r.ck.MipmapMode.None,
r.opacityPaint
)
})
test('retained scene backing allows same-zoom previews while panning', () => {
const r = createRenderer(() => null)
r.zoom = 1
r.sceneBackingPreviewUntil = Number.POSITIVE_INFINITY
r.sceneBacking = {
anchorPanX: 0,
anchorPanY: 0,
marginDeviceX: 0,
marginDeviceY: 0,
image: { delete: mock() } as CKImage,
pageId: 'page',
sceneVersion: 1,
positionPreviewVersion: 0,
panX: 0,
panY: 0,
zoom: 1,
width: 300,
height: 300,
dpr: 1,
worldX: 0,
worldY: 0,
worldWidth: 300,
worldHeight: 300
} as NonNullable<SkiaRenderer['sceneBacking']>
const canvas = createCanvas()
const graph = createGraph()
expect(renderSceneBacking(r, canvas, graph, 1)).toBe('backing')
expect(canvas.drawImageRectOptions).toHaveBeenCalledTimes(1)
expect(r.sceneBackingNeedsCrispRender).toBe(true)
r.sceneBackingPreviewUntil = 0
expect(renderSceneBacking(r, canvas, graph, 1)).toBe('retained-pictures')
expect(r.sceneBackingNeedsCrispRender).toBe(false)
expect(canvas.drawImageRectOptions).toHaveBeenCalledTimes(1)
expect(r.surface.makeSurface).not.toHaveBeenCalled()
})
test('retained scene backing invalidates stale position-preview metadata', () => {
const r = createRenderer(() => null)
r.sceneBacking = {
anchorPanX: 0,
anchorPanY: 0,
marginDeviceX: 0,
marginDeviceY: 0,
image: { delete: mock() } as CKImage,
pageId: 'page',
sceneVersion: 1,
positionPreviewVersion: 1,
panX: 0,
panY: 0,
zoom: 1,
width: 100,
height: 100,
dpr: 1,
worldX: 0,
worldY: 0,
worldWidth: 100,
worldHeight: 100
} as NonNullable<SkiaRenderer['sceneBacking']>
r.scenePicturePositionPreviewVersion = 1
const canvas = createCanvas()
const graph = createGraph(2)
expect(renderSceneBacking(r, canvas, graph, 1)).toBe(false)
expect(canvas.drawImageRectOptions).not.toHaveBeenCalled()
})