* 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.
451 lines
14 KiB
TypeScript
451 lines
14 KiB
TypeScript
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
|
|
import { initCanvasKit } from '#cli/headless'
|
|
import { SkiaRenderer } from '#core/canvas'
|
|
import { RenderChunkIndex, RenderChunkPictureCache } from '#core/canvas/renderer/chunks'
|
|
import {
|
|
deleteRenderedTile,
|
|
renderTile,
|
|
TiledSceneController,
|
|
TILE_DEVICE_SIZE,
|
|
TileSurfacePool,
|
|
tileKeysForWorldBounds,
|
|
tileWorldBounds
|
|
} from '#core/canvas/renderer/tiles'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
let ck: Awaited<ReturnType<typeof initCanvasKit>>
|
|
|
|
beforeAll(async () => {
|
|
ck = await initCanvasKit()
|
|
})
|
|
|
|
function color(r: number, g: number, b: number) {
|
|
return [{ type: 'SOLID' as const, color: { r, g, b, a: 1 }, opacity: 1, visible: true }]
|
|
}
|
|
|
|
function pixels(image: ReturnType<typeof ck.MakeImageFromEncoded>, width: number, height: number) {
|
|
if (!image) throw new Error('Expected image')
|
|
return expectDefined(
|
|
image.readPixels(0, 0, {
|
|
width,
|
|
height,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorSpace: ck.ColorSpace.SRGB
|
|
}),
|
|
'tile 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 squareCommandsBlob(): Uint8Array {
|
|
const blob = new Uint8Array(1 + 4 * 9 + 1)
|
|
const view = new DataView(blob.buffer)
|
|
let offset = 0
|
|
for (const [command, x, y] of [
|
|
[1, 0, 0],
|
|
[2, 1, 0],
|
|
[2, 1, 1],
|
|
[2, 0, 1]
|
|
] as const) {
|
|
blob[offset] = command
|
|
view.setFloat32(offset + 1, x, true)
|
|
view.setFloat32(offset + 5, y, true)
|
|
offset += 9
|
|
}
|
|
blob[offset] = 0
|
|
return blob
|
|
}
|
|
|
|
function testImageBytes(): Uint8Array {
|
|
const surface = expectDefined(ck.MakeSurface(8, 8), 'test image surface')
|
|
const canvas = surface.getCanvas()
|
|
canvas.clear(ck.Color4f(0.95, 0.75, 0.1, 1))
|
|
const paint = new ck.Paint()
|
|
paint.setColor(ck.Color4f(0.1, 0.3, 0.95, 1))
|
|
canvas.drawRect(ck.LTRBRect(0, 0, 4, 8), paint)
|
|
surface.flush()
|
|
const image = surface.makeImageSnapshot()
|
|
const bytes = expectDefined(image.encodeToBytes(ck.ImageFormat.PNG, 100), 'test image PNG')
|
|
image.delete()
|
|
paint.delete()
|
|
surface.delete()
|
|
return bytes
|
|
}
|
|
|
|
function createParityGraph() {
|
|
const graph = new SceneGraph()
|
|
const page = expectDefined(graph.getPages()[0], 'page')
|
|
const clip = graph.createNode('FRAME', page.id, {
|
|
x: 40,
|
|
y: 30,
|
|
width: 260,
|
|
height: 180,
|
|
clipsContent: true,
|
|
cornerRadius: 20,
|
|
fills: color(0.95, 0.95, 0.98)
|
|
})
|
|
const frame = graph.createNode('FRAME', clip.id, { width: 360, height: 160, fills: [] })
|
|
for (let index = 0; index < 48; index++) {
|
|
graph.createNode(index % 2 === 0 ? 'RECTANGLE' : 'ELLIPSE', frame.id, {
|
|
x: (index % 12) * 28,
|
|
y: Math.floor(index / 12) * 36,
|
|
width: 32,
|
|
height: 32,
|
|
fills: color((index % 3) * 0.35, 0.3, 0.85 - (index % 2) * 0.3)
|
|
})
|
|
}
|
|
const translucent = graph.createNode('FRAME', page.id, {
|
|
x: 210,
|
|
y: 55,
|
|
width: 100,
|
|
height: 120,
|
|
opacity: 0.55,
|
|
blendMode: 'MULTIPLY',
|
|
fills: [],
|
|
effects: [
|
|
{
|
|
type: 'FOREGROUND_BLUR',
|
|
visible: true,
|
|
radius: 12,
|
|
spread: 0,
|
|
offset: { x: 0, y: 0 },
|
|
color: { r: 0, g: 0, b: 0, a: 1 }
|
|
}
|
|
]
|
|
})
|
|
graph.createNode('RECTANGLE', translucent.id, {
|
|
x: 5,
|
|
y: 5,
|
|
width: 80,
|
|
height: 90,
|
|
fills: color(0.9, 0.2, 0.4),
|
|
effects: [
|
|
{
|
|
type: 'DROP_SHADOW',
|
|
visible: true,
|
|
color: { r: 0.1, g: 0.1, b: 0.2, a: 0.7 },
|
|
offset: { x: 18, y: 6 },
|
|
radius: 16,
|
|
spread: 3
|
|
}
|
|
]
|
|
})
|
|
const masked = graph.createNode('FRAME', page.id, {
|
|
x: 105,
|
|
y: 150,
|
|
width: 130,
|
|
height: 80,
|
|
fills: []
|
|
})
|
|
graph.createNode('ELLIPSE', masked.id, {
|
|
x: 0,
|
|
y: 0,
|
|
width: 90,
|
|
height: 70,
|
|
isMask: true,
|
|
fills: color(0, 0, 0)
|
|
})
|
|
graph.createNode('RECTANGLE', masked.id, {
|
|
x: -15,
|
|
y: -5,
|
|
width: 150,
|
|
height: 90,
|
|
fills: color(0.1, 0.8, 0.35)
|
|
})
|
|
const imageHash = 'tile-parity-image'
|
|
graph.images.set(imageHash, testImageBytes())
|
|
graph.createNode('RECTANGLE', page.id, {
|
|
x: 238,
|
|
y: 176,
|
|
width: 68,
|
|
height: 52,
|
|
rotation: -7,
|
|
fills: [
|
|
{
|
|
type: 'IMAGE',
|
|
imageHash,
|
|
imageScaleMode: 'FILL',
|
|
opacity: 1,
|
|
visible: true
|
|
}
|
|
]
|
|
})
|
|
graph.createNode('TEXT', page.id, {
|
|
x: 245,
|
|
y: 15,
|
|
width: 64,
|
|
height: 34,
|
|
rotation: 9,
|
|
text: 'tile',
|
|
fontFamily: '__MissingFont__',
|
|
fontSize: 22,
|
|
fills: color(0.15, 0.1, 0.75),
|
|
derivedTextGlyphs: [
|
|
{ commandsBlob: squareCommandsBlob(), x: 0, y: 22, fontSize: 18 },
|
|
{ commandsBlob: squareCommandsBlob(), x: 20, y: 22, fontSize: 18 },
|
|
{ commandsBlob: squareCommandsBlob(), x: 40, y: 22, fontSize: 18 }
|
|
]
|
|
})
|
|
return { graph, page }
|
|
}
|
|
|
|
describe('tile rendering', () => {
|
|
test('restores pooled tile canvas transforms before the surface is reused', () => {
|
|
const graph = new SceneGraph()
|
|
const page = expectDefined(graph.getPages()[0], 'page')
|
|
graph.createNode('RECTANGLE', page.id, {
|
|
x: 20,
|
|
y: 20,
|
|
width: 80,
|
|
height: 80,
|
|
fills: color(1, 0, 0)
|
|
})
|
|
const surface = expectDefined(ck.MakeSurface(320, 240), 'renderer surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
renderer.pageColor = { r: 1, g: 1, b: 1, a: 1 }
|
|
const { index } = RenderChunkIndex.build(graph, page.id)
|
|
const pictureCache = new RenderChunkPictureCache()
|
|
const surfacePool = new TileSurfacePool()
|
|
const key = { pageId: page.id, level: 1, x: 0, y: 0 }
|
|
try {
|
|
const first = expectDefined(
|
|
renderTile(renderer, graph, index, key, pictureCache, surfacePool),
|
|
'first tile'
|
|
)
|
|
const second = expectDefined(
|
|
renderTile(renderer, graph, index, key, pictureCache, surfacePool),
|
|
'second tile'
|
|
)
|
|
const firstPixels = pixels(first.image, TILE_DEVICE_SIZE, TILE_DEVICE_SIZE)
|
|
const secondPixels = pixels(second.image, TILE_DEVICE_SIZE, TILE_DEVICE_SIZE)
|
|
expect(firstPixels.every((value, index) => value === secondPixels[index])).toBe(true)
|
|
deleteRenderedTile(first)
|
|
deleteRenderedTile(second)
|
|
} finally {
|
|
surfacePool.clear()
|
|
pictureCache.clear()
|
|
index.dispose()
|
|
renderer.destroy()
|
|
}
|
|
})
|
|
|
|
test('cancels obsolete refresh jobs and performs no exact work during navigation', () => {
|
|
const graph = new SceneGraph()
|
|
const page = expectDefined(graph.getPages()[0], 'page')
|
|
for (let index = 0; index < 48; index++) {
|
|
graph.createNode('RECTANGLE', page.id, {
|
|
x: (index % 12) * 100,
|
|
y: Math.floor(index / 12) * 100,
|
|
width: 80,
|
|
height: 80,
|
|
fills: color(0.2, 0.4, 0.8)
|
|
})
|
|
}
|
|
const surface = expectDefined(ck.MakeSurface(1280, 800), 'cancellation surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
const controller = new TiledSceneController()
|
|
renderer.pageId = page.id
|
|
renderer.pageColor = { r: 1, g: 1, b: 1, a: 1 }
|
|
renderer.viewportWidth = 1280
|
|
renderer.viewportHeight = 800
|
|
renderer.dpr = 1
|
|
renderer.zoom = 1
|
|
renderer.navigationPhase = 'idle'
|
|
try {
|
|
const initial = controller.renderFrame(renderer, surface.getCanvas(), graph, 1, 0)
|
|
expect(initial.pending).toBe(true)
|
|
|
|
renderer.navigationPhase = 'zoom'
|
|
renderer.navigationGeneration = 1
|
|
const navigating = controller.renderFrame(renderer, surface.getCanvas(), graph, 1, 1)
|
|
expect(navigating.metrics.cancelledJobs).toBeGreaterThan(0)
|
|
expect(navigating.metrics.mandatoryCompleted).toBe(0)
|
|
expect(navigating.metrics.interruptibleCompleted).toBe(0)
|
|
} finally {
|
|
controller.destroy()
|
|
renderer.destroy()
|
|
}
|
|
})
|
|
|
|
test('refreshes only tiles intersecting an updated isolated chunk', () => {
|
|
const graph = new SceneGraph()
|
|
const page = expectDefined(graph.getPages()[0], 'page')
|
|
const changed = graph.createNode('RECTANGLE', page.id, {
|
|
x: 20,
|
|
y: 20,
|
|
width: 40,
|
|
height: 40,
|
|
fills: color(1, 0, 0)
|
|
})
|
|
graph.createNode('RECTANGLE', page.id, {
|
|
x: 540,
|
|
y: 20,
|
|
width: 40,
|
|
height: 40,
|
|
fills: color(0, 0, 1)
|
|
})
|
|
const surface = expectDefined(ck.MakeSurface(640, 240), 'selective invalidation surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
const controller = new TiledSceneController()
|
|
renderer.pageId = page.id
|
|
renderer.pageColor = { r: 1, g: 1, b: 1, a: 1 }
|
|
renderer.viewportWidth = 640
|
|
renderer.viewportHeight = 240
|
|
renderer.dpr = 1
|
|
renderer.zoom = 1
|
|
renderer.navigationPhase = 'idle'
|
|
try {
|
|
let initial = controller.renderFrame(renderer, surface.getCanvas(), graph, 1, 0)
|
|
for (let frame = 0; frame < 10 && initial.pending; frame++) {
|
|
initial = controller.renderFrame(renderer, surface.getCanvas(), graph, 1, 0)
|
|
}
|
|
expect(initial.covered).toBe(true)
|
|
|
|
controller.invalidateNode(changed.id)
|
|
graph.updateNode(changed.id, { x: 30, fills: color(0, 1, 0) })
|
|
const refreshed = controller.renderFrame(renderer, surface.getCanvas(), graph, 2, 0)
|
|
expect(refreshed.metrics.interruptibleCompleted).toBe(1)
|
|
expect(refreshed.covered).toBe(true)
|
|
} finally {
|
|
controller.destroy()
|
|
renderer.destroy()
|
|
}
|
|
})
|
|
|
|
test('live controller progressively replaces fallback pixels without changing the scene', () => {
|
|
const { graph, page } = createParityGraph()
|
|
const directSurface = expectDefined(ck.MakeSurface(320, 240), 'direct controller surface')
|
|
const tiledSurface = expectDefined(ck.MakeSurface(320, 240), 'tiled controller surface')
|
|
const direct = new SkiaRenderer(ck, directSurface)
|
|
const tiled = new SkiaRenderer(ck, tiledSurface)
|
|
const controller = new TiledSceneController()
|
|
const pageColor = { r: 1, g: 1, b: 1, a: 1 }
|
|
for (const renderer of [direct, tiled]) {
|
|
renderer.pageId = page.id
|
|
renderer.pageColor = pageColor
|
|
renderer.viewportWidth = 320
|
|
renderer.viewportHeight = 240
|
|
renderer.dpr = 1
|
|
renderer.zoom = 1
|
|
renderer.navigationPhase = 'idle'
|
|
renderer.nodeFontReadiness = () => 'exhausted'
|
|
}
|
|
|
|
try {
|
|
direct.surface.getCanvas().clear(ck.WHITE)
|
|
direct.renderSceneToCanvas(direct.surface.getCanvas(), graph, page.id)
|
|
direct.surface.flush()
|
|
const directImage = direct.surface.makeImageSnapshot()
|
|
|
|
let result = { covered: false, pending: true }
|
|
for (let frame = 0; frame < 20 && result.pending; frame++) {
|
|
const canvas = tiled.surface.getCanvas()
|
|
canvas.clear(ck.WHITE)
|
|
tiled.renderSceneToCanvas(canvas, graph, page.id)
|
|
result = controller.renderFrame(tiled, canvas, graph, 1, 0)
|
|
tiled.surface.flush()
|
|
}
|
|
expect(result.covered).toBe(true)
|
|
expect(result.pending).toBe(false)
|
|
|
|
const tiledImage = tiled.surface.makeImageSnapshot()
|
|
expect(
|
|
differenceRatio(pixels(directImage, 320, 240), pixels(tiledImage, 320, 240))
|
|
).toBeLessThan(0.01)
|
|
directImage.delete()
|
|
tiledImage.delete()
|
|
} finally {
|
|
controller.destroy()
|
|
direct.destroy()
|
|
tiled.destroy()
|
|
}
|
|
})
|
|
|
|
test('composes multiple queried tiles to match direct scene rendering', () => {
|
|
const { graph, page } = createParityGraph()
|
|
|
|
const directSurface = expectDefined(ck.MakeSurface(320, 240), 'direct surface')
|
|
const tiledSurface = expectDefined(ck.MakeSurface(320, 240), 'tiled surface')
|
|
const tileFactorySurface = expectDefined(ck.MakeSurface(320, 240), 'tile factory surface')
|
|
const direct = new SkiaRenderer(ck, directSurface)
|
|
const tiled = new SkiaRenderer(ck, tiledSurface)
|
|
const tileFactory = new SkiaRenderer(ck, tileFactorySurface)
|
|
direct.pageColor = { r: 1, g: 1, b: 1, a: 1 }
|
|
tiled.pageColor = { r: 1, g: 1, b: 1, a: 1 }
|
|
tileFactory.pageColor = { r: 1, g: 1, b: 1, a: 1 }
|
|
direct.nodeFontReadiness = () => 'exhausted'
|
|
tiled.nodeFontReadiness = () => 'exhausted'
|
|
tileFactory.nodeFontReadiness = () => 'exhausted'
|
|
const { index } = RenderChunkIndex.build(graph, page.id)
|
|
const level = 1
|
|
const keys = tileKeysForWorldBounds(page.id, level, {
|
|
minX: 0,
|
|
minY: 0,
|
|
maxX: 320,
|
|
maxY: 240
|
|
})
|
|
const pictureCache = new RenderChunkPictureCache()
|
|
const surfacePool = new TileSurfacePool()
|
|
try {
|
|
direct.surface.getCanvas().clear(ck.WHITE)
|
|
direct.renderSceneToCanvas(direct.surface.getCanvas(), graph, page.id)
|
|
direct.surface.flush()
|
|
const directImage = direct.surface.makeImageSnapshot()
|
|
|
|
const tiledCanvas = tiled.surface.getCanvas()
|
|
tiledCanvas.clear(ck.WHITE)
|
|
const rendered = keys.map((key) =>
|
|
expectDefined(
|
|
renderTile(tileFactory, graph, index, key, pictureCache, surfacePool),
|
|
'rendered tile'
|
|
)
|
|
)
|
|
const tilePaint = new ck.Paint()
|
|
tilePaint.setBlendMode(ck.BlendMode.Src)
|
|
for (const tile of rendered) {
|
|
const bounds = tileWorldBounds(tile.key)
|
|
tiledCanvas.drawImageRectOptions(
|
|
tile.image,
|
|
ck.LTRBRect(0, 0, TILE_DEVICE_SIZE, TILE_DEVICE_SIZE),
|
|
ck.LTRBRect(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY),
|
|
ck.FilterMode.Nearest,
|
|
ck.MipmapMode.None,
|
|
tilePaint
|
|
)
|
|
}
|
|
tilePaint.delete()
|
|
tiled.surface.flush()
|
|
const tiledImage = tiled.surface.makeImageSnapshot()
|
|
|
|
expect(
|
|
differenceRatio(pixels(directImage, 320, 240), pixels(tiledImage, 320, 240))
|
|
).toBeLessThan(0.01)
|
|
expect(rendered.every((tile) => tile.chunkCount < index.size())).toBe(true)
|
|
expect(rendered.every((tile) => tile.image.width() === TILE_DEVICE_SIZE)).toBe(true)
|
|
|
|
directImage.delete()
|
|
tiledImage.delete()
|
|
for (const tile of rendered) deleteRenderedTile(tile)
|
|
} finally {
|
|
surfacePool.clear()
|
|
pictureCache.clear()
|
|
index.dispose()
|
|
direct.destroy()
|
|
tiled.destroy()
|
|
tileFactory.destroy()
|
|
}
|
|
})
|
|
})
|