From da2f44c5a8b730f65f2c6e4e56321e01f607cac9 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 3 Aug 2026 05:24:18 -0400 Subject: [PATCH 1/5] refactor(benchmarks): isolate icon grid workload state --- .../src/renderer/comparison-workload.ts | 521 ++++------------- .../src/workloads/icon-grid-instance.test.ts | 149 +++++ apps/benchmarks/src/workloads/icon-grid.ts | 538 +++++++++++++++++- 3 files changed, 792 insertions(+), 416 deletions(-) create mode 100644 apps/benchmarks/src/workloads/icon-grid-instance.test.ts diff --git a/apps/benchmarks/src/renderer/comparison-workload.ts b/apps/benchmarks/src/renderer/comparison-workload.ts index d1a19a1e..6bb47c05 100644 --- a/apps/benchmarks/src/renderer/comparison-workload.ts +++ b/apps/benchmarks/src/renderer/comparison-workload.ts @@ -16,22 +16,11 @@ import { ICON_GRID_LABEL_SIZE, ICON_GRID_OVERSCAN_COLUMNS, ICON_GRID_OVERSCAN_ROWS, - advanceIconGridAutoPan, + createIconGridWorkloadInstance, createIconGridEntries, - iconGridAssignments, - iconGridAutoPanStart, - iconGridCenteredScroll, - iconGridContent, - iconGridLabel, - iconGridLayout, - iconGridVirtualWindow, - positionIconGridEntry, resizeIconGridEntries, - smoothIconGridFrameDelta, - type IconGridAutoPanState, - type IconGridFrameDeltaState, - type IconGridLayout, - type IconGridVirtualWindow, + type IconGridEntryPool, + type IconGridWorkloadInstance, } from '../workloads/icon-grid'; import type { MutableTextLadderScenePosition } from '../workloads/text-ladder'; import { ZOOM_TEXT_BASE_CSS_PX } from '../workloads/zoom-text'; @@ -254,8 +243,6 @@ interface PendingConfigurationUpdate { }>; } -const ICON_GRID_AUTO_PAN_PX_PER_SECOND = 160; - interface ComparisonWorkloadRuntime extends ComparisonWorkloadPreview { persistentFrame(context: PersistentRenderFrameContext): void; persistentTelemetry(snapshot: LiveFrameTelemetrySnapshot, viewport: PersistentRenderViewport): void; @@ -406,11 +393,6 @@ async function createComparisonWorkloadRuntime( textLadderPosition: textLadderPositionScratch, zoomText: zoomAnimationState, }; - const iconAutoPanState: IconGridAutoPanState = { directionX: 1, directionY: 1, scrollX: 0, scrollY: 0 }; - const iconFrameDeltaState: IconGridFrameDeltaState = { smoothedElapsedMs: undefined }; - let iconAutoPanTimestamp: number | undefined; - let iconWindowRequestScrollX = 0; - let iconWindowRequestScrollY = 0; const scene = new THREE.Scene(); let camera = createWorkloadCamera(configuration.workload, width, height); gpuFrameTimer = persistent ? undefined : createGpuFrameTimer({ backend, renderer, onError }); @@ -507,24 +489,48 @@ async function createComparisonWorkloadRuntime( } return cachedBitmapAtlasPages; }; - const statsFont = (): LoadedTechniqueFont => iconFont ?? activeFont(); - let iconRecycleCount = 0; - let iconWindowRevision = 0; - let iconAssignmentSignature = '[]'; - let settledIconWindow: IconGridVirtualWindow | undefined; - let pendingIconWindow: IconGridVirtualWindow | undefined; - let iconWindowRefreshing = false; - let iconWindowSuspended = false; - let iconWindowRefreshDeferred = false; + // Keep the companion icon font resident for warm return visits, but never let that retained resource become + // the visible workload's density/configuration source after navigation away from Icon Grid. + const statsFont = (): LoadedTechniqueFont => + configuration.workload === 'icon-grid' ? (iconFont ?? activeFont()) : activeFont(); let fontFixtureSwitching = false; let fontFixtureCommitting = false; let committedContentWidth = comparisonWorkloadContentWidth(configuration, width); - const desiredIconEpochs = new Uint32Array(ICON_GRID_ITEMS.length); - const retainedIconEpochs = new Uint32Array(ICON_GRID_ITEMS.length); - const availableIconEntries: WorkloadEntry[] = []; - const pendingIconEntries: WorkloadEntry[] = []; - const missingIconIndices: number[] = []; - let iconAssignmentEpoch = 0; + const iconGridEntryPool: IconGridEntryPool = { + entries: () => entries, + async resize(poolCapacity, iconSize, layout) { + if (iconFont === undefined) throw new Error('icon grid lost its icon font fixture'); + if (poolCapacity > entries.length) { + const additions = createIconGridEntries({ + count: poolCapacity - entries.length, + dpr: rendererViewport.pixelRatio, + iconFont, + iconSize, + labelFont: activeFont().font, + labelRaster: activeFont().raster, + }); + try { + await Promise.all(additions.flatMap(entryReadyPromises)); + } catch (error) { + disposeEntries(additions); + throw error; + } + if (closing || disposed) { + disposeEntries(additions); + return; + } + entries = [...entries, ...additions]; + for (const { node } of additions) scene.add(node); + } else if (poolCapacity < entries.length) { + const removed = entries.slice(poolCapacity); + entries = entries.slice(0, poolCapacity); + for (const { node } of removed) scene.remove(node); + disposeEntries(removed); + } + resizeIconGridEntries(entries, iconSize, layout); + }, + }; + let iconGridInstance: IconGridWorkloadInstance | undefined; async function switchSelectedFontFixture(nextFixture: BenchmarkFontFixture): Promise { if (nextFixture === activeSelectedFont.current.fixture) return; @@ -586,23 +592,19 @@ async function createComparisonWorkloadRuntime( sharedRegistry, ); } - if (next.workload === 'icon-grid' && !workloadChanged) { - clampIconGridScene(scene, next.fontSize, width, height); - } const commitRevision = ++revision; const readyStarted = performance.now(); + const nextIconGridInstance = + next.workload !== 'icon-grid' + ? undefined + : workloadChanged || iconGridInstance === undefined + ? createIconGridWorkloadInstance(iconGridEntryPool, () => !closing && !disposed) + : iconGridInstance; + const iconGridInstanceChanged = nextIconGridInstance !== iconGridInstance; const initialIconWindow = - workloadChanged && next.workload === 'icon-grid' - ? iconGridVirtualWindow(ICON_GRID_ITEMS.length, next.fontSize, width, height, 0, 0) + next.workload === 'icon-grid' && nextIconGridInstance !== undefined + ? nextIconGridInstance.activate(next, { height, width }) : undefined; - const initialIconPan = - initialIconWindow === undefined - ? undefined - : iconGridAutoPanStart( - next.iconGridView ?? 'origin', - initialIconWindow.maximumScrollX, - initialIconWindow.maximumScrollY, - ); const nextEntries = createEntries( activeFont().font, activeFont().raster, @@ -614,8 +616,8 @@ async function createComparisonWorkloadRuntime( workloadChanged ? 0 : performance.now() - animationEpoch, options.textLadderSpecimen, iconFont, - initialIconPan?.scrollX ?? (workloadChanged ? 0 : -scene.position.x), - initialIconPan?.scrollY ?? (workloadChanged ? 0 : scene.position.y), + initialIconWindow?.scrollX ?? (workloadChanged ? 0 : -scene.position.x), + initialIconWindow?.scrollY ?? (workloadChanged ? 0 : scene.position.y), ); const scheduledAt = performance.now(); try { @@ -632,25 +634,22 @@ async function createComparisonWorkloadRuntime( configuration = next; committedContentWidth = comparisonWorkloadContentWidth(next, width); if (workloadChanged) { - scene.position.set(-(initialIconPan?.scrollX ?? 0), initialIconPan?.scrollY ?? 0, 0); + // Scene transforms belong to the outgoing workload. Text Ladder exits by translating the shared scene, + // while Icon Grid pans it; every newly mounted workload must start from its own explicit view defaults. + scene.position.set(-(initialIconWindow?.scrollX ?? 0), initialIconWindow?.scrollY ?? 0, 0); camera = nextCamera; animationEpoch = performance.now(); zoomAnimationState.phraseIndex = 0; zoomAnimationState.phraseRevision = 0; zoomAnimationState.progress = 0; - iconAutoPanState.directionX = initialIconPan?.directionX ?? 1; - iconAutoPanState.directionY = initialIconPan?.directionY ?? 1; - iconAutoPanState.scrollX = initialIconPan?.scrollX ?? 0; - iconAutoPanState.scrollY = initialIconPan?.scrollY ?? 0; - iconAutoPanTimestamp = undefined; - iconFrameDeltaState.smoothedElapsedMs = undefined; - iconWindowRequestScrollX = initialIconPan?.scrollX ?? 0; - iconWindowRequestScrollY = initialIconPan?.scrollY ?? 0; - settledIconWindow = undefined; } scene.clear(); for (const { node } of entries) scene.add(node); disposeEntries(previous); + if (iconGridInstanceChanged) { + iconGridInstance?.dispose(); + iconGridInstance = next.workload === 'icon-grid' ? nextIconGridInstance : undefined; + } const finishedAt = performance.now(); textReadyMs = finishedAt - readyStarted; textUpdateTelemetry.record({ @@ -660,169 +659,15 @@ async function createComparisonWorkloadRuntime( totalMs: finishedAt - readyStarted, }); if (next.workload === 'icon-grid') { - settleIconWindow( - iconGridVirtualWindow( - ICON_GRID_ITEMS.length, - next.fontSize, - width, - height, - -scene.position.x, - scene.position.y, - ), - ); + iconGridInstance?.settle(next, { height, width }, scene); } } catch (error) { disposeEntries(nextEntries); + if (iconGridInstanceChanged) nextIconGridInstance?.dispose(); throw error; } } - function applyIconWindow(window: IconGridVirtualWindow): void { - if (iconFont === undefined || configuration.workload !== 'icon-grid') return; - if (window.poolCapacity !== entries.length) { - throw new Error('icon grid pool capacity changed without a scene rebuild'); - } - let recycled = 0; - iconAssignmentEpoch = (iconAssignmentEpoch + 1) >>> 0; - if (iconAssignmentEpoch === 0) { - desiredIconEpochs.fill(0); - retainedIconEpochs.fill(0); - iconAssignmentEpoch = 1; - } - availableIconEntries.length = 0; - pendingIconEntries.length = 0; - missingIconIndices.length = 0; - for (const index of window.indices) desiredIconEpochs[index] = iconAssignmentEpoch; - for (const entry of entries) { - const iconIndex = entry.virtualIconIndex; - if ( - iconIndex !== undefined && - desiredIconEpochs[iconIndex] === iconAssignmentEpoch && - retainedIconEpochs[iconIndex] !== iconAssignmentEpoch - ) { - retainedIconEpochs[iconIndex] = iconAssignmentEpoch; - continue; - } - availableIconEntries.push(entry); - } - for (const index of window.indices) { - if (retainedIconEpochs[index] !== iconAssignmentEpoch) missingIconIndices.push(index); - } - if (missingIconIndices.length > availableIconEntries.length) { - throw new Error('icon grid window exceeds its recyclable tile pool'); - } - for (const [missingIndex, iconIndex] of missingIconIndices.entries()) { - const entry = availableIconEntries[missingIndex]!; - const { glyph } = iconGridContent(iconIndex); - // Keep the old assignment visible while every warm replacement is staged. The Three.js lifecycle publishes - // the staged generations together below; no consumer promise coordinates ordinary warm recycling. - entry.text.setProperties({ text: glyph }); - entry.labelText?.setProperties({ text: iconGridLabel(iconIndex) }); - recycled += 1; - pendingIconEntries.push(entry); - } - publishEntryUpdates(pendingIconEntries); - if (closing || disposed) return; - for (const [index, entry] of pendingIconEntries.entries()) { - if (entry.disposed) continue; - const iconIndex = missingIconIndices[index]!; - const { content } = iconGridContent(iconIndex); - entry.virtualIconIndex = iconIndex; - entry.sourceText = content; - const column = iconIndex % window.layout.columns; - const row = Math.floor(iconIndex / window.layout.columns); - positionIconGridEntry(entry, window.layout, column, row, configuration.fontSize); - } - for (let index = missingIconIndices.length; index < availableIconEntries.length; index += 1) { - const entry = availableIconEntries[index]!; - entry.node.visible = false; - delete entry.virtualIconIndex; - } - iconRecycleCount += recycled; - settleIconWindow(window); - } - - async function resizeIconPool(poolCapacity: number, iconSize: number, layout: IconGridLayout): Promise { - if (iconFont === undefined) throw new Error('icon grid lost its icon font fixture'); - if (poolCapacity > entries.length) { - const additions = createIconGridEntries({ - count: poolCapacity - entries.length, - dpr: rendererViewport.pixelRatio, - iconFont, - iconSize, - labelFont: activeFont().font, - labelRaster: activeFont().raster, - }); - try { - await Promise.all(additions.flatMap(entryReadyPromises)); - } catch (error) { - disposeEntries(additions); - throw error; - } - if (closing || disposed) { - disposeEntries(additions); - return; - } - entries = [...entries, ...additions]; - for (const { node } of additions) scene.add(node); - } else if (poolCapacity < entries.length) { - const removed = entries.slice(poolCapacity); - entries = entries.slice(0, poolCapacity); - for (const { node } of removed) scene.remove(node); - disposeEntries(removed); - } - resizeIconGridEntries(entries, iconSize, layout); - } - - function settleIconWindow(window: IconGridVirtualWindow): void { - const assignments = iconGridAssignments(entries); - if ( - assignments.length !== window.indices.length || - assignments.some(({ index }, assignmentIndex) => index !== window.indices[assignmentIndex]) - ) { - throw new Error('icon grid cannot publish a window before every assignment is coherent'); - } - // The scene keeps moving while offscreen replacements shape asynchronously. Publish their assignment against - // the live scroll position; using the request-time window here flashes the visible set one frame backward. - updateIconGridEntryVisibility(entries, window.layout, -scene.position.x, scene.position.y, width, height); - settledIconWindow = window; - iconAssignmentSignature = JSON.stringify(assignments); - iconWindowRevision += 1; - } - - function requestIconWindowRefresh(): void { - if (configuration.workload !== 'icon-grid' || closing || disposed) return; - iconWindowRequestScrollX = -scene.position.x; - iconWindowRequestScrollY = scene.position.y; - if (iconWindowSuspended) { - iconWindowRefreshDeferred = true; - return; - } - pendingIconWindow = iconGridVirtualWindow( - ICON_GRID_ITEMS.length, - configuration.fontSize, - width, - height, - -scene.position.x, - scene.position.y, - ); - if (iconWindowRefreshing) return; - iconWindowRefreshing = true; - try { - while (pendingIconWindow !== undefined) { - if (closing || disposed) break; - const nextWindow = pendingIconWindow; - pendingIconWindow = undefined; - applyIconWindow(nextWindow); - } - } catch (error) { - onError(error); - } finally { - iconWindowRefreshing = false; - } - if (pendingIconWindow !== undefined && !closing && !disposed) requestIconWindowRefresh(); - } - await commit(configuration); signal?.throwIfAborted(); let requestedConfiguration = configuration; @@ -851,44 +696,8 @@ async function createComparisonWorkloadRuntime( next.fontSize !== configuration.fontSize || next.iconGridView !== configuration.iconGridView) ) { - const viewChanged = next.iconGridView !== configuration.iconGridView; - const [requestedScrollX, requestedScrollY] = viewChanged - ? (() => { - const origin = iconGridVirtualWindow(ICON_GRID_ITEMS.length, next.fontSize, width, height, 0, 0); - const start = iconGridAutoPanStart( - next.iconGridView ?? 'origin', - origin.maximumScrollX, - origin.maximumScrollY, - ); - iconAutoPanState.directionX = start.directionX; - iconAutoPanState.directionY = start.directionY; - iconFrameDeltaState.smoothedElapsedMs = undefined; - return [start.scrollX, start.scrollY] as const; - })() - : next.fontSize === configuration.fontSize - ? [-scene.position.x, scene.position.y] - : iconGridCenteredScroll( - ICON_GRID_ITEMS.length, - configuration.fontSize, - next.fontSize, - width, - height, - -scene.position.x, - scene.position.y, - ); - const nextWindow = iconGridVirtualWindow( - ICON_GRID_ITEMS.length, - next.fontSize, - width, - height, - requestedScrollX, - requestedScrollY, - ); - await resizeIconPool(nextWindow.poolCapacity, next.fontSize, nextWindow.layout); - // Pool growth is genuinely cold and stays detached while it loads. Publish size, position, and assignments - // in one continuation so the live scene never exposes new camera coordinates with the old tile defaults. - scene.position.set(-nextWindow.scrollX, nextWindow.scrollY, 0); - await applyIconWindow(nextWindow); + if (iconGridInstance === undefined) throw new Error('icon grid retained update lost its workload instance'); + await iconGridInstance.reconfigure(configuration, next, { height, width }, scene); configuration = next; committedContentWidth = undefined; revision += 1; @@ -968,13 +777,7 @@ async function createComparisonWorkloadRuntime( startUpdateDrain(); return; } - if (iconWindowSuspended) { - iconWindowSuspended = false; - if (iconWindowRefreshDeferred) { - iconWindowRefreshDeferred = false; - requestIconWindowRefresh(); - } - } + iconGridInstance?.resume(configuration, { height, width }, scene, onError); }); } @@ -988,8 +791,7 @@ async function createComparisonWorkloadRuntime( comparisonWorkloadUpdateKind(configuration, next, viewportChanged) === 'rebuild' || next.fontFixture !== configuration.fontFixture ) { - iconWindowSuspended = true; - iconWindowRefreshDeferred = true; + iconGridInstance?.suspend(); } return new Promise((resolve, reject) => { if (pendingUpdate === undefined) { @@ -1029,42 +831,14 @@ async function createComparisonWorkloadRuntime( } const frameId = telemetry?.beginFrame(timestamp); if (renderScene && configuration.workload === 'icon-grid') { - const elapsedMs = iconAutoPanTimestamp === undefined ? 0 : Math.max(0, timestamp - iconAutoPanTimestamp); - iconAutoPanTimestamp = timestamp; - const window = settledIconWindow; - if (configuration.animationEnabled && window !== undefined) { - // Keep the per-frame path to numeric motion, transforms, and visibility toggles. Content reassignment is - // requested only after crossing a complete cell pitch and commits against the overscanned pool. - advanceIconGridAutoPan( - iconAutoPanState, - -scene.position.x, - scene.position.y, - window.maximumScrollX, - window.maximumScrollY, - smoothIconGridFrameDelta(iconFrameDeltaState, elapsedMs), - ICON_GRID_AUTO_PAN_PX_PER_SECOND * animationRate(configuration), - ); - scene.position.set(-iconAutoPanState.scrollX, iconAutoPanState.scrollY, 0); - updateIconGridEntryVisibility( - entries, - window.layout, - iconAutoPanState.scrollX, - iconAutoPanState.scrollY, - width, - height, - ); - const pitchX = window.layout.cellWidth + window.layout.gap; - const pitchY = window.layout.cellHeight + window.layout.gap; - if ( - Math.abs(iconAutoPanState.scrollX - iconWindowRequestScrollX) >= pitchX || - Math.abs(iconAutoPanState.scrollY - iconWindowRequestScrollY) >= pitchY - ) { - requestIconWindowRefresh(); - } - } - } else if (renderScene) { - iconAutoPanTimestamp = undefined; - iconFrameDeltaState.smoothedElapsedMs = undefined; + iconGridInstance?.frame( + configuration, + { height, width }, + scene, + timestamp, + animationRate(configuration), + onError, + ); } if ( renderScene && @@ -1193,18 +967,7 @@ async function createComparisonWorkloadRuntime( : 0, zoomScale: configuration.workload === 'zoom-text' ? zoomScale : 0, zoomMaximumScale: configuration.workload === 'zoom-text' ? (activeZoomEntry?.zoomMaximumScale ?? 1) : 0, - ...iconGridStats( - configuration, - width, - height, - -scene.position.x, - scene.position.y, - entries, - iconRecycleCount, - iconWindowRevision, - iconAssignmentSignature, - settledIconWindow, - ), + ...iconGridStats(configuration, iconGridInstance, { height, width }, scene), }; if (technique === 'bitmap') { const strikePpem = selectBitmapStrikePpem( @@ -1280,33 +1043,22 @@ async function createComparisonWorkloadRuntime( }, panBy(deltaX, deltaY) { if (closing || disposed) return; - const horizontal = finite(deltaX, 'workload horizontal pan'); - const vertical = finite(deltaY, 'workload vertical pan'); if (configuration.workload === 'icon-grid') { - const previousX = scene.position.x; - const previousY = scene.position.y; - scene.position.x += horizontal; - scene.position.y -= vertical; - clampIconGridScene(scene, configuration.fontSize, width, height); - requestIconWindowRefresh(); - return { - deltaX: scene.position.x - previousX, - deltaY: previousY - scene.position.y, - }; + return iconGridInstance?.panBy(configuration, { height, width }, scene, deltaX, deltaY, onError); } + const horizontal = finite(deltaX, 'workload horizontal pan'); + const vertical = finite(deltaY, 'workload vertical pan'); scene.position.x += horizontal; scene.position.y -= vertical; }, resetView() { - scene.position.set(0, 0, 0); - iconAutoPanState.directionX = 1; - iconAutoPanState.directionY = 1; - iconAutoPanState.scrollX = 0; - iconAutoPanState.scrollY = 0; - iconFrameDeltaState.smoothedElapsedMs = undefined; + if (configuration.workload === 'icon-grid') { + iconGridInstance?.resetView(configuration, { height, width }, scene, onError); + } else { + scene.position.set(0, 0, 0); + } camera.zoom = 1; camera.updateProjectionMatrix(); - requestIconWindowRefresh(); }, zoomBy(factor) { if (closing || disposed || configuration.workload !== 'off-axis-3d') return; @@ -1343,6 +1095,7 @@ async function createComparisonWorkloadRuntime( await stopRendering; await updateDrain; disposed = true; + iconGridInstance?.dispose(); await gpuFrameTimer?.dispose(); if (!persistent) { renderer.setRenderTarget(null); @@ -1404,10 +1157,6 @@ function entryReadyPromises(entry: WorkloadEntry): readonly Promise[] { return entry.labelText === undefined ? [entry.text.ready] : [entry.text.ready, entry.labelText.ready]; } -function publishEntryUpdates(entries: readonly WorkloadEntry[]): void { - for (const { node } of entries) node.updateMatrixWorld(true); -} - function layoutEntries( entries: readonly WorkloadEntry[], configuration: ComparisonWorkloadConfiguration, @@ -1543,55 +1292,11 @@ function animationRate(configuration: Pick scrollX && - left < viewportRight && - top + layout.cellHeight > scrollY && - top < viewportBottom; - } -} - -function clampIconGridScene(scene: THREE.Scene, iconSize: number, viewportWidth: number, viewportHeight: number): void { - const layout = iconGridLayout(ICON_GRID_ITEMS.length, iconSize, viewportWidth); - const maximumScrollX = Math.max(0, layout.width - viewportWidth); - const maximumScrollY = Math.max(0, layout.height - viewportHeight); - scene.position.x = Math.min(0, Math.max(-maximumScrollX, scene.position.x)); - scene.position.y = Math.min(maximumScrollY, Math.max(0, scene.position.y)); -} - function iconGridStats( - configuration: Pick, - viewportWidth: number, - viewportHeight: number, - scrollX: number, - scrollY: number, - entries: readonly WorkloadEntry[], - recycleCount: number, - windowRevision: number, - assignmentSignature: string, - settledWindow: IconGridVirtualWindow | undefined, + configuration: Pick, + instance: IconGridWorkloadInstance | undefined, + viewport: { readonly height: number; readonly width: number }, + scene: THREE.Scene, ): Pick< ComparisonWorkloadStats, | 'iconItemCount' @@ -1641,44 +1346,30 @@ function iconGridStats( iconMaximumScrollY: 0, }; } - const window = - settledWindow ?? - iconGridVirtualWindow( - ICON_GRID_ITEMS.length, - configuration.fontSize, - viewportWidth, - viewportHeight, - scrollX, - scrollY, - ); - let assignedCount = 0; - let renderVisibleCount = 0; - for (const entry of entries) { - if (entry.virtualIconIndex !== undefined) assignedCount += 1; - if (entry.node.visible) renderVisibleCount += 1; - } + if (instance === undefined) throw new Error('icon grid telemetry lost its workload instance'); + const metrics = instance.metrics(viewport, scene); return { iconItemCount: ICON_GRID_ITEMS.length, iconLabelCount: ICON_GRID_ITEMS.length, - iconColumnCount: window.layout.columns, - iconRowCount: window.layout.rows, - iconGridWidth: window.layout.width, - iconGridHeight: window.layout.height, + iconColumnCount: metrics.columnCount, + iconRowCount: metrics.rowCount, + iconGridWidth: metrics.gridWidth, + iconGridHeight: metrics.gridHeight, iconLabelSize: ICON_GRID_LABEL_SIZE, - iconPoolCapacity: entries.length, - iconAssignedCount: assignedCount, - iconRenderVisibleCount: renderVisibleCount, - iconAssignmentSignature: assignmentSignature, - iconFirstVisibleIndex: window.firstVisibleIndex, - iconLastVisibleIndex: window.lastVisibleIndex, - iconRecycleCount: recycleCount, - iconWindowRevision: windowRevision, + iconPoolCapacity: metrics.poolCapacity, + iconAssignedCount: metrics.assignedCount, + iconRenderVisibleCount: metrics.renderVisibleCount, + iconAssignmentSignature: metrics.assignmentSignature, + iconFirstVisibleIndex: metrics.firstVisibleIndex, + iconLastVisibleIndex: metrics.lastVisibleIndex, + iconRecycleCount: metrics.recycleCount, + iconWindowRevision: metrics.windowRevision, iconOverscanRows: ICON_GRID_OVERSCAN_ROWS, iconOverscanColumns: ICON_GRID_OVERSCAN_COLUMNS, - iconScrollX: window.scrollX, - iconScrollY: window.scrollY, - iconMaximumScrollX: window.maximumScrollX, - iconMaximumScrollY: window.maximumScrollY, + iconScrollX: metrics.scrollX, + iconScrollY: metrics.scrollY, + iconMaximumScrollX: metrics.maximumScrollX, + iconMaximumScrollY: metrics.maximumScrollY, }; } diff --git a/apps/benchmarks/src/workloads/icon-grid-instance.test.ts b/apps/benchmarks/src/workloads/icon-grid-instance.test.ts new file mode 100644 index 00000000..490f4d96 --- /dev/null +++ b/apps/benchmarks/src/workloads/icon-grid-instance.test.ts @@ -0,0 +1,149 @@ +import type { Text } from '@pmndrs/text'; +import * as THREE from 'three/webgpu'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ComparisonWorkloadConfiguration } from './contracts'; +import type { ComparisonWorkloadEntry } from './factory-contracts'; +import { + createIconGridWorkloadInstance, + iconGridContent, + type IconGridEntryPool, + type IconGridVirtualWindow, +} from './icon-grid'; + +const viewport = { height: 360, width: 720 }; +const configuration: ComparisonWorkloadConfiguration = { + amount: 50, + animationEnabled: true, + animationSpeed: 50, + fontFixture: 'inter', + fontSize: 48, + iconGridView: 'origin', + layoutWidthRatio: 0.8, + paintOpacity: 1, + paintShadowEnabled: true, + paintStrokeWidth: 0.5, + showGrid: true, + showLayoutBounds: true, + textLadderExitEnabled: false, + workload: 'icon-grid', +}; + +describe('retained Icon Grid workload instance', () => { + it('keeps virtual-window state isolated between independently mounted scenes', () => { + const left = createFixture(); + const alternateConfiguration = { ...configuration, iconGridView: 'alternate' } as const; + const right = createFixture({ configuration: alternateConfiguration }); + const leftWindow = left.instance.activate(configuration, viewport); + const rightWindow = right.instance.activate(alternateConfiguration, viewport); + left.scene.position.set(-leftWindow.scrollX, leftWindow.scrollY, 0); + right.scene.position.set(-rightWindow.scrollX, rightWindow.scrollY, 0); + left.instance.settle(configuration, viewport, left.scene); + right.instance.settle(alternateConfiguration, viewport, right.scene); + const rightBefore = right.instance.metrics(viewport, right.scene); + + left.scene.position.x -= leftWindow.layout.cellWidth + leftWindow.layout.gap; + left.instance.requestRefresh(configuration, viewport, left.scene, fail); + + expect(left.instance.metrics(viewport, left.scene).assignmentSignature).not.toBe('[]'); + expect(right.instance.metrics(viewport, right.scene)).toEqual(rightBefore); + }); + + it('keeps the committed window when cold pool growth fails', async () => { + const fixture = createFixture({ resize: async () => Promise.reject(new Error('cold pool failure')) }); + const initial = fixture.instance.activate(configuration, viewport); + fixture.scene.position.set(-initial.scrollX, initial.scrollY, 0); + fixture.instance.settle(configuration, viewport, fixture.scene); + const before = fixture.instance.metrics(viewport, fixture.scene); + + await expect( + fixture.instance.reconfigure(configuration, { ...configuration, fontSize: 64 }, viewport, fixture.scene), + ).rejects.toThrow('cold pool failure'); + + expect(fixture.instance.metrics(viewport, fixture.scene)).toEqual(before); + expect(fixture.scene.position.toArray()).toEqual([-initial.scrollX, initial.scrollY, 0]); + }); + + it('does not publish a refresh after its mount is no longer current', async () => { + let current = true; + let releaseResize: (() => void) | undefined; + const fixture = createFixture({ + isCurrent: () => current, + resize: async () => new Promise((resolve) => (releaseResize = resolve)), + }); + const initial = fixture.instance.activate(configuration, viewport); + fixture.scene.position.set(-initial.scrollX, initial.scrollY, 0); + fixture.instance.settle(configuration, viewport, fixture.scene); + const before = fixture.instance.metrics(viewport, fixture.scene); + + const reconfigure = fixture.instance.reconfigure( + configuration, + { ...configuration, fontSize: 64 }, + viewport, + fixture.scene, + ); + current = false; + releaseResize?.(); + await reconfigure; + + expect(fixture.instance.metrics(viewport, fixture.scene)).toEqual(before); + }); +}); + +function createFixture({ + configuration: fixtureConfiguration = configuration, + isCurrent = () => true, + resize, +}: { + readonly configuration?: ComparisonWorkloadConfiguration; + readonly isCurrent?: () => boolean; + readonly resize?: IconGridEntryPool['resize']; +} = {}) { + const scene = new THREE.Scene(); + const initialWindow = createWindow(fixtureConfiguration); + let entries = Array.from({ length: initialWindow.poolCapacity }, (_, poolIndex) => + createEntry(initialWindow.indices[poolIndex] ?? 0, initialWindow.indices[poolIndex]), + ); + const pool: IconGridEntryPool = { + entries: () => entries, + resize: + resize ?? + (async (poolCapacity) => { + entries = entries.slice(0, poolCapacity); + }), + }; + return { instance: createIconGridWorkloadInstance(pool, isCurrent), scene }; +} + +function createEntry(index: number, virtualIconIndex = index): ComparisonWorkloadEntry { + const text = createText(); + const labelText = createText(); + const node = new THREE.Group(); + const { content } = iconGridContent(index); + return { + labelText, + node, + role: 'primary', + sourceText: content, + text, + ...(virtualIconIndex === undefined ? {} : { virtualIconIndex }), + }; +} + +function createText(): Text { + const text = new THREE.Object3D() as unknown as Text; + Object.assign(text, { + layout: { width: 48 }, + setProperties: vi.fn<(properties: { readonly text?: string }) => void>(), + }); + return text; +} + +function createWindow(fixtureConfiguration: ComparisonWorkloadConfiguration): IconGridVirtualWindow { + const instance = createIconGridWorkloadInstance({ entries: () => [], resize: async () => undefined }, () => true); + return instance.activate(fixtureConfiguration, viewport); +} + +function fail(error: unknown): never { + throw error; +} diff --git a/apps/benchmarks/src/workloads/icon-grid.ts b/apps/benchmarks/src/workloads/icon-grid.ts index 60190381..0832b3d1 100644 --- a/apps/benchmarks/src/workloads/icon-grid.ts +++ b/apps/benchmarks/src/workloads/icon-grid.ts @@ -3,7 +3,7 @@ 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 { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from './contracts'; import { committedTextLayout, type ComparisonWorkloadEntry } from './factory-contracts'; export const ICON_GRID_LABEL_SIZE = 11; @@ -258,6 +258,542 @@ export function smoothIconGridFrameDelta(state: IconGridFrameDeltaState, elapsed return smoothedElapsedMs; } +const ICON_GRID_AUTO_PAN_PX_PER_SECOND = 160; + +export interface IconGridViewport { + readonly height: number; + readonly width: number; +} + +/** + * The renderer owns Text readiness, scene attachment, and disposal. Icon Grid owns which of those retained Texts + * represent the current virtual window, including its scroll, recycling, and metrics state. + */ +export interface IconGridEntryPool { + entries(): readonly ComparisonWorkloadEntry[]; + resize(poolCapacity: number, iconSize: number, layout: IconGridLayout): Promise; +} + +export interface IconGridWorkloadMetrics { + readonly assignedCount: number; + readonly assignmentSignature: string; + readonly firstVisibleIndex: number; + readonly gridHeight: number; + readonly gridWidth: number; + readonly lastVisibleIndex: number; + readonly maximumScrollX: number; + readonly maximumScrollY: number; + readonly poolCapacity: number; + readonly recycleCount: number; + readonly renderVisibleCount: number; + readonly scrollX: number; + readonly scrollY: number; + readonly windowRevision: number; + readonly columnCount: number; + readonly rowCount: number; +} + +export interface IconGridWorkloadInstance { + activate(configuration: ComparisonWorkloadConfiguration, viewport: IconGridViewport): IconGridVirtualWindow; + dispose(): void; + frame( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + timestamp: number, + animationRate: number, + onError: (error: unknown) => void, + ): void; + metrics(viewport: IconGridViewport, scene: THREE.Scene): IconGridWorkloadMetrics; + panBy( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + deltaX: number, + deltaY: number, + onError: (error: unknown) => void, + ): { readonly deltaX: number; readonly deltaY: number }; + reconfigure( + previous: ComparisonWorkloadConfiguration, + next: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + ): Promise; + requestRefresh( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + onError: (error: unknown) => void, + ): void; + resetView( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + onError: (error: unknown) => void, + ): void; + resume( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + onError: (error: unknown) => void, + ): void; + settle(configuration: ComparisonWorkloadConfiguration, viewport: IconGridViewport, scene: THREE.Scene): void; + suspend(): void; +} + +export function createIconGridWorkloadInstance( + pool: IconGridEntryPool, + isCurrent: () => boolean, +): IconGridWorkloadInstance { + return new RetainedIconGridWorkload(pool, isCurrent); +} + +class RetainedIconGridWorkload implements IconGridWorkloadInstance { + readonly #autoPan: IconGridAutoPanState = { directionX: 1, directionY: 1, scrollX: 0, scrollY: 0 }; + readonly #frameDelta: IconGridFrameDeltaState = { smoothedElapsedMs: undefined }; + readonly #desiredEpochs = new Uint32Array(ICON_GRID_ITEMS.length); + readonly #retainedEpochs = new Uint32Array(ICON_GRID_ITEMS.length); + readonly #availableEntries: ComparisonWorkloadEntry[] = []; + readonly #pendingEntries: ComparisonWorkloadEntry[] = []; + readonly #missingIndices: number[] = []; + #assignmentEpoch = 0; + #assignmentSignature = '[]'; + #autoPanTimestamp: number | undefined; + #disposed = false; + #iconSize = 1; + #pendingWindow: IconGridVirtualWindow | undefined; + #refreshDeferred = false; + #refreshing = false; + #requestScrollX = 0; + #requestScrollY = 0; + #recycleCount = 0; + #settledWindow: IconGridVirtualWindow | undefined; + #suspended = false; + #windowRevision = 0; + readonly #pool: IconGridEntryPool; + readonly #isCurrent: () => boolean; + + constructor(pool: IconGridEntryPool, isCurrent: () => boolean) { + this.#pool = pool; + this.#isCurrent = isCurrent; + } + + activate(configuration: ComparisonWorkloadConfiguration, viewport: IconGridViewport): IconGridVirtualWindow { + this.#assertLive(); + this.#iconSize = configuration.fontSize; + const origin = iconGridVirtualWindow( + ICON_GRID_ITEMS.length, + configuration.fontSize, + viewport.width, + viewport.height, + 0, + 0, + ); + const start = iconGridAutoPanStart( + configuration.iconGridView ?? 'origin', + origin.maximumScrollX, + origin.maximumScrollY, + ); + this.#autoPan.directionX = start.directionX; + this.#autoPan.directionY = start.directionY; + this.#autoPan.scrollX = start.scrollX; + this.#autoPan.scrollY = start.scrollY; + this.#autoPanTimestamp = undefined; + this.#frameDelta.smoothedElapsedMs = undefined; + this.#requestScrollX = start.scrollX; + this.#requestScrollY = start.scrollY; + this.#settledWindow = undefined; + return iconGridVirtualWindow( + ICON_GRID_ITEMS.length, + configuration.fontSize, + viewport.width, + viewport.height, + start.scrollX, + start.scrollY, + ); + } + + dispose(): void { + this.#disposed = true; + this.#pendingWindow = undefined; + this.#availableEntries.length = 0; + this.#pendingEntries.length = 0; + this.#missingIndices.length = 0; + } + + frame( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + timestamp: number, + animationRate: number, + onError: (error: unknown) => void, + ): void { + if (this.#disposed) return; + const elapsedMs = this.#autoPanTimestamp === undefined ? 0 : Math.max(0, timestamp - this.#autoPanTimestamp); + this.#autoPanTimestamp = timestamp; + const window = this.#settledWindow; + if (!configuration.animationEnabled || window === undefined) return; + advanceIconGridAutoPan( + this.#autoPan, + -scene.position.x, + scene.position.y, + window.maximumScrollX, + window.maximumScrollY, + smoothIconGridFrameDelta(this.#frameDelta, elapsedMs), + ICON_GRID_AUTO_PAN_PX_PER_SECOND * animationRate, + ); + scene.position.set(-this.#autoPan.scrollX, this.#autoPan.scrollY, 0); + updateIconGridEntryVisibility( + this.#pool.entries(), + window.layout, + this.#autoPan.scrollX, + this.#autoPan.scrollY, + viewport, + ); + const pitchX = window.layout.cellWidth + window.layout.gap; + const pitchY = window.layout.cellHeight + window.layout.gap; + if ( + Math.abs(this.#autoPan.scrollX - this.#requestScrollX) >= pitchX || + Math.abs(this.#autoPan.scrollY - this.#requestScrollY) >= pitchY + ) { + this.requestRefresh(configuration, viewport, scene, onError); + } + } + + metrics(viewport: IconGridViewport, scene: THREE.Scene): IconGridWorkloadMetrics { + const window = + this.#settledWindow ?? + iconGridVirtualWindow( + ICON_GRID_ITEMS.length, + this.#iconSize, + viewport.width, + viewport.height, + -scene.position.x, + scene.position.y, + ); + let assignedCount = 0; + let renderVisibleCount = 0; + const entries = this.#pool.entries(); + for (const entry of entries) { + if (entry.virtualIconIndex !== undefined) assignedCount += 1; + if (entry.node.visible) renderVisibleCount += 1; + } + return { + assignedCount, + assignmentSignature: this.#assignmentSignature, + columnCount: window.layout.columns, + firstVisibleIndex: window.firstVisibleIndex, + gridHeight: window.layout.height, + gridWidth: window.layout.width, + lastVisibleIndex: window.lastVisibleIndex, + maximumScrollX: window.maximumScrollX, + maximumScrollY: window.maximumScrollY, + poolCapacity: entries.length, + recycleCount: this.#recycleCount, + renderVisibleCount, + rowCount: window.layout.rows, + scrollX: window.scrollX, + scrollY: window.scrollY, + windowRevision: this.#windowRevision, + }; + } + + panBy( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + deltaX: number, + deltaY: number, + onError: (error: unknown) => void, + ): { readonly deltaX: number; readonly deltaY: number } { + this.#assertLive(); + const horizontal = finite(deltaX, 'workload horizontal pan'); + const vertical = finite(deltaY, 'workload vertical pan'); + const previousX = scene.position.x; + const previousY = scene.position.y; + scene.position.x += horizontal; + scene.position.y -= vertical; + this.#clampScene(configuration.fontSize, viewport, scene); + this.requestRefresh(configuration, viewport, scene, onError); + return { deltaX: scene.position.x - previousX, deltaY: previousY - scene.position.y }; + } + + async reconfigure( + previous: ComparisonWorkloadConfiguration, + next: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + ): Promise { + this.#assertLive(); + const viewChanged = next.iconGridView !== previous.iconGridView; + const nextAutoPan = viewChanged ? iconGridViewStart(next, viewport) : undefined; + const [requestedScrollX, requestedScrollY] = + nextAutoPan !== undefined + ? [nextAutoPan.scrollX, nextAutoPan.scrollY] + : next.fontSize === previous.fontSize + ? [-scene.position.x, scene.position.y] + : iconGridCenteredScroll( + ICON_GRID_ITEMS.length, + previous.fontSize, + next.fontSize, + viewport.width, + viewport.height, + -scene.position.x, + scene.position.y, + ); + const nextWindow = iconGridVirtualWindow( + ICON_GRID_ITEMS.length, + next.fontSize, + viewport.width, + viewport.height, + requestedScrollX, + requestedScrollY, + ); + await this.#pool.resize(nextWindow.poolCapacity, next.fontSize, nextWindow.layout); + if (!this.#isLive()) return; + if (nextAutoPan !== undefined) { + this.#autoPan.directionX = nextAutoPan.directionX; + this.#autoPan.directionY = nextAutoPan.directionY; + this.#frameDelta.smoothedElapsedMs = undefined; + } + this.#iconSize = next.fontSize; + scene.position.set(-nextWindow.scrollX, nextWindow.scrollY, 0); + this.#applyWindow(nextWindow, next.fontSize, viewport, scene); + } + + requestRefresh( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + onError: (error: unknown) => void, + ): void { + if (!this.#isLive()) return; + this.#requestScrollX = -scene.position.x; + this.#requestScrollY = scene.position.y; + if (this.#suspended) { + this.#refreshDeferred = true; + return; + } + this.#pendingWindow = iconGridVirtualWindow( + ICON_GRID_ITEMS.length, + configuration.fontSize, + viewport.width, + viewport.height, + this.#requestScrollX, + this.#requestScrollY, + ); + if (this.#refreshing) return; + this.#refreshing = true; + try { + while (this.#pendingWindow !== undefined && this.#isLive()) { + const nextWindow = this.#pendingWindow; + this.#pendingWindow = undefined; + this.#applyWindow(nextWindow, configuration.fontSize, viewport, scene); + } + } catch (error) { + onError(error); + } finally { + this.#refreshing = false; + } + if (this.#pendingWindow !== undefined && this.#isLive()) { + this.requestRefresh(configuration, viewport, scene, onError); + } + } + + resetView( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + onError: (error: unknown) => void, + ): void { + if (this.#disposed) return; + this.#autoPan.directionX = 1; + this.#autoPan.directionY = 1; + this.#autoPan.scrollX = 0; + this.#autoPan.scrollY = 0; + this.#autoPanTimestamp = undefined; + this.#frameDelta.smoothedElapsedMs = undefined; + scene.position.set(0, 0, 0); + this.requestRefresh(configuration, viewport, scene, onError); + } + + resume( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, + scene: THREE.Scene, + onError: (error: unknown) => void, + ): void { + if (this.#disposed) return; + this.#suspended = false; + if (!this.#refreshDeferred) return; + this.#refreshDeferred = false; + this.requestRefresh(configuration, viewport, scene, onError); + } + + suspend(): void { + if (this.#disposed) return; + this.#suspended = true; + this.#refreshDeferred = true; + } + + settle(configuration: ComparisonWorkloadConfiguration, viewport: IconGridViewport, scene: THREE.Scene): void { + this.#iconSize = configuration.fontSize; + this.#settleWindow( + iconGridVirtualWindow( + ICON_GRID_ITEMS.length, + configuration.fontSize, + viewport.width, + viewport.height, + -scene.position.x, + scene.position.y, + ), + viewport, + scene, + ); + } + + #applyWindow(window: IconGridVirtualWindow, iconSize: number, viewport: IconGridViewport, scene: THREE.Scene): void { + const entries = this.#pool.entries(); + if (window.poolCapacity !== entries.length) { + throw new Error('icon grid pool capacity changed without a scene rebuild'); + } + this.#assignmentEpoch = (this.#assignmentEpoch + 1) >>> 0; + if (this.#assignmentEpoch === 0) { + this.#desiredEpochs.fill(0); + this.#retainedEpochs.fill(0); + this.#assignmentEpoch = 1; + } + this.#availableEntries.length = 0; + this.#pendingEntries.length = 0; + this.#missingIndices.length = 0; + for (const index of window.indices) this.#desiredEpochs[index] = this.#assignmentEpoch; + for (const entry of entries) { + const iconIndex = entry.virtualIconIndex; + if ( + iconIndex !== undefined && + this.#desiredEpochs[iconIndex] === this.#assignmentEpoch && + this.#retainedEpochs[iconIndex] !== this.#assignmentEpoch + ) { + this.#retainedEpochs[iconIndex] = this.#assignmentEpoch; + continue; + } + this.#availableEntries.push(entry); + } + for (const index of window.indices) { + if (this.#retainedEpochs[index] !== this.#assignmentEpoch) this.#missingIndices.push(index); + } + if (this.#missingIndices.length > this.#availableEntries.length) { + throw new Error('icon grid window exceeds its recyclable tile pool'); + } + for (const [poolIndex, iconIndex] of this.#missingIndices.entries()) { + const entry = this.#availableEntries[poolIndex]!; + const { glyph } = iconGridContent(iconIndex); + entry.text.setProperties({ text: glyph }); + entry.labelText?.setProperties({ text: iconGridLabel(iconIndex) }); + this.#pendingEntries.push(entry); + } + for (const entry of this.#pendingEntries) entry.node.updateMatrixWorld(true); + if (!this.#isLive()) return; + for (const [poolIndex, entry] of this.#pendingEntries.entries()) { + if (entry.disposed) continue; + const iconIndex = this.#missingIndices[poolIndex]!; + const { content } = iconGridContent(iconIndex); + entry.virtualIconIndex = iconIndex; + entry.sourceText = content; + const column = iconIndex % window.layout.columns; + const row = Math.floor(iconIndex / window.layout.columns); + positionIconGridEntry(entry, window.layout, column, row, iconSize); + } + for (let index = this.#missingIndices.length; index < this.#availableEntries.length; index += 1) { + const entry = this.#availableEntries[index]!; + entry.node.visible = false; + delete entry.virtualIconIndex; + } + this.#recycleCount += this.#missingIndices.length; + this.#settleWindow(window, viewport, scene); + } + + #assertLive(): void { + if (!this.#isLive()) throw new DOMException('The Icon Grid workload instance is disposed', 'AbortError'); + } + + #clampScene(iconSize: number, viewport: IconGridViewport, scene: THREE.Scene): void { + const layout = iconGridLayout(ICON_GRID_ITEMS.length, iconSize, viewport.width); + const maximumScrollX = Math.max(0, layout.width - viewport.width); + const maximumScrollY = Math.max(0, layout.height - viewport.height); + scene.position.x = Math.min(0, Math.max(-maximumScrollX, scene.position.x)); + scene.position.y = Math.min(maximumScrollY, Math.max(0, scene.position.y)); + } + + #isLive(): boolean { + return !this.#disposed && this.#isCurrent(); + } + + #settleWindow(window: IconGridVirtualWindow, viewport: IconGridViewport, scene: THREE.Scene): void { + const assignments = iconGridAssignments(this.#pool.entries()); + if ( + assignments.length !== window.indices.length || + assignments.some(({ index }, assignmentIndex) => index !== window.indices[assignmentIndex]) + ) { + throw new Error('icon grid cannot publish a window before every assignment is coherent'); + } + updateIconGridEntryVisibility(this.#pool.entries(), window.layout, -scene.position.x, scene.position.y, viewport); + this.#settledWindow = window; + this.#assignmentSignature = JSON.stringify(assignments); + this.#windowRevision += 1; + } +} + +function iconGridViewStart( + configuration: ComparisonWorkloadConfiguration, + viewport: IconGridViewport, +): IconGridAutoPanState { + const origin = iconGridVirtualWindow( + ICON_GRID_ITEMS.length, + configuration.fontSize, + viewport.width, + viewport.height, + 0, + 0, + ); + return iconGridAutoPanStart(configuration.iconGridView ?? 'origin', origin.maximumScrollX, origin.maximumScrollY); +} + +function updateIconGridEntryVisibility( + entries: readonly ComparisonWorkloadEntry[], + layout: IconGridLayout, + scrollX: number, + scrollY: number, + viewport: IconGridViewport, +): void { + const pitchX = layout.cellWidth + layout.gap; + const pitchY = layout.cellHeight + layout.gap; + const viewportRight = scrollX + viewport.width; + const viewportBottom = scrollY + viewport.height; + for (const entry of entries) { + const index = entry.virtualIconIndex; + if (index === undefined) { + entry.node.visible = false; + continue; + } + const column = index % layout.columns; + const row = Math.floor(index / layout.columns); + const left = layout.inset + column * pitchX; + const top = layout.inset + row * pitchY; + entry.node.visible = + left + layout.cellWidth > scrollX && + left < viewportRight && + top + layout.cellHeight > scrollY && + top < viewportBottom; + } +} + +function finite(value: number, label: string): number { + if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite`); + return value; +} + export function advanceIconGridAutoPan( state: IconGridAutoPanState, scrollX: number, From 480a8543f4841ad7f430a6f2bd1647d0392c7ce7 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 3 Aug 2026 05:24:26 -0400 Subject: [PATCH 2/5] refactor(benchmarks): extract route surface hierarchy --- apps/benchmarks/src/app.tsx | 507 +----------------- .../src/components/harness-layout.tsx | 286 ++++++++++ .../src/components/runtime-controls.tsx | 89 +++ .../benchmark/bake-progress-overlay.tsx | 80 +++ .../benchmark/live-benchmark-surface.tsx | 90 ++++ 5 files changed, 562 insertions(+), 490 deletions(-) create mode 100644 apps/benchmarks/src/components/harness-layout.tsx create mode 100644 apps/benchmarks/src/components/runtime-controls.tsx create mode 100644 apps/benchmarks/src/surfaces/benchmark/bake-progress-overlay.tsx create mode 100644 apps/benchmarks/src/surfaces/benchmark/live-benchmark-surface.tsx diff --git a/apps/benchmarks/src/app.tsx b/apps/benchmarks/src/app.tsx index d059b7c5..f4b3a71b 100644 --- a/apps/benchmarks/src/app.tsx +++ b/apps/benchmarks/src/app.tsx @@ -1,6 +1,5 @@ import { Activity, - lazy, Suspense, use, useEffect, @@ -9,7 +8,6 @@ import { useState, useSyncExternalStore, useTransition, - type ComponentProps, type ReactNode, type RefObject, } from 'react'; @@ -28,11 +26,9 @@ import type { BenchmarkSummary, RunnerEvent } from './benchmark/contracts'; import { environmentResource } from './benchmark/environment'; import { runRegisteredBenchmark } from './benchmark/execution'; import { - RuntimeAnimationControls, defaultRuntimeFontSizeForWorkload, resetRuntimeControlsForWorkload, RuntimeLayoutControls, - RuntimePaintControls, RuntimeTelemetry, RuntimeViewControls, useRuntimeAnimationControls, @@ -45,7 +41,6 @@ import { } from './benchmark/runtime-world'; import { RuntimeWorldProvider } from './benchmark/runtime-world-provider'; import { captureLiveTextStats, type LiveBenchmarkCapture } from './benchmark/product-result'; -import { createPayloadSummary } from './benchmark/payload-summary'; import { adjacentPresentationWorkload, presentationFrame, @@ -57,13 +52,9 @@ import { type MutableParagraphStressMotionFrame, } from './benchmark/paragraph-stress-motion'; import { - ADVANCED_FONT_FIXTURES, - BENCHMARK_FONT_LABELS, - SELECTABLE_FONT_FIXTURES, benchmarkIpsumText, liveWorkloadFontFixtures, rasterConformanceSpecimen, - selectableFontFixture, type BenchmarkFontFixture, type SelectableFontFixture, } from './benchmark/font-fixtures'; @@ -77,28 +68,16 @@ import { type HarnessMode, type RasterTechnique, } from './benchmark/url-state'; -import { ExportPanel } from './components/export-panel'; -import { Report } from './components/report'; -import { Controls, type ConformanceView } from './components/render-controls'; -import { CompactSheet, CompactWorkloadPanel, MobileNavigation } from './components/responsive-shell'; -import { TechniqueSwitcher } from './components/technique-switcher'; -import { TelemetryCharts } from './components/telemetry-charts'; -import { TopBar } from './components/top-bar'; +import type { ConformanceView } from './components/render-controls'; +import { HarnessLayout as HarnessAppLayout, type HarnessLayoutProps } from './components/harness-layout'; +import { RuntimeControls } from './components/runtime-controls'; import { workloadById, - workloadsFor, isConformanceWorkloadId, type ConformanceWorkloadId, type WorkloadOption, } from './benchmark/workloads'; -import { WorkloadRail } from './components/workload-rail'; -import { PresentationLayout } from './components/presentation-layout'; -import { PresentationPayloadPills } from './components/presentation-payload-pills'; -import { Chip, Metric } from './components/ui'; -import packageSizes from './generated/package-sizes.json'; -import bitmapFixtures from '../fixtures/rendering/showcase-bitmap-density-fixtures-v0.json'; -import mtsdfFixtures from '../fixtures/rendering/showcase-mtsdf-fixtures-v0.json'; -import slugFixtures from '../fixtures/rendering/showcase-slug-fixtures-v0.json'; +import { Chip } from './components/ui'; import type { BitmapTextLiveStats, BitmapTextPersistentScene, @@ -119,7 +98,6 @@ import { benchmarkWorkloadDefinition, comparisonWorkloadId, isBenchmarkWorkloadId, - type BenchmarkWorkloadDefinition, type BenchmarkWorkloadId, } from './workloads/catalog'; import { @@ -129,6 +107,8 @@ import { } from './workloads/shared/text-style'; import { PersistentRenderHostProvider, usePersistentRenderHost } from './renderer/persistent-render-host-context'; import { ConformanceSurface } from './surfaces/conformance/conformance-surface'; +import { BakeProgressOverlay, useBakeProgress } from './surfaces/benchmark/bake-progress-overlay'; +import { LiveBenchmarkSurface } from './surfaces/benchmark/live-benchmark-surface'; import type { BakeProgress } from '@pmndrs/text'; import { Route, Switch, useLocation } from 'wouter'; @@ -259,7 +239,6 @@ const INITIAL_CONFORMANCE_VIEW: ConformanceView = { const EMPTY_FONT_FEATURES: BitmapTextPreviewUpdate['features'] = []; const GLYPH_POSITION_TRANSITION_MS = 110; -const FontNoticesDialog = lazy(() => import('./components/font-notices-dialog')); function techniqueLabel(technique: RasterTechnique): 'Bitmap' | 'MSDF' | 'Slug' { return technique === 'mtsdf' ? 'MSDF' : technique === 'slug' ? 'Slug' : 'Bitmap'; } @@ -273,36 +252,6 @@ function workloadAmountLabel(workload: BenchmarkWorkloadId, amount: number): str return range === undefined ? undefined : `${range.label} · ${amount}%`; } -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 { return value === undefined ? '—' : `${value.toFixed(2)} ms`; } @@ -956,13 +905,13 @@ function PersistentHarnessLayout({ onBenchmarkAction, onConformanceAction, ...properties -}: Omit[0], 'onAction'> & { +}: Omit & { readonly onBenchmarkAction: () => void; readonly onConformanceAction: (runExclusiveJob: RunExclusiveJob) => void; }) { const { runExclusiveJob } = usePersistentRenderHost(); return ( - onConformanceAction(runExclusiveJob) @@ -971,306 +920,6 @@ function PersistentHarnessLayout({ ); } -type RuntimeControlsProps = Omit< - ComponentProps, - | 'animationEnabled' - | 'animationSpeed' - | 'fontSize' - | 'layoutWidthPercent' - | 'liveStats' - | 'onAnimationEnabled' - | 'onAnimationSpeed' - | 'onFontSize' - | 'onLayoutWidthPercent' - | 'onPaintOpacityPercent' - | 'onPaintShadowEnabled' - | 'onPaintStrokePercent' - | 'onShowGrid' - | 'onShowLayoutBounds' - | 'onWorkloadAmount' - | 'paintOpacityPercent' - | 'paintShadowEnabled' - | 'paintStrokePercent' - | 'showGrid' - | 'showLayoutBounds' - | 'workloadAmount' -> & { - readonly onBeforeShowGrid: () => void; - readonly onRuntimeControl: () => void; -}; - -function RuntimeControls({ onBeforeShowGrid, onRuntimeControl, ...props }: RuntimeControlsProps) { - const world = useRuntimeWorld(); - const view = useRuntimeViewControls(); - const layout = useRuntimeLayoutControls(); - const animation = useRuntimeAnimationControls(); - const paint = useRuntimePaintControls(); - const { stats: liveStats } = useRuntimeTelemetry(); - const changed = (change: () => void): void => { - change(); - onRuntimeControl(); - }; - return ( - - changed(() => world.set(RuntimeAnimationControls, { animationEnabled })) - } - onAnimationSpeed={(animationSpeed) => changed(() => world.set(RuntimeAnimationControls, { animationSpeed }))} - onFontSize={(fontSize) => changed(() => world.set(RuntimeLayoutControls, { fontSize }))} - onLayoutWidthPercent={(layoutWidthPercent) => - changed(() => world.set(RuntimeLayoutControls, { layoutWidthPercent })) - } - onPaintOpacityPercent={(paintOpacityPercent) => - changed(() => world.set(RuntimePaintControls, { paintOpacityPercent })) - } - onPaintShadowEnabled={(paintShadowEnabled) => - changed(() => world.set(RuntimePaintControls, { paintShadowEnabled })) - } - onPaintStrokePercent={(paintStrokePercent) => - changed(() => world.set(RuntimePaintControls, { paintStrokePercent })) - } - onShowGrid={(showGrid) => { - onBeforeShowGrid(); - changed(() => world.set(RuntimeViewControls, { showGrid })); - }} - onShowLayoutBounds={(showLayoutBounds) => changed(() => world.set(RuntimeViewControls, { showLayoutBounds }))} - onWorkloadAmount={(workloadAmount) => changed(() => world.set(RuntimeLayoutControls, { workloadAmount }))} - /> - ); -} - -function HarnessLayout({ - actionEligible, - activeFontFixture, - controls, - desktop, - fontNoticesOpen, - isPending, - liveCapture, - liveTechniqueComparison, - location, - phone, - presentationPlaying, - scene, - showcaseFrame, - summary, - webgpu, - workloadPanelOpen, - onAction, - onAdvancedFontFixture, - onCloseFontNotices, - onLocation, - onMode, - onTechnique, - onWorkloadPanelOpen, -}: { - readonly actionEligible: boolean; - readonly activeFontFixture: BenchmarkFontFixture; - readonly controls: ReactNode; - readonly desktop: boolean; - readonly fontNoticesOpen: boolean; - readonly isPending: boolean; - readonly liveCapture: LiveBenchmarkCapture | undefined; - readonly liveTechniqueComparison: boolean; - readonly location: HarnessLocation; - readonly phone: boolean; - readonly presentationPlaying: boolean; - readonly scene: ReactNode; - readonly showcaseFrame: AdvancedShapingFrame; - readonly summary: BenchmarkSummary | undefined; - readonly webgpu: boolean; - readonly workloadPanelOpen: boolean; - readonly onAction: () => void; - readonly onAdvancedFontFixture: (value: BenchmarkFontFixture) => void; - readonly onCloseFontNotices: () => void; - readonly onLocation: (value: Partial) => void; - readonly onMode: (mode: HarnessMode) => void; - readonly onTechnique: (technique: RasterTechnique) => void; - readonly onWorkloadPanelOpen: (open: boolean | ((current: boolean) => boolean)) => void; -}) { - const { stats: liveStats } = useRuntimeTelemetry(); - const actionReady = actionEligible && (location.mode === 'conformance' || liveStats !== undefined); - const presentationMode = location.layout === 'presentation' && location.mode === 'benchmark'; - if (presentationMode) { - 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 }, - fontFixture: activeFontFixture, - ...(liveStats === undefined ? {} : { liveStats }), - packageSizes, - technique: location.technique, - workload: location.workload, - }); - return ( - <> - } - playing={presentationPlaying} - scene={scene} - techniqueControl={ - - } - telemetry={} - workloadOptions={workloadsFor('benchmark').map((option) => ({ - disabled: option.techniques[location.technique].kind !== 'ready', - label: option.label, - value: option.id, - }))} - workloadValue={presentationWorkload} - onExit={() => onLocation({ layout: 'main' })} - onFont={(value) => { - const policy = presentationDefinition.fontPolicy; - if (policy.kind === 'fixed' || policy.kind === 'icon-grid') return; - if (policy.kind === 'advanced-case') { - onAdvancedFontFixture(value as BenchmarkFontFixture); - return; - } - onLocation({ fontFixture: selectableFontFixture(value) }); - }} - onWorkload={(workloadId) => onLocation({ workload: workloadId, view: 'scene' })} - /> - {fontNoticesOpen && ( - - - - )} - - ); - } - - return ( -
- onLocation({ view: 'scene' }) - : onAction - : onAction - } - onControls={() => { - onWorkloadPanelOpen(false); - onLocation({ view: location.view === 'controls' ? 'scene' : 'controls' }); - }} - onMenu={() => { - if (!workloadPanelOpen && location.view === 'controls') { - onLocation({ view: 'scene' }); - } - onWorkloadPanelOpen((open) => !open); - }} - onMode={onMode} - onTechnique={onTechnique} - onPresentationMode={() => onLocation({ layout: 'presentation', mode: 'benchmark', view: 'scene' })} - workloadPanelOpen={workloadPanelOpen} - /> -
-
- onLocation({ fontFixture: value })} - onAdvancedFontFixture={onAdvancedFontFixture} - onLocation={onLocation} - onTechnique={onTechnique} - /> -
-
-
{scene}
- {!desktop && location.view === 'controls' && ( - onLocation({ view: 'scene' })}> - {controls} - - )} - {location.view === 'report' && ( -
- -
- )} - {location.view === 'export' && ( -
- -
- )} -
- - {!desktop && workloadPanelOpen && ( - onWorkloadPanelOpen(false)}> - onLocation({ fontFixture: value })} - onAdvancedFontFixture={onAdvancedFontFixture} - onLocation={(value) => { - onLocation({ ...value, view: 'scene' }); - onWorkloadPanelOpen(false); - }} - onTechnique={onTechnique} - /> - - )} - {!desktop && phone && } -
- {fontNoticesOpen && ( - - - - )} -
- ); -} - function locationSearch(): string { return typeof globalThis.location === 'undefined' ? '' : globalThis.location.search; } @@ -1655,62 +1304,15 @@ function BenchmarkSurface({ /> ); return ( -
-
- {presentation === 'main' && ( - <> - -
- -
-
- -
- - )} -
-
-
- {presentation === 'main' && ( - <> -
-

Realtime scene

-

{liveWorkloadSceneDescription(workload, showcaseFrame)}

-
- LIVE - - )} -
-
- {viewport} -
-
-
+ ); } @@ -3038,78 +2640,3 @@ function ComparisonWorkloadViewport({ ); } - -function useBakeProgress(label: string): { - readonly value: BakeProgress | undefined; - readonly active: boolean; - readonly publish: (progress: BakeProgress) => void; - readonly finish: () => void; -} { - const [value, setValue] = useState(); - const [active, setActive] = useState(false); - const lastConsoleKey = useRef(''); - const publish = (progress: BakeProgress): void => { - setValue(progress); - setActive(true); - if (!import.meta.env.DEV) return; - const percentage = Math.round((progress.completed / progress.total) * 100); - const bucket = Math.floor(percentage / 10) * 10; - const key = `${progress.stage}:${progress.phase}:${String(bucket)}`; - if (key === lastConsoleKey.current) return; - lastConsoleKey.current = key; - console.info(`[pmndrs/text] ${label} ${progress.stage} bake: ${progress.phase} ${String(percentage)}%`); - }; - const finish = (): void => setActive(false); - return { value, active, publish, finish }; -} - -function BakeProgressOverlay({ - backend, - progress, - technique, -}: { - readonly backend: GraphicsBackend; - readonly progress: BakeProgress | undefined; - readonly technique: 'BITMAP' | 'MSDF' | 'SLUG'; -}) { - const percentage = bakeProgressPercentage(progress); - const label = - progress === undefined - ? `INITIALIZING ${technique} ${backend.toUpperCase()}` - : `${progress.stage === 'font' ? 'FONT' : technique} ${progress.phase.toUpperCase()}`; - return ( -
-
-
- {label} - {percentage}% -
- -
-
- ); -} - -function bakeProgressPercentage(progress: BakeProgress | undefined): number { - if (progress === undefined) return 0; - const ratio = progress.completed / progress.total; - if (progress.stage === 'font') { - if (progress.phase === 'loading') return 2; - if (progress.phase === 'baking') return 8; - if (progress.phase === 'packaging') return 16; - if (progress.phase === 'transferring') return 19; - if (progress.phase === 'complete') return 20; - return Math.round(ratio * 20); - } - if (progress.phase === 'loading') return 22; - if (progress.phase === 'rasterizing') return 25 + Math.round(ratio * 65); - if (progress.phase === 'packaging') return 92; - if (progress.phase === 'transferring') return 97; - if (progress.phase === 'complete') return 100; - return 20; -} diff --git a/apps/benchmarks/src/components/harness-layout.tsx b/apps/benchmarks/src/components/harness-layout.tsx new file mode 100644 index 00000000..b85730af --- /dev/null +++ b/apps/benchmarks/src/components/harness-layout.tsx @@ -0,0 +1,286 @@ +import { lazy, Suspense, type ReactNode } from 'react'; + +import type { BenchmarkSummary } from '../benchmark/contracts'; +import { createPayloadSummary } from '../benchmark/payload-summary'; +import { useRuntimeTelemetry } from '../benchmark/runtime-world'; +import type { LiveBenchmarkCapture } from '../benchmark/product-result'; +import type { AdvancedShapingFrame } from '../workloads/advanced-shaping'; +import { + benchmarkWorkloadDefinition, + isBenchmarkWorkloadId, + type BenchmarkWorkloadDefinition, +} from '../workloads/catalog'; +import { + ADVANCED_FONT_FIXTURES, + BENCHMARK_FONT_LABELS, + SELECTABLE_FONT_FIXTURES, + type BenchmarkFontFixture, + selectableFontFixture, +} from '../benchmark/font-fixtures'; +import { workloadsFor } from '../benchmark/workloads'; +import type { HarnessLocation, HarnessMode, RasterTechnique } from '../benchmark/url-state'; +import bitmapFixtures from '../../fixtures/rendering/showcase-bitmap-density-fixtures-v0.json'; +import mtsdfFixtures from '../../fixtures/rendering/showcase-mtsdf-fixtures-v0.json'; +import slugFixtures from '../../fixtures/rendering/showcase-slug-fixtures-v0.json'; +import packageSizes from '../generated/package-sizes.json'; +import { CompactSheet, CompactWorkloadPanel, MobileNavigation } from './responsive-shell'; +import { ExportPanel } from './export-panel'; +import { PresentationLayout } from './presentation-layout'; +import { PresentationPayloadPills } from './presentation-payload-pills'; +import { Report } from './report'; +import { TechniqueSwitcher } from './technique-switcher'; +import { TelemetryCharts } from './telemetry-charts'; +import { TopBar } from './top-bar'; +import { WorkloadRail } from './workload-rail'; + +const FontNoticesDialog = lazy(() => import('./font-notices-dialog')); + +export interface HarnessLayoutProps { + readonly actionEligible: boolean; + readonly activeFontFixture: BenchmarkFontFixture; + readonly controls: ReactNode; + readonly desktop: boolean; + readonly fontNoticesOpen: boolean; + readonly isPending: boolean; + readonly liveCapture: LiveBenchmarkCapture | undefined; + readonly liveTechniqueComparison: boolean; + readonly location: HarnessLocation; + readonly phone: boolean; + readonly presentationPlaying: boolean; + readonly scene: ReactNode; + readonly showcaseFrame: AdvancedShapingFrame; + readonly summary: BenchmarkSummary | undefined; + readonly webgpu: boolean; + readonly workloadPanelOpen: boolean; + readonly onAction: () => void; + readonly onAdvancedFontFixture: (value: BenchmarkFontFixture) => void; + readonly onCloseFontNotices: () => void; + readonly onLocation: (value: Partial) => void; + readonly onMode: (mode: HarnessMode) => void; + readonly onTechnique: (technique: RasterTechnique) => void; + readonly onWorkloadPanelOpen: (open: boolean | ((current: boolean) => boolean)) => void; +} + +export function HarnessLayout({ + actionEligible, + activeFontFixture, + controls, + desktop, + fontNoticesOpen, + isPending, + liveCapture, + liveTechniqueComparison, + location, + phone, + presentationPlaying, + scene, + showcaseFrame, + summary, + webgpu, + workloadPanelOpen, + onAction, + onAdvancedFontFixture, + onCloseFontNotices, + onLocation, + onMode, + onTechnique, + onWorkloadPanelOpen, +}: HarnessLayoutProps) { + const { stats: liveStats } = useRuntimeTelemetry(); + const actionReady = actionEligible && (location.mode === 'conformance' || liveStats !== undefined); + const presentationMode = location.layout === 'presentation' && location.mode === 'benchmark'; + if (presentationMode) { + 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 }, + fontFixture: activeFontFixture, + ...(liveStats === undefined ? {} : { liveStats }), + packageSizes, + technique: location.technique, + workload: location.workload, + }); + return ( + <> + } + playing={presentationPlaying} + scene={scene} + techniqueControl={ + + } + telemetry={} + workloadOptions={workloadsFor('benchmark').map((option) => ({ + disabled: option.techniques[location.technique].kind !== 'ready', + label: option.label, + value: option.id, + }))} + workloadValue={presentationWorkload} + onExit={() => onLocation({ layout: 'main' })} + onFont={(value) => { + const policy = presentationDefinition.fontPolicy; + if (policy.kind === 'fixed' || policy.kind === 'icon-grid') return; + if (policy.kind === 'advanced-case') { + onAdvancedFontFixture(value as BenchmarkFontFixture); + return; + } + onLocation({ fontFixture: selectableFontFixture(value) }); + }} + onWorkload={(workloadId) => onLocation({ workload: workloadId, view: 'scene' })} + /> + {fontNoticesOpen && ( + + + + )} + + ); + } + + return ( +
+ onLocation({ view: 'scene' }) + : onAction + : onAction + } + onControls={() => { + onWorkloadPanelOpen(false); + onLocation({ view: location.view === 'controls' ? 'scene' : 'controls' }); + }} + onMenu={() => { + if (!workloadPanelOpen && location.view === 'controls') onLocation({ view: 'scene' }); + onWorkloadPanelOpen((open) => !open); + }} + onMode={onMode} + onTechnique={onTechnique} + onPresentationMode={() => onLocation({ layout: 'presentation', mode: 'benchmark', view: 'scene' })} + workloadPanelOpen={workloadPanelOpen} + /> +
+
+ onLocation({ fontFixture: value })} + onAdvancedFontFixture={onAdvancedFontFixture} + onLocation={onLocation} + onTechnique={onTechnique} + /> +
+
+
{scene}
+ {!desktop && location.view === 'controls' && ( + onLocation({ view: 'scene' })}> + {controls} + + )} + {location.view === 'report' && ( +
+ +
+ )} + {location.view === 'export' && ( +
+ +
+ )} +
+ + {!desktop && workloadPanelOpen && ( + onWorkloadPanelOpen(false)}> + onLocation({ fontFixture: value })} + onAdvancedFontFixture={onAdvancedFontFixture} + onLocation={(value) => { + onLocation({ ...value, view: 'scene' }); + onWorkloadPanelOpen(false); + }} + onTechnique={onTechnique} + /> + + )} + {!desktop && phone && } +
+ {fontNoticesOpen && ( + + + + )} +
+ ); +} + +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; +} diff --git a/apps/benchmarks/src/components/runtime-controls.tsx b/apps/benchmarks/src/components/runtime-controls.tsx new file mode 100644 index 00000000..1fde092a --- /dev/null +++ b/apps/benchmarks/src/components/runtime-controls.tsx @@ -0,0 +1,89 @@ +import type { ComponentProps } from 'react'; + +import { + RuntimeAnimationControls, + RuntimeLayoutControls, + RuntimePaintControls, + RuntimeViewControls, + useRuntimeAnimationControls, + useRuntimeLayoutControls, + useRuntimePaintControls, + useRuntimeTelemetry, + useRuntimeViewControls, + useRuntimeWorld, +} from '../benchmark/runtime-world'; +import { Controls } from './render-controls'; + +export type RuntimeControlsProps = Omit< + ComponentProps, + | 'animationEnabled' + | 'animationSpeed' + | 'fontSize' + | 'layoutWidthPercent' + | 'liveStats' + | 'onAnimationEnabled' + | 'onAnimationSpeed' + | 'onFontSize' + | 'onLayoutWidthPercent' + | 'onPaintOpacityPercent' + | 'onPaintShadowEnabled' + | 'onPaintStrokePercent' + | 'onShowGrid' + | 'onShowLayoutBounds' + | 'onWorkloadAmount' + | 'paintOpacityPercent' + | 'paintShadowEnabled' + | 'paintStrokePercent' + | 'showGrid' + | 'showLayoutBounds' + | 'workloadAmount' +> & { + readonly onBeforeShowGrid: () => void; + readonly onRuntimeControl: () => void; +}; + +export function RuntimeControls({ onBeforeShowGrid, onRuntimeControl, ...props }: RuntimeControlsProps) { + const world = useRuntimeWorld(); + const view = useRuntimeViewControls(); + const layout = useRuntimeLayoutControls(); + const animation = useRuntimeAnimationControls(); + const paint = useRuntimePaintControls(); + const { stats: liveStats } = useRuntimeTelemetry(); + const changed = (change: () => void): void => { + change(); + onRuntimeControl(); + }; + return ( + + changed(() => world.set(RuntimeAnimationControls, { animationEnabled })) + } + onAnimationSpeed={(animationSpeed) => changed(() => world.set(RuntimeAnimationControls, { animationSpeed }))} + onFontSize={(fontSize) => changed(() => world.set(RuntimeLayoutControls, { fontSize }))} + onLayoutWidthPercent={(layoutWidthPercent) => + changed(() => world.set(RuntimeLayoutControls, { layoutWidthPercent })) + } + onPaintOpacityPercent={(paintOpacityPercent) => + changed(() => world.set(RuntimePaintControls, { paintOpacityPercent })) + } + onPaintShadowEnabled={(paintShadowEnabled) => + changed(() => world.set(RuntimePaintControls, { paintShadowEnabled })) + } + onPaintStrokePercent={(paintStrokePercent) => + changed(() => world.set(RuntimePaintControls, { paintStrokePercent })) + } + onShowGrid={(showGrid) => { + onBeforeShowGrid(); + changed(() => world.set(RuntimeViewControls, { showGrid })); + }} + onShowLayoutBounds={(showLayoutBounds) => changed(() => world.set(RuntimeViewControls, { showLayoutBounds }))} + onWorkloadAmount={(workloadAmount) => changed(() => world.set(RuntimeLayoutControls, { workloadAmount }))} + /> + ); +} diff --git a/apps/benchmarks/src/surfaces/benchmark/bake-progress-overlay.tsx b/apps/benchmarks/src/surfaces/benchmark/bake-progress-overlay.tsx new file mode 100644 index 00000000..59e0d24b --- /dev/null +++ b/apps/benchmarks/src/surfaces/benchmark/bake-progress-overlay.tsx @@ -0,0 +1,80 @@ +import { useRef, useState } from 'react'; + +import type { BakeProgress } from '@pmndrs/text'; + +import type { GraphicsBackend } from '../../benchmark/url-state'; + +export function useBakeProgress(label: string): { + readonly value: BakeProgress | undefined; + readonly active: boolean; + readonly publish: (progress: BakeProgress) => void; + readonly finish: () => void; +} { + const [value, setValue] = useState(); + const [active, setActive] = useState(false); + const lastConsoleKey = useRef(''); + const publish = (progress: BakeProgress): void => { + setValue(progress); + setActive(true); + if (!import.meta.env.DEV) return; + const percentage = Math.round((progress.completed / progress.total) * 100); + const bucket = Math.floor(percentage / 10) * 10; + const key = `${progress.stage}:${progress.phase}:${String(bucket)}`; + if (key === lastConsoleKey.current) return; + lastConsoleKey.current = key; + console.info(`[pmndrs/text] ${label} ${progress.stage} bake: ${progress.phase} ${String(percentage)}%`); + }; + const finish = (): void => setActive(false); + return { value, active, publish, finish }; +} + +export function BakeProgressOverlay({ + backend, + progress, + technique, +}: { + readonly backend: GraphicsBackend; + readonly progress: BakeProgress | undefined; + readonly technique: 'BITMAP' | 'MSDF' | 'SLUG'; +}) { + const percentage = bakeProgressPercentage(progress); + const label = + progress === undefined + ? `INITIALIZING ${technique} ${backend.toUpperCase()}` + : `${progress.stage === 'font' ? 'FONT' : technique} ${progress.phase.toUpperCase()}`; + return ( +
+
+
+ {label} + {percentage}% +
+ +
+
+ ); +} + +function bakeProgressPercentage(progress: BakeProgress | undefined): number { + if (progress === undefined) return 0; + const ratio = progress.completed / progress.total; + if (progress.stage === 'font') { + if (progress.phase === 'loading') return 2; + if (progress.phase === 'baking') return 8; + if (progress.phase === 'packaging') return 16; + if (progress.phase === 'transferring') return 19; + if (progress.phase === 'complete') return 20; + return Math.round(ratio * 20); + } + if (progress.phase === 'loading') return 22; + if (progress.phase === 'rasterizing') return 25 + Math.round(ratio * 65); + if (progress.phase === 'packaging') return 92; + if (progress.phase === 'transferring') return 97; + if (progress.phase === 'complete') return 100; + return 20; +} diff --git a/apps/benchmarks/src/surfaces/benchmark/live-benchmark-surface.tsx b/apps/benchmarks/src/surfaces/benchmark/live-benchmark-surface.tsx new file mode 100644 index 00000000..936085d1 --- /dev/null +++ b/apps/benchmarks/src/surfaces/benchmark/live-benchmark-surface.tsx @@ -0,0 +1,90 @@ +import type { ReactNode, RefObject } from 'react'; + +import type { RuntimeLiveStats } from '../../benchmark/runtime-world'; +import { TelemetryCharts } from '../../components/telemetry-charts'; +import { Metric } from '../../components/ui'; +import type { AdvancedShapingFrame } from '../../workloads/advanced-shaping'; +import { benchmarkWorkloadDefinition, type BenchmarkWorkloadId } from '../../workloads/catalog'; + +export function LiveBenchmarkSurface({ + advanced, + presentation, + showcaseFrame, + stats, + surfaceAnchorRef, + viewport, + workload, +}: { + readonly advanced: boolean; + readonly presentation: 'main' | 'presentation'; + readonly showcaseFrame: AdvancedShapingFrame; + readonly stats: RuntimeLiveStats | undefined; + readonly surfaceAnchorRef: RefObject; + readonly viewport: ReactNode; + readonly workload: BenchmarkWorkloadId; +}) { + return ( +
+
+ {presentation === 'main' && ( + <> + +
+ +
+
+ +
+ + )} +
+
+
+ {presentation === 'main' && ( + <> +
+

Realtime scene

+

{workloadSceneDescription(workload, showcaseFrame)}

+
+ LIVE + + )} +
+
+ {viewport} +
+
+
+ ); +} + +function workloadSceneDescription(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; +} From 91d7fca19863183a9f21abe91a2b04fe97d49b60 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 3 Aug 2026 05:24:35 -0400 Subject: [PATCH 3/5] refactor(benchmarks): isolate raster conformance sessions --- .../src/benchmark/target-boundary.test.ts | 6 + .../benchmark/targets/conformance/index.ts | 70 +++---- .../targets/conformance/raster/contracts.ts | 41 ++++ .../targets/conformance/raster/mtsdf.ts | 27 +++ .../targets/conformance/raster/slug.ts | 26 +++ .../targets/conformance/raster/target.test.ts | 188 ++++++++++++++++++ .../targets/conformance/raster/target.ts | 166 ++++++++++++++++ 7 files changed, 477 insertions(+), 47 deletions(-) create mode 100644 apps/benchmarks/src/benchmark/targets/conformance/raster/contracts.ts create mode 100644 apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf.ts create mode 100644 apps/benchmarks/src/benchmark/targets/conformance/raster/slug.ts create mode 100644 apps/benchmarks/src/benchmark/targets/conformance/raster/target.test.ts create mode 100644 apps/benchmarks/src/benchmark/targets/conformance/raster/target.ts diff --git a/apps/benchmarks/src/benchmark/target-boundary.test.ts b/apps/benchmarks/src/benchmark/target-boundary.test.ts index ddb15d50..a7c03cd0 100644 --- a/apps/benchmarks/src/benchmark/target-boundary.test.ts +++ b/apps/benchmarks/src/benchmark/target-boundary.test.ts @@ -39,6 +39,8 @@ describe('benchmark target boundaries', () => { const registry = await readFile(new URL('./targets/registry.ts', import.meta.url), 'utf8'); const execution = await readFile(new URL('./execution.ts', import.meta.url), 'utf8'); const conformance = await readFile(new URL('./targets/conformance/index.ts', import.meta.url), 'utf8'); + const mtsdfAdapter = await readFile(new URL('./targets/conformance/raster/mtsdf.ts', import.meta.url), 'utf8'); + const slugAdapter = await readFile(new URL('./targets/conformance/raster/slug.ts', import.meta.url), 'utf8'); expect(registry).toContain("import('./product')"); expect(registry).toContain("import('./measurement/font-baker')"); @@ -46,6 +48,10 @@ describe('benchmark target boundaries', () => { expect(execution).toContain('await loadRegisteredTarget(request.targetId)'); expect(conformance).toContain("import('./advanced-shaping')"); expect(conformance).not.toContain('renderer/advanced-shaping-conformance'); + expect(conformance).not.toContain("import('../../../renderer/mtsdf-text')"); + expect(conformance).not.toContain("import('../../../renderer/slug-text')"); + expect(mtsdfAdapter).toContain("import('../../../../renderer/mtsdf-text')"); + expect(slugAdapter).toContain("import('../../../../renderer/slug-text')"); const { createAdvancedShapingConformanceTarget } = await import('./targets/conformance/advanced-shaping'); expect(createAdvancedShapingConformanceTarget().id).toBe('advanced-shaping-conformance'); expect(await loadRegisteredTarget('missing')).toBeUndefined(); diff --git a/apps/benchmarks/src/benchmark/targets/conformance/index.ts b/apps/benchmarks/src/benchmark/targets/conformance/index.ts index 29a2f15e..6a816f6e 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/index.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/index.ts @@ -2,6 +2,9 @@ import type { BenchmarkInput, BenchmarkTarget, Capability } from '../../contract import { selectableFontFixture } from '../../font-fixtures'; import { createShapingConformanceTargets } from './direct-runtime'; import { createFontLoaderWorkerConformanceTarget } from './font-loader-worker'; +import { mtsdfRasterConformanceAdapter } from './raster/mtsdf'; +import { slugRasterConformanceAdapter } from './raster/slug'; +import { createRasterSamplingConformanceTarget, createRasterSourceOutlineConformanceTarget } from './raster/target'; import { createDeferredTarget, sha256 } from '../shared'; type Backend = 'webgpu' | 'webgl2'; @@ -35,21 +38,9 @@ function tslBaselineTarget(backend: Backend): BenchmarkTarget { } function samplingTarget(technique: Extract, backend: Backend): BenchmarkTarget { - return createDeferredTarget( - { - id: `${technique}-conformance-${backend}`, - label: `${technique === 'mtsdf' ? 'MTSDF' : 'Slug'} sampling conformance · ${backendLabel(backend)}`, - detail: 'GPU TSL candidate · independent scalar CPU reconstruction', - color: technique === 'slug' && backend === 'webgpu' ? 'green' : backendColor(backend), - capabilities: rasterCapabilities, - status: () => 'ready', - }, - async () => { - if (technique === 'mtsdf') - return (await import('../../../renderer/mtsdf-text')).createMtsdfConformanceTarget(backend); - return (await import('../../../renderer/slug-text')).createSlugConformanceTarget(backend); - }, - { forwardsConfiguration: true }, + return createRasterSamplingConformanceTarget( + technique === 'mtsdf' ? mtsdfRasterConformanceAdapter : slugRasterConformanceAdapter, + backend, ); } @@ -67,6 +58,12 @@ const advancedShapingTarget = () => ); function sourceOutlineFidelityTarget(technique: Technique, backend: Backend): BenchmarkTarget { + if (technique === 'mtsdf' || technique === 'slug') { + return createRasterSourceOutlineConformanceTarget( + technique === 'mtsdf' ? mtsdfRasterConformanceAdapter : slugRasterConformanceAdapter, + backend, + ); + } let configuredInput: BenchmarkInput = {}; return { id: `source-outline-${technique}-${backend}`, @@ -81,43 +78,22 @@ function sourceOutlineFidelityTarget(technique: Technique, backend: Backend): Be load: async () => undefined, run: async (input, _sampleIndex, controls, context) => { const fontFixture = input.fontFixture ?? configuredInput.fontFixture ?? 'inter'; - const capture = - technique === 'slug' - ? await import('../../../renderer/slug-text').then(({ captureSlugSourceOutlineFidelity }) => - captureSlugSourceOutlineFidelity({ - backend, - dpr: controls.dpr, - fontFixture, - ...(context?.renderer === undefined ? {} : { renderer: context.renderer }), - ...(context?.signal === undefined ? {} : { signal: context.signal }), - }), - ) - : technique === 'mtsdf' - ? await import('../../../renderer/mtsdf-text').then(({ captureMtsdfSourceOutlineFidelity }) => - captureMtsdfSourceOutlineFidelity({ - backend, - dpr: controls.dpr, - fontFixture: selectableFontFixture(fontFixture), - ...(context?.renderer === undefined ? {} : { renderer: context.renderer }), - ...(context?.signal === undefined ? {} : { signal: context.signal }), - }), - ) - : await import('../../../renderer/bitmap-text').then(({ captureBitmapSourceOutlineFidelity }) => - captureBitmapSourceOutlineFidelity({ - backend, - dpr: controls.dpr, - fontFixture: selectableFontFixture(fontFixture), - ...(context?.renderer === undefined ? {} : { renderer: context.renderer }), - ...(context?.signal === undefined ? {} : { signal: context.signal }), - }), - ); + const capture = await import('../../../renderer/bitmap-text').then(({ captureBitmapSourceOutlineFidelity }) => + captureBitmapSourceOutlineFidelity({ + backend, + dpr: controls.dpr, + fontFixture: selectableFontFixture(fontFixture), + ...(context?.renderer === undefined ? {} : { renderer: context.renderer }), + ...(context?.signal === undefined ? {} : { signal: context.signal }), + }), + ); return { bytes: capture.candidate.byteLength, hash: await sha256(capture.candidate), metrics: { techniqueBitmap: technique === 'bitmap' ? 1 : 0, - techniqueMtsdf: technique === 'mtsdf' ? 1 : 0, - techniqueSlug: technique === 'slug' ? 1 : 0, + techniqueMtsdf: 0, + techniqueSlug: 0, fixtureIsDotGothic: fontFixture === 'dot-gothic-16' ? 1 : 0, backendWebGpu: backend === 'webgpu' ? 1 : 0, backendWebGl2: backend === 'webgl2' ? 1 : 0, diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/contracts.ts b/apps/benchmarks/src/benchmark/targets/conformance/raster/contracts.ts new file mode 100644 index 00000000..39a26bc9 --- /dev/null +++ b/apps/benchmarks/src/benchmark/targets/conformance/raster/contracts.ts @@ -0,0 +1,41 @@ +import type { BenchmarkControls, BenchmarkExecutionContext, BenchmarkInput, TargetRunOutput } from '../../../contracts'; + +export type RasterConformanceBackend = 'webgpu' | 'webgl2'; +export type RasterConformanceTechnique = 'mtsdf' | 'slug'; + +/** The subset of a source-outline capture the conformance target publishes. */ +export interface RasterSourceOutlineCapture { + readonly candidate: Uint8Array; + readonly width: number; + readonly height: number; + readonly physicalPpem: number; + readonly meanAbsoluteError: number; + readonly maximumError: number; + readonly errorPixels: number; + readonly renderSubmitMs: number; +} + +/** + * Backend-private resources stay in their renderer module. The target owns this + * session's warm load, capture, and disposal lifecycle without owning a renderer. + */ +export interface RasterConformanceSession { + load(input: BenchmarkInput, controls: BenchmarkControls, context?: BenchmarkExecutionContext): Promise; + captureSampling( + input: BenchmarkInput, + sampleIndex: number, + controls: BenchmarkControls, + context?: BenchmarkExecutionContext, + ): Promise; + captureSourceOutline( + input: BenchmarkInput, + controls: BenchmarkControls, + context?: BenchmarkExecutionContext, + ): Promise; + dispose(): Promise; +} + +export interface RasterConformanceAdapter { + readonly technique: RasterConformanceTechnique; + createSession(backend: RasterConformanceBackend): Promise; +} diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf.ts b/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf.ts new file mode 100644 index 00000000..b2d00bec --- /dev/null +++ b/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf.ts @@ -0,0 +1,27 @@ +import { selectableFontFixture } from '../../../font-fixtures'; +import type { RasterConformanceAdapter } from './contracts'; + +export const mtsdfRasterConformanceAdapter: RasterConformanceAdapter = { + technique: 'mtsdf', + async createSession(backend) { + const { captureMtsdfSourceOutlineFidelity, createMtsdfConformanceTarget } = + await import('../../../../renderer/mtsdf-text'); + const target = createMtsdfConformanceTarget(backend); + return { + load: async (input, controls, context) => { + target.configure?.(input); + await target.load(controls, context); + }, + captureSampling: (input, sampleIndex, controls, context) => target.run(input, sampleIndex, controls, context), + captureSourceOutline: async (input, controls, context) => + captureMtsdfSourceOutlineFidelity({ + backend, + dpr: controls.dpr, + fontFixture: selectableFontFixture(input.fontFixture ?? 'inter'), + ...(context?.renderer === undefined ? {} : { renderer: context.renderer }), + ...(context?.signal === undefined ? {} : { signal: context.signal }), + }), + dispose: () => target.dispose(), + }; + }, +}; diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/slug.ts b/apps/benchmarks/src/benchmark/targets/conformance/raster/slug.ts new file mode 100644 index 00000000..5ca03a97 --- /dev/null +++ b/apps/benchmarks/src/benchmark/targets/conformance/raster/slug.ts @@ -0,0 +1,26 @@ +import type { RasterConformanceAdapter } from './contracts'; + +export const slugRasterConformanceAdapter: RasterConformanceAdapter = { + technique: 'slug', + async createSession(backend) { + const { captureSlugSourceOutlineFidelity, createSlugConformanceTarget } = + await import('../../../../renderer/slug-text'); + const target = createSlugConformanceTarget(backend); + return { + load: async (input, controls, context) => { + target.configure?.(input); + await target.load(controls, context); + }, + captureSampling: (input, sampleIndex, controls, context) => target.run(input, sampleIndex, controls, context), + captureSourceOutline: async (input, controls, context) => + captureSlugSourceOutlineFidelity({ + backend, + dpr: controls.dpr, + fontFixture: input.fontFixture ?? 'inter', + ...(context?.renderer === undefined ? {} : { renderer: context.renderer }), + ...(context?.signal === undefined ? {} : { signal: context.signal }), + }), + dispose: () => target.dispose(), + }; + }, +}; diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/target.test.ts b/apps/benchmarks/src/benchmark/targets/conformance/raster/target.test.ts new file mode 100644 index 00000000..8930d1b2 --- /dev/null +++ b/apps/benchmarks/src/benchmark/targets/conformance/raster/target.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import type { BenchmarkControls, BenchmarkExecutionContext, BenchmarkInput, TargetRunOutput } from '../../../contracts'; +import type { RasterConformanceAdapter, RasterConformanceSession } from './contracts'; +import { createRasterSamplingConformanceTarget, createRasterSourceOutlineConformanceTarget } from './target'; + +const controls: BenchmarkControls = { dpr: 2, samples: 1, warmup: 0 }; +const input: BenchmarkInput = { fontFixture: 'dot-gothic-16' }; +const output: TargetRunOutput = { bytes: 4, hash: 'candidate' }; + +function createSession(overrides: Partial = {}): RasterConformanceSession { + return { + load: async () => undefined, + captureSampling: async () => output, + captureSourceOutline: async () => ({ + candidate: new Uint8Array([1, 2, 3, 4]), + width: 1, + height: 1, + physicalPpem: 32, + meanAbsoluteError: 0, + maximumError: 0, + errorPixels: 0, + renderSubmitMs: 1, + }), + dispose: async () => undefined, + ...overrides, + }; +} + +function createAdapter( + create: RasterConformanceAdapter['createSession'], + technique: RasterConformanceAdapter['technique'] = 'mtsdf', +): RasterConformanceAdapter { + return { technique, createSession: create }; +} + +describe('raster conformance target session', () => { + it('keeps one warm session and forwards the same borrowed renderer to every sampling phase', async () => { + const calls: unknown[][] = []; + const session = createSession({ + load: async (...args) => { + calls.push(['load', ...args]); + }, + captureSampling: async (...args) => { + calls.push(['capture', ...args]); + return output; + }, + }); + const target = createRasterSamplingConformanceTarget( + createAdapter(async () => session), + 'webgpu', + ); + const renderer = {} as NonNullable; + const context: BenchmarkExecutionContext = { renderer }; + + target.configure?.(input); + await target.load(controls, context); + await expect(target.run(input, 0, controls, context)).resolves.toEqual(output); + await expect(target.run(input, 1, controls, context)).resolves.toEqual(output); + + expect(calls).toEqual([ + ['load', input, controls, context], + ['capture', input, 0, controls, context], + ['capture', input, 1, controls, context], + ]); + }); + + it('disposes a created session after a load failure and permits a clean replacement session', async () => { + let creates = 0; + let disposals = 0; + const target = createRasterSamplingConformanceTarget( + createAdapter(async () => { + creates += 1; + return createSession({ + load: async () => { + if (creates === 1) throw new Error('fixture load failed'); + }, + dispose: async () => { + disposals += 1; + }, + }); + }), + 'webgl2', + ); + + await expect(target.load(controls)).rejects.toThrow('fixture load failed'); + await target.dispose(); + await target.load(controls); + await target.dispose(); + + expect({ creates, disposals }).toEqual({ creates: 2, disposals: 2 }); + }); + + it('does not create a session after abort and leaves an existing session disposable after a capture abort', async () => { + let creates = 0; + let disposals = 0; + const session = createSession({ + dispose: async () => { + disposals += 1; + }, + }); + const target = createRasterSamplingConformanceTarget( + createAdapter(async () => { + creates += 1; + return session; + }), + 'webgpu', + ); + const beforeLoad = new AbortController(); + beforeLoad.abort(); + + await expect(target.load(controls, { signal: beforeLoad.signal })).rejects.toMatchObject({ name: 'AbortError' }); + expect(creates).toBe(0); + + await target.load(controls); + const beforeCapture = new AbortController(); + beforeCapture.abort(); + await expect(target.run(input, 0, controls, { signal: beforeCapture.signal })).rejects.toMatchObject({ + name: 'AbortError', + }); + await target.dispose(); + + expect({ creates, disposals }).toEqual({ creates: 1, disposals: 1 }); + }); + + it('waits for an in-flight session creation before disposal so no late session survives navigation', async () => { + let resolve: ((session: RasterConformanceSession) => void) | undefined; + const created = new Promise((complete) => { + resolve = complete; + }); + let disposals = 0; + const session = createSession({ + dispose: async () => { + disposals += 1; + }, + }); + const target = createRasterSamplingConformanceTarget( + createAdapter(async () => created), + 'webgpu', + ); + + const loading = target.load(controls); + const disposing = target.dispose(); + const loadingFailure = loading.then( + () => new Error('Expected disposal to abort the in-flight session load'), + (error: unknown) => error, + ); + resolve?.(session); + await expect(loadingFailure).resolves.toMatchObject({ name: 'AbortError' }); + await disposing; + + expect(disposals).toBe(1); + await expect(target.run(input, 0, controls)).rejects.toThrow('MTSDF conformance target was not loaded'); + }); + + it('keeps source-outline capture isolated in the session and restores host state on a failed capture', async () => { + const rendererState = { target: 'host-target' }; + let disposals = 0; + const target = createRasterSourceOutlineConformanceTarget( + createAdapter( + async () => + createSession({ + captureSourceOutline: async (_input, _controls, context) => { + const host = context?.renderer as unknown as { state: { target: string } }; + const prior = host.state.target; + try { + host.state.target = 'finite-capture-target'; + throw new Error('readback failed'); + } finally { + host.state.target = prior; + } + }, + dispose: async () => { + disposals += 1; + }, + }), + 'slug', + ), + 'webgpu', + ); + const renderer = { state: rendererState } as unknown as NonNullable; + + await target.load(controls, { renderer }); + await expect(target.run(input, 0, controls, { renderer })).rejects.toThrow('readback failed'); + expect(rendererState).toEqual({ target: 'host-target' }); + await target.dispose(); + expect(disposals).toBe(1); + }); +}); diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/target.ts b/apps/benchmarks/src/benchmark/targets/conformance/raster/target.ts new file mode 100644 index 00000000..ac53f7be --- /dev/null +++ b/apps/benchmarks/src/benchmark/targets/conformance/raster/target.ts @@ -0,0 +1,166 @@ +import type { BenchmarkExecutionContext, BenchmarkInput, BenchmarkTarget, Capability } from '../../../contracts'; +import { sha256 } from '../../shared'; +import type { + RasterConformanceAdapter, + RasterConformanceBackend, + RasterConformanceSession, + RasterConformanceTechnique, +} from './contracts'; + +const rasterCapabilities: ReadonlySet = new Set([ + 'deterministic', + 'font-bytes', + 'wasm', + 'shaping', + 'paragraph', + 'raster', +]); + +const backendColor = (backend: RasterConformanceBackend): 'cyan' | 'amber' => (backend === 'webgpu' ? 'cyan' : 'amber'); +const backendLabel = (backend: RasterConformanceBackend): 'WebGPU' | 'WebGL' => + backend === 'webgpu' ? 'WebGPU' : 'WebGL'; +const techniqueLabel = (technique: RasterConformanceTechnique): 'MTSDF' | 'Slug' => + technique === 'mtsdf' ? 'MTSDF' : 'Slug'; + +interface RasterTargetSession { + get(context?: BenchmarkExecutionContext): Promise; + loaded(): RasterConformanceSession | undefined; + dispose(): Promise; +} + +function createRasterTargetSession( + adapter: RasterConformanceAdapter, + backend: RasterConformanceBackend, +): RasterTargetSession { + let session: RasterConformanceSession | undefined; + let creation: Promise | undefined; + let lifecycleVersion = 0; + + const get = async (context?: BenchmarkExecutionContext): Promise => { + context?.signal?.throwIfAborted(); + if (session !== undefined) return session; + const requestedVersion = lifecycleVersion; + const pending = (creation ??= adapter.createSession(backend).then( + (created) => { + session = created; + return created; + }, + (error: unknown) => { + creation = undefined; + throw error; + }, + )); + const created = await pending; + if (requestedVersion !== lifecycleVersion) { + throw new DOMException('Raster conformance session was disposed during creation', 'AbortError'); + } + context?.signal?.throwIfAborted(); + return created; + }; + + return { + get, + loaded: () => session, + dispose: async () => { + lifecycleVersion += 1; + const pending = creation; + if (pending !== undefined) { + try { + await pending; + } catch { + // The caller that started session creation receives its failure. Disposal only releases a created session. + } + } + const current = session; + session = undefined; + creation = undefined; + if (current !== undefined) await current.dispose(); + }, + }; +} + +export function createRasterSamplingConformanceTarget( + adapter: RasterConformanceAdapter, + backend: RasterConformanceBackend, +): BenchmarkTarget { + const session = createRasterTargetSession(adapter, backend); + let configuredInput: BenchmarkInput = {}; + const technique = adapter.technique; + return { + id: `${technique}-conformance-${backend}`, + label: `${techniqueLabel(technique)} sampling conformance · ${backendLabel(backend)}`, + detail: 'GPU TSL candidate · independent scalar CPU reconstruction · visual difference', + color: technique === 'slug' && backend === 'webgpu' ? 'green' : backendColor(backend), + capabilities: rasterCapabilities, + configure: (input) => { + configuredInput = input; + }, + status: () => 'ready', + load: async (controls, context) => { + const current = await session.get(context); + await current.load(configuredInput, controls, context); + context?.signal?.throwIfAborted(); + }, + run: async (input, sampleIndex, controls, context) => { + context?.signal?.throwIfAborted(); + const current = session.loaded(); + if (current === undefined) throw new Error(`${techniqueLabel(technique)} conformance target was not loaded`); + return current.captureSampling(input, sampleIndex, controls, context); + }, + dispose: session.dispose, + }; +} + +export function createRasterSourceOutlineConformanceTarget( + adapter: RasterConformanceAdapter, + backend: RasterConformanceBackend, +): BenchmarkTarget { + const session = createRasterTargetSession(adapter, backend); + let configuredInput: BenchmarkInput = {}; + const technique = adapter.technique; + return { + id: `source-outline-${technique}-${backend}`, + label: `${techniqueLabel(technique)} source-outline fidelity · ${backendLabel(backend)}`, + detail: 'GPU candidate · pinned source font · browser Canvas2D reference', + color: backendColor(backend), + capabilities: rasterCapabilities, + configure: (input) => { + configuredInput = input; + }, + status: () => 'ready', + load: async (_controls, context) => { + await session.get(context); + }, + run: async (input, _sampleIndex, controls, context) => { + context?.signal?.throwIfAborted(); + const current = session.loaded(); + if (current === undefined) throw new Error(`${techniqueLabel(technique)} source-outline target was not loaded`); + const capture = await current.captureSourceOutline( + input.fontFixture === undefined ? configuredInput : input, + controls, + context, + ); + context?.signal?.throwIfAborted(); + return { + bytes: capture.candidate.byteLength, + hash: await sha256(capture.candidate), + metrics: { + techniqueBitmap: 0, + techniqueMtsdf: technique === 'mtsdf' ? 1 : 0, + techniqueSlug: technique === 'slug' ? 1 : 0, + fixtureIsDotGothic: (input.fontFixture ?? configuredInput.fontFixture ?? 'inter') === 'dot-gothic-16' ? 1 : 0, + backendWebGpu: backend === 'webgpu' ? 1 : 0, + backendWebGl2: backend === 'webgl2' ? 1 : 0, + dpr: controls.dpr, + pixelCount: capture.width * capture.height, + physicalPpem: capture.physicalPpem, + meanAbsoluteError: capture.meanAbsoluteError, + maximumError: capture.maximumError, + errorPixels: capture.errorPixels, + renderMs: capture.renderSubmitMs, + }, + }; + }, + dispose: session.dispose, + }; +} From bc3f5c48730c10fb081c3b781618577ead35e277 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 3 Aug 2026 05:24:43 -0400 Subject: [PATCH 4/5] test(benchmarks): isolate raster artifact authentication --- .../raster-fixture-manifests.test.ts | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/apps/benchmarks/src/benchmark/raster-fixture-manifests.test.ts b/apps/benchmarks/src/benchmark/raster-fixture-manifests.test.ts index 5ccc1bb5..8a0ca4ce 100644 --- a/apps/benchmarks/src/benchmark/raster-fixture-manifests.test.ts +++ b/apps/benchmarks/src/benchmark/raster-fixture-manifests.test.ts @@ -93,34 +93,35 @@ describe('checked raster fixture manifests', () => { } }); - it('authenticates every complete MTSDF font artifact and page total', async () => { + it('covers every complete MTSDF font fixture', () => { expect(mtsdfManifest.artifacts.map(({ fontFixture }) => fontFixture)).toEqual(Object.keys(fixtureIdentities)); - for (const artifact of mtsdfManifest.artifacts) { - const fixtureId = checkedFixtureId(artifact.fontFixture); - const identity = fixtureIdentities[fixtureId]; - const compressed = await readFile(new URL(`rendering/${artifact.file}`, fixtureRoot)); - expect(compressed.byteLength).toBe(artifact.compressed.bytes); - expect(sha256(compressed)).toBe(artifact.compressed.sha256); - const expanded = await authenticateGzipGlb(compressed); - expect(expanded.bytes).toBe(artifact.uncompressed.bytes); - expect(expanded.sha256).toBe(artifact.uncompressed.sha256); - const document = glbDocument(expanded.jsonPrefix, artifact.file); - await expectCompleteFont(document, identity, fixtureId); - const raster = objectProperty( - objectProperty(document, 'extensions', artifact.file), - 'PMNDRS_font_distance_field', - artifact.file, - ); - expect(integerProperty(raster, 'glyphCount', artifact.file)).toBe(identity.glyphCount); - expect(arrayProperty(raster, 'pages', artifact.file)).toHaveLength(artifact.raster.pages.length); - expect(sum(artifact.raster.pages.map(({ decodedGpuBytes }) => decodedGpuBytes))).toBe( - artifact.raster.decodedGpuBytes, - ); - const textureArray = artifact.raster.runtimeTextureArray; - expect(exactBaseTextureArrayBytes(textureArray.width, textureArray.height, textureArray.layers, 4)).toBe( - textureArray.basePaddedGpuBytes, - ); - } + }); + + it.each(mtsdfManifest.artifacts)('authenticates MTSDF artifact $fontFixture and its page total', async (artifact) => { + const fixtureId = checkedFixtureId(artifact.fontFixture); + const identity = fixtureIdentities[fixtureId]; + const compressed = await readFile(new URL(`rendering/${artifact.file}`, fixtureRoot)); + expect(compressed.byteLength).toBe(artifact.compressed.bytes); + expect(sha256(compressed)).toBe(artifact.compressed.sha256); + const expanded = await authenticateGzipGlb(compressed); + expect(expanded.bytes).toBe(artifact.uncompressed.bytes); + expect(expanded.sha256).toBe(artifact.uncompressed.sha256); + const document = glbDocument(expanded.jsonPrefix, artifact.file); + await expectCompleteFont(document, identity, fixtureId); + const raster = objectProperty( + objectProperty(document, 'extensions', artifact.file), + 'PMNDRS_font_distance_field', + artifact.file, + ); + expect(integerProperty(raster, 'glyphCount', artifact.file)).toBe(identity.glyphCount); + expect(arrayProperty(raster, 'pages', artifact.file)).toHaveLength(artifact.raster.pages.length); + expect(sum(artifact.raster.pages.map(({ decodedGpuBytes }) => decodedGpuBytes))).toBe( + artifact.raster.decodedGpuBytes, + ); + const textureArray = artifact.raster.runtimeTextureArray; + expect(exactBaseTextureArrayBytes(textureArray.width, textureArray.height, textureArray.layers, 4)).toBe( + textureArray.basePaddedGpuBytes, + ); }); }); From f79e3471c863cdfeb7c51919e8cdaf13b13f756d Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 3 Aug 2026 05:24:51 -0400 Subject: [PATCH 5/5] docs(benchmarks): record workload isolation evidence --- docs/log.md | 1 + docs/packages/benchmarks.md | 17 ++++++++++++++--- docs/roadmap/roadmap.md | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/log.md b/docs/log.md index 198bd013..7ea0cb1b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,7 @@ ## 2026-08-03 +- **Workload instance and low-level target isolation** — Moved Icon Grid's active virtual window, recycle epochs, pan/autopan smoothing, refresh suspension, visibility, and metrics into one per-mount workload instance while leaving renderer, RAF, font transactions, scene attachment, and telemetry in the persistent host. Extracted Main/Presentation composition, runtime control binding, benchmark surface chrome, and bake progress into named React modules. Added a common target-owned MTSDF/Slug conformance session that forwards the borrowed renderer and abort signal while retaining renderer-private resources behind adapters. The first browser pass exposed Text Ladder's offscreen scene transform leaking into Zoom Text; explicit per-workload scene initialization corrected the black frame. The repeated 42-cell dual-backend Presentation matrix then completed with visible pixels and one renderer per lane, all 19 isolated headless conformance scenarios passed, React Doctor reported zero diagnostics, and the complete deterministic benchmark gate passed 301 tests. - **Complete workload phase ownership** — Added typed animation and retained-configuration hooks to all seven retained workload definitions using preallocated host scratch, eliminating the renderer's remaining create/layout/animate/apply dispatch switches without moving RAF, telemetry, or renderer lifecycle into workload code. Moved the Benchmark Ipsum corpus and Advanced Shaping timeline beside the other authored workloads, and moved the self-contained Advanced Shaping conformance target under `benchmark/targets/conformance` behind the same literal selected-target dynamic import. Coupled raster targets remain in place pending a session adapter that preserves their warm load/run/dispose lifecycle. The post-move Chromium run completed all 42 dual-backend Presentation workload cells with visible pixels and one renderer per lane; all 19 isolated headless conformance scenarios passed, including three exact 68-frame Advanced Shaping timelines with zero warm readiness waits. - **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. diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 89d17996..8abd3a6f 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:e6e8fa72d40f4ad578ed8c640a82a363419993dbdcd7b19ed39ba425bdc8dfc2' +source_digest: 'sha256:b8bff40a525909b92d99699e6f2e4c5b257ede6bf15fa36570d43846e9c560fb' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -56,6 +56,15 @@ sources: - id: workload-catalog resource: ../../apps/benchmarks/src/workloads/catalog.ts title: Typed live-workload policy catalog + - id: icon-grid-workload + resource: ../../apps/benchmarks/src/workloads/icon-grid.ts + title: Retained Icon Grid workload instance and public Text example + - id: harness-layout + resource: ../../apps/benchmarks/src/components/harness-layout.tsx + title: Main and Presentation route composition + - id: raster-conformance-session + resource: ../../apps/benchmarks/src/benchmark/targets/conformance/raster/contracts.ts + title: Low-level raster conformance session contract - id: comparison-workload resource: ../../apps/benchmarks/src/renderer/comparison-workload.ts title: Retained comparison-workload renderer @@ -73,7 +82,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-03T08:41:03Z' + at: '2026-08-03T09:23:50Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -109,7 +118,9 @@ 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. 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. Benchmark Ipsum and Advanced Shaping now keep their authored corpus and timeline in the same workload hierarchy as Text Ladder, Zoom Text, Icon Grid, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects. The seven retained comparison definitions own construction, layout, animation, and retained configuration hooks; no workload-specific dispatch switch remains for those phases. 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`. The self-contained Advanced Shaping conformance target now lives in that target hierarchy behind the registry's literal selected-target dynamic import; coupled raster targets remain behind narrow renderer sessions until their shared adapter contract can preserve warm `load → run → dispose` reuse. The 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, authenticate the literal Advanced Shaping target import, 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. Benchmark Ipsum and Advanced Shaping keep their authored corpus and timeline in the same workload hierarchy as Text Ladder, Zoom Text, Icon Grid, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects. The seven retained comparison definitions own construction, layout, animation, and retained configuration hooks; no workload-specific dispatch switch remains for those phases. Icon Grid additionally owns one per-mount instance containing virtual-window epochs, pool assignment and recycling, scroll and auto-pan state, frame smoothing, refresh suspension, visibility, and metrics. The host exposes only generic cold pool resize/readiness, scene attachment, and disposal; renderer, canvas, RAF, GPU timer, font transactions, and telemetry history remain route infrastructure. Each workload mount explicitly initializes the shared scene transform, preventing Text Ladder's authored offscreen exit or Icon Grid pan from polluting the next workload. 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. + +Main and Presentation composition now lives in `components/harness-layout.tsx`, runtime control binding in `components/runtime-controls.tsx`, and benchmark surface/bake status in `surfaces/benchmark`; the root application remains the route coordinator rather than the sole component hierarchy. 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`. Advanced Shaping lives in that target hierarchy behind the registry's literal selected-target dynamic import. MTSDF and Slug sampling plus source-outline targets share a target-owned raster conformance session that preserves warm `load → run → dispose` reuse and forwards the borrowed renderer and abort signal unchanged; renderer-private capture resources remain inside their adapters. The 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, isolate renderer imports to the raster adapters, authenticate the literal Advanced Shaping target import, 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 a3d2fa8f..f0934030 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. All seven retained workload definitions now own app-private construction, layout, animation, and retained-configuration hooks; Icon Grid owns its pure virtualization mechanics, while moving active pool state and metrics behind one workload instance remains in progress. Benchmark Ipsum and Advanced Shaping authored state now live beside the other workloads, and the self-contained Advanced Shaping conformance target lives under low-level targets without changing selected-target chunking. 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 own app-private construction, layout, animation, and retained-configuration hooks. Icon Grid also owns its per-mount active pool assignment, recycling, pan, frame smoothing, refresh suspension, visibility, and metrics state behind a narrow host lifecycle adapter. Benchmark Ipsum and Advanced Shaping authored state live beside the other workloads; Main/Presentation composition and benchmark surfaces have named React modules; Advanced Shaping and renderer-safe MTSDF/Slug session adapters live under low-level conformance targets without changing selected-target chunking or borrowed-renderer ownership. 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