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:
Danila Poyarkov 2026-09-15 16:32:07 +03:00
parent f4f3736034
commit dceae73c6e
7 changed files with 285 additions and 19 deletions

View file

@ -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) {

View file

@ -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
}

View 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

View file

@ -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(

View file

@ -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 {

View 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()
}
}
})
}