diff --git a/apps/benchmarks/src/app.tsx b/apps/benchmarks/src/app.tsx index 53588e97..04eb43ad 100644 --- a/apps/benchmarks/src/app.tsx +++ b/apps/benchmarks/src/app.tsx @@ -59,7 +59,6 @@ import { import { ADVANCED_FONT_FIXTURES, BENCHMARK_FONT_LABELS, - ICON_GRID_FONT_FIXTURE, SELECTABLE_FONT_FIXTURES, benchmarkIpsumText, liveWorkloadFontFixtures, @@ -116,7 +115,13 @@ import type { ComparisonWorkloadPersistentScene, ComparisonWorkloadStats, } from './renderer/comparison-workload'; -import { comparisonWorkloadId, isBenchmarkWorkloadId, type BenchmarkWorkloadId } from './workloads/catalog'; +import { + benchmarkWorkloadDefinition, + comparisonWorkloadId, + isBenchmarkWorkloadId, + type BenchmarkWorkloadDefinition, + type BenchmarkWorkloadId, +} from './workloads/catalog'; import { benchmarkContentWidth, BENCHMARK_CONTENT_INSET, @@ -196,8 +201,10 @@ function liveSceneAssetResource( fontFixture: BenchmarkFontFixture, workload: HarnessLocation['workload'], ): Promise { - const fixtures = workload === 'icon-grid' ? [fontFixture, ICON_GRID_FONT_FIXTURE] : [fontFixture]; - const comparison = isBenchmarkWorkloadId(workload) && comparisonWorkloadId(workload) !== undefined; + const definition = isBenchmarkWorkloadId(workload) ? benchmarkWorkloadDefinition(workload) : undefined; + const fixtures = + definition?.fontPolicy.kind === 'icon-grid' ? [fontFixture, definition.fontPolicy.iconFixture] : [fontFixture]; + const comparison = definition?.surface === 'comparison'; const key = `${technique}:${delivery}:${fixtures.join(',')}:${String(comparison)}`; const existing = liveSceneAssetResources.get(key); if (existing !== undefined) return existing; @@ -261,48 +268,39 @@ function isComparisonWorkloadStats(stats: LiveTextStats | undefined): stats is C return stats !== undefined && 'workload' in stats; } -function workloadAmountLabel(workload: string, amount: number): string | undefined { - switch (workload) { - case 'off-axis-3d': - return `Perspective intensity · ${amount}%`; - case 'dynamic-layout': - return `Reflow amplitude · ${amount}%`; - case 'paragraph-stress': - return `Text volume · ${amount}%`; - case 'paint-effects': - return `Hue spread · ${amount}%`; - default: - return undefined; - } +function workloadAmountLabel(workload: BenchmarkWorkloadId, amount: number): string | undefined { + const range = benchmarkWorkloadDefinition(workload).controls.amount; + return range === undefined ? undefined : `${range.label} · ${amount}%`; } -function liveWorkloadSceneDescription( - workload: string, - showcaseFrame: AdvancedShapingFrame, - technique: RasterTechnique, -): string { - switch (workload) { - case 'advanced-shaping': - return `Tests whether ${showcaseFrame.caseDefinition.label.toLowerCase()} stay correct while the paragraph types and wraps.`; - case 'text-ladder': - return 'Tests one sentence at every size from 8 through 1024 pixels.'; - case 'zoom-text': - return 'Tests retained center scaling from 8 pt to viewport fit while authenticated Inter translations of “Shape” cycle by language.'; - case 'icon-grid': - return 'Tests a virtualized grid spanning all 1,402 named Font Awesome Solid icons with fixed font-rendered labels.'; - case 'off-axis-3d': - return 'Tests readability and frame cost as a paragraph leans deep into the scene.'; - case 'dynamic-layout': - return 'Tests whether three animated paragraphs reflow without stretching their glyphs.'; - case 'paragraph-stress': - return 'Tests glyph, line, draw, memory, CPU, and GPU cost under paragraph pressure.'; - case 'paint-effects': - return technique === 'slug' - ? 'Tests the live cost and quality of Slug V0 animated fill color and opacity.' - : 'Tests the live cost and quality of animated color, opacity, stroke, and shadow.'; - default: - return 'Tests paragraph rendering cost while the viewport reflows the text.'; +function liveWorkloadSceneDescription(workload: BenchmarkWorkloadId, showcaseFrame: AdvancedShapingFrame): string { + return workload === 'advanced-shaping' + ? `Tests whether ${showcaseFrame.caseDefinition.label.toLowerCase()} stay correct while the paragraph types and wraps.` + : benchmarkWorkloadDefinition(workload).description; +} + +function presentationFontOptions(definition: BenchmarkWorkloadDefinition) { + const policy = definition.fontPolicy; + if (policy.kind === 'advanced-case') { + return ADVANCED_FONT_FIXTURES.map((fixture) => ({ label: fixture.label, value: fixture.id })); } + if (policy.kind === 'icon-grid') { + return [{ label: BENCHMARK_FONT_LABELS[policy.iconFixture], value: policy.iconFixture }]; + } + if (policy.kind === 'fixed') { + return [{ label: BENCHMARK_FONT_LABELS[policy.defaultFixture], value: policy.defaultFixture }]; + } + return SELECTABLE_FONT_FIXTURES.map((fixture) => ({ label: fixture.label, value: fixture.id })); +} + +function presentationFontValue( + definition: BenchmarkWorkloadDefinition, + activeFontFixture: BenchmarkFontFixture, +): BenchmarkFontFixture { + const policy = definition.fontPolicy; + if (policy.kind === 'icon-grid') return policy.iconFixture; + if (policy.kind === 'fixed') return policy.defaultFixture; + return activeFontFixture; } function formatMs(value: number | undefined): string { @@ -1100,14 +1098,8 @@ function HarnessLayout({ const actionReady = actionEligible && (location.mode === 'conformance' || liveStats !== undefined); const presentationMode = location.layout === 'presentation' && location.mode === 'benchmark'; if (presentationMode) { - const presentationFontOptions = - location.workload === 'icon-grid' - ? [{ label: BENCHMARK_FONT_LABELS[ICON_GRID_FONT_FIXTURE], value: ICON_GRID_FONT_FIXTURE }] - : location.workload === 'zoom-text' - ? [{ label: BENCHMARK_FONT_LABELS.inter, value: 'inter' }] - : location.workload === 'advanced-shaping' - ? ADVANCED_FONT_FIXTURES.map((fixture) => ({ label: fixture.label, value: fixture.id })) - : SELECTABLE_FONT_FIXTURES.map((fixture) => ({ label: fixture.label, value: fixture.id })); + const presentationWorkload = isBenchmarkWorkloadId(location.workload) ? location.workload : 'benchmark-ipsum'; + const presentationDefinition = benchmarkWorkloadDefinition(presentationWorkload); const presentationPayload = createPayloadSummary({ delivery: location.delivery, fixtureManifests: { bitmap: bitmapFixtures, mtsdf: mtsdfFixtures, slug: slugFixtures }, @@ -1121,14 +1113,8 @@ function HarnessLayout({ <> } playing={presentationPlaying} scene={scene} @@ -1146,11 +1132,12 @@ function HarnessLayout({ label: option.label, value: option.id, }))} - workloadValue={isBenchmarkWorkloadId(location.workload) ? location.workload : 'benchmark-ipsum'} + workloadValue={presentationWorkload} onExit={() => onLocation({ layout: 'main' })} onFont={(value) => { - if (location.workload === 'icon-grid' || location.workload === 'zoom-text') return; - if (location.workload === 'advanced-shaping') { + const policy = presentationDefinition.fontPolicy; + if (policy.kind === 'fixed' || policy.kind === 'icon-grid') return; + if (policy.kind === 'advanced-case') { onAdvancedFontFixture(value as BenchmarkFontFixture); return; } @@ -1713,9 +1700,7 @@ function BenchmarkSurface({ <>

Realtime scene

-

- {liveWorkloadSceneDescription(workload, showcaseFrame, technique)} -

+

{liveWorkloadSceneDescription(workload, showcaseFrame)}

LIVE @@ -2866,6 +2851,7 @@ function ComparisonWorkloadViewport({ const configurePersistentSurface = useEffectEvent(configureSurface); const containerRef = useRef(null); const previewRef = useRef(undefined); + const workloadDefinition = benchmarkWorkloadDefinition(workload); const workloadFonts = liveWorkloadFontFixtures(workload, fontFixture); const [error, setError] = useState(); const { @@ -2915,6 +2901,7 @@ function ComparisonWorkloadViewport({ const { createComparisonWorkloadPersistentScene } = await preloadComparisonWorkload(); if (cancelled) return; const configuration = currentConfiguration(); + const interaction = benchmarkWorkloadDefinition(configuration.workload).interaction; const created = createComparisonWorkloadPersistentScene({ ...configuration, backend, @@ -2931,9 +2918,9 @@ function ComparisonWorkloadViewport({ anchor: surfaceAnchor, controller: previewRef, label: `Live ${techniqueLabel(technique)} benchmark using ${backend}`, - pan: configuration.workload !== 'zoom-text', + pan: interaction.pan, scene: created, - zoom: configuration.workload === 'off-axis-3d', + zoom: interaction.zoom, }, controller.signal, ); @@ -2960,11 +2947,12 @@ function ComparisonWorkloadViewport({ }, [backend, delivery, publishBakeProgress, surfaceAnchorRef, technique]); useEffect(() => { + const interaction = benchmarkWorkloadDefinition(workload).interaction; configurePersistentSurface({ controller: previewRef, label: `Live ${techniqueLabel(technique)} benchmark using ${backend}`, - pan: workload !== 'zoom-text', - zoom: workload === 'off-axis-3d', + pan: interaction.pan, + zoom: interaction.zoom, }); }, [backend, technique, workload]); @@ -3028,8 +3016,12 @@ function ComparisonWorkloadViewport({ className="pointer-events-none absolute bottom-0 left-0 bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-6 font-mono text-[9px] text-muted" data-testid="canvas-navigation-status" > - {workload === 'off-axis-3d' ? 'PAN · PINCH/WHEEL ZOOM' : workload === 'zoom-text' ? 'AUTO FIT' : 'PAN'} · {dpr}× - DPR + {workloadDefinition.interaction.zoom + ? 'PAN · PINCH/WHEEL ZOOM' + : workloadDefinition.interaction.pan + ? 'PAN' + : 'AUTO FIT'}{' '} + · {dpr}× DPR {!suppressLoading && (stats === undefined || bakeProgressActive) && error === undefined && ( ; } -const ICON_GRID_LABEL_SIZE = 11; -const ICON_GRID_LABEL_WIDTH = 112; -const ICON_GRID_INSET = 24; -const ICON_GRID_GAP = 18; -const ICON_GRID_MIN_CELL_WIDTH = 112; -const ICON_GRID_ICON_PADDING = 16; -const ICON_GRID_LABEL_GAP = 8; -const ICON_GRID_OVERSCAN_ROWS = 3; -const ICON_GRID_OVERSCAN_COLUMNS = 3; const ICON_GRID_AUTO_PAN_PX_PER_SECOND = 160; -const ICON_GRID_FRAME_DELTA_RESPONSE = 0.2; -const ICON_GRID_MAX_FRAME_DELTA_MULTIPLIER = 2; -// Authenticated fa-solid-900.ttf metrics: 512 units/em and a 640-unit maximum advance. -const ICON_GRID_FONT_UNITS_PER_EM = 512; -const ICON_GRID_MAX_ADVANCE = 640; -const ICON_GRID_MAX_ADVANCE_EM = ICON_GRID_MAX_ADVANCE / ICON_GRID_FONT_UNITS_PER_EM; interface ComparisonWorkloadRuntime extends ComparisonWorkloadPreview { persistentFrame(context: PersistentRenderFrameContext): void; @@ -834,7 +831,11 @@ async function createComparisonWorkloadRuntime( configuration = next; committedContentWidth = undefined; revision += 1; - layoutZoomTextEntries(entries, width, height); + comparisonWorkloadDefinition('zoom-text').layout(entries, { + configuration, + viewportHeight: height, + viewportWidth: width, + }); applyRetainedConfiguration(entries, technique, configuration); return; } @@ -1381,133 +1382,41 @@ function createEntries( iconScrollX = 0, iconScrollY = 0, ): readonly WorkloadEntry[] { - switch (configuration.workload) { - case 'text-ladder': - return createTextLadderEntries({ - dpr, - font, - raster, - ...(textLadderSpecimen === undefined ? {} : { specimen: textLadderSpecimen }), - viewportHeight, - }); - case 'zoom-text': - return createZoomTextEntries({ dpr, font, raster }); - case 'icon-grid': { - if (iconFont === undefined) throw new Error('icon grid requires its icon font fixture'); - const window = iconGridVirtualWindow( - ICON_GRID_ITEMS.length, - configuration.fontSize, - viewportWidth, - viewportHeight, - iconScrollX, - iconScrollY, - ); - return createIconGridEntries({ - count: window.poolCapacity, - dpr, - iconFont, - iconSize: configuration.fontSize, - indices: window.indices, - labelFont: font, - labelRaster: raster, - }); - } - case 'paint-effects': - return createPaintEffectsEntries({ ...configuration, dpr, font, raster, technique, viewportWidth }); - case 'dynamic-layout': - return createDynamicLayoutEntries({ ...configuration, animationElapsedMs, dpr, font, raster, viewportWidth }); - case 'off-axis-3d': - return createOffAxis3dEntries({ ...configuration, dpr, font, raster, viewportWidth }); - case 'paragraph-stress': - return createParagraphStressEntries({ ...configuration, dpr, font, raster, viewportWidth }); - } + return comparisonWorkloadDefinition(configuration.workload).create({ + animationElapsedMs, + configuration, + dpr, + font, + ...(iconFont === undefined ? {} : { iconFont }), + iconScrollX, + iconScrollY, + raster, + technique, + ...(textLadderSpecimen === undefined ? {} : { textLadderSpecimen }), + viewportHeight, + viewportWidth, + }); } function entryReadyPromises(entry: WorkloadEntry): readonly Promise[] { return entry.labelText === undefined ? [entry.text.ready] : [entry.text.ready, entry.labelText.ready]; } -function resizeIconGridEntries(entries: readonly WorkloadEntry[], iconSize: number, layout: IconGridLayout): void { - for (const entry of entries) entry.text.setProperties({ fontSize: iconSize }); - publishEntryUpdates(entries); - for (const entry of entries) { - if (entry.virtualIconIndex === undefined) continue; - const column = entry.virtualIconIndex % layout.columns; - const row = Math.floor(entry.virtualIconIndex / layout.columns); - positionIconGridEntry(entry, layout, column, row, iconSize); - } -} - function publishEntryUpdates(entries: readonly WorkloadEntry[]): void { for (const { node } of entries) node.updateMatrixWorld(true); } -function positionIconGridEntry( - entry: WorkloadEntry, - layout: IconGridLayout, - column: number, - row: number, - iconSize: number, -): void { - const iconLayout = committedTextLayout(entry.text); - entry.node.position.set( - layout.inset + column * (layout.cellWidth + layout.gap), - -(layout.inset + row * (layout.cellHeight + layout.gap)), - 0, - ); - entry.text.position.set((layout.cellWidth - iconLayout.width) / 2, 0, 0); - entry.labelText?.position.set( - (layout.cellWidth - ICON_GRID_LABEL_WIDTH) / 2, - -(iconSize * LIVE_TEXT_LINE_HEIGHT + ICON_GRID_LABEL_GAP), - 0, - ); - freezeLocalMatrices(entry.node); -} - -function freezeLocalMatrices(root: THREE.Object3D): void { - root.traverse((object) => { - object.updateMatrix(); - object.matrixAutoUpdate = false; - }); -} - function layoutEntries( entries: readonly WorkloadEntry[], configuration: ComparisonWorkloadConfiguration, width: number, height: number, ): void { - if (configuration.workload === 'text-ladder') { - layoutTextLadderEntries(entries, width); - return; - } - if (configuration.workload === 'zoom-text') { - layoutZoomTextEntries(entries, width, height); - return; - } - if (configuration.workload === 'icon-grid') { - const grid = iconGridLayout(ICON_GRID_ITEMS.length, configuration.fontSize, width); - for (const entry of entries) { - if (entry.virtualIconIndex === undefined) continue; - const column = entry.virtualIconIndex % grid.columns; - const row = Math.floor(entry.virtualIconIndex / grid.columns); - positionIconGridEntry(entry, grid, column, row, configuration.fontSize); - } - return; - } - if (configuration.workload === 'paint-effects') { - layoutPaintEffectsEntries(entries, width, height); - return; - } - if (configuration.workload === 'dynamic-layout') { - layoutDynamicLayoutEntries(entries, width, height); - return; - } - if (configuration.workload === 'off-axis-3d') { - layoutOffAxis3dEntries(entries, width, height); - return; - } - layoutParagraphStressEntries(entries, width, height); + comparisonWorkloadDefinition(configuration.workload).layout(entries, { + configuration, + viewportHeight: height, + viewportWidth: width, + }); } function animateEntries( @@ -1649,329 +1558,6 @@ function animationRate(configuration: Pick, -): 'rebuild' | 'retained' { - if (!Number.isSafeInteger(currentPoolCapacity) || currentPoolCapacity < 0) { - throw new RangeError('icon grid pool capacity must be a non-negative safe integer'); - } - return currentPoolCapacity === nextWindow.poolCapacity ? 'retained' : 'rebuild'; -} - -export function iconGridAssignmentSignature( - entries: readonly { - readonly sourceText: string; - readonly virtualIconIndex?: number; - }[], -): string { - return JSON.stringify(iconGridAssignments(entries)); -} - -function iconGridAssignments( - entries: readonly { - readonly sourceText: string; - readonly virtualIconIndex?: number; - }[], -): readonly IconGridAssignment[] { - const assignments = entries - .filter( - (entry): entry is typeof entry & { readonly virtualIconIndex: number } => entry.virtualIconIndex !== undefined, - ) - .map(({ sourceText, virtualIconIndex }) => ({ index: virtualIconIndex, content: sourceText })) - .sort((left, right) => left.index - right.index); - for (let index = 1; index < assignments.length; index += 1) { - if (assignments[index - 1]!.index === assignments[index]!.index) { - throw new Error(`icon grid assigned catalog index ${String(assignments[index]!.index)} twice`); - } - } - return assignments; -} - -export function iconGridLayout(itemCount: number, iconSize: number, viewportWidth: number): IconGridLayout { - if (!Number.isSafeInteger(itemCount) || itemCount <= 0) { - throw new RangeError('icon grid item count must be a positive safe integer'); - } - positive(iconSize, 'icon grid icon size'); - positive(viewportWidth, 'icon grid viewport width'); - const cellWidth = Math.max( - ICON_GRID_MIN_CELL_WIDTH, - iconSize * ICON_GRID_MAX_ADVANCE_EM + ICON_GRID_ICON_PADDING * 2, - ); - const cellHeight = (iconSize + ICON_GRID_LABEL_SIZE) * LIVE_TEXT_LINE_HEIGHT + ICON_GRID_LABEL_GAP; - const columns = Math.ceil(Math.sqrt(itemCount)); - const rows = Math.ceil(itemCount / columns); - return { - columns, - rows, - cellWidth, - cellHeight, - gap: ICON_GRID_GAP, - inset: ICON_GRID_INSET, - width: ICON_GRID_INSET * 2 + columns * cellWidth + Math.max(0, columns - 1) * ICON_GRID_GAP, - height: ICON_GRID_INSET * 2 + rows * cellHeight + Math.max(0, rows - 1) * ICON_GRID_GAP, - }; -} - -export function iconGridVirtualWindow( - itemCount: number, - iconSize: number, - viewportWidth: number, - viewportHeight: number, - requestedScrollX: number, - requestedScrollY: number, -): IconGridVirtualWindow { - positive(viewportHeight, 'icon grid viewport height'); - if (!Number.isFinite(requestedScrollX) || !Number.isFinite(requestedScrollY)) { - throw new TypeError('icon grid scroll positions must be finite'); - } - const layout = iconGridLayout(itemCount, iconSize, viewportWidth); - const maximumScrollX = Math.max(0, layout.width - viewportWidth); - const maximumScrollY = Math.max(0, layout.height - viewportHeight); - const scrollX = Math.min(maximumScrollX, Math.max(0, requestedScrollX)); - const scrollY = Math.min(maximumScrollY, Math.max(0, requestedScrollY)); - const pitchX = layout.cellWidth + layout.gap; - const pitchY = layout.cellHeight + layout.gap; - const [firstVisibleColumn, lastVisibleColumn] = intersectingGridRange( - scrollX, - scrollX + viewportWidth, - layout.inset, - layout.cellWidth, - pitchX, - layout.columns, - ); - const [firstVisibleRow, lastVisibleRow] = intersectingGridRange( - scrollY, - scrollY + viewportHeight, - layout.inset, - layout.cellHeight, - pitchY, - layout.rows, - ); - const visibleColumnCapacity = Math.ceil(viewportWidth / pitchX) + 1; - const poolColumns = Math.min(layout.columns, visibleColumnCapacity + ICON_GRID_OVERSCAN_COLUMNS * 2); - const poolStartColumn = Math.min( - Math.max(0, layout.columns - poolColumns), - Math.max(0, firstVisibleColumn - ICON_GRID_OVERSCAN_COLUMNS), - ); - const visibleRowCapacity = Math.ceil(viewportHeight / pitchY) + 1; - const poolRows = Math.min(layout.rows, visibleRowCapacity + ICON_GRID_OVERSCAN_ROWS * 2); - const poolStartRow = Math.min( - Math.max(0, layout.rows - poolRows), - Math.max(0, firstVisibleRow - ICON_GRID_OVERSCAN_ROWS), - ); - const poolEndRow = poolStartRow + poolRows; - const indices: number[] = []; - for (let row = poolStartRow; row < poolEndRow; row += 1) { - for (let column = poolStartColumn; column < poolStartColumn + poolColumns; column += 1) { - const index = row * layout.columns + column; - if (index < itemCount) indices.push(index); - } - } - const visibleIndices: number[] = []; - for (let row = firstVisibleRow; row <= lastVisibleRow; row += 1) { - for (let column = firstVisibleColumn; column <= lastVisibleColumn; column += 1) { - const index = row * layout.columns + column; - if (index < itemCount) visibleIndices.push(index); - } - } - return { - layout, - indices, - visibleIndices, - poolCapacity: poolRows * poolColumns, - firstVisibleIndex: visibleIndices.at(0) ?? -1, - lastVisibleIndex: visibleIndices.at(-1) ?? -1, - scrollX, - scrollY, - maximumScrollX, - maximumScrollY, - }; -} - -function intersectingGridRange( - minimum: number, - maximum: number, - origin: number, - cellSize: number, - pitch: number, - count: number, -): readonly [number, number] { - const first = Math.floor((minimum - origin - cellSize) / pitch) + 1; - const last = Math.ceil((maximum - origin) / pitch) - 1; - return [Math.min(count - 1, Math.max(0, first)), Math.min(count - 1, Math.max(0, last))]; -} - function updateIconGridEntryVisibility( entries: readonly WorkloadEntry[], layout: IconGridLayout, diff --git a/apps/benchmarks/src/workloads/contracts.ts b/apps/benchmarks/src/workloads/contracts.ts index 72ed0577..c9be010e 100644 --- a/apps/benchmarks/src/workloads/contracts.ts +++ b/apps/benchmarks/src/workloads/contracts.ts @@ -1,4 +1,8 @@ -import type { BenchmarkFontFixture } from '../benchmark/font-fixtures'; +import type { AnyRasterInput, RegisteredFont } from '@pmndrs/text'; + +import type { RasterConformanceSpecimen, BenchmarkFontFixture } from '../benchmark/font-fixtures'; +import type { RasterTechnique } from '../benchmark/url-state'; +import type { ComparisonWorkloadEntry } from './factory-contracts'; /** The comparison workloads that share the retained benchmark render host. */ export type ComparisonWorkloadId = @@ -32,8 +36,28 @@ export interface ComparisonWorkloadConfiguration { export type ComparisonWorkloadUpdateKind = 'rebuild' | 'retained'; export type WorkloadCameraKind = 'orthographic' | 'perspective'; +/** App-private inputs made available to a workload's layout hook. */ +export interface ComparisonWorkloadLayoutContext { + readonly configuration: ComparisonWorkloadConfiguration; + readonly viewportHeight: number; + readonly viewportWidth: number; +} + +/** App-private inputs made available to a workload's scene factory. */ +export interface ComparisonWorkloadCreateContext extends ComparisonWorkloadLayoutContext { + readonly animationElapsedMs: number; + readonly dpr: number; + readonly font: RegisteredFont; + readonly iconFont?: { readonly font: RegisteredFont; readonly raster: AnyRasterInput }; + readonly iconScrollX: number; + readonly iconScrollY: number; + readonly raster: AnyRasterInput; + readonly technique: RasterTechnique; + readonly textLadderSpecimen?: RasterConformanceSpecimen; +} + /** - * Per-workload policy only. The retained host remains responsible for renderer, + * App-private workload policy and scene hooks. The retained host remains responsible for renderer, * scene activation, cancellation, telemetry, and transactional Text publication. */ export interface ComparisonWorkloadDefinition { @@ -41,6 +65,8 @@ export interface ComparisonWorkloadDefinition { readonly contentWidth: 'none' | { readonly maximumWidth?: number; readonly multiplier?: number }; readonly id: ComparisonWorkloadId; readonly suspendsIconWindow: boolean; + create(context: ComparisonWorkloadCreateContext): readonly ComparisonWorkloadEntry[]; + layout(entries: readonly ComparisonWorkloadEntry[], context: ComparisonWorkloadLayoutContext): void; updateKind( previous: ComparisonWorkloadConfiguration, next: ComparisonWorkloadConfiguration, diff --git a/apps/benchmarks/src/workloads/dynamic-layout.ts b/apps/benchmarks/src/workloads/dynamic-layout.ts index 91b58bc6..53785a23 100644 --- a/apps/benchmarks/src/workloads/dynamic-layout.ts +++ b/apps/benchmarks/src/workloads/dynamic-layout.ts @@ -19,7 +19,20 @@ export const DYNAMIC_LAYOUT_TEXT = [ export const dynamicLayoutWorkload = { cameraKind: 'orthographic', contentWidth: { maximumWidth: 1_000 }, + create(context) { + return createDynamicLayoutEntries({ + ...context.configuration, + animationElapsedMs: context.animationElapsedMs, + dpr: context.dpr, + font: context.font, + raster: context.raster, + viewportWidth: context.viewportWidth, + }); + }, id: 'dynamic-layout', + layout(entries, context) { + layoutDynamicLayoutEntries(entries, context.viewportWidth, context.viewportHeight); + }, suspendsIconWindow: false, updateKind: () => 'retained', } satisfies ComparisonWorkloadDefinition; diff --git a/apps/benchmarks/src/workloads/icon-grid.ts b/apps/benchmarks/src/workloads/icon-grid.ts index fc0296fe..59fc3a22 100644 --- a/apps/benchmarks/src/workloads/icon-grid.ts +++ b/apps/benchmarks/src/workloads/icon-grid.ts @@ -4,10 +4,23 @@ import * as THREE from 'three/webgpu'; import fontAwesomeIcons from '../../fixtures/fonts/font-awesome-free-6.7.2/icons.json'; import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from './shared/text-style'; import type { ComparisonWorkloadDefinition } from './contracts'; -import type { ComparisonWorkloadEntry } from './factory-contracts'; +import { committedTextLayout, type ComparisonWorkloadEntry } from './factory-contracts'; -const ICON_GRID_LABEL_SIZE = 11; +export const ICON_GRID_LABEL_SIZE = 11; const ICON_GRID_LABEL_WIDTH = 112; +const ICON_GRID_INSET = 24; +const ICON_GRID_GAP = 18; +const ICON_GRID_MIN_CELL_WIDTH = 112; +const ICON_GRID_ICON_PADDING = 16; +const ICON_GRID_LABEL_GAP = 8; +export const ICON_GRID_OVERSCAN_ROWS = 3; +export const ICON_GRID_OVERSCAN_COLUMNS = 3; +const ICON_GRID_FRAME_DELTA_RESPONSE = 0.2; +const ICON_GRID_MAX_FRAME_DELTA_MULTIPLIER = 2; +// Authenticated fa-solid-900.ttf metrics: 512 units/em and a 640-unit maximum advance. +const ICON_GRID_FONT_UNITS_PER_EM = 512; +const ICON_GRID_MAX_ADVANCE = 640; +const ICON_GRID_MAX_ADVANCE_EM = ICON_GRID_MAX_ADVANCE / ICON_GRID_FONT_UNITS_PER_EM; export const ICON_GRID_ITEMS = fontAwesomeIcons.icons; const ICON_GRID_CONTENT = ICON_GRID_ITEMS.map((icon) => { const glyph = String.fromCodePoint(icon.codePoint); @@ -18,7 +31,36 @@ const ICON_GRID_CONTENT = ICON_GRID_ITEMS.map((icon) => { export const iconGridWorkload = { cameraKind: 'orthographic', contentWidth: 'none', + create(context) { + if (context.iconFont === undefined) throw new Error('icon grid requires its icon font fixture'); + const window = iconGridVirtualWindow( + ICON_GRID_ITEMS.length, + context.configuration.fontSize, + context.viewportWidth, + context.viewportHeight, + context.iconScrollX, + context.iconScrollY, + ); + return createIconGridEntries({ + count: window.poolCapacity, + dpr: context.dpr, + iconFont: context.iconFont, + iconSize: context.configuration.fontSize, + indices: window.indices, + labelFont: context.font, + labelRaster: context.raster, + }); + }, id: 'icon-grid', + layout(entries, context) { + const layout = iconGridLayout(ICON_GRID_ITEMS.length, context.configuration.fontSize, context.viewportWidth); + for (const entry of entries) { + if (entry.virtualIconIndex === undefined) continue; + const column = entry.virtualIconIndex % layout.columns; + const row = Math.floor(entry.virtualIconIndex / layout.columns); + positionIconGridEntry(entry, layout, column, row, context.configuration.fontSize); + } + }, suspendsIconWindow: true, updateKind: () => 'retained', } satisfies ComparisonWorkloadDefinition; @@ -108,3 +150,369 @@ export function iconGridLabel(iconIndex: number): string { if (content === undefined) throw new RangeError(`Unknown Font Awesome icon index: ${String(iconIndex)}`); return content.label; } + +export function positionIconGridEntry( + entry: ComparisonWorkloadEntry, + layout: IconGridLayout, + column: number, + row: number, + iconSize: number, +): void { + const iconLayout = committedTextLayout(entry.text); + entry.node.position.set( + layout.inset + column * (layout.cellWidth + layout.gap), + -(layout.inset + row * (layout.cellHeight + layout.gap)), + 0, + ); + entry.text.position.set((layout.cellWidth - iconLayout.width) / 2, 0, 0); + entry.labelText?.position.set( + (layout.cellWidth - ICON_GRID_LABEL_WIDTH) / 2, + -(iconSize * LIVE_TEXT_LINE_HEIGHT + ICON_GRID_LABEL_GAP), + 0, + ); + freezeLocalMatrices(entry.node); +} + +export function resizeIconGridEntries( + entries: readonly ComparisonWorkloadEntry[], + iconSize: number, + layout: IconGridLayout, +): void { + for (const entry of entries) entry.text.setProperties({ fontSize: iconSize }); + for (const { node } of entries) node.updateMatrixWorld(true); + for (const entry of entries) { + if (entry.virtualIconIndex === undefined) continue; + const column = entry.virtualIconIndex % layout.columns; + const row = Math.floor(entry.virtualIconIndex / layout.columns); + positionIconGridEntry(entry, layout, column, row, iconSize); + } +} + +function freezeLocalMatrices(root: THREE.Object3D): void { + root.traverse((object) => { + object.updateMatrix(); + object.matrixAutoUpdate = false; + }); +} + +export interface IconGridLayout { + readonly columns: number; + readonly rows: number; + readonly cellWidth: number; + readonly cellHeight: number; + readonly gap: number; + readonly inset: number; + readonly width: number; + readonly height: number; +} + +export interface IconGridAutoPanState { + directionX: -1 | 1; + directionY: -1 | 1; + scrollX: number; + scrollY: number; +} + +export interface IconGridFrameDeltaState { + smoothedElapsedMs: number | undefined; +} + +export function iconGridAutoPanStart( + view: 'origin' | 'alternate', + maximumScrollX: number, + maximumScrollY: number, +): IconGridAutoPanState { + if (!Number.isFinite(maximumScrollX) || !Number.isFinite(maximumScrollY)) { + throw new TypeError('icon grid auto-pan bounds must be finite'); + } + if (maximumScrollX < 0 || maximumScrollY < 0) { + throw new RangeError('icon grid auto-pan bounds must be non-negative'); + } + if (view === 'origin') return { directionX: 1, directionY: 1, scrollX: 0, scrollY: 0 }; + if (view === 'alternate') { + return { + directionX: -1, + directionY: -1, + scrollX: maximumScrollX * 0.72, + scrollY: maximumScrollY * 0.58, + }; + } + throw new RangeError(`unknown icon grid view: ${String(view)}`); +} + +export function smoothIconGridFrameDelta(state: IconGridFrameDeltaState, elapsedMs: number): number { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + throw new RangeError('icon grid frame delta must be finite and nonnegative'); + } + if (elapsedMs === 0) return 0; + const previous = state.smoothedElapsedMs; + if (previous === undefined) { + state.smoothedElapsedMs = elapsedMs; + return elapsedMs; + } + const boundedElapsedMs = Math.min(elapsedMs, previous * ICON_GRID_MAX_FRAME_DELTA_MULTIPLIER); + const smoothedElapsedMs = previous + (boundedElapsedMs - previous) * ICON_GRID_FRAME_DELTA_RESPONSE; + state.smoothedElapsedMs = smoothedElapsedMs; + return smoothedElapsedMs; +} + +export function advanceIconGridAutoPan( + state: IconGridAutoPanState, + scrollX: number, + scrollY: number, + maximumScrollX: number, + maximumScrollY: number, + elapsedMs: number, + speedPxPerSecond: number, +): void { + if (![scrollX, scrollY, maximumScrollX, maximumScrollY, elapsedMs, speedPxPerSecond].every(Number.isFinite)) { + throw new TypeError('icon grid auto-pan inputs must be finite'); + } + if (maximumScrollX < 0 || maximumScrollY < 0) { + throw new RangeError('icon grid auto-pan bounds must be non-negative'); + } + if (elapsedMs < 0 || speedPxPerSecond < 0) { + throw new RangeError('icon grid auto-pan elapsed time and speed must be non-negative'); + } + if ((state.directionX !== -1 && state.directionX !== 1) || (state.directionY !== -1 && state.directionY !== 1)) { + throw new RangeError('icon grid auto-pan directions must be -1 or 1'); + } + state.scrollX = Math.min(maximumScrollX, Math.max(0, scrollX)); + state.scrollY = Math.min(maximumScrollY, Math.max(0, scrollY)); + const distance = (elapsedMs / 1_000) * speedPxPerSecond; + advanceIconGridAutoPanAxis(state, 'x', distance, maximumScrollX); + advanceIconGridAutoPanAxis(state, 'y', distance, maximumScrollY); +} + +export interface IconGridVirtualWindow { + readonly layout: IconGridLayout; + readonly indices: readonly number[]; + readonly visibleIndices: readonly number[]; + readonly poolCapacity: number; + readonly firstVisibleIndex: number; + readonly lastVisibleIndex: number; + readonly scrollX: number; + readonly scrollY: number; + readonly maximumScrollX: number; + readonly maximumScrollY: number; +} + +export interface IconGridAssignment { + readonly index: number; + readonly content: string; +} + +export function iconGridCenteredScroll( + itemCount: number, + previousIconSize: number, + nextIconSize: number, + viewportWidth: number, + viewportHeight: number, + scrollX: number, + scrollY: number, +): readonly [number, number] { + positive(viewportHeight, 'icon grid viewport height'); + if (!Number.isFinite(scrollX) || !Number.isFinite(scrollY)) { + throw new TypeError('icon grid scroll positions must be finite'); + } + const previous = iconGridLayout(itemCount, previousIconSize, viewportWidth); + const next = iconGridLayout(itemCount, nextIconSize, viewportWidth); + const previousPitchX = previous.cellWidth + previous.gap; + const previousPitchY = previous.cellHeight + previous.gap; + const nextPitchX = next.cellWidth + next.gap; + const nextPitchY = next.cellHeight + next.gap; + const anchorColumn = (scrollX + viewportWidth / 2 - previous.inset) / previousPitchX; + const anchorRow = (scrollY + viewportHeight / 2 - previous.inset) / previousPitchY; + const requestedScrollX = next.inset + anchorColumn * nextPitchX - viewportWidth / 2; + const requestedScrollY = next.inset + anchorRow * nextPitchY - viewportHeight / 2; + const maximumScrollX = Math.max(0, next.width - viewportWidth); + const maximumScrollY = Math.max(0, next.height - viewportHeight); + return [ + Math.min(maximumScrollX, Math.max(0, requestedScrollX)), + Math.min(maximumScrollY, Math.max(0, requestedScrollY)), + ]; +} + +export function iconGridViewportUpdateKind( + currentPoolCapacity: number, + nextWindow: Pick, +): 'rebuild' | 'retained' { + if (!Number.isSafeInteger(currentPoolCapacity) || currentPoolCapacity < 0) { + throw new RangeError('icon grid pool capacity must be a non-negative safe integer'); + } + return currentPoolCapacity === nextWindow.poolCapacity ? 'retained' : 'rebuild'; +} + +export function iconGridAssignmentSignature( + entries: readonly { readonly sourceText: string; readonly virtualIconIndex?: number }[], +): string { + return JSON.stringify(iconGridAssignments(entries)); +} + +export function iconGridLayout(itemCount: number, iconSize: number, viewportWidth: number): IconGridLayout { + if (!Number.isSafeInteger(itemCount) || itemCount <= 0) { + throw new RangeError('icon grid item count must be a positive safe integer'); + } + positive(iconSize, 'icon grid icon size'); + positive(viewportWidth, 'icon grid viewport width'); + const cellWidth = Math.max( + ICON_GRID_MIN_CELL_WIDTH, + iconSize * ICON_GRID_MAX_ADVANCE_EM + ICON_GRID_ICON_PADDING * 2, + ); + const cellHeight = (iconSize + ICON_GRID_LABEL_SIZE) * LIVE_TEXT_LINE_HEIGHT + ICON_GRID_LABEL_GAP; + const columns = Math.ceil(Math.sqrt(itemCount)); + const rows = Math.ceil(itemCount / columns); + return { + columns, + rows, + cellWidth, + cellHeight, + gap: ICON_GRID_GAP, + inset: ICON_GRID_INSET, + width: ICON_GRID_INSET * 2 + columns * cellWidth + Math.max(0, columns - 1) * ICON_GRID_GAP, + height: ICON_GRID_INSET * 2 + rows * cellHeight + Math.max(0, rows - 1) * ICON_GRID_GAP, + }; +} + +export function iconGridVirtualWindow( + itemCount: number, + iconSize: number, + viewportWidth: number, + viewportHeight: number, + requestedScrollX: number, + requestedScrollY: number, +): IconGridVirtualWindow { + positive(viewportHeight, 'icon grid viewport height'); + if (!Number.isFinite(requestedScrollX) || !Number.isFinite(requestedScrollY)) { + throw new TypeError('icon grid scroll positions must be finite'); + } + const layout = iconGridLayout(itemCount, iconSize, viewportWidth); + const maximumScrollX = Math.max(0, layout.width - viewportWidth); + const maximumScrollY = Math.max(0, layout.height - viewportHeight); + const scrollX = Math.min(maximumScrollX, Math.max(0, requestedScrollX)); + const scrollY = Math.min(maximumScrollY, Math.max(0, requestedScrollY)); + const pitchX = layout.cellWidth + layout.gap; + const pitchY = layout.cellHeight + layout.gap; + const [firstVisibleColumn, lastVisibleColumn] = intersectingGridRange( + scrollX, + scrollX + viewportWidth, + layout.inset, + layout.cellWidth, + pitchX, + layout.columns, + ); + const [firstVisibleRow, lastVisibleRow] = intersectingGridRange( + scrollY, + scrollY + viewportHeight, + layout.inset, + layout.cellHeight, + pitchY, + layout.rows, + ); + const visibleColumnCapacity = Math.ceil(viewportWidth / pitchX) + 1; + const poolColumns = Math.min(layout.columns, visibleColumnCapacity + ICON_GRID_OVERSCAN_COLUMNS * 2); + const poolStartColumn = Math.min( + Math.max(0, layout.columns - poolColumns), + Math.max(0, firstVisibleColumn - ICON_GRID_OVERSCAN_COLUMNS), + ); + const visibleRowCapacity = Math.ceil(viewportHeight / pitchY) + 1; + const poolRows = Math.min(layout.rows, visibleRowCapacity + ICON_GRID_OVERSCAN_ROWS * 2); + const poolStartRow = Math.min( + Math.max(0, layout.rows - poolRows), + Math.max(0, firstVisibleRow - ICON_GRID_OVERSCAN_ROWS), + ); + const poolEndRow = poolStartRow + poolRows; + const indices: number[] = []; + for (let row = poolStartRow; row < poolEndRow; row += 1) { + for (let column = poolStartColumn; column < poolStartColumn + poolColumns; column += 1) { + const index = row * layout.columns + column; + if (index < itemCount) indices.push(index); + } + } + const visibleIndices: number[] = []; + for (let row = firstVisibleRow; row <= lastVisibleRow; row += 1) { + for (let column = firstVisibleColumn; column <= lastVisibleColumn; column += 1) { + const index = row * layout.columns + column; + if (index < itemCount) visibleIndices.push(index); + } + } + return { + layout, + indices, + visibleIndices, + poolCapacity: poolRows * poolColumns, + firstVisibleIndex: visibleIndices.at(0) ?? -1, + lastVisibleIndex: visibleIndices.at(-1) ?? -1, + scrollX, + scrollY, + maximumScrollX, + maximumScrollY, + }; +} + +function advanceIconGridAutoPanAxis( + state: IconGridAutoPanState, + axis: 'x' | 'y', + distance: number, + maximum: number, +): void { + if (maximum === 0) { + if (axis === 'x') { + state.scrollX = 0; + state.directionX = 1; + } else { + state.scrollY = 0; + state.directionY = 1; + } + return; + } + const position = axis === 'x' ? state.scrollX : state.scrollY; + const direction = axis === 'x' ? state.directionX : state.directionY; + const cycle = maximum * 2; + const startingPhase = direction === 1 ? position : cycle - position; + const phase = (startingPhase + distance) % cycle; + const nextPosition = phase <= maximum ? phase : cycle - phase; + const nextDirection = phase < maximum || phase === 0 ? 1 : -1; + if (axis === 'x') { + state.scrollX = nextPosition; + state.directionX = nextDirection; + } else { + state.scrollY = nextPosition; + state.directionY = nextDirection; + } +} + +export function iconGridAssignments( + entries: readonly { readonly sourceText: string; readonly virtualIconIndex?: number }[], +): readonly IconGridAssignment[] { + const assignments = entries + .filter( + (entry): entry is typeof entry & { readonly virtualIconIndex: number } => entry.virtualIconIndex !== undefined, + ) + .map(({ sourceText, virtualIconIndex }) => ({ index: virtualIconIndex, content: sourceText })) + .sort((left, right) => left.index - right.index); + for (let index = 1; index < assignments.length; index += 1) { + if (assignments[index - 1]!.index === assignments[index]!.index) { + throw new Error(`icon grid assigned catalog index ${String(assignments[index]!.index)} twice`); + } + } + return assignments; +} + +function intersectingGridRange( + minimum: number, + maximum: number, + origin: number, + cellSize: number, + pitch: number, + count: number, +): readonly [number, number] { + const first = Math.floor((minimum - origin - cellSize) / pitch) + 1; + const last = Math.ceil((maximum - origin) / pitch) - 1; + return [Math.min(count - 1, Math.max(0, first)), Math.min(count - 1, Math.max(0, last))]; +} + +function positive(value: number, label: string): number { + if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${label} must be positive`); + return value; +} diff --git a/apps/benchmarks/src/workloads/off-axis-3d.ts b/apps/benchmarks/src/workloads/off-axis-3d.ts index 3f2752e2..f091c17a 100644 --- a/apps/benchmarks/src/workloads/off-axis-3d.ts +++ b/apps/benchmarks/src/workloads/off-axis-3d.ts @@ -33,7 +33,19 @@ const colorAt = createOklabColorCycle(OFF_AXIS_WORD_COLORS.map(({ color }) => co export const offAxis3dWorkload = { cameraKind: 'perspective', contentWidth: { multiplier: 2 }, + create(context) { + return createOffAxis3dEntries({ + ...context.configuration, + dpr: context.dpr, + font: context.font, + raster: context.raster, + viewportWidth: context.viewportWidth, + }); + }, id: 'off-axis-3d', + layout(entries, context) { + layoutOffAxis3dEntries(entries, context.viewportWidth, context.viewportHeight); + }, suspendsIconWindow: false, updateKind: () => 'retained', } satisfies ComparisonWorkloadDefinition; diff --git a/apps/benchmarks/src/workloads/paint-effects.ts b/apps/benchmarks/src/workloads/paint-effects.ts index 06995ebd..8844f141 100644 --- a/apps/benchmarks/src/workloads/paint-effects.ts +++ b/apps/benchmarks/src/workloads/paint-effects.ts @@ -25,7 +25,20 @@ const PAINT_WORD_RANGES = Array.from(PAINT_EFFECTS_TEXT.matchAll(/\S+/g), (match export const paintEffectsWorkload = { cameraKind: 'orthographic', contentWidth: {}, + create(context) { + return createPaintEffectsEntries({ + ...context.configuration, + dpr: context.dpr, + font: context.font, + raster: context.raster, + technique: context.technique, + viewportWidth: context.viewportWidth, + }); + }, id: 'paint-effects', + layout(entries, context) { + layoutPaintEffectsEntries(entries, context.viewportWidth, context.viewportHeight); + }, suspendsIconWindow: false, updateKind: () => 'retained', } satisfies ComparisonWorkloadDefinition; diff --git a/apps/benchmarks/src/workloads/paragraph-stress.ts b/apps/benchmarks/src/workloads/paragraph-stress.ts index a58dd8b0..aac3897e 100644 --- a/apps/benchmarks/src/workloads/paragraph-stress.ts +++ b/apps/benchmarks/src/workloads/paragraph-stress.ts @@ -14,7 +14,19 @@ import { export const paragraphStressWorkload = { cameraKind: 'orthographic', contentWidth: {}, + create(context) { + return createParagraphStressEntries({ + ...context.configuration, + dpr: context.dpr, + font: context.font, + raster: context.raster, + viewportWidth: context.viewportWidth, + }); + }, id: 'paragraph-stress', + layout(entries, context) { + layoutParagraphStressEntries(entries, context.viewportWidth, context.viewportHeight); + }, suspendsIconWindow: false, updateKind: (previous: ComparisonWorkloadConfiguration, next: ComparisonWorkloadConfiguration) => previous.amount === next.amount ? 'retained' : 'rebuild', diff --git a/apps/benchmarks/src/workloads/registry.test.ts b/apps/benchmarks/src/workloads/registry.test.ts index 8a9d8caa..d5844011 100644 --- a/apps/benchmarks/src/workloads/registry.test.ts +++ b/apps/benchmarks/src/workloads/registry.test.ts @@ -16,12 +16,14 @@ describe('comparison workload registry', () => { expect(Object.values(COMPARISON_WORKLOADS).map(({ id }) => id)).toEqual(COMPARISON_WORKLOAD_IDS); }); - it('gives every example one host-safe update, camera, and content-width policy', () => { + it('gives every example host-safe construction, layout, update, camera, and content-width behavior', () => { for (const id of COMPARISON_WORKLOAD_IDS) { const definition = comparisonWorkloadDefinition(id); expect(definition.id).toBe(id); expect(['orthographic', 'perspective']).toContain(definition.cameraKind); expect(definition.contentWidth === 'none' || typeof definition.contentWidth === 'object').toBe(true); + expect(typeof definition.create).toBe('function'); + expect(typeof definition.layout).toBe('function'); expect(typeof definition.updateKind).toBe('function'); } }); diff --git a/apps/benchmarks/src/workloads/text-ladder.ts b/apps/benchmarks/src/workloads/text-ladder.ts index 4095f3bb..1173fb76 100644 --- a/apps/benchmarks/src/workloads/text-ladder.ts +++ b/apps/benchmarks/src/workloads/text-ladder.ts @@ -25,7 +25,19 @@ export interface MutableTextLadderScenePosition { export const textLadderWorkload = { cameraKind: 'orthographic', contentWidth: 'none', + create(context) { + return createTextLadderEntries({ + dpr: context.dpr, + font: context.font, + raster: context.raster, + ...(context.textLadderSpecimen === undefined ? {} : { specimen: context.textLadderSpecimen }), + viewportHeight: context.viewportHeight, + }); + }, id: 'text-ladder', + layout(entries, context) { + layoutTextLadderEntries(entries, context.viewportWidth); + }, suspendsIconWindow: false, updateKind: () => 'retained', } satisfies ComparisonWorkloadDefinition; diff --git a/apps/benchmarks/src/workloads/zoom-text.ts b/apps/benchmarks/src/workloads/zoom-text.ts index 1b459f9d..cf38ae55 100644 --- a/apps/benchmarks/src/workloads/zoom-text.ts +++ b/apps/benchmarks/src/workloads/zoom-text.ts @@ -43,7 +43,13 @@ export interface ZoomTextAnimationState { export const zoomTextWorkload = { cameraKind: 'orthographic', contentWidth: 'none', + create(context) { + return createZoomTextEntries({ dpr: context.dpr, font: context.font, raster: context.raster }); + }, id: 'zoom-text', + layout(entries, context) { + layoutZoomTextEntries(entries, context.viewportWidth, context.viewportHeight); + }, suspendsIconWindow: false, updateKind: () => 'retained', } satisfies ComparisonWorkloadDefinition; diff --git a/docs/log.md b/docs/log.md index dc8d8c4c..7fdd9470 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,7 @@ ## 2026-08-03 +- **Workload controller boundary** — Made all seven retained workload definitions own typed app-private construction and layout hooks, so the renderer selects those behaviors only through the exhaustive registry. Moved Icon Grid layout, virtual-window calculation, assignment validation, recycled-entry positioning, pan integration, and frame-delta smoothing beside its canonical public `Text` example. Main and Presentation now derive descriptions, amount labels, fixed/selectable font behavior, preload grouping, and pan/zoom capability from the typed catalog instead of duplicating workload-ID policy. Renderer, canvas, RAF, GPU timer, telemetry, font transactions, active pool ownership, and teardown remain host infrastructure; no public package API was added. Chromium 149 completed all 42 Bitmap/MTSDF/Slug × WebGPU/WebGL2 × seven-workload Presentation cells with visible pixels, one renderer per lane, and no reported console failure. - **Workload scene and conformance locality** — Moved Text Ladder, Zoom Text, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects layout and animation behavior beside their public `Text` constructors, leaving the retained renderer as a dispatcher and Icon Grid virtualization as the next controller extraction. Extracted the conformance React hierarchy from the root application while preserving one host-owned renderer for retained comparisons and exclusive finite captures. A deterministic delayed-peer regression proved the realtime MSDF / Slug comparison could refresh one candidate generation early; the private scene now retains the last complete target pair, defers target resize, publishes both retained objects in one task, rolls both back on failure, and drains an in-flight pair before disposal. Seven focused lifecycle cases cover renderer-state restoration, success, failure, abort, partial readiness, rollback, and delayed resize without admitting a renderer-wide grouped-publication API. - **Benchmark workload policy and target ownership** — Added one typed catalog for all nine live benchmark workloads, including exact Main/Presentation reset defaults, font policy, controls, interaction, preload, and surface kind. Benchmark rail metadata and runtime resets project from that authority, and URL parsing normalizes unknown workload IDs inside the selected mode before downstream policy executes. Moved technique-invariant text styling and color motion below `workloads/shared` and added a static/dynamic source boundary that rejects workload dependencies on renderer implementation modules. Reclassified public loader/Worker parity as conformance, moved direct Wasm dependencies into one shared selected-ABI adapter, and retained boundary tests that reject raw Wasm imports elsewhere. - **Benchmark-driven API discovery** — Audited every live workload against the published `Text`, loader, registry, raster, and React surfaces. The seven retained comparison examples need no new package API; benchmark telemetry, fixture authentication, and ABI measurement remain application concerns. Corrected the nullable baked-source override fixture and recorded delayed-peer publication in the MSDF / Slug comparison as an evidence candidate that must fail a distinguishing probe before any grouped public transaction is considered. diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 689581a6..3ad78c2a 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:4ebab25aef3c6b970b8a783498c75dcb0e5a78c2a4146254a10f1a5ee53ad73f' +source_digest: 'sha256:efcc04f561fb0f5580962aa34941ecff31f01398ed12f8809095e01cffc71a15' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -70,7 +70,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-03T08:09:59Z' + at: '2026-08-03T08:25:13Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -106,7 +106,7 @@ The Vite build runs the pinned React Compiler preset and emitted production bund Main and Presentation are exclusive URL-selected root presentations. Presentation owns the scene and floating chrome directly: it does not render Main's header, workload rail, control aside, compact sheets, navigation, or hidden Conformance Activity. Each route owns one persistent render host, canvas, animation loop, GPU timer, and telemetry ring for its backend generation. Workload and technique changes activate a replacement scene while the committed scene continues rendering, then atomically swap and dispose the old scene resources; compatible font changes update retained `Text` objects in place. React Suspense owns genuinely cold asset loading, while scene selections are committed with `useTransition` after preloading so warm transitions do not replace the visible scene or reset the graphs. Runtime diagnostics and the live workload probe require one active renderer, one active canvas, and a peak concurrency of one through rapid presentation, workload, and technique changes. Main enters Presentation through one accessible expand-corners icon rather than a text label. Presentation's workload, font, and shaping-case selectors use the official shadcn Base UI `Select`; Base UI owns portal placement, outside-press dismissal, Escape handling, focus restoration, and keyboard listbox behavior, while the application renders selected human labels explicitly instead of exposing stored keys. A fixed left-edge viewport slot top-aligns one compact shadcn `ButtonGroup`, so workload-specific controls grow downward or scroll without moving the dock anchor. Each dynamic value owns a shadcn `Popover`: global GPU/GL and DPR selectors, workload sliders and toggles, Advanced Shaping selection/text/timeline panels, and MTSDF-only stroke/shadow controls. Informational descriptions and controls unavailable to the selected technique do not enter the Presentation dock. MTSDF paint starts with zero stroke and shadow disabled. One 1.5× presentation-scale boundary enlarges the top controls, telemetry, dock, and payload labels; inverse-scale viewport constraints, group-specific transform origins, and a 32 CSS px safe area keep every floating group clear of host-frame corners and on-screen without overlap at 1,280×1,280. Presentation surfaces use restrained shadcn-radius corners, plain 80%-black composition without a backdrop filter, and opaque borders. The graph rail owns one continuous background with opaque dividers instead of exposing translucent seams between child charts. Canvas render captions and navigation status remain Main-only. No visible exit action competes with the compact top-right telemetry stack, and Escape returns to Main. The comparison workloads retain framework-neutral `Text` objects behind the active presentation. Paint & Effects advances one circular per-word chromatic sequence directly on the renderer RAF through the synchronous paint-only batch path; shaping, paragraph layout, geometry, and React do not drive individual color frames. Its source span topology and update object are retained across animation frames, while `Text` retains the glyph-to-paint index plan. Animate, speed, hue, opacity, shadow, stroke, and layout-bounds controls mutate retained scene state and never enter the scene-rebuild path. Ordinary font-size and paragraph-volume changes may replace a generation because they alter glyph geometry or authored text. Resident layout-width, compatible viewport-width, font-size, font-fixture, and Dynamic Layout changes stage every affected `Text`, publish through Three.js matrix traversal, and reposition only after the complete synchronous lifecycle publication; `ready` remains a cold/error observation channel rather than warm control flow. Rapid controls pass through a latest-value serialized drain so obsolete intermediate values are never staged.[^comparison-workload] Live controls invalidate only an explicit capture, not the continuously updated typed-array telemetry, so metric labels and graphs do not disappear while a slider moves. Font selection retains the active benchmark shell and its last telemetry rather than showing an intermediate empty metric frame; comparison workloads additionally retain their canvas while the next font prepares. The live probe observes causal, monotonically increasing paint revisions, rejects layout work or batch replacement during paint updates, and proves comparison-workload font switching preserves canvas identity and never empties the CPU metric. Dynamic Layout stages all three paragraph reflows as one batch and positions the complete trio after lifecycle publication; it never recenters a mixture of old and new layouts. Neutral inspection frames remain default-on and share the same typed configuration boundary. -Every live benchmark identity resolves through one typed catalog under `apps/benchmarks/src/workloads/`. The catalog owns labels, descriptions, exact Main and Presentation defaults, font policy, controls and ranges, pan/zoom capability, preload policy, and surface kind; URL parsing normalizes an unknown workload inside its selected mode before font or control policy executes. Text Ladder, Zoom Text, Icon Grid, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects each own their authored content and public `Text` construction. Text Ladder, Zoom Text, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects also own their scene layout and animation behavior; Icon Grid's virtualization controller remains the next locality extraction. Their technique-invariant content-width, text-style, and color-cycle utilities live below `workloads/shared`; a source-boundary test rejects static or dynamic imports from any workload module back into renderer implementation files. The route-owned renderer, RAF, telemetry, font delivery, transactional publication, and disposal remain benchmark infrastructure. Conformance React composition lives under `surfaces/conformance`: both the retained comparison and finite captures can receive only the host-owned renderer, while finite low-level dispatch stays below `benchmark/targets/conformance` and `benchmark/targets/measurement`. Those targets share the explicitly named `targets/shared/direct-wasm.ts` dependency adapter only after target selection. The public missing-sibling loader Worker is conformance because it proves authenticated Worker bytes and loader fallback behavior; it is not a rendering product target. Boundary tests reject workload imports back into renderer implementation and reject direct font-baker or Wasm URL imports outside the shared adapter, preventing raw tooling from leaking into the normal Presentation module graph. +Every live benchmark identity resolves through one typed catalog under `apps/benchmarks/src/workloads/`. The catalog owns labels, descriptions, exact Main and Presentation defaults, font policy, controls and ranges, pan/zoom capability, preload policy, and surface kind; URL parsing normalizes an unknown workload inside its selected mode before font or control policy executes. Main and Presentation derive scene descriptions, amount labels, font selection, preload grouping, and pan/zoom capability from that authority rather than repeating workload-ID switches. Text Ladder, Zoom Text, Icon Grid, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects each own their authored content and public `Text` construction. Their typed definition objects also own scene construction and layout; Text Ladder, Zoom Text, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects own animation behavior. Icon Grid owns its layout, virtual-window calculation, assignment validation, recycled-entry positioning, pan integration, and frame-delta smoothing, while the host still owns the active pool and telemetry until the instance-controller slice. Their technique-invariant content-width, text-style, and color-cycle utilities live below `workloads/shared`; a source-boundary test rejects static or dynamic imports from any workload module back into renderer implementation files. The route-owned renderer, RAF, telemetry, font delivery, transactional publication, and disposal remain benchmark infrastructure. Conformance React composition lives under `surfaces/conformance`: both the retained comparison and finite captures can receive only the host-owned renderer, while finite low-level dispatch stays below `benchmark/targets/conformance` and `benchmark/targets/measurement`. Those targets share the explicitly named `targets/shared/direct-wasm.ts` dependency adapter only after target selection. The public missing-sibling loader Worker is conformance because it proves authenticated Worker bytes and loader fallback behavior; it is not a rendering product target. Boundary tests reject workload imports back into renderer implementation and reject direct font-baker or Wasm URL imports outside the shared adapter, preventing raw tooling from leaking into the normal Presentation module graph. Timed playback compares each frame with the latest requested location rather than the last committed scene, so an in-flight preload receives exactly one request and cannot be superseded by a duplicate transition that skips workload-default initialization. Presentation captures Space at the window capture boundary to start or stop timed playback even while a button, switch, slider, select, or combobox owns focus; matching key-up activation is suppressed, while inputs, textareas, and editable text retain ordinary space entry. Arrow navigation remains disabled on interactive controls. diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 915d5910..a116c022 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -687,7 +687,7 @@ Milestone 9 is closed. Additional Slug optimization hypotheses are future measur ## Milestone 10 — harden the first shippable release -Item 10.6 is active. Item 10.1 established the required renderer-neutral transaction and Three.js adapter parity, item 10.2 moved resident shaping, layout, paint planning, raster staging, and atomic publication into the Three.js object-update lifecycle without warm consumer readiness waits, item 10.3 added bounded retained instance capacity to all three first-party rasters, item 10.4 proved the published extension boundary with a private external consumer package, and item 10.5 removed benchmark workarounds and retained complete dual-backend Presentation evidence. The remaining accepted work is release review. Its current pass makes every live workload a readable consumer example with one typed default/control/font/surface policy, isolates low-level conformance targets, and records any concrete package escape hatch in the API fixture before proposing implementation. A delayed-peer probe confirmed that the paired MSDF / Slug scene needs comparison-local target coordination; retaining the last complete target pair, publishing both objects in one task, and rolling back failures resolves it without a grouped public transaction. Each layer must remain independently green; renderer-wide batching across separate `Text` objects is explicitly not part of this milestone. +Item 10.6 is active. Item 10.1 established the required renderer-neutral transaction and Three.js adapter parity, item 10.2 moved resident shaping, layout, paint planning, raster staging, and atomic publication into the Three.js object-update lifecycle without warm consumer readiness waits, item 10.3 added bounded retained instance capacity to all three first-party rasters, item 10.4 proved the published extension boundary with a private external consumer package, and item 10.5 removed benchmark workarounds and retained complete dual-backend Presentation evidence. The remaining accepted work is release review. Its current pass makes every live workload a readable consumer example with one typed default/control/font/surface policy, isolates low-level conformance targets, and records any concrete package escape hatch in the API fixture before proposing implementation. All seven retained workload definitions now own app-private construction and layout hooks, and Icon Grid owns its pure virtualization mechanics; moving animation, active pool state, and metrics behind workload instances remains in progress. A delayed-peer probe confirmed that the paired MSDF / Slug scene needs comparison-local target coordination; retaining the last complete target pair, publishing both objects in one task, and rolling back failures resolves it without a grouped public transaction. Each layer must remain independently green; renderer-wide batching across separate `Text` objects is explicitly not part of this milestone. ### 10.1–10.6 closure checklist