openpencil/tests/engine/render/canvas/retained-backing-pixels.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

189 lines
6.5 KiB
TypeScript

import { beforeAll, expect, spyOn, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import { initCanvasKit } from '#cli/headless'
import { SkiaRenderer } from '#core/canvas'
import { render } from '#core/canvas/renderer/pipeline'
import { expectDefined } from '#tests/helpers/assert'
let ck: Awaited<ReturnType<typeof initCanvasKit>>
beforeAll(async () => {
ck = await initCanvasKit()
})
function createFixture() {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0], 'page')
for (const x of [20, 40.25, 60.5, 80.75]) {
graph.createNode('RECTANGLE', page.id, {
x,
y: 20,
width: 1,
height: 80,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
}
return { graph, pageId: page.id }
}
function createRenderer(pageId: string) {
const surface = expectDefined(ck.MakeSurface(2176, 2128), 'surface')
const renderer = new SkiaRenderer(ck, surface)
renderer.viewportWidth = 1088
renderer.viewportHeight = 1064
renderer.dpr = 2
renderer.pageId = pageId
renderer.pageColor = { r: 1, g: 1, b: 1, a: 1 }
renderer.panX = 0.125
renderer.panY = 0.375
return renderer
}
function pixels(renderer: SkiaRenderer) {
renderer.surface.flush()
const image = renderer.surface.makeImageSnapshot()
try {
return expectDefined(
image.readPixels(0, 0, {
width: 200,
height: 220,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
}),
'scene pixels'
)
} finally {
image.delete()
}
}
function directPixels(renderer: SkiaRenderer, graph: SceneGraph, pageId: string) {
const canvas = renderer.surface.getCanvas()
canvas.clear(ck.WHITE)
canvas.save()
try {
canvas.scale(renderer.dpr, renderer.dpr)
canvas.translate(renderer.panX, renderer.panY)
canvas.scale(renderer.zoom, renderer.zoom)
renderer.renderSceneToCanvas(canvas, graph, pageId)
} finally {
canvas.restore()
}
return pixels(renderer)
}
test('capped backing preserves the pixel grid after fractional pan and pixel-density changes', () => {
const { graph, pageId } = createFixture()
const direct = createRenderer(pageId)
const retained = createRenderer(pageId)
try {
for (const { pan, dpr, zoom } of [
{ pan: 0.125, dpr: 2, zoom: 1 },
{ pan: 0.25, dpr: 2, zoom: 1 },
{ pan: 0.25, dpr: 1, zoom: 1 },
{ pan: 0.25, dpr: 2, zoom: 1 },
{ pan: 0.1, dpr: 1.25, zoom: 1 },
{ pan: 0.3, dpr: 1.5, zoom: 1 },
{ pan: 0.3, dpr: 1.5, zoom: 1.00001 }
]) {
direct.zoom = zoom
retained.zoom = zoom
direct.panX = pan
retained.panX = pan
direct.dpr = dpr
retained.dpr = dpr
retained.sceneBackingPreviewUntil = 0
retained.render(graph, new Set(), {}, 1, 'scene')
for (let frame = 0; frame < 20 && retained.sceneBackingNeedsCrispRender; frame++) {
retained.sceneBackingPreviewUntil = 0
retained.render(graph, new Set(), {}, 1, 'scene')
}
expect(retained.sceneBackingNeedsCrispRender).toBe(false)
const backing = expectDefined(retained.sceneBacking, 'settled backing')
expect(backing.zoom).toBe(zoom)
expect(backing.anchorPanX).toBe(pan)
// Matching an unchanged viewport must not repeatedly rebuild due to
// floating-point subtraction of the overscan margin.
retained.render(graph, new Set(), {}, 1, 'scene')
expect(retained.sceneBacking).toBe(backing)
const expected = directPixels(direct, graph, pageId)
const actual = pixels(retained)
const differingChannels = actual.reduce(
(count, value, index) => count + Number(value !== expected[index]),
0
)
expect(differingChannels).toBe(0)
}
} finally {
direct.destroy()
retained.destroy()
}
})
test('settlement reuses pictures and the navigation image without another cache', () => {
const { graph, pageId } = createFixture()
const renderer = createRenderer(pageId)
try {
renderer.render(graph, new Set(), {}, 1, 'scene')
const backing = expectDefined(renderer.sceneBacking, 'backing')
const pictures = new Map(
[...renderer.subtreePictureCache].map(([id, entry]) => [id, entry.picture])
)
expect(pictures.size).toBeGreaterThan(0)
const nodeDraws = spyOn(renderer, 'renderNode')
const allocations = spyOn(renderer.surface, 'makeSurface')
try {
renderer.sceneBackingPreviewUntil = 0
renderer.render(graph, new Set(), {}, 1, 'scene')
expect(renderer.profiler.stats.scenePictureMissReason).toBe('retained-pictures')
expect(renderer.sceneBacking).toBe(backing)
expect(renderer.scenePicture).toBeNull()
expect(renderer.subtreePictureCache.size).toBe(pictures.size)
for (const [id, picture] of pictures)
expect(renderer.subtreePictureCache.get(id)?.picture).toBe(picture)
expect(nodeDraws).not.toHaveBeenCalled()
expect(allocations).not.toHaveBeenCalled()
renderer.navigationPhase = 'pan'
renderer.render(graph, new Set(), {}, 1, 'scene')
expect(renderer.profiler.stats.scenePictureMissReason).toBe('backing')
expect(renderer.sceneBacking).toBe(backing)
expect(nodeDraws).not.toHaveBeenCalled()
expect(allocations).not.toHaveBeenCalled()
} finally {
nodeDraws.mockRestore()
allocations.mockRestore()
}
} finally {
renderer.destroy()
}
})
test('active edits draw directly without replacing the overscan backing', () => {
const { graph, pageId } = createFixture()
const renderer = createRenderer(pageId)
try {
renderer.render(graph, new Set(), {}, 1, 'scene')
const backing = expectDefined(renderer.sceneBacking, 'initial backing')
renderer.sceneBackingNeedsCrispRender = true
renderer.tiledScenePending = true
for (const sceneVersion of [2, 3, 4]) {
render(renderer, graph, new Set(), {}, sceneVersion, 'scene', true)
expect(renderer.sceneBacking).toBe(backing)
expect(renderer.sceneBackingBuild).toBeNull()
expect(renderer.sceneBackingNeedsCrispRender).toBe(false)
expect(renderer.tiledScenePending).toBe(false)
expect(renderer.profiler.stats.scenePictureMode).toBe('volatile')
expect(renderer.profiler.stats.scenePictureMissReason).toBe('active-edit')
}
renderer.render(graph, new Set(), {}, 4, 'scene')
expect(renderer.sceneBacking?.sceneVersion).toBe(4)
expect(renderer.sceneBacking).not.toBe(backing)
} finally {
renderer.destroy()
}
})