openpencil/tests/engine/render/canvas/chunks/record.test.ts
Danila Poyarkov 379ef8b62d
perf(canvas): rebuild navigation rendering (#591)
* perf(canvas): add traced navigation benchmarks

- Record and replay timestamped pan and zoom gestures through DOM and CDP input paths\n- Correlate input, viewport, render, long-task, and retained-backing events in Chromium traces\n- Report frame pacing, latency, jump, anchor drift, and crisp-settlement metrics

* perf(canvas): stabilize navigation comparisons

- Separate low-overhead metric runs from optional CPU-profile traces\n- Warm scenarios before recording and use a consistent SwiftShader browser configuration\n- Add a canonical momentum-pan reversal gesture alongside pinch reversal

* fix(canvas): require hardware GPU navigation benchmarks

- Run macOS performance captures through Metal-backed ANGLE and reject accidental SwiftShader fallback\n- Record the GL renderer and reserve software GPU mode for portable correctness smoke runs

* perf(canvas): cache shadow rasters for crisp backing

- Rasterize local drop and inner shadows only while constructing retained scene backing\n- Bound native image memory and invalidate cached entries with node and renderer lifecycle changes\n- Quantize zoom-aware raster resolution and reuse nearby scales without lowering normal scene quality

* test(canvas): verify retained shadow raster fidelity

- Compare settled retained-backing shadow output with direct CanvasKit rendering\n- Keep backdrop blur on the picture fallback and exercise graph-driven cache invalidation\n- Cover updates, deletion, and reparenting through actual SceneGraph events

* perf(canvas): benchmark real FIG fixtures

- Serve exact local fixture bytes through an isolated Playwright route for production preview runs\n- Wait for document loading and page population before zooming to fit and recording navigation\n- Record the resolved fixture path in benchmark environment artifacts

* fix(canvas): preserve nested effect subtree pictures

- Keep deeply nested shadow documents on one retained subtree picture instead of exploding them into per-node image draws\n- Restrict shadow raster acceleration to effect-bearing page children\n- Cover nested shadow fallback and restore gold-preview FIG pinch performance to master levels

* refactor(canvas): share recorded wheel sample type

* perf(canvas): defer backing settlement across zoom reversals

- Track explicit navigation phases and gesture generations instead of inferring idle from viewport timing\n- Cancel or defer retained backing construction while pan, zoom, momentum, or tentative settlement is active\n- Add a repeated short-pause pinch reversal fixture based on the user trace

* perf(canvas): index bounded render chunks

- Split oversized painter subtrees into self-paint and bounded descendant chunks without dropping container visuals\n- Bulk-load chunk visual bounds into RBush for selective world-space queries\n- Cover bounded updates and gold-preview build/query complexity before tile rendering consumes the index

* refactor(canvas): namespace render chunk coverage

* perf(canvas): model chunk paint context

- Preserve ancestor transform and clip dependencies for independently renderable chunks\n- Keep opacity, blend, blur, and mask isolation subtrees atomic until command-level splitting exists\n- Report oversized atomic chunks and lock gold-preview to bounded painter units

* perf(canvas): record pixel-correct render chunks

- Record interruptible chunks in world coordinates with ancestor transforms, clips, and chunk-local culling bounds\n- Draw opacity, blend, blur, and mask isolation chunks directly into destination surfaces in painter order\n- Compare composited chunk output with direct CanvasKit rendering instead of relaxing visual thresholds

* perf(canvas): render selective world tiles

- Map world regions to fixed 256-device-pixel tile targets and quantized sharpness levels\n- Query only intersecting render chunks and preserve atomic destination compositing\n- Match multi-tile CanvasKit output against direct rendering and measure gold-preview tile cost

* perf(canvas): cache chunk pictures across tiles

- Reuse world-space chunk command pictures for every intersecting tile\n- Pool 256-pixel tile surfaces and expose allocation, draw, flush, and snapshot timings\n- Keep expensive atomic foreground blur visible as an over-budget scheduler constraint

* perf(canvas): schedule cached tile rendering

- Bound tile images with an LRU cache and reuse pooled CanvasKit surfaces\n- Plan mandatory holes, stale visible refreshes, and overscan by navigation and content generation\n- Stop jobs at a strict deadline while reporting fallbacks, stale work, overruns, and over-budget effects

* perf(canvas): integrate progressive tiled rendering

- Keep retained scene output as the interaction fallback while exact tiles refine only after navigation becomes idle
- Centralize runtime URL flags and pass renderer selection through the typed Vue canvas API
- Replace benchmark sleeps with explicit mode-aware renderer settlement and report exact tiled coverage
- Preserve bounded scheduler metrics, generation cancellation, native resource cleanup, and shared visual-bounds logic

* refactor(app): centralize runtime query configuration

- Parse collaboration, recent-files, benchmark, presentation, and renderer flags in one typed app module
- Remove ad hoc URL parsing from workspace and collaboration runtime consumers
- Cover supported values and production-safe defaults without adding a repository lint rule

* fix(canvas): replace fallback pixels with exact tiles

- Render opaque page-background tile cells and install them with source replacement instead of double-compositing translucent scene content
- Exercise the live progressive controller against direct rendering across masks, effects, blend isolation, images, fallback text, transforms, and clipping
- Preserve the bounded reversal path with zero Long Tasks and exact settlement near 128 ms on gold-preview.fig

* perf(canvas): invalidate tiled content selectively

- Index chunk dependencies across contained nodes and transform or clipping ancestors
- Re-record affected chunk pictures and invalidate tiles intersecting old or new visual bounds
- Advance unaffected cached tiles to the new scene generation instead of rebuilding the full chunk index and tile cache
- Keep structural graph mutations on the safe full-rebuild path and cover selective refresh end to end

* perf(canvas): bound atomic blur tile refresh

- Render atomic blur chunks with tile-local isolation bounds and blur halos instead of replaying full-subtree layers
- Keep content refresh behind the retained fallback, cap GPU submissions to four tile jobs per frame, and adapt estimates from measured work
- Preserve large-radius CPU over-budget visibility while preventing Metal-backed refresh bursts and deferred GPU overload
- Add deterministic node-mutation benchmarks and summarize scheduler throughput, job duration, overruns, and exact content settlement

* perf(canvas): cancel obsolete tile refresh generations

- Count and report queued jobs removed by content or navigation generation changes
- Add deterministic mutation-then-reversal benchmark support without sleeps
- Assert exact tile work remains suspended during navigation and resumes for the final viewport
- Summarize cancellation alongside scheduler throughput, overruns, and settlement metrics

* test(canvas): cover live tiled blur settlement

- Load gold-preview.fig through the real tiled canvas surface and wait on explicit renderer settlement
- Commit the settled radius-210 large-blur browser snapshot
- Replay the canonical zoom reversal during refresh and require byte-identical final canvas convergence

* fix(canvas): harden renderer resource lifecycle

- Release tiled surfaces, images, pictures, and queued work across surface, font, graph, page, structure, and renderer lifecycle boundaries
- Restore pooled canvas, viewport, and backing state through exception-safe native recording and raster paths
- Rebuild tiled chunk topology only when isolation requirements actually change, preserving selective blur mutation performance
- Document deterministic active-renderer settlement and add lifecycle, graph replacement, cache failure, and surface replacement regressions

* test(canvas): remove source-matching renderer claims

- Delete the autopsy suite that inferred runtime correctness from source text, regexes, line placement, and symbol counts
- Keep renderer ordering, cache cleanup, effect behavior, and pixel fidelity covered by executable behavioral and lifecycle tests

* perf(canvas): present retained backing during tiled navigation

- Profile production reversal traces and attribute tiled p95 cost to GPU command-buffer flushes from full-scene fallback replay and tile presentation
- Use the retained backing as the moving fallback while tile scheduling and cached lookup remain allocation-free
- Defer tile image presentation until idle and expose visible versus presented tile counts in navigation telemetry
- Reduce tiled reversal render p95 from about 8ms to 0.3ms while preserving exact idle replacement and visual parity

* perf(canvas): prioritize visible tile settlement

- Profile per-tile allocation, draw, flush, snapshot, and chunk costs through scheduler telemetry
- Defer overscan until all visible exact tiles are covered
- Replace the four-job idle cap with a higher safety ceiling while the measured five-millisecond deadline controls cheap work
- Reduce mutation-plus-reversal exact settlement from about 272ms to 160ms without Long Tasks, overruns, or over-budget jobs

* refactor(canvas): clarify renderer lifecycle boundaries

- Extract retained backing state types and navigation preview timing\n- Isolate tiled scheduler telemetry from frame orchestration\n- Document settlement and CanvasKit ownership invariants\n- Preserve hot drawing loops, budgets, cache limits, and rendering decisions

* fix(canvas): preserve current label rendering

Retain the merged paragraph-label cache lifecycle and substituted-font readiness while reconstructing the renderer stack on current master.

* test(canvas): keep tile benchmark assertions deterministic

Keep performance timing in benchmark telemetry while asserting structural tile selectivity and cache behavior in CI.

* feat(canvas): expose experimental tiled rendering

- Persist retained or tiled canvas mode in General settings\n- Keep retained rendering as the default and apply changes after reload\n- Preserve URL overrides for deterministic benchmarks and support reproduction

* refactor(app): centralize renderer preference state

Expose renderer override provenance from runtime configuration and keep the settings control's derived state separate from its explicit persistence action.

* refactor(app): share settings layout anatomy

Reuse slot-based section headers and bordered groups while keeping each settings control row explicit.

* fix(canvas): harden tiled renderer boundaries

- Bound low-zoom tile planning and handle failed tile surface allocation\n- Preserve effect raster dependencies, runtime-safe clocks, and navigation timing contracts\n- Keep benchmarks deterministic, backward compatible, and accurately localized

* fix(canvas): invalidate dependent node pictures

Track first-child shadow dependencies for retained node pictures so child geometry updates cannot leave stale parent shadows.
2026-08-31 13:39:40 +03:00

200 lines
6.3 KiB
TypeScript

import { beforeAll, describe, expect, mock, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import { initCanvasKit } from '#cli/headless'
import { SkiaRenderer } from '#core/canvas'
import type { RenderChunk } from '#core/canvas/renderer/chunks'
import {
deleteRecordedRenderChunks,
recordRenderChunk,
RenderChunkIndex
} from '#core/canvas/renderer/chunks'
import { expectDefined } from '#tests/helpers/assert'
let ck: Awaited<ReturnType<typeof initCanvasKit>>
beforeAll(async () => {
ck = await initCanvasKit()
})
function renderPixels(renderer: SkiaRenderer, graph: SceneGraph, pageId: string, chunked: boolean) {
const canvas = renderer.surface.getCanvas()
canvas.clear(ck.WHITE)
if (chunked) {
const { index } = RenderChunkIndex.build(graph, pageId)
const chunks = index.search({ minX: 0, minY: 0, maxX: 320, maxY: 240 })
const recorded = chunks.map((chunk) => recordRenderChunk(renderer, graph, chunk))
for (const chunk of chunks) {
const picture = recorded.find((entry) => entry.chunk.id === chunk.id)?.picture
if (picture) canvas.drawPicture(picture)
}
deleteRecordedRenderChunks(recorded)
index.dispose()
} else {
renderer.renderSceneToCanvas(canvas, graph, pageId)
}
renderer.surface.flush()
const image = renderer.surface.makeImageSnapshot()
const pixels = image.readPixels(0, 0, {
width: 320,
height: 240,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
})
image.delete()
return expectDefined(pixels, 'rendered chunk pixels')
}
function differenceRatio(a: Uint8Array, b: Uint8Array, tolerance = 8) {
let different = 0
for (let index = 0; index < a.length; index++) {
if (Math.abs(a[index] - b[index]) > tolerance) different++
}
return different / a.length
}
function renderPair(graph: SceneGraph, pageId: string) {
const directSurface = expectDefined(ck.MakeSurface(320, 240), 'direct surface')
const chunkSurface = expectDefined(ck.MakeSurface(320, 240), 'chunk surface')
const direct = new SkiaRenderer(ck, directSurface)
const chunked = new SkiaRenderer(ck, chunkSurface)
try {
const expected = renderPixels(direct, graph, pageId, false)
const actual = renderPixels(chunked, graph, pageId, true)
return differenceRatio(expected, actual)
} finally {
direct.destroy()
chunked.destroy()
}
}
function color(r: number, g: number, b: number) {
return [{ type: 'SOLID' as const, color: { r, g, b, a: 1 }, opacity: 1, visible: true }]
}
describe('recorded render chunks', () => {
test('deletes the native picture recorder when chunk rendering throws', () => {
const recorder = {
beginRecording: mock(() => ({ save: mock(), concat: mock() })),
finishRecordingAsPicture: mock(),
delete: mock()
}
const renderer = {
ck: {
PictureRecorder: function PictureRecorder() {
return recorder
},
LTRBRect: mock(() => new Float32Array(4))
},
worldViewport: { x: 0, y: 0, w: 10, h: 10 },
renderNode: mock(() => {
throw new Error('render failed')
})
} as SkiaRenderer
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0], 'page')
const node = graph.createNode('RECTANGLE', page.id)
const chunk: RenderChunk = {
id: `${node.id}:subtree`,
nodeId: node.id,
kind: 'subtree',
context: { parentTransform: [1, 0, 0, 0, 1, 0, 0, 0, 1], ancestorClipIds: [] },
interruptible: true,
painterOrder: 0,
minX: 0,
minY: 0,
maxX: 10,
maxY: 10,
nodeCount: 1,
estimatedCost: 1
}
expect(() => recordRenderChunk(renderer, graph, chunk)).toThrow('render failed')
expect(recorder.delete).toHaveBeenCalledTimes(1)
})
test('match direct rendering for split nested transforms and rounded clips', () => {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0], 'page')
const clip = graph.createNode('FRAME', page.id, {
x: 60,
y: 40,
width: 180,
height: 140,
rotation: 8,
cornerRadius: 18,
clipsContent: true,
fills: color(0.9, 0.9, 0.95)
})
const frame = graph.createNode('FRAME', clip.id, { x: 20, y: 15, width: 300, height: 100 })
for (let index = 0; index < 40; index++) {
graph.createNode('RECTANGLE', frame.id, {
x: index * 12,
y: index % 2 === 0 ? 10 : 45,
width: 18,
height: 18,
fills: color(0.1, 0.4 + (index % 3) * 0.15, 0.8)
})
}
expect(renderPair(graph, page.id)).toBeLessThan(0.01)
})
test('records atomic chunks as indivisible destination-dependent command pictures', () => {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0], 'page')
const group = graph.createNode('FRAME', page.id, { opacity: 0.5 })
for (let index = 0; index < 40; index++) {
graph.createNode('RECTANGLE', group.id, { width: 10, height: 10 })
}
const surface = expectDefined(ck.MakeSurface(320, 240), 'atomic surface')
const renderer = new SkiaRenderer(ck, surface)
try {
const { index } = RenderChunkIndex.build(graph, page.id)
const chunk = index.getChunksForNode(group.id)[0]
if (!chunk) throw new Error('Expected atomic chunk')
const recorded = recordRenderChunk(renderer, graph, chunk)
expect(recorded.picture).toBeDefined()
recorded.picture.delete()
index.dispose()
} finally {
renderer.destroy()
}
})
test('match direct rendering for atomic opacity and blend isolation', () => {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0], 'page')
graph.createNode('RECTANGLE', page.id, {
x: 20,
y: 20,
width: 240,
height: 180,
fills: color(0.9, 0.2, 0.2)
})
const group = graph.createNode('FRAME', page.id, {
x: 40,
y: 30,
width: 220,
height: 160,
opacity: 0.6,
blendMode: 'MULTIPLY',
fills: []
})
for (let index = 0; index < 40; index++) {
graph.createNode('ELLIPSE', group.id, {
x: (index % 10) * 18,
y: Math.floor(index / 10) * 28,
width: 28,
height: 28,
fills: color(0.2, 0.7, 0.9)
})
}
expect(renderPair(graph, page.id)).toBeLessThan(0.01)
})
})