perf(text): preserve coverage across paragraph eviction
Keep successful glyph coverage weakly owned by source nodes rather than repeating shaping when dense scans evict native paragraphs. Preserve font/input invalidation and bound pending ID invalidations without increasing native cache limits. Add a dense-preview regression with exact raster comparison and count paragraph builds across all canvas renderers. The previous compiled build rebuilds 3,600 paragraphs over three held frames; the fixed build rebuilds none.
This commit is contained in:
parent
f4f3736034
commit
dceae73c6e
|
|
@ -134,6 +134,12 @@ function demandRemoteCoverage(r: TextRenderer, node: SceneNode, characters: stri
|
|||
}
|
||||
|
||||
function observedGlyphReadiness(r: TextRenderer, node: SceneNode): NodeFontReadiness {
|
||||
if (
|
||||
r.fontProvider &&
|
||||
r.textPreparationCache?.hasGlyphCoverage(node, fontManager.generation(), r.fontProvider)
|
||||
)
|
||||
return 'ready'
|
||||
|
||||
const missingOccurrences = withPreparedText(
|
||||
r,
|
||||
node,
|
||||
|
|
@ -151,7 +157,10 @@ function observedGlyphReadiness(r: TextRenderer, node: SceneNode): NodeFontReadi
|
|||
return prepared.missingGlyphs
|
||||
}
|
||||
)
|
||||
if (missingOccurrences.length === 0) return 'ready'
|
||||
if (missingOccurrences.length === 0) {
|
||||
r.textPreparationCache?.recordGlyphCoverage(node)
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
const charactersByScript = new Map<FontFallbackScript, string[]>()
|
||||
for (const { character, utf16Start } of missingOccurrences) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ interface Entry extends PreparedText {
|
|||
export class TextPreparationCache {
|
||||
private readonly entries = new Map<string, Entry>()
|
||||
private readonly nodeKeys = new Map<string, Set<string>>()
|
||||
// A successful coverage check contains no native resources. Keep that result
|
||||
// independently of paragraph LRU eviction, weakly owned by the source node.
|
||||
private glyphCoverage = new WeakMap<SceneNode, PreparationInput[]>()
|
||||
private readonly invalidatedCoverage = new Set<string>()
|
||||
private units = 0
|
||||
private generation = -1
|
||||
private provider: TypefaceFontProvider | null = null
|
||||
|
|
@ -91,7 +95,36 @@ export class TextPreparationCache {
|
|||
return consume(entry)
|
||||
}
|
||||
|
||||
hasGlyphCoverage(node: SceneNode, generation: number, provider: TypefaceFontProvider): boolean {
|
||||
if (this.generation !== generation || this.provider !== provider) return false
|
||||
if (this.invalidatedCoverage.delete(node.id)) {
|
||||
this.glyphCoverage.delete(node)
|
||||
return false
|
||||
}
|
||||
const inputs = this.glyphCoverage.get(node)
|
||||
if (!inputs) return false
|
||||
if (PARAGRAPH_INPUT_KEYS.every((prop, index) => inputs[index] === node[prop])) return true
|
||||
this.glyphCoverage.delete(node)
|
||||
return false
|
||||
}
|
||||
|
||||
/** Call only after observing complete coverage with this cache's current font scope. */
|
||||
recordGlyphCoverage(node: SceneNode): void {
|
||||
this.invalidatedCoverage.delete(node.id)
|
||||
this.glyphCoverage.set(
|
||||
node,
|
||||
PARAGRAPH_INPUT_KEYS.map((prop) => node[prop])
|
||||
)
|
||||
}
|
||||
|
||||
deleteNode(id: string): void {
|
||||
// Invalidation arrives by ID; don't add strong node ownership just to find
|
||||
// weak observations. Bound pending IDs and conservatively reset on overflow.
|
||||
this.invalidatedCoverage.add(id)
|
||||
if (this.invalidatedCoverage.size > this.maxEntries) {
|
||||
this.glyphCoverage = new WeakMap()
|
||||
this.invalidatedCoverage.clear()
|
||||
}
|
||||
const keys = this.nodeKeys.get(id)
|
||||
if (keys) for (const key of keys) this.remove(key)
|
||||
}
|
||||
|
|
@ -100,6 +133,8 @@ export class TextPreparationCache {
|
|||
for (const entry of this.entries.values()) entry.paragraph.delete()
|
||||
this.entries.clear()
|
||||
this.nodeKeys.clear()
|
||||
this.glyphCoverage = new WeakMap()
|
||||
this.invalidatedCoverage.clear()
|
||||
this.units = 0
|
||||
}
|
||||
|
||||
|
|
|
|||
101
tests/e2e/canvas/dense-text-preview.spec.ts
Normal file
101
tests/e2e/canvas/dense-text-preview.spec.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
|
||||
import { probeParagraphBuilds } from '#tests/helpers/canvas/text-preparation'
|
||||
|
||||
const editor = useEditorSetupWithClear('/?test&no-rulers&navigation-benchmark')
|
||||
|
||||
test('dense text keeps exact pixels without reshaping unchanged labels during previews', async () => {
|
||||
const interaction = await editor.page.evaluateHandle(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('Editor unavailable')
|
||||
return store.beginInteractiveEdit()
|
||||
})
|
||||
try {
|
||||
const previewId = await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('Editor unavailable')
|
||||
const page = store.state.currentPageId
|
||||
const group = store.graph.createNode('FRAME', page, {
|
||||
name: 'Dense text',
|
||||
width: 8000,
|
||||
height: 3600,
|
||||
fills: []
|
||||
})
|
||||
// Coverage and drawing formerly needed 1,200 entries in a 1,024-paragraph cache.
|
||||
for (let i = 0; i < 600; i++) {
|
||||
store.graph.createNode('TEXT', group.id, {
|
||||
x: (i % 20) * 400,
|
||||
y: Math.floor(i / 20) * 120,
|
||||
width: 380,
|
||||
height: 110,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 400,
|
||||
fontSize: 48,
|
||||
text: `Label ${i}`,
|
||||
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
||||
})
|
||||
}
|
||||
const marker = store.graph.createNode('RECTANGLE', page, {
|
||||
x: -10000,
|
||||
y: -10000,
|
||||
width: 10,
|
||||
height: 10
|
||||
})
|
||||
store.clearSelection()
|
||||
store.setZoomAroundPoint(0.06, 0, 0)
|
||||
store.pan(-store.state.panX, -store.state.panY)
|
||||
store.requestRender()
|
||||
return marker.id
|
||||
})
|
||||
await editor.page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement())
|
||||
await editor.page.evaluate((id) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store?.renderer) throw new Error('Renderer unavailable')
|
||||
store.renderer.textPreparationCache.clear()
|
||||
store.graph.updateNodePreview(id, { x: -10004 })
|
||||
store.requestRepaint()
|
||||
}, previewId)
|
||||
await editor.canvas.waitForRender()
|
||||
const reference = await editor.canvas.screenshotCanvasRegion(520, 240)
|
||||
expect(reference).toMatchSnapshot('dense-text-preview.png', { threshold: 0, maxDiffPixels: 0 })
|
||||
|
||||
// One additional pass lets drawing paragraphs occupy the cache after coverage
|
||||
// observations have been memoized. Subsequent preview frames must not shape text.
|
||||
await editor.page.evaluate((id) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
store?.graph.updateNodePreview(id, { x: -10003 })
|
||||
store?.requestRepaint()
|
||||
}, previewId)
|
||||
await editor.canvas.waitForRender()
|
||||
const probe = await probeParagraphBuilds(editor.page)
|
||||
try {
|
||||
for (const x of [-10002, -10001, -10000]) {
|
||||
await editor.page.evaluate(
|
||||
({ id, x }) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
store?.graph.updateNodePreview(id, { x })
|
||||
store?.requestRepaint()
|
||||
},
|
||||
{ id: previewId, x }
|
||||
)
|
||||
await editor.canvas.waitForRender()
|
||||
}
|
||||
expect(await probe.evaluate((probe) => probe.readiness())).toBeGreaterThanOrEqual(1800)
|
||||
expect(await probe.evaluate((probe) => probe.count())).toBe(0)
|
||||
const actual = await editor.canvas.screenshotCanvasRegion(520, 240)
|
||||
expect(actual.equals(reference)).toBe(true)
|
||||
editor.canvas.assertNoErrors()
|
||||
} finally {
|
||||
try {
|
||||
await probe.evaluate((probe) => probe.restore())
|
||||
} finally {
|
||||
await probe.dispose()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await interaction.evaluate((end) => end())
|
||||
} finally {
|
||||
await interaction.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
|
|
@ -1,4 +1,5 @@
|
|||
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
|
||||
import { probeParagraphBuilds } from '#tests/helpers/canvas/text-preparation'
|
||||
|
||||
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
|
||||
|
||||
|
|
@ -88,23 +89,7 @@ test('text case vertical alignment and ending truncation', async () => {
|
|||
}, previewId)
|
||||
await editor.canvas.waitForRender()
|
||||
const reference = await editor.canvas.screenshotCanvasRegion()
|
||||
const preparations = await editor.page.evaluateHandle(() => {
|
||||
const renderer = window.openPencil?.getStore?.().renderer
|
||||
if (!renderer) throw new Error('Renderer unavailable')
|
||||
const builder = renderer.ck.ParagraphBuilder
|
||||
const original = builder.MakeFromFontProvider
|
||||
let count = 0
|
||||
builder.MakeFromFontProvider = (...args) => {
|
||||
count++
|
||||
return original.apply(builder, args)
|
||||
}
|
||||
return {
|
||||
count: () => count,
|
||||
restore: () => {
|
||||
builder.MakeFromFontProvider = original
|
||||
}
|
||||
}
|
||||
})
|
||||
const preparations = await probeParagraphBuilds(editor.page)
|
||||
try {
|
||||
for (let step = 0; step < 3; step++) {
|
||||
await editor.page.evaluate(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type { Paragraph } from 'canvaskit-wasm'
|
||||
import type { CanvasKit, Paragraph } from 'canvaskit-wasm'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { initCanvasKit } from '#cli/headless'
|
||||
import { buildParagraph, nodeFontReadiness } from '#core/canvas/text'
|
||||
import type { ParagraphNode } from '#core/canvas/text/paragraph-inputs'
|
||||
import { TextPreparationCache } from '#core/canvas/text/preparation-cache'
|
||||
import { fontManager } from '#core/text/fonts'
|
||||
|
||||
async function fixture(maxEntries = 8, maxUnits = 1000) {
|
||||
const ck = await initCanvasKit()
|
||||
|
|
@ -53,6 +55,102 @@ async function fixture(maxEntries = 8, maxUnits = 1000) {
|
|||
}
|
||||
|
||||
describe('text preparation cache', () => {
|
||||
test('does not repeatedly shape coverage when a dense scan exceeds paragraph capacity', async () => {
|
||||
const f = await fixture(8)
|
||||
const data = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
|
||||
f.provider.registerFont(data, 'Inter')
|
||||
fontManager.markLoaded('Inter', 'Regular', data)
|
||||
let builds = 0
|
||||
const ck: CanvasKit = {
|
||||
...f.ck,
|
||||
ParagraphBuilder: {
|
||||
...f.ck.ParagraphBuilder,
|
||||
MakeFromFontProvider(
|
||||
...args: Parameters<typeof f.ck.ParagraphBuilder.MakeFromFontProvider>
|
||||
) {
|
||||
builds++
|
||||
return f.ck.ParagraphBuilder.MakeFromFontProvider(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
const renderer = {
|
||||
ck,
|
||||
fontProvider: f.provider,
|
||||
fontsLoaded: true,
|
||||
textPreparationCache: f.cache
|
||||
}
|
||||
const nodes = Array.from({ length: 6 }, (_, i) =>
|
||||
f.graph.createNode('TEXT', f.node.parentId, {
|
||||
text: `Label ${i}`,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 400,
|
||||
width: 100,
|
||||
height: 30
|
||||
})
|
||||
)
|
||||
function draw() {
|
||||
for (const node of nodes) {
|
||||
expect(nodeFontReadiness(renderer, node)).toBe('ready')
|
||||
f.cache.use(
|
||||
node,
|
||||
'draw',
|
||||
fontManager.generation(),
|
||||
f.provider,
|
||||
() => buildParagraph(renderer, node),
|
||||
({ paragraph }) => paragraph.getHeight()
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
draw()
|
||||
expect(builds).toBe(12)
|
||||
draw()
|
||||
const warmBuilds = builds
|
||||
draw()
|
||||
expect(builds).toBe(warmBuilds)
|
||||
f.cache.deleteNode(nodes[0].id)
|
||||
draw()
|
||||
expect(builds).toBe(warmBuilds + 2)
|
||||
f.graph.updateNodePreview(nodes[0].id, { text: 'Edited label' })
|
||||
draw()
|
||||
expect(builds).toBe(warmBuilds + 4)
|
||||
f.cache.clear()
|
||||
draw()
|
||||
expect(builds).toBe(warmBuilds + 16)
|
||||
} finally {
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('scopes successful coverage to fonts, inputs and explicit invalidation', async () => {
|
||||
const f = await fixture(2)
|
||||
const otherProvider = f.ck.TypefaceFontProvider.Make()
|
||||
try {
|
||||
f.use('coverage', 1)
|
||||
f.cache.recordGlyphCoverage(f.node)
|
||||
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
|
||||
expect(f.cache.hasGlyphCoverage(f.node, 2, f.provider)).toBe(false)
|
||||
expect(f.cache.hasGlyphCoverage(f.node, 1, otherProvider)).toBe(false)
|
||||
f.graph.updateNodePreview(f.node.id, { x: 50, rotation: 20 })
|
||||
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
|
||||
f.graph.updateNodePreview(f.node.id, { width: 80 })
|
||||
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(false)
|
||||
f.use('coverage', 1)
|
||||
f.cache.recordGlyphCoverage(f.node)
|
||||
f.cache.deleteNode('unrelated')
|
||||
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
|
||||
f.cache.deleteNode('another')
|
||||
f.cache.deleteNode('overflow')
|
||||
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(false)
|
||||
f.cache.recordGlyphCoverage(f.node)
|
||||
f.use('draw', 2)
|
||||
expect(f.cache.hasGlyphCoverage(f.node, 2, f.provider)).toBe(false)
|
||||
} finally {
|
||||
otherProvider.delete()
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('reuses native paragraphs during movement and invalidates text layout inputs', async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
|
|
|
|||
38
tests/helpers/canvas/text-preparation.ts
Normal file
38
tests/helpers/canvas/text-preparation.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
|
||||
export function probeParagraphBuilds(page: Page) {
|
||||
return page.evaluateHandle(() => {
|
||||
const renderers = window.openPencil?.getStore?.().canvasRenderers
|
||||
if (!renderers?.length) throw new Error('Renderer unavailable')
|
||||
const restores: Array<() => void> = []
|
||||
let count = 0
|
||||
let readiness = 0
|
||||
for (const builder of new Set(renderers.map((renderer) => renderer.ck.ParagraphBuilder))) {
|
||||
const original = builder.MakeFromFontProvider
|
||||
builder.MakeFromFontProvider = (...args) => {
|
||||
count++
|
||||
return original.apply(builder, args)
|
||||
}
|
||||
restores.push(() => {
|
||||
builder.MakeFromFontProvider = original
|
||||
})
|
||||
}
|
||||
for (const renderer of renderers) {
|
||||
const original = renderer.nodeFontReadiness
|
||||
renderer.nodeFontReadiness = (node) => {
|
||||
readiness++
|
||||
return original.call(renderer, node)
|
||||
}
|
||||
restores.push(() => {
|
||||
renderer.nodeFontReadiness = original
|
||||
})
|
||||
}
|
||||
return {
|
||||
count: () => count,
|
||||
readiness: () => readiness,
|
||||
restore: () => {
|
||||
for (const restore of restores) restore()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue