diff --git a/README.md b/README.md index 1ac6f92b..c3002d3e 100644 --- a/README.md +++ b/README.md @@ -144,12 +144,12 @@ Every package and subpath is native ESM. Optional engines and the runtime baker Applications select a raster explicitly; the package never silently changes technique. -| Need | Planned recommendation | -| --------------------------------------------------------------- | --------------------------------- | -| General-purpose UI and scalable text | MSDF module using its MTSDF atlas | -| Tiny text at known pixel sizes | Generated bitmap strikes | -| Large text, extreme zoom, complex outlines, color vector layers | Slug | -| Pixel-art or intentionally raster typography | Bitmap | +| Need | V1 recommendation | +| ------------------------------------------------------- | --------------------------------- | +| General-purpose UI and scalable text | MSDF module using its MTSDF atlas | +| Tiny text at known pixel sizes | Generated bitmap strikes | +| Large text, extreme zoom, intricate monochrome outlines | Slug | +| Pixel-art or intentionally raster typography | Bitmap | The [renderer capability matrix](docs/planning/renderer-capabilities.md) records supported content and effects, while the [implementation difficulty](docs/planning/implementation-difficulty.md) explains the correctness and performance effort behind their order. Windfoil remains research prior art rather than a planned backend. diff --git a/apps/benchmarks/doctor.config.json b/apps/benchmarks/doctor.config.json index b4fd7e47..65eda5b3 100644 --- a/apps/benchmarks/doctor.config.json +++ b/apps/benchmarks/doctor.config.json @@ -8,7 +8,7 @@ "vitexec/**", "src/benchmark/bake-host-baseline.ts", "src/benchmark/worker-queue-evidence.ts", - "src/benchmark/targets/measurement/comparison-preview.ts" + "src/benchmark/probes/comparison-workload-preview.ts" ], "overrides": [ { diff --git a/apps/benchmarks/package.json b/apps/benchmarks/package.json index 6f87c605..24500e06 100644 --- a/apps/benchmarks/package.json +++ b/apps/benchmarks/package.json @@ -25,7 +25,7 @@ "check:size": "pnpm --filter @pmndrs/text build && pnpm --filter @pmndrs/text-font-baker build && node ./scripts/measure-package-sizes.mts --check", "check:paragraph-bidi-contract": "pnpm --filter @pmndrs/text build && pnpm --filter @pmndrs/text-font-baker build && node ./scripts/generate-paragraph-bidi-contract.mts --check", "check:paragraph-cjk-contract": "pnpm --filter @pmndrs/text build && pnpm --filter @pmndrs/text-font-baker build && node ./scripts/generate-paragraph-cjk-contract.mts --check", - "check:react-doctor": "pnpm dlx react-doctor@0.7.2 . --scope full --blocking warning --no-supply-chain --no-color", + "check:react-doctor": "pnpm dlx react-doctor@0.7.2 . --scope full --blocking warning --verbose --no-supply-chain --no-color", "capture:presentation-workloads": "node ./scripts/capture-presentation-workloads.mts", "check:mtsdf-render-fixture": "pnpm --filter @pmndrs/text build && pnpm --filter @pmndrs/text-font-baker build && node ./scripts/generate-mtsdf-render-fixture.mts --check", "check:slug-render-fixture": "pnpm --filter @pmndrs/text build && pnpm --filter @pmndrs/text-font-baker build && node ./scripts/generate-slug-render-fixture.mts --check", diff --git a/apps/benchmarks/scripts/generate-japanese-showcase-subset.mts b/apps/benchmarks/scripts/generate-japanese-showcase-subset.mts index 9ff163b7..1eb00974 100644 --- a/apps/benchmarks/scripts/generate-japanese-showcase-subset.mts +++ b/apps/benchmarks/scripts/generate-japanese-showcase-subset.mts @@ -4,7 +4,7 @@ import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promi import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { ADVANCED_SHAPING_CASES } from '../src/workloads/advanced-shaping.ts'; +import { ADVANCED_SHAPING_CASES } from '../src/workloads/advanced-shaping/scene.ts'; const harfBuzzVersion = '13.0.0'; const sourceDirectory = resolve('fixtures/fonts/noto-sans-cjk-2.004'); diff --git a/apps/benchmarks/src/app.tsx b/apps/benchmarks/src/app.tsx index 8152bff9..fb4d1d1f 100644 --- a/apps/benchmarks/src/app.tsx +++ b/apps/benchmarks/src/app.tsx @@ -1,220 +1,14 @@ -import { - Activity, - Suspense, - use, - useEffect, - useEffectEvent, - useRef, - useState, - useSyncExternalStore, - useTransition, - type ReactNode, - type RefObject, -} from 'react'; +import { Suspense } from 'react'; -import { - advanceAdvancedShapingByTime, - advancedShapingCase, - advancedShapingFrame, - initialAdvancedShapingState, - updateAdvancedShaping, - type AdvancedShapingCommand, - type AdvancedShapingFrame, -} from './workloads/advanced-shaping'; -import type { BenchmarkSummary, RunnerEvent } from './benchmark/contracts'; -import { environmentResource } from './benchmark/environment'; -import { runRegisteredBenchmark } from './benchmark/execution'; -import { - defaultRuntimeFontSizeForWorkload, - resetRuntimeControlsForWorkload, - RuntimeLayoutControls, - RuntimeTelemetry, - RuntimeViewControls, - useRuntimeAnimationControls, - useRuntimeLayoutControls, - useRuntimePaintControls, - useRuntimeTelemetry, - useRuntimeViewControls, - useRuntimeWorld, - type RuntimeLiveStats, -} from './benchmark/runtime-world'; -import { RuntimeWorldProvider } from './benchmark/runtime-world-provider'; -import { captureLiveTextStats, type LiveBenchmarkCapture } from './benchmark/product-result'; -import { - adjacentPresentationWorkload, - presentationFrame, - type PresentationPreset, - type PresentationWorkload, -} from './benchmark/presentation-sequence'; -import { - setParagraphStressMotionFrame, - type MutableParagraphStressMotionFrame, -} from './benchmark/paragraph-stress-motion'; -import { - liveWorkloadFontFixtures, - rasterConformanceSpecimen, - type BenchmarkFontFixture, - type SelectableFontFixture, -} from './benchmark/font-fixtures'; -import { - readHarnessLocation, - writeHarnessUrl, - type FontDelivery, - type GraphicsBackend, - type HarnessLayout, - type HarnessLocation, - type HarnessMode, - type RasterTechnique, -} from './benchmark/url-state'; -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, - isConformanceWorkloadId, - type ConformanceWorkloadId, - type WorkloadOption, -} from './benchmark/workloads'; -import { Chip } from './components/ui'; -import type { PersistentRenderJob } from './renderer/persistent-render-host'; -import type { - ComparisonWorkloadConfiguration, - ComparisonWorkloadId, - ComparisonWorkloadPersistentScene, - ComparisonWorkloadStats, -} from './workloads/comparison/scene'; -import { - benchmarkWorkloadDefinition, - comparisonWorkloadId, - isBenchmarkWorkloadId, - type BenchmarkWorkloadId, -} from './workloads/catalog'; -import { BENCHMARK_CONTENT_INSET, BENCHMARK_CONTENT_MINIMUM_VIEWPORT_WIDTH } from './workloads/shared/text-style'; -import type { LiveTextScene } from './workloads/live-text-scene'; -import { liveTextSceneForWorkload } from './workloads/live-text-scenes'; -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 { BitmapTextViewport } from './surfaces/benchmark/bitmap-text-viewport'; -import { MtsdfTextViewport, SlugTextViewport } from './surfaces/benchmark/sdf-text-viewports'; -import type { LiveTextConfiguration } from './surfaces/benchmark/live-text-viewport-contracts'; -import { LiveBenchmarkSurface } from './surfaces/benchmark/live-benchmark-surface'; -import { Route, Switch, useLocation } from 'wouter'; - -type LiveTextStats = RuntimeLiveStats; -type RunExclusiveJob = (job: PersistentRenderJob, signal?: AbortSignal) => Promise>; - -let comparisonWorkloadModule: ReturnType | undefined; -const liveSceneAssetResources = new Map>(); - -function importComparisonWorkload() { - return import('./workloads/comparison/scene'); -} - -function loadBenchmarkFontAssets() { - return import('./workloads/font-assets'); -} - -function preloadComparisonWorkload(): ReturnType { - comparisonWorkloadModule ??= importComparisonWorkload(); - return comparisonWorkloadModule; -} - -function scheduleComparisonWorkloadPreload(): () => void { - if (globalThis.requestIdleCallback === undefined) return () => undefined; - const request = globalThis.requestIdleCallback(() => { - void preloadComparisonWorkload(); - }); - return () => globalThis.cancelIdleCallback(request); -} - -const PRESENTATION_FONT_FIXTURES = [ - 'inter', - 'font-awesome-free-6.7.2', - 'amiri', - 'noto-sans-devanagari', - 'noto-sans-cjk-showcase', -] as const satisfies readonly BenchmarkFontFixture[]; - -async function preloadPresentationAssets( - technique: RasterTechnique, - delivery: FontDelivery, - selectedFont: BenchmarkFontFixture, - signal: AbortSignal, -): Promise { - if (delivery !== 'baked') return; - const fixtures = Array.from(new Set([selectedFont, ...PRESENTATION_FONT_FIXTURES])); - const { preloadBenchmarkFontAssets } = await loadBenchmarkFontAssets(); - await preloadBenchmarkFontAssets({ technique, fixtures, signal, bitmapDensity: 'live' }); -} - -function liveSceneAssetResource( - technique: RasterTechnique, - delivery: FontDelivery, - fontFixture: BenchmarkFontFixture, - workload: HarnessLocation['workload'], -): Promise { - const definition = isBenchmarkWorkloadId(workload) ? benchmarkWorkloadDefinition(workload) : undefined; - const fixtures = - definition?.fontPolicy.kind === 'icon-grid' ? [fontFixture, definition.fontPolicy.iconFixture] : [fontFixture]; - const comparison = definition?.surface === 'comparison'; - const key = `${technique}:${delivery}:${fixtures.join(',')}:${String(comparison)}`; - const existing = liveSceneAssetResources.get(key); - if (existing !== undefined) return existing; - const resource = (async () => { - if (comparison) await preloadComparisonWorkload(); - if (delivery !== 'baked') return; - const { preloadBenchmarkFontAssets } = await loadBenchmarkFontAssets(); - await preloadBenchmarkFontAssets({ technique, fixtures, bitmapDensity: 'live' }); - })(); - liveSceneAssetResources.set(key, resource); - void resource.catch(() => liveSceneAssetResources.delete(key)); - return resource; -} - -interface ActivityWorkloads { - readonly benchmark: BenchmarkWorkloadId; - readonly conformance: ConformanceWorkloadId; -} - -const INITIAL_CONFORMANCE_VIEW: ConformanceView = { - zoom: 1, - panXPercent: 0, - panYPercent: 0, -}; - -function techniqueLabel(technique: RasterTechnique): 'Bitmap' | 'MSDF' | 'Slug' { - return technique === 'mtsdf' ? 'MSDF' : technique === 'slug' ? 'Slug' : 'Bitmap'; -} - -function isComparisonWorkloadStats(stats: LiveTextStats | undefined): stats is ComparisonWorkloadStats { - return stats !== undefined && 'workload' in stats; -} - -function workloadAmountLabel(workload: BenchmarkWorkloadId, amount: number): string | undefined { - const range = benchmarkWorkloadDefinition(workload).controls.amount; - return range === undefined ? undefined : `${range.label} · ${amount}%`; -} - -function formatMs(value: number | undefined): string { - return value === undefined ? '—' : `${value.toFixed(2)} ms`; -} +import { HarnessRoute } from './routes/harness-route'; +import { Route, Switch } from 'wouter'; function ShellFallback() { return
Loading harness…
; } -function SceneSuspenseFallback({ technique }: { readonly technique: RasterTechnique }) { - return ( -
-
- Loading {techniqueLabel(technique)} scene… -
-
- ); +function locationSearch(): string { + return typeof globalThis.location === 'undefined' ? '' : globalThis.location.search; } export function App() { @@ -232,1389 +26,12 @@ export function App() { }> - + - + ); } - -function Harness({ layout }: { readonly layout: HarnessLayout }) { - return ( - - - - ); -} - -function HarnessApplication({ layout }: { readonly layout: HarnessLayout }) { - return useHarnessController(layout); -} - -function useHarnessController(routeLayout: HarnessLayout): ReactNode { - const [, navigate] = useLocation(); - const environment = use(environmentResource()); - const runtimeWorld = useRuntimeWorld(); - const presentationAnimation = useRuntimeAnimationControls(); - const desktop = useSyncExternalStore(subscribeDesktop, desktopSnapshot, () => true); - const phone = useSyncExternalStore(subscribePhone, phoneSnapshot, () => false); - const [location, setLocationState] = useState(() => { - const value = readHarnessLocation(locationSearch(), defaultDeviceDpr(), routeLayout); - if (!environment.webgpu && !new URLSearchParams(locationSearch()).has('backend')) { - return { ...value, backend: 'webgl2' as const }; - } - return value; - }); - const [activityWorkloads, setActivityWorkloads] = useState(() => { - const initial = readHarnessLocation(locationSearch(), defaultDeviceDpr(), routeLayout); - return { - benchmark: - initial.mode === 'benchmark' && isBenchmarkWorkloadId(initial.workload) ? initial.workload : 'benchmark-ipsum', - conformance: - initial.mode === 'conformance' && isConformanceWorkloadId(initial.workload) - ? initial.workload - : 'text-accuracy', - }; - }); - const [summary, setSummary] = useState(); - const [event, setEvent] = useState(); - const [liveCapture, setLiveCapture] = useState(); - const [error, setError] = useState(); - const [dpr, setDpr] = useState<1 | 2>(location.dpr); - const [samples, setSamples] = useState(3); - const [warmup, setWarmup] = useState(1); - const [conformanceView, setConformanceView] = useState(INITIAL_CONFORMANCE_VIEW); - const [comparisonText, setComparisonText] = useState(() => rasterConformanceSpecimen(location.fontFixture).text); - const [showcaseState, setShowcaseState] = useState(initialAdvancedShapingState); - const [advancedFontFixture, setAdvancedFontFixture] = useState('noto-sans-cjk-showcase'); - const [workloadPanelOpen, setWorkloadPanelOpen] = useState(() => desktopSnapshot()); - const [fontNoticesOpen, setFontNoticesOpen] = useState(false); - const [presentationPlaying, setPresentationPlaying] = useState(false); - const [presentationPreset, setPresentationPreset] = useState(); - const [isPending, startTransition] = useTransition(); - const reportCaptureRequested = useRef(false); - const conformanceRunRevision = useRef(0); - const conformanceRunController = useRef(undefined); - const committedLocationRef = useRef(location); - const requestedLocationRef = useRef(location); - const locationRequestRevisionRef = useRef(0); - const presentationPlayback = useRef< - | { - preset: PresentationPreset | undefined; - readonly startedAt: number; - readonly startWorkload: PresentationWorkload; - workload: PresentationWorkload; - } - | undefined - >(undefined); - const paragraphStressMotionScratch = useRef({ - fontSize: 0, - layoutWidthPercent: 0, - scrollProgress: 0, - }); - - const workload = workloadById(location.mode, location.workload); - const fontFixture = location.fontFixture; - const workloadTechnique = workload.techniques[location.technique]; - const showcaseFrame = advancedShapingFrame(showcaseState); - const activeFontFixture: BenchmarkFontFixture = - location.workload === 'advanced-shaping' - ? advancedFontFixture - : location.workload === 'zoom-text' - ? 'inter' - : fontFixture; - const available = workloadTechnique.kind === 'ready'; - const backendAvailable = location.backend !== 'webgpu' || environment.webgpu; - const presentationMode = location.layout === 'presentation' && location.mode === 'benchmark'; - - useEffect(() => { - if (!presentationMode) return; - const controller = new AbortController(); - let started = false; - const preload = (): void => { - started = true; - void preloadPresentationAssets(location.technique, location.delivery, fontFixture, controller.signal).catch( - (caught: unknown) => { - if (!(caught instanceof DOMException && caught.name === 'AbortError')) console.warn(caught); - }, - ); - }; - if (globalThis.requestIdleCallback === undefined) { - preload(); - return () => { - if (!started) controller.abort(); - }; - } - const request = globalThis.requestIdleCallback(preload); - return () => { - globalThis.cancelIdleCallback(request); - if (!started) controller.abort(); - }; - }, [fontFixture, location.delivery, location.technique, presentationMode]); - - const animateParagraphStressControls = useEffectEvent((elapsedMs: number) => { - const startFontSize = defaultRuntimeFontSizeForWorkload('paragraph-stress', location.layout); - const frame = paragraphStressMotionScratch.current; - setParagraphStressMotionFrame(frame, elapsedMs, presentationAnimation.animationSpeed, startFontSize); - const { fontSize, layoutWidthPercent } = frame; - const current = runtimeWorld.get(RuntimeLayoutControls); - if (current?.fontSize === fontSize && current.layoutWidthPercent === layoutWidthPercent) return; - runtimeWorld.set(RuntimeLayoutControls, { fontSize, layoutWidthPercent, workloadAmount: 100 }); - }); - useEffect(() => { - if ( - location.mode !== 'benchmark' || - location.workload !== 'paragraph-stress' || - !presentationAnimation.animationEnabled - ) { - return; - } - let animationFrame = 0; - let active = true; - const startedAt = performance.now(); - const animate = (): void => { - if (!active) return; - if (requestedLocationRef.current.workload === 'paragraph-stress') { - animateParagraphStressControls(Math.max(0, performance.now() - startedAt)); - } - if (active) animationFrame = requestAnimationFrame(animate); - }; - animationFrame = requestAnimationFrame(animate); - return () => { - active = false; - cancelAnimationFrame(animationFrame); - }; - }, [location.mode, location.workload, presentationAnimation.animationEnabled, presentationAnimation.animationSpeed]); - - function setLocation(next: Partial): void { - if (presentationPlayback.current === undefined && next.workload !== undefined) setPresentationPreset(undefined); - const previous = requestedLocationRef.current; - const value = { ...previous, ...next }; - const requestRevision = ++locationRequestRevisionRef.current; - requestedLocationRef.current = value; - const updatesRuntimeDefaults = - (next.workload !== undefined && next.workload !== previous.workload) || - (next.layout !== undefined && next.layout !== previous.layout); - const replacesLiveSurface = - next.mode !== undefined || - next.technique !== undefined || - next.backend !== undefined || - next.delivery !== undefined || - next.dpr !== undefined || - next.workload !== undefined; - const replacesRendererGeneration = next.mode !== undefined || next.backend !== undefined; - if (replacesRendererGeneration) runtimeWorld.set(RuntimeTelemetry, { stats: undefined }); - if (replacesLiveSurface || next.fontFixture !== undefined) { - conformanceRunController.current?.abort(new DOMException('conformance run was superseded', 'AbortError')); - conformanceRunController.current = undefined; - conformanceRunRevision.current += 1; - setSummary(undefined); - setEvent(undefined); - setLiveCapture(undefined); - } - const commitLocation = (): void => { - if (requestRevision !== locationRequestRevisionRef.current) return; - if (next.workload !== undefined) { - setActivityWorkloads((current) => ({ ...current, [value.mode]: value.workload })); - } - committedLocationRef.current = value; - setLocationState(value); - if (value.layout === previous.layout) { - globalThis.history?.replaceState(null, '', writeHarnessUrl(value)); - } else { - navigate(writeHarnessUrl(value)); - } - }; - const transitionsScene = - (next.technique !== undefined && next.technique !== previous.technique) || - (next.workload !== undefined && next.workload !== previous.workload) || - (next.fontFixture !== undefined && next.fontFixture !== previous.fontFixture) || - (next.delivery !== undefined && next.delivery !== previous.delivery); - const applyRuntimeDefaults = (): void => { - if (!updatesRuntimeDefaults) return; - resetRuntimeControlsForWorkload(runtimeWorld, value.workload, value.layout); - }; - if (transitionsScene) { - startTransition(() => { - void liveSceneAssetResource(value.technique, value.delivery, value.fontFixture, value.workload).then( - () => { - // React does not preserve the transition marker across an await yet. Mark the committed scene update as a - // transition as well, so a newly observed resource can suspend without replacing the currently visible scene. - startTransition(() => { - applyRuntimeDefaults(); - commitLocation(); - }); - }, - (caught: unknown) => { - if (requestRevision !== locationRequestRevisionRef.current) return; - requestedLocationRef.current = committedLocationRef.current; - setError(caught instanceof Error ? caught.message : String(caught)); - }, - ); - }); - } else { - applyRuntimeDefaults(); - commitLocation(); - } - } - - function selectMode(mode: HarnessMode): void { - setLocation({ - layout: mode === 'conformance' ? 'main' : location.layout, - mode, - workload: activityWorkloads[mode], - view: 'scene', - }); - } - - function selectTechnique(technique: RasterTechnique): void { - const currentWorkload = workloadById(location.mode, location.workload); - const selectedWorkload = - currentWorkload.techniques[technique].kind === 'ready' - ? currentWorkload.id - : location.mode === 'benchmark' - ? 'benchmark-ipsum' - : 'text-accuracy'; - setLocation({ - technique, - workload: selectedWorkload, - }); - } - - const stopPresentation = useEffectEvent(() => { - presentationPlayback.current = undefined; - setPresentationPlaying(false); - }); - const navigatePresentation = useEffectEvent((direction: -1 | 1) => { - stopPresentation(); - setLocation({ - mode: 'benchmark', - view: 'scene', - workload: adjacentPresentationWorkload(location.workload, direction), - }); - }); - const togglePresentation = useEffectEvent(() => { - if (presentationPlayback.current !== undefined) { - stopPresentation(); - return; - } - const startFrame = presentationFrame(location.workload, 0); - const startWorkload = startFrame.workload; - presentationPlayback.current = { - preset: startFrame.preset, - startedAt: performance.now(), - startWorkload, - workload: startWorkload, - }; - if (startWorkload === 'advanced-shaping') { - const initial = initialAdvancedShapingState(); - setShowcaseState(initial); - setAdvancedFontFixture(advancedShapingCase(initial.caseId).fontFixture); - } - setPresentationPreset(startFrame.preset); - setPresentationPlaying(true); - if (location.mode !== 'benchmark' || location.workload !== startWorkload) { - setLocation({ mode: 'benchmark', view: 'scene', workload: startWorkload }); - } - }); - const advancePresentation = useEffectEvent((timestamp: number) => { - const playback = presentationPlayback.current; - if (playback === undefined) return; - const frame = presentationFrame(playback.startWorkload, timestamp - playback.startedAt); - if (frame.workload !== playback.workload) { - playback.workload = frame.workload; - if (frame.workload === 'advanced-shaping') { - const initial = initialAdvancedShapingState(); - setShowcaseState(initial); - setAdvancedFontFixture(advancedShapingCase(initial.caseId).fontFixture); - } - } - if (frame.preset !== playback.preset) { - playback.preset = frame.preset; - setPresentationPreset(frame.preset); - } - const requestedLocation = requestedLocationRef.current; - if (frame.workload !== requestedLocation.workload || requestedLocation.mode !== 'benchmark') { - setLocation({ mode: 'benchmark', view: 'scene', workload: frame.workload }); - } - if (frame.complete) stopPresentation(); - }); - useEffect(() => { - const onKeyDown = (keyboardEvent: KeyboardEvent): void => { - const requestedLocation = requestedLocationRef.current; - if (requestedLocation.layout !== 'presentation' || requestedLocation.mode !== 'benchmark') return; - if (keyboardEvent.code === 'Space' && !isPresentationTextEntryTarget(keyboardEvent.target)) { - keyboardEvent.preventDefault(); - keyboardEvent.stopImmediatePropagation(); - if (!keyboardEvent.repeat) togglePresentation(); - return; - } - if (keyboardEvent.repeat || isPresentationShortcutTarget(keyboardEvent.target)) return; - if (keyboardEvent.key === 'ArrowLeft') { - keyboardEvent.preventDefault(); - navigatePresentation(-1); - } else if (keyboardEvent.key === 'ArrowRight') { - keyboardEvent.preventDefault(); - navigatePresentation(1); - } - }; - const onKeyUp = (keyboardEvent: KeyboardEvent): void => { - const requestedLocation = requestedLocationRef.current; - if ( - requestedLocation.layout !== 'presentation' || - requestedLocation.mode !== 'benchmark' || - keyboardEvent.code !== 'Space' || - isPresentationTextEntryTarget(keyboardEvent.target) - ) { - return; - } - keyboardEvent.preventDefault(); - keyboardEvent.stopImmediatePropagation(); - }; - globalThis.addEventListener?.('keydown', onKeyDown, true); - globalThis.addEventListener?.('keyup', onKeyUp, true); - return () => { - globalThis.removeEventListener?.('keydown', onKeyDown, true); - globalThis.removeEventListener?.('keyup', onKeyUp, true); - }; - }, []); - useEffect(() => { - if (!presentationPlaying) return; - let animationFrame = 0; - const animate = (timestamp: number): void => { - advancePresentation(timestamp); - if (presentationPlayback.current !== undefined) animationFrame = requestAnimationFrame(animate); - }; - animationFrame = requestAnimationFrame(animate); - return () => cancelAnimationFrame(animationFrame); - }, [presentationPlaying]); - - function runConformance(runExclusiveJob: RunExclusiveJob): void { - conformanceRunController.current?.abort(new DOMException('conformance run was superseded', 'AbortError')); - const controller = new AbortController(); - conformanceRunController.current = controller; - const revision = ++conformanceRunRevision.current; - setError(undefined); - startTransition(() => { - const request = Promise.resolve().then(() => - runExclusiveJob( - ({ renderer, signal }) => - runRegisteredBenchmark({ - targetId: - location.workload === 'runtime-fallback' - ? `runtime-fallback-${location.technique}-${location.backend}` - : location.workload === 'cross-technique-fidelity' - ? `source-outline-${location.technique}-${location.backend}` - : location.technique === 'slug' - ? `slug-conformance-${location.backend}` - : location.technique === 'mtsdf' - ? `mtsdf-conformance-${location.backend}` - : `bitmap-text-${location.backend}`, - scenarioId: - location.workload === 'runtime-fallback' - ? 'runtime-fallback-parity' - : location.workload === 'cross-technique-fidelity' - ? 'source-outline-fidelity' - : location.technique === 'slug' - ? 'slug-sampling-conformance' - : location.technique === 'mtsdf' - ? 'mtsdf-sampling-conformance' - : 'bitmap-text-frame', - input: { fontFixture: activeFontFixture }, - controls: { dpr, samples, warmup }, - environment, - executionContext: { renderer, signal }, - onEvent: (nextEvent) => { - if (revision === conformanceRunRevision.current) setEvent(nextEvent); - }, - }), - controller.signal, - ), - ); - void request - .then( - (value) => { - if (revision === conformanceRunRevision.current) startTransition(() => setSummary(value)); - }, - (caught: unknown) => { - if ( - revision === conformanceRunRevision.current && - !(caught instanceof DOMException && caught.name === 'AbortError') - ) { - setError(caught instanceof Error ? caught.message : String(caught)); - } - }, - ) - .then(() => { - if (conformanceRunController.current === controller) conformanceRunController.current = undefined; - }); - }); - } - - function completeLiveCapture(stats: LiveTextStats): void { - const workloadFonts = liveWorkloadFontFixtures(location.workload, activeFontFixture); - setLiveCapture({ - kind: 'live-benchmark', - schemaVersion: 0, - capturedAt: new Date().toISOString(), - technique: location.technique, - backend: location.backend, - workload: location.workload, - dpr, - fontFixture: workloadFonts.primary, - ...(workloadFonts.kind === 'icon-grid' ? { labelFontFixture: workloadFonts.labels } : {}), - environment, - stats: captureLiveTextStats(stats), - }); - setLocation({ view: 'report' }); - } - - function captureWindow(): void { - const liveStats = runtimeWorld.get(RuntimeTelemetry)?.stats; - if (liveStats === undefined) return; - if (runtimeWorld.get(RuntimeViewControls)?.showGrid || liveStats.showGrid) { - reportCaptureRequested.current = true; - runtimeWorld.set(RuntimeViewControls, { showGrid: false }); - return; - } - completeLiveCapture(liveStats); - } - - function publishLiveStats(stats: LiveTextStats): void { - runtimeWorld.set(RuntimeTelemetry, { stats }); - if (!reportCaptureRequested.current || stats.showGrid) return; - reportCaptureRequested.current = false; - completeLiveCapture(stats); - } - - function invalidateLiveCapture(): void { - setLiveCapture(undefined); - } - - function dispatchShowcase(command: AdvancedShapingCommand): void { - if (command.kind === 'select-case') { - setAdvancedFontFixture(advancedShapingCase(command.caseId).fontFixture); - } - setShowcaseState((state) => updateAdvancedShaping(state, command)); - invalidateLiveCapture(); - } - - const advanceShowcase = useEffectEvent((elapsedMs: number) => { - const next = advanceAdvancedShapingByTime(showcaseState, elapsedMs); - setShowcaseState(next); - setAdvancedFontFixture(advancedShapingCase(next.caseId).fontFixture); - }); - useEffect(() => { - if ( - location.mode !== 'benchmark' || - location.workload !== 'advanced-shaping' || - !showcaseState.playing || - showcaseState.editedText !== undefined - ) - return; - let animationFrame = 0; - let previousTimestamp: number | undefined; - const animate = (timestamp: number): void => { - const elapsedMs = previousTimestamp === undefined ? 0 : timestamp - previousTimestamp; - previousTimestamp = timestamp; - advanceShowcase(elapsedMs); - animationFrame = requestAnimationFrame(animate); - }; - animationFrame = requestAnimationFrame(animate); - return () => cancelAnimationFrame(animationFrame); - }, [location.mode, location.workload, showcaseState.editedText, showcaseState.playing]); - - const controls = ( - setLocation({ backend })} - onDelivery={(delivery) => setLocation({ delivery })} - onDpr={(value) => { - setDpr(value); - setLocation({ dpr: value }); - }} - onFontNotices={() => setFontNoticesOpen(true)} - onConformanceReset={() => setConformanceView(INITIAL_CONFORMANCE_VIEW)} - onConformanceZoom={(zoom) => setConformanceView((view) => ({ ...view, zoom }))} - onComparisonText={setComparisonText} - onRuntimeControl={invalidateLiveCapture} - onSelectedFontFixture={(value) => { - setLocation({ fontFixture: value }); - }} - onSamples={setSamples} - onShowcase={dispatchShowcase} - onBeforeShowGrid={() => { - reportCaptureRequested.current = false; - }} - onWarmup={setWarmup} - /> - ); - - const liveTechniqueComparison = location.mode === 'conformance' && location.workload === 'mtsdf-slug-compare'; - const actionEligible = available && backendAvailable && !isPending && !liveTechniqueComparison; - - const scene = ( - }> - - setConformanceView((view) => ({ - ...view, - panXPercent: view.panXPercent + deltaXPercent, - panYPercent: view.panYPercent + deltaYPercent, - })) - } - onConformanceZoom={(zoom) => setConformanceView((view) => ({ ...view, zoom }))} - onLiveStats={publishLiveStats} - /> - - ); - const reportRendererError = (caught: unknown): void => { - setError(caught instanceof Error ? caught.message : String(caught)); - }; - - return ( - - { - setAdvancedFontFixture(value); - invalidateLiveCapture(); - }} - onCloseFontNotices={() => setFontNoticesOpen(false)} - onLocation={setLocation} - onMode={selectMode} - onTechnique={selectTechnique} - onWorkloadPanelOpen={setWorkloadPanelOpen} - /> - - ); -} - -function PersistentHarnessLayout({ - onBenchmarkAction, - onConformanceAction, - ...properties -}: Omit & { - readonly onBenchmarkAction: () => void; - readonly onConformanceAction: (runExclusiveJob: RunExclusiveJob) => void; -}) { - const { runExclusiveJob } = usePersistentRenderHost(); - return ( - onConformanceAction(runExclusiveJob) - } - /> - ); -} - -function locationSearch(): string { - return typeof globalThis.location === 'undefined' ? '' : globalThis.location.search; -} - -function isPresentationShortcutTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) return false; - return ( - target.closest('input, textarea, select, button, [contenteditable="true"], [role="slider"], [role="combobox"]') !== - null - ); -} - -function isPresentationTextEntryTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) return false; - return ( - target.closest( - 'textarea, [contenteditable="true"], input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="url"], input[type="tel"], input[type="password"]', - ) !== null - ); -} - -function subscribeDesktop(listener: () => void): () => void { - if (typeof globalThis.matchMedia !== 'function') return () => undefined; - const media = globalThis.matchMedia('(min-width: 1200px)'); - media.addEventListener('change', listener); - return () => media.removeEventListener('change', listener); -} - -function desktopSnapshot(): boolean { - return typeof globalThis.matchMedia !== 'function' || globalThis.matchMedia('(min-width: 1200px)').matches; -} - -function subscribePhone(listener: () => void): () => void { - if (typeof globalThis.matchMedia !== 'function') return () => undefined; - const media = globalThis.matchMedia('(max-width: 699px)'); - media.addEventListener('change', listener); - return () => media.removeEventListener('change', listener); -} - -function phoneSnapshot(): boolean { - return typeof globalThis.matchMedia === 'function' && globalThis.matchMedia('(max-width: 699px)').matches; -} - -function defaultDeviceDpr(): 1 | 2 { - return (globalThis.devicePixelRatio ?? 1) >= 1.5 ? 2 : 1; -} - -function Scene({ - activeFontFixture, - activityWorkloads, - comparisonText, - conformanceView, - dpr, - error, - event, - fontFixture, - liveCapture, - location, - demoMode, - presentation, - presentationPreset, - showcaseFrame, - summary, - onConformancePan, - onConformanceZoom, - onLiveStats, -}: { - readonly activeFontFixture: BenchmarkFontFixture; - readonly activityWorkloads: ActivityWorkloads; - readonly comparisonText: string; - readonly conformanceView: ConformanceView; - readonly dpr: 1 | 2; - readonly error: string | undefined; - readonly event: RunnerEvent | undefined; - readonly fontFixture: SelectableFontFixture; - readonly liveCapture: LiveBenchmarkCapture | undefined; - readonly location: HarnessLocation; - readonly demoMode: boolean; - readonly presentation: 'main' | 'presentation'; - readonly presentationPreset: PresentationPreset | undefined; - readonly showcaseFrame: AdvancedShapingFrame; - readonly summary: BenchmarkSummary | undefined; - readonly onConformancePan: (deltaXPercent: number, deltaYPercent: number) => void; - readonly onConformanceZoom: (zoom: number) => void; - readonly onLiveStats: (stats: LiveTextStats) => void; -}) { - const { showGrid: grid, showLayoutBounds } = useRuntimeViewControls(); - const { fontSize, layoutWidthPercent, workloadAmount } = useRuntimeLayoutControls(); - const { animationEnabled, animationSpeed } = useRuntimeAnimationControls(); - const { paintOpacityPercent, paintShadowEnabled, paintStrokePercent } = useRuntimePaintControls(); - const { stats: liveStats } = useRuntimeTelemetry(); - const workload = workloadById(location.mode, location.workload); - const benchmarkWorkload = workloadById('benchmark', activityWorkloads.benchmark); - const benchmarkStatus = benchmarkWorkload.techniques[location.technique]; - const conformanceWorkload = workloadById('conformance', activityWorkloads.conformance); - const conformanceStatus = conformanceWorkload.techniques[location.technique]; - const benchmarkSurface = - benchmarkStatus.kind === 'planned' ? ( - - ) : ( - - ); - - if (presentation === 'presentation') { - return ( -
- {benchmarkSurface} - {error !== undefined && ( -
- {error} -
- )} -
- ); - } - - return ( -
-
-
-

{location.mode === 'benchmark' ? 'Live benchmark' : 'Correctness inspection'}

-

{workload.label}

-

{workload.description}

-
-
- {techniqueLabel(location.technique)} - {location.backend === 'webgpu' ? 'WebGPU' : 'WebGL'} - {location.delivery === 'runtime' ? 'Runtime bake' : 'Baked asset'} - {dpr}× DPR -
-
- -
- {benchmarkSurface} -
-
- -
- {conformanceStatus.kind === 'planned' ? ( - - ) : ( - - )} -
-
- {error !== undefined && ( -
{error}
- )} - {location.mode === 'benchmark' && liveCapture !== undefined && ( -
- Captured the current rolling window at {liveCapture.capturedAt} ·{' '} - {liveCapture.stats.framesPerSecond.toFixed(1)} FPS · {formatMs(liveCapture.stats.medianSubmitMs)} CPU frame -
- )} -
- ); -} - -function PlannedWorkloadSurface({ - milestone, - technique, - workload, -}: { - readonly milestone: 8 | 9; - readonly technique: RasterTechnique; - readonly workload: WorkloadOption; -}) { - return ( -
-
-

Milestone {milestone}

-

- {techniqueLabel(technique)} · {workload.label} -

-

{workload.description}

-
-
- ); -} - -function BenchmarkSurface({ - animationEnabled, - animationSpeed, - backend, - delivery, - demoMode, - dpr, - fontFixture, - fontSize, - grid, - layoutWidthPercent, - paintOpacityPercent, - paintShadowEnabled, - paintStrokePercent, - presentation, - presentationPreset, - showLayoutBounds, - workloadAmount, - showcaseFrame, - stats, - technique, - workload, - onStats, -}: { - readonly animationEnabled: boolean; - readonly animationSpeed: number; - readonly backend: GraphicsBackend; - readonly delivery: FontDelivery; - readonly demoMode: boolean; - readonly dpr: 1 | 2; - readonly fontFixture: BenchmarkFontFixture; - readonly fontSize: number; - readonly grid: boolean; - readonly layoutWidthPercent: number; - readonly paintOpacityPercent: number; - readonly paintShadowEnabled: boolean; - readonly paintStrokePercent: number; - readonly presentation: 'main' | 'presentation'; - readonly presentationPreset: PresentationPreset | undefined; - readonly showLayoutBounds: boolean; - readonly workloadAmount: number; - readonly showcaseFrame: AdvancedShapingFrame; - readonly stats: LiveTextStats | undefined; - readonly technique: RasterTechnique; - readonly workload: BenchmarkWorkloadId; - readonly onStats: (stats: LiveTextStats) => void; -}) { - use(liveSceneAssetResource(technique, delivery, fontFixture, workload)); - const surfaceAnchorRef = useRef(null); - useEffect(() => { - return scheduleComparisonWorkloadPreload(); - }, []); - const comparisonWorkload = comparisonWorkloadId(workload); - const bitmapStats = stats?.technique === 'bitmap' ? stats : undefined; - const mtsdfStats = stats?.technique === 'mtsdf' ? stats : undefined; - const slugStats = stats?.technique === 'slug' ? stats : undefined; - const comparisonStats = isComparisonWorkloadStats(stats) ? stats : undefined; - const textConfiguration = liveTextSceneForWorkload(workload, { - fontFixture, - layoutWidthRatio: layoutWidthPercent / 100, - showcaseFrame, - }); - if (comparisonWorkload === undefined && textConfiguration === undefined) { - throw new Error(`Live workload ${workload} has no authored Text scene`); - } - const viewport = - comparisonWorkload !== undefined ? ( - - ) : technique === 'slug' ? ( - - ) : technique === 'mtsdf' ? ( - - ) : ( - - ); - return ( - - ); -} - -function withLiveTextFontSize(scene: LiveTextScene | undefined, fontSize: number): LiveTextConfiguration { - if (scene === undefined) throw new Error('A single-paragraph live surface requires an authored Text scene'); - return { ...scene, fontSize }; -} - -function comparisonViewportEvidence({ - layoutWidthRatio, - stats, - technique, - workload, - workloadFonts, -}: { - readonly layoutWidthRatio: number; - readonly stats: ComparisonWorkloadStats | undefined; - readonly technique: RasterTechnique; - readonly workload: ComparisonWorkloadId; - readonly workloadFonts: ReturnType; -}): Record<`data-${string}`, string | number | boolean | undefined> { - const iconStats = stats?.workload === 'icon-grid' ? stats : undefined; - const paintStats = stats?.workload === 'paint-effects' ? stats : undefined; - const zoomStats = stats?.workload === 'zoom-text' ? stats : undefined; - const appliedWorkloadFonts = - stats === undefined ? undefined : liveWorkloadFontFixtures(stats.workload, stats.appliedFontFixture); - const requestedFontFixture = workloadFonts.kind === 'icon-grid' ? workloadFonts.labels : workloadFonts.primary; - const animatedStats = stats; - return { - 'data-canvas-grid': stats === undefined ? undefined : String(stats.showGrid), - 'data-artifact-bytes': stats?.artifactBytes, - 'data-atlas-gpu-bytes': stats?.atlasGpuBytes, - 'data-backend': stats?.backend, - 'data-dpr': stats?.dpr, - 'data-font-delivery': stats?.delivery, - 'data-core-bake-ms': stats?.coreBakeMs, - 'data-raster-bake-ms': stats?.rasterBakeMs, - 'data-source-font-bytes': stats?.sourceFontBytes, - 'data-core-artifact-bytes': stats?.coreArtifactBytes, - 'data-raster-artifact-bytes': stats?.rasterArtifactBytes, - 'data-draw-count': stats?.drawCount, - 'data-first-draw-ms': stats?.firstDrawMs, - 'data-font-fixture': appliedWorkloadFonts?.primary, - 'data-label-font-fixture': appliedWorkloadFonts?.kind === 'icon-grid' ? appliedWorkloadFonts.labels : undefined, - 'data-font-load-ms': stats?.fontLoadMs, - 'data-frames-per-second': stats?.framesPerSecond, - 'data-glyph-count': stats?.glyphCount, - 'data-gpu-history-length': stats?.gpuHistoryLength, - 'data-gpu-timing-supported': stats?.gpuTimingSupported, - 'data-layout-width': stats?.layoutWidth, - 'data-content-inset': BENCHMARK_CONTENT_INSET, - 'data-content-min-width': - workload === 'text-ladder' || workload === 'icon-grid' || workload === 'zoom-text' - ? undefined - : (workload === 'dynamic-layout' ? 1_000 : BENCHMARK_CONTENT_MINIMUM_VIEWPORT_WIDTH) * layoutWidthRatio, - 'data-content-policy': - workload === 'text-ladder' || workload === 'icon-grid' ? 'pan' : workload === 'zoom-text' ? 'fit' : 'bounded-pan', - 'data-icon-item-count': iconStats?.iconItemCount, - 'data-icon-label-count': iconStats?.iconLabelCount, - 'data-icon-column-count': iconStats?.iconColumnCount, - 'data-icon-row-count': iconStats?.iconRowCount, - 'data-icon-size': iconStats?.appliedFontSize, - 'data-icon-grid-width': iconStats?.iconGridWidth, - 'data-icon-grid-height': iconStats?.iconGridHeight, - 'data-icon-label-size': iconStats?.iconLabelSize, - 'data-icon-pool-capacity': iconStats?.iconPoolCapacity, - 'data-icon-assigned-count': iconStats?.iconAssignedCount, - 'data-icon-render-visible-count': iconStats?.iconRenderVisibleCount, - 'data-icon-assignment-signature': iconStats?.iconAssignmentSignature, - 'data-icon-first-visible-index': iconStats?.iconFirstVisibleIndex, - 'data-icon-last-visible-index': iconStats?.iconLastVisibleIndex, - 'data-icon-recycle-count': iconStats?.iconRecycleCount, - 'data-icon-window-revision': iconStats?.iconWindowRevision, - 'data-icon-overscan-rows': iconStats?.iconOverscanRows, - 'data-icon-overscan-columns': iconStats?.iconOverscanColumns, - 'data-icon-scroll-x': iconStats?.iconScrollX, - 'data-icon-scroll-y': iconStats?.iconScrollY, - 'data-icon-maximum-scroll-x': iconStats?.iconMaximumScrollX, - 'data-icon-maximum-scroll-y': iconStats?.iconMaximumScrollY, - 'data-line-count': stats?.lineCount, - 'data-median-gpu-ms': stats?.medianGpuMs, - 'data-median-submit-ms': stats?.medianSubmitMs, - 'data-missing-glyph-count': stats?.missingGlyphCount, - 'data-p95-gpu-ms': stats?.p95GpuMs, - 'data-p95-submit-ms': stats?.p95SubmitMs, - 'data-renderer-init-ms': stats?.rendererInitMs, - 'data-configuration-revision': stats?.configurationRevision, - 'data-camera-kind': stats?.cameraKind, - 'data-applied-font-size': stats?.appliedFontSize, - 'data-applied-workload-amount': stats?.appliedAmount, - 'data-layout-width-ratio': stats?.appliedLayoutWidthRatio, - 'data-paint-opacity': paintStats?.appliedPaintOpacity, - 'data-paint-shadow-enabled': paintStats === undefined ? undefined : String(paintStats.appliedPaintShadowEnabled), - 'data-paint-stroke-width': paintStats?.appliedPaintStrokeWidth, - 'data-paint-revision': paintStats?.paintRevision, - 'data-paint-update-ms': paintStats?.lastPaintUpdateMs, - 'data-presentation-pending': - stats !== undefined && - (stats.technique !== technique || - stats.workload !== workload || - stats.appliedFontFixture !== requestedFontFixture), - 'data-layout-bounds-visible': - stats?.workload === 'dynamic-layout' ? String(stats.appliedShowLayoutBounds) : undefined, - 'data-reflow-count': stats?.reflowCount, - 'data-reflow-ms': stats?.lastReflowMs, - 'data-rendered-device-px': stats?.renderedPpem, - 'data-raster-em-size': stats?.technique === 'mtsdf' ? stats.rasterEmSize : undefined, - 'data-raster-pixel-range': stats?.technique === 'mtsdf' ? stats.rasterPixelRange : undefined, - 'data-scale-ratio': stats?.technique === 'slug' ? undefined : stats?.scaleRatio, - 'data-slug-curve-gpu-bytes': stats?.technique === 'slug' ? stats.slugCurveGpuBytes : undefined, - 'data-slug-header-gpu-bytes': stats?.technique === 'slug' ? stats.slugHeaderGpuBytes : undefined, - 'data-slug-page-count': stats?.technique === 'slug' ? stats.slugPageCount : undefined, - 'data-slug-reference-gpu-bytes': stats?.technique === 'slug' ? stats.slugReferenceGpuBytes : undefined, - 'data-slug-gpu-bytes': stats?.technique === 'slug' ? stats.slugGpuBytes : undefined, - 'data-startup-ms': stats?.startupMs, - 'data-source-text-length': stats?.sourceTextLength, - 'data-submit-history-length': stats?.submitHistoryLength, - 'data-text-ready-ms': stats?.textReadyMs, - 'data-text-update-sample-count': stats?.textUpdateTimings.sampleCount, - 'data-technique': technique, - 'data-total-gpu-bytes': stats?.totalGpuBytes, - 'data-upload-frame-gpu-ms': stats?.uploadFrameGpuMs, - 'data-upload-frame-complete-ms': stats?.uploadFrameCompleteMs, - 'data-workload': stats?.workload, - 'data-zoom-text': zoomStats?.zoomText, - 'data-zoom-language': zoomStats?.zoomLanguage, - 'data-zoom-phrase-index': zoomStats?.zoomPhraseIndex, - 'data-zoom-phrase-revision': zoomStats?.zoomPhraseRevision, - 'data-zoom-base-css-px': zoomStats?.zoomBaseCssPx, - 'data-zoom-effective-css-px': zoomStats?.zoomEffectiveCssPx, - 'data-zoom-maximum-css-px': zoomStats?.zoomMaximumCssPx, - 'data-zoom-scale': zoomStats?.zoomScale, - 'data-zoom-maximum-scale': zoomStats?.zoomMaximumScale, - 'data-workload-amount': - stats === undefined || workloadAmountLabel(stats.workload, stats.appliedAmount) === undefined - ? undefined - : stats.appliedAmount, - 'data-animation-enabled': animatedStats === undefined ? undefined : String(animatedStats.appliedAnimationEnabled), - 'data-animation-speed': animatedStats?.appliedAnimationSpeed, - }; -} - -function ComparisonWorkloadViewport({ - amount, - animationEnabled, - animationSpeed, - backend, - delivery, - demoMode, - dpr, - fontFixture, - fontSize, - grid, - layoutWidthRatio, - paintOpacity, - paintShadowEnabled, - paintStrokeWidth, - presentationPreset, - showLayoutBounds, - suppressLoading, - stats, - surfaceAnchorRef, - technique, - workload, - onStats, -}: { - readonly amount: number; - readonly animationEnabled: boolean; - readonly animationSpeed: number; - readonly backend: GraphicsBackend; - readonly delivery: FontDelivery; - readonly demoMode: boolean; - readonly dpr: 1 | 2; - readonly fontFixture: BenchmarkFontFixture; - readonly fontSize: number; - readonly grid: boolean; - readonly layoutWidthRatio: number; - readonly paintOpacity: number; - readonly paintShadowEnabled: boolean; - readonly paintStrokeWidth: number; - readonly presentationPreset: PresentationPreset | undefined; - readonly showLayoutBounds: boolean; - readonly suppressLoading: boolean; - readonly stats: ComparisonWorkloadStats | undefined; - readonly surfaceAnchorRef: RefObject; - readonly technique: RasterTechnique; - readonly workload: ComparisonWorkloadId; - readonly onStats: (stats: LiveTextStats) => void; -}) { - const { activateSurface, configureSurface } = usePersistentRenderHost(); - const activatePersistentSurface = useEffectEvent(activateSurface); - const configurePersistentSurface = useEffectEvent(configureSurface); - const containerRef = useRef(null); - const previewRef = useRef(undefined); - const workloadDefinition = benchmarkWorkloadDefinition(workload); - const workloadFonts = liveWorkloadFontFixtures(workload, fontFixture); - const [error, setError] = useState(); - const { - active: bakeProgressActive, - finish: finishBakeProgress, - publish: publishBakeProgress, - value: bakeProgressValue, - } = useBakeProgress(techniqueLabel(technique)); - const publishStats = useEffectEvent((next: ComparisonWorkloadStats) => { - finishBakeProgress(); - onStats(next); - setError(undefined); - }); - const publishError = useEffectEvent((caught: unknown) => { - if (caught instanceof DOMException && caught.name === 'AbortError') return; - finishBakeProgress(); - setError(caught instanceof Error ? caught.message : String(caught)); - }); - const currentConfiguration = useEffectEvent( - (): ComparisonWorkloadConfiguration => ({ - amount, - animationEnabled, - animationSpeed, - fontFixture, - fontSize, - iconGridView: presentationPreset === 'icon-grid-return' ? 'alternate' : 'origin', - layoutWidthRatio, - paintOpacity, - paintShadowEnabled, - paintStrokeWidth, - showGrid: grid, - showLayoutBounds, - textLadderExitEnabled: demoMode && workload === 'text-ladder', - workload, - }), - ); - - useEffect(() => { - const container = containerRef.current; - const surfaceAnchor = surfaceAnchorRef.current; - if (container === null || surfaceAnchor === null) return; - const controller = new AbortController(); - let preview: ComparisonWorkloadPersistentScene | undefined; - let surfaceLease: Awaited> | undefined; - let cancelled = false; - const initialization = (async () => { - const { createComparisonWorkloadPersistentScene } = await preloadComparisonWorkload(); - if (cancelled) return; - const configuration = currentConfiguration(); - const interaction = benchmarkWorkloadDefinition(configuration.workload).interaction; - const created = createComparisonWorkloadPersistentScene({ - ...configuration, - backend, - delivery, - technique, - onError: publishError, - onStats: publishStats, - onBakeProgress: publishBakeProgress, - }); - preview = created; - previewRef.current = created; - surfaceLease = await activatePersistentSurface( - { - anchor: surfaceAnchor, - controller: previewRef, - label: `Live ${techniqueLabel(technique)} benchmark using ${backend}`, - pan: interaction.pan, - scene: created, - zoom: interaction.zoom, - }, - controller.signal, - ); - if (cancelled) await surfaceLease.release(); - })(); - void initialization.catch(publishError); - return () => { - cancelled = true; - controller.abort(); - void initialization.then( - async () => { - const current = preview; - preview = undefined; - if (previewRef.current === current) previewRef.current = undefined; - await surfaceLease?.release(); - }, - () => { - const current = preview; - preview = undefined; - if (previewRef.current === current) previewRef.current = undefined; - }, - ); - }; - }, [backend, delivery, publishBakeProgress, surfaceAnchorRef, technique]); - - useEffect(() => { - const interaction = benchmarkWorkloadDefinition(workload).interaction; - configurePersistentSurface({ - controller: previewRef, - label: `Live ${techniqueLabel(technique)} benchmark using ${backend}`, - pan: interaction.pan, - zoom: interaction.zoom, - }); - }, [backend, technique, workload]); - - useEffect(() => { - const preview = previewRef.current; - if (preview === undefined) return; - void preview.update(currentConfiguration()).catch(publishError); - }, [ - amount, - animationEnabled, - animationSpeed, - dpr, - demoMode, - fontFixture, - fontSize, - layoutWidthRatio, - paintOpacity, - paintShadowEnabled, - paintStrokeWidth, - presentationPreset, - grid, - showLayoutBounds, - workload, - ]); - - const rangeLabel = - workload === 'text-ladder' - ? '8–1024 CSS PX' - : workload === 'zoom-text' - ? '8 PT · 10.67 CSS PX → VIEWPORT FIT' - : workload === 'icon-grid' - ? `${fontSize} CSS PX ICONS` - : `${fontSize} CSS PX`; - return ( -
-
- - {stats?.technique === 'mtsdf' - ? `MTSDF ${String(stats.rasterEmSize)} PX/EM` - : stats?.technique === 'slug' - ? `SLUG ANALYTIC · ${String(stats.slugPageCount)} PAGE${stats.slugPageCount === 1 ? '' : 'S'}` - : stats?.technique === 'bitmap' - ? `BITMAP ${String(stats.strikePpem)} PX STRIKE` - : technique === 'mtsdf' - ? 'MTSDF — PX/EM' - : technique === 'slug' - ? 'SLUG ANALYTIC · — PAGES' - : 'BITMAP — PX STRIKE'}{' '} - · {rangeLabel} - -
-
- {workloadDefinition.interaction.zoom - ? 'PAN · PINCH/WHEEL ZOOM' - : workloadDefinition.interaction.pan - ? 'PAN' - : 'AUTO FIT'}{' '} - · {dpr}× DPR -
- {!suppressLoading && (stats === undefined || bakeProgressActive) && error === undefined && ( - - )} - {error !== undefined && ( -
- {error} -
- )} -
- ); -} diff --git a/apps/benchmarks/src/benchmark/font-fixtures.ts b/apps/benchmarks/src/benchmark/font-fixtures.ts index 88f440d4..4af5dda7 100644 --- a/apps/benchmarks/src/benchmark/font-fixtures.ts +++ b/apps/benchmarks/src/benchmark/font-fixtures.ts @@ -1,5 +1,5 @@ -import type { AdvancedShapingFontFixture } from '../workloads/advanced-shaping'; -import { BENCHMARK_IPSUM_CONFORMANCE_TEXT, BENCHMARK_IPSUM_TEXT } from '../workloads/benchmark-ipsum'; +import type { AdvancedShapingFontFixture } from '../workloads/advanced-shaping/scene'; +import { BENCHMARK_IPSUM_CONFORMANCE_TEXT, BENCHMARK_IPSUM_TEXT } from '../workloads/benchmark-ipsum/scene'; export type BenchmarkFontFixture = | AdvancedShapingFontFixture diff --git a/apps/benchmarks/src/benchmark/low-level/raster/bitmap-finite-scene.ts b/apps/benchmarks/src/benchmark/low-level/raster/bitmap-finite-scene.ts index fdddecf1..a4ddd5be 100644 --- a/apps/benchmarks/src/benchmark/low-level/raster/bitmap-finite-scene.ts +++ b/apps/benchmarks/src/benchmark/low-level/raster/bitmap-finite-scene.ts @@ -6,7 +6,7 @@ import { conformanceText, type BenchmarkFontFixture } from '../../font-fixtures' import type { TargetRunOutput } from '../../contracts'; import type { FontDelivery } from '../../url-state'; import { loadBitmapFontAsset } from '../../../workloads/font-assets/bitmap'; -import { createBitmapLine, disposeBitmapLine, type BitmapLine } from '../../../renderer/bitmap-line'; +import { createBitmapLine, disposeBitmapLine, type BitmapLine } from '../../../techniques/bitmap/line'; import { createConfiguredRenderer, disposeConfiguredRenderer, diff --git a/apps/benchmarks/src/benchmark/presentation-sequence.test.ts b/apps/benchmarks/src/benchmark/presentation-sequence.test.ts index 4d8ee3b1..88c8fa2d 100644 --- a/apps/benchmarks/src/benchmark/presentation-sequence.test.ts +++ b/apps/benchmarks/src/benchmark/presentation-sequence.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { ADVANCED_SHAPING_PRESENTATION_CYCLE_DURATION_MS } from '../workloads/advanced-shaping'; +import { ADVANCED_SHAPING_PRESENTATION_CYCLE_DURATION_MS } from '../workloads/advanced-shaping/scene'; import { adjacentPresentationWorkload, PRESENTATION_SCENES, diff --git a/apps/benchmarks/src/benchmark/presentation-sequence.ts b/apps/benchmarks/src/benchmark/presentation-sequence.ts index 3ab1ee9a..26f9121b 100644 --- a/apps/benchmarks/src/benchmark/presentation-sequence.ts +++ b/apps/benchmarks/src/benchmark/presentation-sequence.ts @@ -1,4 +1,4 @@ -import { ADVANCED_SHAPING_PRESENTATION_CYCLE_DURATION_MS } from '../workloads/advanced-shaping'; +import { ADVANCED_SHAPING_PRESENTATION_CYCLE_DURATION_MS } from '../workloads/advanced-shaping/scene'; export const TEXT_LADDER_PRESENTATION_DURATION_MS = 7_200; // Three complete 3.5-second base cycles at the default 1.125× animation rate, rounded up to the next millisecond. diff --git a/apps/benchmarks/src/benchmark/targets/measurement/comparison-preview.ts b/apps/benchmarks/src/benchmark/probes/comparison-workload-preview.ts similarity index 94% rename from apps/benchmarks/src/benchmark/targets/measurement/comparison-preview.ts rename to apps/benchmarks/src/benchmark/probes/comparison-workload-preview.ts index 80fa7dcc..8e37e371 100644 --- a/apps/benchmarks/src/benchmark/targets/measurement/comparison-preview.ts +++ b/apps/benchmarks/src/benchmark/probes/comparison-workload-preview.ts @@ -4,8 +4,8 @@ import { type ComparisonWorkloadId, type ComparisonWorkloadPersistentSceneOptions, type ComparisonWorkloadStats, -} from '../../../workloads/comparison/scene'; -import { createPersistentRenderHost } from '../../../renderer/persistent-render-host'; +} from '../../surfaces/benchmark/scenes/comparison-workload'; +import { createPersistentRenderHost } from '../../renderer/persistent-render-host'; /** * Measurement-only adapter for probes that need an isolated canvas. The retained diff --git a/apps/benchmarks/src/benchmark/product-result.test.ts b/apps/benchmarks/src/benchmark/product-result.test.ts index 78c32e21..1ab6453d 100644 --- a/apps/benchmarks/src/benchmark/product-result.test.ts +++ b/apps/benchmarks/src/benchmark/product-result.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { BitmapTextLiveStats } from '../renderer/bitmap-text'; +import type { BitmapTextLiveStats } from '../techniques/bitmap/persistent-scene'; import { captureLiveTextStats, snapshotCircularSeries } from './product-result'; describe('live benchmark capture', () => { diff --git a/apps/benchmarks/src/benchmark/product-result.ts b/apps/benchmarks/src/benchmark/product-result.ts index 5dff43ad..8be43f0d 100644 --- a/apps/benchmarks/src/benchmark/product-result.ts +++ b/apps/benchmarks/src/benchmark/product-result.ts @@ -1,6 +1,6 @@ -import type { BitmapTextLiveStats } from '../renderer/bitmap-text'; -import type { MtsdfTextLiveStats } from '../renderer/mtsdf-text'; -import type { SlugTextLiveStats } from '../renderer/slug-text'; +import type { BitmapTextLiveStats } from '../techniques/bitmap/persistent-scene'; +import type { MtsdfTextLiveStats } from '../techniques/mtsdf/persistent-scene'; +import type { SlugTextLiveStats } from '../techniques/slug/persistent-scene'; import type { BenchmarkEnvironment } from './contracts'; import type { BenchmarkFontFixture } from './font-fixtures'; import type { GraphicsBackend, RasterTechnique } from './url-state'; diff --git a/apps/benchmarks/src/benchmark/runtime-world.ts b/apps/benchmarks/src/benchmark/runtime-world.ts index 1334ecd5..72a74214 100644 --- a/apps/benchmarks/src/benchmark/runtime-world.ts +++ b/apps/benchmarks/src/benchmark/runtime-world.ts @@ -1,9 +1,9 @@ import { createWorld, trait, type Trait, type TraitRecord, type World } from 'koota'; import { useTrait, useWorld } from 'koota/react'; -import type { BitmapTextLiveStats } from '../renderer/bitmap-text'; -import type { MtsdfTextLiveStats } from '../renderer/mtsdf-text'; -import type { SlugTextLiveStats } from '../renderer/slug-text'; +import type { BitmapTextLiveStats } from '../techniques/bitmap/persistent-scene'; +import type { MtsdfTextLiveStats } from '../techniques/mtsdf/persistent-scene'; +import type { SlugTextLiveStats } from '../techniques/slug/persistent-scene'; import type { CanvasViewController } from '../renderer/canvas-view-controller'; import { benchmarkWorkloadDefinition, isBenchmarkWorkloadId } from '../workloads/catalog'; import type { HarnessLayout } from './url-state'; diff --git a/apps/benchmarks/src/benchmark/scenarios.ts b/apps/benchmarks/src/benchmark/scenarios.ts index 217f9ed2..429c7969 100644 --- a/apps/benchmarks/src/benchmark/scenarios.ts +++ b/apps/benchmarks/src/benchmark/scenarios.ts @@ -1,5 +1,5 @@ import type { BenchmarkScenario } from './contracts'; -import { ADVANCED_SHAPING_CASES } from '../workloads/advanced-shaping'; +import { ADVANCED_SHAPING_CASES } from '../workloads/advanced-shaping/scene'; import paragraphBidiContract from '../../fixtures/contracts/paragraph-bidi-layout-v0.json'; import cjkContract from '../../fixtures/contracts/paragraph-cjk-layout-v0.json'; import cjkManifest from '../../fixtures/fonts/noto-sans-cjk-2.004/manifest.json'; diff --git a/apps/benchmarks/src/benchmark/target-boundary.test.ts b/apps/benchmarks/src/benchmark/target-boundary.test.ts index 612c2df4..d42f52f8 100644 --- a/apps/benchmarks/src/benchmark/target-boundary.test.ts +++ b/apps/benchmarks/src/benchmark/target-boundary.test.ts @@ -36,13 +36,15 @@ describe('benchmark target boundaries', () => { expect(offenders.filter((file): file is string => file !== undefined)).toEqual([directWasmDependencyModule]); }); - it('keeps low-level targets downstream from renderer infrastructure', async () => { + it('keeps renderer infrastructure independent of benchmark targets, low-level references, and techniques', async () => { const files = await sourceFiles(rendererDirectory); expect(files.map((file) => file.slice(rendererDirectory.length + 1))).not.toContain('tsl-baseline.ts'); const offenders = await Promise.all( files.map(async (file) => { const source = await readFile(file, 'utf8'); - return /benchmark\/targets/.test(source) ? file.slice(rendererDirectory.length + 1) : undefined; + return /benchmark\/(?:targets|low-level)|techniques\//.test(source) + ? file.slice(rendererDirectory.length + 1) + : undefined; }), ); expect(offenders.filter((file): file is string => file !== undefined)).toEqual([]); @@ -81,7 +83,7 @@ describe('benchmark target boundaries', () => { expect(conformance).not.toContain('renderer/advanced-shaping-conformance'); expect(conformance).not.toContain('renderer/tsl-baseline'); expect(conformance).not.toContain('renderer/runtime-fallback-conformance'); - expect(conformance).not.toContain('renderer/bitmap-text'); + expect(conformance).not.toContain('techniques/bitmap/persistent-scene'); expect(product).toContain("import('./external-raster-proof')"); expect(product).toContain("import('./react-text')"); expect(product).toContain("import('./mtsdf-text')"); @@ -89,23 +91,23 @@ describe('benchmark target boundaries', () => { expect(product).toContain("import('./bitmap-text')"); expect(product).not.toContain('renderer/external-raster-proof'); expect(product).not.toContain('renderer/react-text'); - expect(product).not.toContain('renderer/mtsdf-text'); - expect(product).not.toContain('renderer/slug-text'); - expect(product).not.toContain('renderer/bitmap-text'); + expect(product).not.toContain('techniques/mtsdf/persistent-scene'); + expect(product).not.toContain('techniques/slug/persistent-scene'); + expect(product).not.toContain('techniques/bitmap/persistent-scene'); expect(mtsdfAdapter).toContain("import { createMtsdfConformanceSession } from './mtsdf-capture'"); - expect(mtsdfAdapter).not.toContain('renderer/mtsdf-text'); + expect(mtsdfAdapter).not.toContain('techniques/mtsdf/persistent-scene'); expect(slugAdapter).toContain("import { createSlugConformanceSession } from './slug-capture'"); - expect(slugAdapter).not.toContain('renderer/slug-text'); + expect(slugAdapter).not.toContain('techniques/slug/persistent-scene'); expect(bitmapCapture).toContain('createBitmapFiniteScene'); - expect(bitmapCapture).not.toContain('renderer/bitmap-text'); + expect(bitmapCapture).not.toContain('techniques/bitmap/persistent-scene'); expect(runtimeFallback).toContain("from './bitmap-capture'"); - expect(runtimeFallback).not.toContain('renderer/bitmap-text'); + expect(runtimeFallback).not.toContain('techniques/bitmap/persistent-scene'); expect(finiteCaptureSurface).toContain("import('../../benchmark/targets/conformance/raster/bitmap-capture')"); - expect(finiteCaptureSurface).not.toContain('renderer/bitmap-text'); + expect(finiteCaptureSurface).not.toContain('techniques/bitmap/persistent-scene'); expect( slugCaptureProbes.every((probe) => probe.includes('/benchmark/targets/conformance/raster/slug-capture.ts')), ).toBe(true); - expect(slugCaptureProbes.every((probe) => !probe.includes('/renderer/slug-text.ts'))).toBe(true); + expect(slugCaptureProbes.every((probe) => !probe.includes('/techniques/slug/persistent-scene.ts'))).toBe(true); expect(slugCaptureProbes.every((probe) => !probe.includes('/renderer/slug-role-scenes.ts'))).toBe(true); const { createAdvancedShapingConformanceTarget } = await import('./targets/conformance/advanced-shaping'); expect(createAdvancedShapingConformanceTarget().id).toBe('advanced-shaping-conformance'); diff --git a/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts b/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts index 113374ff..69ed919e 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts @@ -10,7 +10,7 @@ import { ADVANCED_SHAPING_CASES, advancedShapingFrames, type AdvancedShapingFontFixture, -} from '../../../workloads/advanced-shaping'; +} from '../../../workloads/advanced-shaping/scene'; import type { BenchmarkTarget } from '../../contracts'; import { hashParagraphLayout, paragraphLayoutBytes } from '../../paragraph-layout-digest'; diff --git a/apps/benchmarks/src/benchmark/targets/product/mtsdf-text.ts b/apps/benchmarks/src/benchmark/targets/product/mtsdf-text.ts index 4267e57e..83ac7bdb 100644 --- a/apps/benchmarks/src/benchmark/targets/product/mtsdf-text.ts +++ b/apps/benchmarks/src/benchmark/targets/product/mtsdf-text.ts @@ -3,7 +3,7 @@ import { msdf } from '@pmndrs/text/raster/msdf'; import * as THREE from 'three/webgpu'; import type { BenchmarkTarget, TargetRunOutput } from '../../contracts'; -import { BENCHMARK_IPSUM_CONFORMANCE_TEXT } from '../../../workloads/benchmark-ipsum'; +import { BENCHMARK_IPSUM_CONFORMANCE_TEXT } from '../../../workloads/benchmark-ipsum/scene'; import { loadMtsdfFontAsset } from '../../../workloads/font-assets/mtsdf'; import { compactRgba8Readback } from '../../low-level/raster/rgba-readback'; import { diff --git a/apps/benchmarks/src/benchmark/targets/product/slug-text.ts b/apps/benchmarks/src/benchmark/targets/product/slug-text.ts index ef162026..7334a691 100644 --- a/apps/benchmarks/src/benchmark/targets/product/slug-text.ts +++ b/apps/benchmarks/src/benchmark/targets/product/slug-text.ts @@ -4,8 +4,8 @@ import * as THREE from 'three/webgpu'; import type { BenchmarkTarget, TargetRunOutput } from '../../contracts'; import { compactRgba8Readback } from '../../low-level/raster/rgba-readback'; -import { BENCHMARK_IPSUM_CONFORMANCE_TEXT } from '../../../workloads/benchmark-ipsum'; -import { registeredSlugConfiguration } from '../../low-level/raster/slug-configuration'; +import { BENCHMARK_IPSUM_CONFORMANCE_TEXT } from '../../../workloads/benchmark-ipsum/scene'; +import { registeredSlugConfiguration } from '../../../techniques/slug/metadata'; import { loadSlugFontAsset } from '../../../workloads/font-assets/slug'; import { createConfiguredRenderer, diff --git a/apps/benchmarks/src/components/harness-layout.tsx b/apps/benchmarks/src/components/harness-layout.tsx index b85730af..c6501911 100644 --- a/apps/benchmarks/src/components/harness-layout.tsx +++ b/apps/benchmarks/src/components/harness-layout.tsx @@ -4,7 +4,7 @@ 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 type { AdvancedShapingFrame } from '../workloads/advanced-shaping/scene'; import { benchmarkWorkloadDefinition, isBenchmarkWorkloadId, diff --git a/apps/benchmarks/src/components/presentation-control-dock.tsx b/apps/benchmarks/src/components/presentation-control-dock.tsx index 31ee16e0..521de0d1 100644 --- a/apps/benchmarks/src/components/presentation-control-dock.tsx +++ b/apps/benchmarks/src/components/presentation-control-dock.tsx @@ -21,7 +21,7 @@ import { type AdvancedShapingCommand, type AdvancedShapingFrame, type AdvancedShapingState, -} from '../workloads/advanced-shaping'; +} from '../workloads/advanced-shaping/scene'; /* * The dock intentionally consumes the same typed control state as the main panel. * shadcn/Base UI owns interaction semantics; this component only maps state to a compact presentation. diff --git a/apps/benchmarks/src/components/render-controls.tsx b/apps/benchmarks/src/components/render-controls.tsx index 527340c0..1d8ee599 100644 --- a/apps/benchmarks/src/components/render-controls.tsx +++ b/apps/benchmarks/src/components/render-controls.tsx @@ -5,7 +5,7 @@ import { type AdvancedShapingCommand, type AdvancedShapingFrame, type AdvancedShapingState, -} from '../workloads/advanced-shaping'; +} from '../workloads/advanced-shaping/scene'; import { BENCHMARK_FONT_LABELS, ICON_GRID_FONT_FIXTURE, @@ -17,9 +17,9 @@ import { } from '../benchmark/font-fixtures'; import type { FontDelivery, GraphicsBackend, HarnessMode, RasterTechnique } from '../benchmark/url-state'; import packageSizes from '../generated/package-sizes.json'; -import type { BitmapTextLiveStats } from '../renderer/bitmap-text'; -import type { MtsdfTextLiveStats } from '../renderer/mtsdf-text'; -import type { SlugTextLiveStats } from '../renderer/slug-text'; +import type { BitmapTextLiveStats } from '../techniques/bitmap/persistent-scene'; +import type { MtsdfTextLiveStats } from '../techniques/mtsdf/persistent-scene'; +import type { SlugTextLiveStats } from '../techniques/slug/persistent-scene'; import { FontFixtureButtons } from './font-fixture-buttons'; import { Button, Field, SelectField, TextareaField, Toggle } from './ui'; import { PresentationControlDock } from './presentation-control-dock'; diff --git a/apps/benchmarks/src/components/telemetry-charts.tsx b/apps/benchmarks/src/components/telemetry-charts.tsx index 8fea5a94..2f0c3d7d 100644 --- a/apps/benchmarks/src/components/telemetry-charts.tsx +++ b/apps/benchmarks/src/components/telemetry-charts.tsx @@ -1,8 +1,8 @@ import { useEffect, useEffectEvent, useRef, type RefObject } from 'react'; -import type { BitmapTextLiveStats } from '../renderer/bitmap-text'; -import type { MtsdfTextLiveStats } from '../renderer/mtsdf-text'; -import type { SlugTextLiveStats } from '../renderer/slug-text'; +import type { BitmapTextLiveStats } from '../techniques/bitmap/persistent-scene'; +import type { MtsdfTextLiveStats } from '../techniques/mtsdf/persistent-scene'; +import type { SlugTextLiveStats } from '../techniques/slug/persistent-scene'; import { formatSparklineValue, sparklineCanvasMetrics, diff --git a/apps/benchmarks/src/components/workload-rail.tsx b/apps/benchmarks/src/components/workload-rail.tsx index c9946bad..40b4341d 100644 --- a/apps/benchmarks/src/components/workload-rail.tsx +++ b/apps/benchmarks/src/components/workload-rail.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'; -import type { AdvancedShapingFrame } from '../workloads/advanced-shaping'; +import type { AdvancedShapingFrame } from '../workloads/advanced-shaping/scene'; import { ADVANCED_FONT_FIXTURES, BENCHMARK_FONT_LABELS, @@ -24,7 +24,7 @@ import { TechniqueSwitcher } from './technique-switcher'; let comparisonWorkloadModule: ReturnType | undefined; function importComparisonWorkload() { - return import('../workloads/comparison/scene'); + return import('../surfaces/benchmark/scenes/comparison-workload'); } function preloadComparisonWorkload(): ReturnType { diff --git a/apps/benchmarks/src/controllers/harness-controller.tsx b/apps/benchmarks/src/controllers/harness-controller.tsx new file mode 100644 index 00000000..72c681c2 --- /dev/null +++ b/apps/benchmarks/src/controllers/harness-controller.tsx @@ -0,0 +1,779 @@ +import { + Suspense, + use, + useEffect, + useEffectEvent, + useRef, + useState, + useSyncExternalStore, + useTransition, + type ReactNode, +} from 'react'; + +import { + advanceAdvancedShapingByTime, + advancedShapingCase, + advancedShapingFrame, + initialAdvancedShapingState, + updateAdvancedShaping, + type AdvancedShapingCommand, +} from '../workloads/advanced-shaping/scene'; +import type { BenchmarkSummary, RunnerEvent } from '../benchmark/contracts'; +import { environmentResource } from '../benchmark/environment'; +import { runRegisteredBenchmark } from '../benchmark/execution'; +import { + defaultRuntimeFontSizeForWorkload, + resetRuntimeControlsForWorkload, + RuntimeLayoutControls, + RuntimeTelemetry, + RuntimeViewControls, + useRuntimeAnimationControls, + useRuntimeWorld, + type RuntimeLiveStats, +} from '../benchmark/runtime-world'; +import { captureLiveTextStats, type LiveBenchmarkCapture } from '../benchmark/product-result'; +import { + adjacentPresentationWorkload, + presentationFrame, + type PresentationPreset, + type PresentationWorkload, +} from '../benchmark/presentation-sequence'; +import { + setParagraphStressMotionFrame, + type MutableParagraphStressMotionFrame, +} from '../benchmark/paragraph-stress-motion'; +import { + liveWorkloadFontFixtures, + rasterConformanceSpecimen, + type BenchmarkFontFixture, +} from '../benchmark/font-fixtures'; +import { + readHarnessLocation, + writeHarnessUrl, + type FontDelivery, + type HarnessLayout, + type HarnessLocation, + type HarnessMode, + type RasterTechnique, +} from '../benchmark/url-state'; +import type { ConformanceView } from '../components/render-controls'; +import { RuntimeControls } from '../components/runtime-controls'; +import { workloadById, isConformanceWorkloadId, type ConformanceWorkloadId } from '../benchmark/workloads'; +import type { PersistentRenderJob } from '../renderer/persistent-render-host'; +import { isBenchmarkWorkloadId, type BenchmarkWorkloadId } from '../workloads/catalog'; +import { liveSceneAssetResource, loadBenchmarkFontAssets } from '../surfaces/benchmark/scene-preload'; +import { PersistentHarnessLayout } from '../surfaces/harness/persistent-layout'; +import { Scene, SceneSuspenseFallback } from '../surfaces/harness/scene'; +import { useLocation } from 'wouter'; + +type RunExclusiveJob = (job: PersistentRenderJob, signal?: AbortSignal) => Promise>; +type LiveTextStats = RuntimeLiveStats; + +const PRESENTATION_FONT_FIXTURES = [ + 'inter', + 'font-awesome-free-6.7.2', + 'amiri', + 'noto-sans-devanagari', + 'noto-sans-cjk-showcase', +] as const satisfies readonly BenchmarkFontFixture[]; + +async function preloadPresentationAssets( + technique: RasterTechnique, + delivery: FontDelivery, + selectedFont: BenchmarkFontFixture, + signal: AbortSignal, +): Promise { + if (delivery !== 'baked') return; + const fixtures = Array.from(new Set([selectedFont, ...PRESENTATION_FONT_FIXTURES])); + const { preloadBenchmarkFontAssets } = await loadBenchmarkFontAssets(); + await preloadBenchmarkFontAssets({ technique, fixtures, signal, bitmapDensity: 'live' }); +} + +interface ActivityWorkloads { + readonly benchmark: BenchmarkWorkloadId; + readonly conformance: ConformanceWorkloadId; +} + +const INITIAL_CONFORMANCE_VIEW: ConformanceView = { + zoom: 1, + panXPercent: 0, + panYPercent: 0, +}; + +export function HarnessController({ layout }: { readonly layout: HarnessLayout }): ReactNode { + return useHarnessController(layout); +} + +function useHarnessController(routeLayout: HarnessLayout): ReactNode { + const [, navigate] = useLocation(); + const environment = use(environmentResource()); + const runtimeWorld = useRuntimeWorld(); + const presentationAnimation = useRuntimeAnimationControls(); + const desktop = useSyncExternalStore(subscribeDesktop, desktopSnapshot, () => true); + const phone = useSyncExternalStore(subscribePhone, phoneSnapshot, () => false); + const [location, setLocationState] = useState(() => { + const value = readHarnessLocation(locationSearch(), defaultDeviceDpr(), routeLayout); + if (!environment.webgpu && !new URLSearchParams(locationSearch()).has('backend')) { + return { ...value, backend: 'webgl2' as const }; + } + return value; + }); + const [activityWorkloads, setActivityWorkloads] = useState(() => { + const initial = readHarnessLocation(locationSearch(), defaultDeviceDpr(), routeLayout); + return { + benchmark: + initial.mode === 'benchmark' && isBenchmarkWorkloadId(initial.workload) ? initial.workload : 'benchmark-ipsum', + conformance: + initial.mode === 'conformance' && isConformanceWorkloadId(initial.workload) + ? initial.workload + : 'text-accuracy', + }; + }); + const [summary, setSummary] = useState(); + const [event, setEvent] = useState(); + const [liveCapture, setLiveCapture] = useState(); + const [error, setError] = useState(); + const [dpr, setDpr] = useState<1 | 2>(location.dpr); + const [samples, setSamples] = useState(3); + const [warmup, setWarmup] = useState(1); + const [conformanceView, setConformanceView] = useState(INITIAL_CONFORMANCE_VIEW); + const [comparisonText, setComparisonText] = useState(() => rasterConformanceSpecimen(location.fontFixture).text); + const [showcaseState, setShowcaseState] = useState(initialAdvancedShapingState); + const [advancedFontFixture, setAdvancedFontFixture] = useState('noto-sans-cjk-showcase'); + const [workloadPanelOpen, setWorkloadPanelOpen] = useState(() => desktopSnapshot()); + const [fontNoticesOpen, setFontNoticesOpen] = useState(false); + const [presentationPlaying, setPresentationPlaying] = useState(false); + const [presentationPreset, setPresentationPreset] = useState(); + const [isPending, startTransition] = useTransition(); + const reportCaptureRequested = useRef(false); + const conformanceRunRevision = useRef(0); + const conformanceRunController = useRef(undefined); + const committedLocationRef = useRef(location); + const requestedLocationRef = useRef(location); + const locationRequestRevisionRef = useRef(0); + const advancedFontRequestRevisionRef = useRef(0); + const presentationPlayback = useRef< + | { + preset: PresentationPreset | undefined; + readonly startedAt: number; + readonly startWorkload: PresentationWorkload; + workload: PresentationWorkload; + } + | undefined + >(undefined); + const paragraphStressMotionScratch = useRef({ + fontSize: 0, + layoutWidthPercent: 0, + scrollProgress: 0, + }); + + const workload = workloadById(location.mode, location.workload); + const fontFixture = location.fontFixture; + const workloadTechnique = workload.techniques[location.technique]; + const showcaseFrame = advancedShapingFrame(showcaseState); + const activeFontFixture: BenchmarkFontFixture = + location.workload === 'advanced-shaping' + ? advancedFontFixture + : location.workload === 'zoom-text' + ? 'inter' + : fontFixture; + const available = workloadTechnique.kind === 'ready'; + const backendAvailable = location.backend !== 'webgpu' || environment.webgpu; + const presentationMode = location.layout === 'presentation' && location.mode === 'benchmark'; + + useEffect(() => { + if (!presentationMode) return; + const controller = new AbortController(); + let started = false; + const preload = (): void => { + started = true; + void preloadPresentationAssets(location.technique, location.delivery, fontFixture, controller.signal).catch( + (caught: unknown) => { + if (!(caught instanceof DOMException && caught.name === 'AbortError')) console.warn(caught); + }, + ); + }; + if (globalThis.requestIdleCallback === undefined) { + preload(); + return () => { + if (!started) controller.abort(); + }; + } + const request = globalThis.requestIdleCallback(preload); + return () => { + globalThis.cancelIdleCallback(request); + if (!started) controller.abort(); + }; + }, [fontFixture, location.delivery, location.technique, presentationMode]); + + const animateParagraphStressControls = useEffectEvent((elapsedMs: number) => { + const startFontSize = defaultRuntimeFontSizeForWorkload('paragraph-stress', location.layout); + const frame = paragraphStressMotionScratch.current; + setParagraphStressMotionFrame(frame, elapsedMs, presentationAnimation.animationSpeed, startFontSize); + const { fontSize, layoutWidthPercent } = frame; + const current = runtimeWorld.get(RuntimeLayoutControls); + if (current?.fontSize === fontSize && current.layoutWidthPercent === layoutWidthPercent) return; + runtimeWorld.set(RuntimeLayoutControls, { fontSize, layoutWidthPercent, workloadAmount: 100 }); + }); + useEffect(() => { + if ( + location.mode !== 'benchmark' || + location.workload !== 'paragraph-stress' || + !presentationAnimation.animationEnabled + ) { + return; + } + let animationFrame = 0; + let active = true; + const startedAt = performance.now(); + const animate = (): void => { + if (!active) return; + if (requestedLocationRef.current.workload === 'paragraph-stress') { + animateParagraphStressControls(Math.max(0, performance.now() - startedAt)); + } + if (active) animationFrame = requestAnimationFrame(animate); + }; + animationFrame = requestAnimationFrame(animate); + return () => { + active = false; + cancelAnimationFrame(animationFrame); + }; + }, [location.mode, location.workload, presentationAnimation.animationEnabled, presentationAnimation.animationSpeed]); + + function setLocation(next: Partial): void { + if (presentationPlayback.current === undefined && next.workload !== undefined) setPresentationPreset(undefined); + const previous = requestedLocationRef.current; + const value = { ...previous, ...next }; + const requestRevision = ++locationRequestRevisionRef.current; + requestedLocationRef.current = value; + const entersAdvancedShaping = value.workload === 'advanced-shaping' && previous.workload !== 'advanced-shaping'; + const nextAdvancedShapingState = entersAdvancedShaping ? initialAdvancedShapingState() : undefined; + const sceneFontFixture = + value.workload === 'advanced-shaping' + ? nextAdvancedShapingState === undefined + ? advancedFontFixture + : advancedShapingCase(nextAdvancedShapingState.caseId).fontFixture + : value.workload === 'zoom-text' + ? 'inter' + : value.fontFixture; + const updatesRuntimeDefaults = + (next.workload !== undefined && next.workload !== previous.workload) || + (next.layout !== undefined && next.layout !== previous.layout); + const replacesLiveSurface = + next.mode !== undefined || + next.technique !== undefined || + next.backend !== undefined || + next.delivery !== undefined || + next.dpr !== undefined || + next.workload !== undefined; + const replacesRendererGeneration = next.mode !== undefined || next.backend !== undefined; + if (replacesRendererGeneration) runtimeWorld.set(RuntimeTelemetry, { stats: undefined }); + if (replacesLiveSurface || next.fontFixture !== undefined) { + conformanceRunController.current?.abort(new DOMException('conformance run was superseded', 'AbortError')); + conformanceRunController.current = undefined; + conformanceRunRevision.current += 1; + setSummary(undefined); + setEvent(undefined); + setLiveCapture(undefined); + } + const commitLocation = (): void => { + if (requestRevision !== locationRequestRevisionRef.current) return; + if (next.workload !== undefined) { + setActivityWorkloads((current) => ({ ...current, [value.mode]: value.workload })); + } + committedLocationRef.current = value; + setLocationState(value); + if (value.layout === previous.layout) { + globalThis.history?.replaceState(null, '', writeHarnessUrl(value)); + } else { + navigate(writeHarnessUrl(value)); + } + }; + const transitionsScene = + (next.technique !== undefined && next.technique !== previous.technique) || + (next.workload !== undefined && next.workload !== previous.workload) || + (next.fontFixture !== undefined && next.fontFixture !== previous.fontFixture) || + (next.delivery !== undefined && next.delivery !== previous.delivery); + const applyRuntimeDefaults = (): void => { + if (!updatesRuntimeDefaults) return; + resetRuntimeControlsForWorkload(runtimeWorld, value.workload, value.layout); + if (nextAdvancedShapingState !== undefined) { + advancedFontRequestRevisionRef.current += 1; + setShowcaseState(nextAdvancedShapingState); + setAdvancedFontFixture(advancedShapingCase(nextAdvancedShapingState.caseId).fontFixture); + } + }; + if (transitionsScene) { + startTransition(() => { + void liveSceneAssetResource(value.technique, value.delivery, sceneFontFixture, value.workload).then( + () => { + // React does not preserve the transition marker across an await yet. Mark the committed scene update as a + // transition as well, so a newly observed resource can suspend without replacing the currently visible scene. + startTransition(() => { + applyRuntimeDefaults(); + commitLocation(); + }); + }, + (caught: unknown) => { + if (requestRevision !== locationRequestRevisionRef.current) return; + requestedLocationRef.current = committedLocationRef.current; + setError(caught instanceof Error ? caught.message : String(caught)); + }, + ); + }); + } else { + applyRuntimeDefaults(); + commitLocation(); + } + } + + function selectMode(mode: HarnessMode): void { + setLocation({ + layout: mode === 'conformance' ? 'main' : location.layout, + mode, + workload: activityWorkloads[mode], + view: 'scene', + }); + } + + function selectTechnique(technique: RasterTechnique): void { + const currentWorkload = workloadById(location.mode, location.workload); + const selectedWorkload = + currentWorkload.techniques[technique].kind === 'ready' + ? currentWorkload.id + : location.mode === 'benchmark' + ? 'benchmark-ipsum' + : 'text-accuracy'; + setLocation({ + technique, + workload: selectedWorkload, + }); + } + + function selectAdvancedFontFixture(nextFontFixture: BenchmarkFontFixture): void { + const requestRevision = ++advancedFontRequestRevisionRef.current; + const { delivery, technique } = requestedLocationRef.current; + startTransition(() => { + void liveSceneAssetResource(technique, delivery, nextFontFixture, 'advanced-shaping').then( + () => { + if ( + requestRevision !== advancedFontRequestRevisionRef.current || + requestedLocationRef.current.workload !== 'advanced-shaping' || + requestedLocationRef.current.technique !== technique || + requestedLocationRef.current.delivery !== delivery + ) { + return; + } + startTransition(() => { + setAdvancedFontFixture(nextFontFixture); + invalidateLiveCapture(); + }); + }, + (caught: unknown) => { + if (requestRevision !== advancedFontRequestRevisionRef.current) return; + setError(caught instanceof Error ? caught.message : String(caught)); + }, + ); + }); + } + + const stopPresentation = useEffectEvent(() => { + presentationPlayback.current = undefined; + setPresentationPlaying(false); + }); + const navigatePresentation = useEffectEvent((direction: -1 | 1) => { + stopPresentation(); + setLocation({ + mode: 'benchmark', + view: 'scene', + workload: adjacentPresentationWorkload(location.workload, direction), + }); + }); + const togglePresentation = useEffectEvent(() => { + if (presentationPlayback.current !== undefined) { + stopPresentation(); + return; + } + const startFrame = presentationFrame(location.workload, 0); + const startWorkload = startFrame.workload; + presentationPlayback.current = { + preset: startFrame.preset, + startedAt: performance.now(), + startWorkload, + workload: startWorkload, + }; + if (startWorkload === 'advanced-shaping') { + const initial = initialAdvancedShapingState(); + setShowcaseState(initial); + setAdvancedFontFixture(advancedShapingCase(initial.caseId).fontFixture); + } + setPresentationPreset(startFrame.preset); + setPresentationPlaying(true); + if (location.mode !== 'benchmark' || location.workload !== startWorkload) { + setLocation({ mode: 'benchmark', view: 'scene', workload: startWorkload }); + } + }); + const advancePresentation = useEffectEvent((timestamp: number) => { + const playback = presentationPlayback.current; + if (playback === undefined) return; + const frame = presentationFrame(playback.startWorkload, timestamp - playback.startedAt); + if (frame.workload !== playback.workload) { + playback.workload = frame.workload; + if (frame.workload === 'advanced-shaping') { + const initial = initialAdvancedShapingState(); + setShowcaseState(initial); + setAdvancedFontFixture(advancedShapingCase(initial.caseId).fontFixture); + } + } + if (frame.preset !== playback.preset) { + playback.preset = frame.preset; + setPresentationPreset(frame.preset); + } + const requestedLocation = requestedLocationRef.current; + if (frame.workload !== requestedLocation.workload || requestedLocation.mode !== 'benchmark') { + setLocation({ mode: 'benchmark', view: 'scene', workload: frame.workload }); + } + if (frame.complete) stopPresentation(); + }); + useEffect(() => { + const onKeyDown = (keyboardEvent: KeyboardEvent): void => { + const requestedLocation = requestedLocationRef.current; + if (requestedLocation.layout !== 'presentation' || requestedLocation.mode !== 'benchmark') return; + if (keyboardEvent.code === 'Space' && !isPresentationTextEntryTarget(keyboardEvent.target)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopImmediatePropagation(); + if (!keyboardEvent.repeat) togglePresentation(); + return; + } + if (keyboardEvent.repeat || isPresentationShortcutTarget(keyboardEvent.target)) return; + if (keyboardEvent.key === 'ArrowLeft') { + keyboardEvent.preventDefault(); + navigatePresentation(-1); + } else if (keyboardEvent.key === 'ArrowRight') { + keyboardEvent.preventDefault(); + navigatePresentation(1); + } + }; + const onKeyUp = (keyboardEvent: KeyboardEvent): void => { + const requestedLocation = requestedLocationRef.current; + if ( + requestedLocation.layout !== 'presentation' || + requestedLocation.mode !== 'benchmark' || + keyboardEvent.code !== 'Space' || + isPresentationTextEntryTarget(keyboardEvent.target) + ) { + return; + } + keyboardEvent.preventDefault(); + keyboardEvent.stopImmediatePropagation(); + }; + globalThis.addEventListener?.('keydown', onKeyDown, true); + globalThis.addEventListener?.('keyup', onKeyUp, true); + return () => { + globalThis.removeEventListener?.('keydown', onKeyDown, true); + globalThis.removeEventListener?.('keyup', onKeyUp, true); + }; + }, []); + useEffect(() => { + if (!presentationPlaying) return; + let animationFrame = 0; + const animate = (timestamp: number): void => { + advancePresentation(timestamp); + if (presentationPlayback.current !== undefined) animationFrame = requestAnimationFrame(animate); + }; + animationFrame = requestAnimationFrame(animate); + return () => cancelAnimationFrame(animationFrame); + }, [presentationPlaying]); + + function runConformance(runExclusiveJob: RunExclusiveJob): void { + conformanceRunController.current?.abort(new DOMException('conformance run was superseded', 'AbortError')); + const controller = new AbortController(); + conformanceRunController.current = controller; + const revision = ++conformanceRunRevision.current; + setError(undefined); + startTransition(() => { + const request = Promise.resolve().then(() => + runExclusiveJob( + ({ renderer, signal }) => + runRegisteredBenchmark({ + targetId: + location.workload === 'runtime-fallback' + ? `runtime-fallback-${location.technique}-${location.backend}` + : location.workload === 'cross-technique-fidelity' + ? `source-outline-${location.technique}-${location.backend}` + : location.technique === 'slug' + ? `slug-conformance-${location.backend}` + : location.technique === 'mtsdf' + ? `mtsdf-conformance-${location.backend}` + : `bitmap-text-${location.backend}`, + scenarioId: + location.workload === 'runtime-fallback' + ? 'runtime-fallback-parity' + : location.workload === 'cross-technique-fidelity' + ? 'source-outline-fidelity' + : location.technique === 'slug' + ? 'slug-sampling-conformance' + : location.technique === 'mtsdf' + ? 'mtsdf-sampling-conformance' + : 'bitmap-text-frame', + input: { fontFixture: activeFontFixture }, + controls: { dpr, samples, warmup }, + environment, + executionContext: { renderer, signal }, + onEvent: (nextEvent) => { + if (revision === conformanceRunRevision.current) setEvent(nextEvent); + }, + }), + controller.signal, + ), + ); + void request + .then( + (value) => { + if (revision === conformanceRunRevision.current) startTransition(() => setSummary(value)); + }, + (caught: unknown) => { + if ( + revision === conformanceRunRevision.current && + !(caught instanceof DOMException && caught.name === 'AbortError') + ) { + setError(caught instanceof Error ? caught.message : String(caught)); + } + }, + ) + .then(() => { + if (conformanceRunController.current === controller) conformanceRunController.current = undefined; + }); + }); + } + + function completeLiveCapture(stats: LiveTextStats): void { + const workloadFonts = liveWorkloadFontFixtures(location.workload, activeFontFixture); + setLiveCapture({ + kind: 'live-benchmark', + schemaVersion: 0, + capturedAt: new Date().toISOString(), + technique: location.technique, + backend: location.backend, + workload: location.workload, + dpr, + fontFixture: workloadFonts.primary, + ...(workloadFonts.kind === 'icon-grid' ? { labelFontFixture: workloadFonts.labels } : {}), + environment, + stats: captureLiveTextStats(stats), + }); + setLocation({ view: 'report' }); + } + + function captureWindow(): void { + const liveStats = runtimeWorld.get(RuntimeTelemetry)?.stats; + if (liveStats === undefined) return; + if (runtimeWorld.get(RuntimeViewControls)?.showGrid || liveStats.showGrid) { + reportCaptureRequested.current = true; + runtimeWorld.set(RuntimeViewControls, { showGrid: false }); + return; + } + completeLiveCapture(liveStats); + } + + function publishLiveStats(stats: LiveTextStats): void { + runtimeWorld.set(RuntimeTelemetry, { stats }); + if (!reportCaptureRequested.current || stats.showGrid) return; + reportCaptureRequested.current = false; + completeLiveCapture(stats); + } + + function invalidateLiveCapture(): void { + setLiveCapture(undefined); + } + + function dispatchShowcase(command: AdvancedShapingCommand): void { + if (command.kind === 'select-case') { + setAdvancedFontFixture(advancedShapingCase(command.caseId).fontFixture); + } + setShowcaseState((state) => updateAdvancedShaping(state, command)); + invalidateLiveCapture(); + } + + const advanceShowcase = useEffectEvent((elapsedMs: number) => { + const next = advanceAdvancedShapingByTime(showcaseState, elapsedMs); + setShowcaseState(next); + setAdvancedFontFixture(advancedShapingCase(next.caseId).fontFixture); + }); + useEffect(() => { + if ( + location.mode !== 'benchmark' || + location.workload !== 'advanced-shaping' || + !showcaseState.playing || + showcaseState.editedText !== undefined + ) + return; + let animationFrame = 0; + let previousTimestamp: number | undefined; + const animate = (timestamp: number): void => { + const elapsedMs = previousTimestamp === undefined ? 0 : timestamp - previousTimestamp; + previousTimestamp = timestamp; + advanceShowcase(elapsedMs); + animationFrame = requestAnimationFrame(animate); + }; + animationFrame = requestAnimationFrame(animate); + return () => cancelAnimationFrame(animationFrame); + }, [location.mode, location.workload, showcaseState.editedText, showcaseState.playing]); + + const controls = ( + setLocation({ backend })} + onDelivery={(delivery) => setLocation({ delivery })} + onDpr={(value) => { + setDpr(value); + setLocation({ dpr: value }); + }} + onFontNotices={() => setFontNoticesOpen(true)} + onConformanceReset={() => setConformanceView(INITIAL_CONFORMANCE_VIEW)} + onConformanceZoom={(zoom) => setConformanceView((view) => ({ ...view, zoom }))} + onComparisonText={setComparisonText} + onRuntimeControl={invalidateLiveCapture} + onSelectedFontFixture={(value) => { + setLocation({ fontFixture: value }); + }} + onSamples={setSamples} + onShowcase={dispatchShowcase} + onBeforeShowGrid={() => { + reportCaptureRequested.current = false; + }} + onWarmup={setWarmup} + /> + ); + + const liveTechniqueComparison = location.mode === 'conformance' && location.workload === 'mtsdf-slug-compare'; + const actionEligible = available && backendAvailable && !isPending && !liveTechniqueComparison; + + const scene = ( + }> + + setConformanceView((view) => ({ + ...view, + panXPercent: view.panXPercent + deltaXPercent, + panYPercent: view.panYPercent + deltaYPercent, + })) + } + onConformanceZoom={(zoom) => setConformanceView((view) => ({ ...view, zoom }))} + onLiveStats={publishLiveStats} + /> + + ); + const reportRendererError = (caught: unknown): void => { + setError(caught instanceof Error ? caught.message : String(caught)); + }; + + return ( + setFontNoticesOpen(false)} + onLocation={setLocation} + onMode={selectMode} + onRendererError={reportRendererError} + onTechnique={selectTechnique} + onWorkloadPanelOpen={setWorkloadPanelOpen} + /> + ); +} + +function locationSearch(): string { + return typeof globalThis.location === 'undefined' ? '' : globalThis.location.search; +} + +function isPresentationShortcutTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return ( + target.closest('input, textarea, select, button, [contenteditable="true"], [role="slider"], [role="combobox"]') !== + null + ); +} + +function isPresentationTextEntryTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return ( + target.closest( + 'textarea, [contenteditable="true"], input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="url"], input[type="tel"], input[type="password"]', + ) !== null + ); +} + +function subscribeDesktop(listener: () => void): () => void { + if (typeof globalThis.matchMedia !== 'function') return () => undefined; + const media = globalThis.matchMedia('(min-width: 1200px)'); + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); +} + +function desktopSnapshot(): boolean { + return typeof globalThis.matchMedia !== 'function' || globalThis.matchMedia('(min-width: 1200px)').matches; +} + +function subscribePhone(listener: () => void): () => void { + if (typeof globalThis.matchMedia !== 'function') return () => undefined; + const media = globalThis.matchMedia('(max-width: 699px)'); + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); +} + +function phoneSnapshot(): boolean { + return typeof globalThis.matchMedia === 'function' && globalThis.matchMedia('(max-width: 699px)').matches; +} + +function defaultDeviceDpr(): 1 | 2 { + return (globalThis.devicePixelRatio ?? 1) >= 1.5 ? 2 : 1; +} diff --git a/apps/benchmarks/src/routes/harness-route.test.ts b/apps/benchmarks/src/routes/harness-route.test.ts new file mode 100644 index 00000000..3297516f --- /dev/null +++ b/apps/benchmarks/src/routes/harness-route.test.ts @@ -0,0 +1,22 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +describe('harness route ownership', () => { + it('keeps Main and Presentation on one persistent component identity', async () => { + const [app, route, controller, layout] = await Promise.all([ + readFile(new URL('../app.tsx', import.meta.url), 'utf8'), + readFile(new URL('./harness-route.tsx', import.meta.url), 'utf8'), + readFile(new URL('../controllers/harness-controller.tsx', import.meta.url), 'utf8'), + readFile(new URL('../surfaces/harness/persistent-layout.tsx', import.meta.url), 'utf8'), + ]); + + expect(app.match(/'); + expect(route).toContain(''); + expect(controller).not.toContain('PersistentRenderHostProvider'); + expect(layout).toContain(' + + + ); +} diff --git a/apps/benchmarks/src/surfaces/benchmark/benchmark-surface.tsx b/apps/benchmarks/src/surfaces/benchmark/benchmark-surface.tsx new file mode 100644 index 00000000..b87417a7 --- /dev/null +++ b/apps/benchmarks/src/surfaces/benchmark/benchmark-surface.tsx @@ -0,0 +1,175 @@ +import { use, useEffect, useRef } from 'react'; + +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { RuntimeLiveStats } from '../../benchmark/runtime-world'; +import type { FontDelivery, GraphicsBackend, RasterTechnique } from '../../benchmark/url-state'; +import type { PresentationPreset } from '../../benchmark/presentation-sequence'; +import { comparisonWorkloadId, type BenchmarkWorkloadId } from '../../workloads/catalog'; +import type { AdvancedShapingFrame } from '../../workloads/advanced-shaping/scene'; +import type { ComparisonWorkloadStats } from './scenes/comparison-workload'; +import type { LiveTextScene } from '../../workloads/live-text-scene'; +import { liveTextSceneForWorkload } from '../../workloads/live-text-scenes'; +import { BitmapTextViewport } from './bitmap-text-viewport'; +import { ComparisonWorkloadViewport } from './comparison-workload-viewport'; +import type { LiveTextConfiguration } from './live-text-viewport-contracts'; +import { LiveBenchmarkSurface } from './live-benchmark-surface'; +import { MtsdfTextViewport, SlugTextViewport } from './sdf-text-viewports'; +import { liveSceneAssetResource, scheduleComparisonWorkloadPreload } from './scene-preload'; + +export interface BenchmarkSurfaceProps { + readonly animationEnabled: boolean; + readonly animationSpeed: number; + readonly backend: GraphicsBackend; + readonly delivery: FontDelivery; + readonly demoMode: boolean; + readonly dpr: 1 | 2; + readonly fontFixture: BenchmarkFontFixture; + readonly fontSize: number; + readonly grid: boolean; + readonly layoutWidthPercent: number; + readonly paintOpacityPercent: number; + readonly paintShadowEnabled: boolean; + readonly paintStrokePercent: number; + readonly presentation: 'main' | 'presentation'; + readonly presentationPreset: PresentationPreset | undefined; + readonly showLayoutBounds: boolean; + readonly workloadAmount: number; + readonly showcaseFrame: AdvancedShapingFrame; + readonly stats: RuntimeLiveStats | undefined; + readonly technique: RasterTechnique; + readonly workload: BenchmarkWorkloadId; + readonly onStats: (stats: RuntimeLiveStats) => void; +} + +export function BenchmarkSurface({ + animationEnabled, + animationSpeed, + backend, + delivery, + demoMode, + dpr, + fontFixture, + fontSize, + grid, + layoutWidthPercent, + paintOpacityPercent, + paintShadowEnabled, + paintStrokePercent, + presentation, + presentationPreset, + showLayoutBounds, + workloadAmount, + showcaseFrame, + stats, + technique, + workload, + onStats, +}: BenchmarkSurfaceProps) { + use(liveSceneAssetResource(technique, delivery, fontFixture, workload)); + const surfaceAnchorRef = useRef(null); + useEffect(() => { + return scheduleComparisonWorkloadPreload(); + }, []); + const comparisonWorkload = comparisonWorkloadId(workload); + const bitmapStats = stats?.technique === 'bitmap' ? stats : undefined; + const mtsdfStats = stats?.technique === 'mtsdf' ? stats : undefined; + const slugStats = stats?.technique === 'slug' ? stats : undefined; + const comparisonStats = isComparisonWorkloadStats(stats) ? stats : undefined; + const textConfiguration = liveTextSceneForWorkload(workload, { + fontFixture, + layoutWidthRatio: layoutWidthPercent / 100, + showcaseFrame, + }); + if (comparisonWorkload === undefined && textConfiguration === undefined) { + throw new Error(`Live workload ${workload} has no authored Text scene`); + } + const viewport = + comparisonWorkload !== undefined ? ( + + ) : technique === 'slug' ? ( + + ) : technique === 'mtsdf' ? ( + + ) : ( + + ); + return ( + + ); +} + +function withLiveTextFontSize(scene: LiveTextScene | undefined, fontSize: number): LiveTextConfiguration { + if (scene === undefined) throw new Error('A single-paragraph live surface requires an authored Text scene'); + return { ...scene, fontSize }; +} + +function isComparisonWorkloadStats(stats: RuntimeLiveStats | undefined): stats is ComparisonWorkloadStats { + return stats !== undefined && 'workload' in stats; +} diff --git a/apps/benchmarks/src/surfaces/benchmark/bitmap-text-viewport.tsx b/apps/benchmarks/src/surfaces/benchmark/bitmap-text-viewport.tsx index f05b7fe8..da2a5901 100644 --- a/apps/benchmarks/src/surfaces/benchmark/bitmap-text-viewport.tsx +++ b/apps/benchmarks/src/surfaces/benchmark/bitmap-text-viewport.tsx @@ -6,7 +6,7 @@ import type { BitmapTextLiveStats, BitmapTextPersistentScene, BitmapTextSceneSnapshot, -} from '../../renderer/bitmap-text'; +} from '../../techniques/bitmap/persistent-scene'; import { createLatestAsyncQueue, type LatestAsyncQueue } from './latest-async-queue'; import { usePersistentRenderHost } from '../../renderer/persistent-render-host-context'; import { @@ -27,7 +27,7 @@ import type { const GLYPH_POSITION_TRANSITION_MS = 110; function loadBitmapTextRenderer() { - return import('../../renderer/bitmap-text'); + return import('../../techniques/bitmap/persistent-scene'); } function bitmapViewportEvidence({ diff --git a/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx b/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx new file mode 100644 index 00000000..a38c7e15 --- /dev/null +++ b/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx @@ -0,0 +1,394 @@ +import { useEffect, useEffectEvent, useRef, useState, type RefObject } from 'react'; + +import { liveWorkloadFontFixtures, type BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { RuntimeLiveStats } from '../../benchmark/runtime-world'; +import type { FontDelivery, GraphicsBackend, RasterTechnique } from '../../benchmark/url-state'; +import type { PresentationPreset } from '../../benchmark/presentation-sequence'; +import { BENCHMARK_CONTENT_INSET, BENCHMARK_CONTENT_MINIMUM_VIEWPORT_WIDTH } from '../../workloads/shared/text-style'; +import { benchmarkWorkloadDefinition } from '../../workloads/catalog'; +import type { + ComparisonWorkloadConfiguration, + ComparisonWorkloadId, + ComparisonWorkloadPersistentScene, + ComparisonWorkloadStats, +} from './scenes/comparison-workload'; +import { usePersistentRenderHost } from '../../renderer/persistent-render-host-context'; +import { BakeProgressOverlay, useBakeProgress } from './bake-progress-overlay'; +import { techniqueLabel, workloadAmountLabel } from './labels'; +import { preloadComparisonWorkload } from './scene-preload'; + +export interface ComparisonWorkloadViewportProps { + readonly amount: number; + readonly animationEnabled: boolean; + readonly animationSpeed: number; + readonly backend: GraphicsBackend; + readonly delivery: FontDelivery; + readonly demoMode: boolean; + readonly dpr: 1 | 2; + readonly fontFixture: BenchmarkFontFixture; + readonly fontSize: number; + readonly grid: boolean; + readonly layoutWidthRatio: number; + readonly paintOpacity: number; + readonly paintShadowEnabled: boolean; + readonly paintStrokeWidth: number; + readonly presentationPreset: PresentationPreset | undefined; + readonly showLayoutBounds: boolean; + readonly suppressLoading: boolean; + readonly stats: ComparisonWorkloadStats | undefined; + readonly surfaceAnchorRef: RefObject; + readonly technique: RasterTechnique; + readonly workload: ComparisonWorkloadId; + readonly onStats: (stats: RuntimeLiveStats) => void; +} + +function comparisonViewportEvidence({ + layoutWidthRatio, + stats, + technique, + workload, + workloadFonts, +}: { + readonly layoutWidthRatio: number; + readonly stats: ComparisonWorkloadStats | undefined; + readonly technique: RasterTechnique; + readonly workload: ComparisonWorkloadId; + readonly workloadFonts: ReturnType; +}): Record<`data-${string}`, string | number | boolean | undefined> { + const iconStats = stats?.workload === 'icon-grid' ? stats : undefined; + const paintStats = stats?.workload === 'paint-effects' ? stats : undefined; + const zoomStats = stats?.workload === 'zoom-text' ? stats : undefined; + const appliedWorkloadFonts = + stats === undefined ? undefined : liveWorkloadFontFixtures(stats.workload, stats.appliedFontFixture); + const requestedFontFixture = workloadFonts.kind === 'icon-grid' ? workloadFonts.labels : workloadFonts.primary; + const animatedStats = stats; + return { + 'data-canvas-grid': stats === undefined ? undefined : String(stats.showGrid), + 'data-artifact-bytes': stats?.artifactBytes, + 'data-atlas-gpu-bytes': stats?.atlasGpuBytes, + 'data-backend': stats?.backend, + 'data-dpr': stats?.dpr, + 'data-font-delivery': stats?.delivery, + 'data-core-bake-ms': stats?.coreBakeMs, + 'data-raster-bake-ms': stats?.rasterBakeMs, + 'data-source-font-bytes': stats?.sourceFontBytes, + 'data-core-artifact-bytes': stats?.coreArtifactBytes, + 'data-raster-artifact-bytes': stats?.rasterArtifactBytes, + 'data-draw-count': stats?.drawCount, + 'data-first-draw-ms': stats?.firstDrawMs, + 'data-font-fixture': appliedWorkloadFonts?.primary, + 'data-label-font-fixture': appliedWorkloadFonts?.kind === 'icon-grid' ? appliedWorkloadFonts.labels : undefined, + 'data-font-load-ms': stats?.fontLoadMs, + 'data-frames-per-second': stats?.framesPerSecond, + 'data-glyph-count': stats?.glyphCount, + 'data-gpu-history-length': stats?.gpuHistoryLength, + 'data-gpu-timing-supported': stats?.gpuTimingSupported, + 'data-layout-width': stats?.layoutWidth, + 'data-content-inset': BENCHMARK_CONTENT_INSET, + 'data-content-min-width': + workload === 'text-ladder' || workload === 'icon-grid' || workload === 'zoom-text' + ? undefined + : (workload === 'dynamic-layout' ? 1_000 : BENCHMARK_CONTENT_MINIMUM_VIEWPORT_WIDTH) * layoutWidthRatio, + 'data-content-policy': + workload === 'text-ladder' || workload === 'icon-grid' ? 'pan' : workload === 'zoom-text' ? 'fit' : 'bounded-pan', + 'data-icon-item-count': iconStats?.iconItemCount, + 'data-icon-label-count': iconStats?.iconLabelCount, + 'data-icon-column-count': iconStats?.iconColumnCount, + 'data-icon-row-count': iconStats?.iconRowCount, + 'data-icon-size': iconStats?.appliedFontSize, + 'data-icon-grid-width': iconStats?.iconGridWidth, + 'data-icon-grid-height': iconStats?.iconGridHeight, + 'data-icon-label-size': iconStats?.iconLabelSize, + 'data-icon-pool-capacity': iconStats?.iconPoolCapacity, + 'data-icon-assigned-count': iconStats?.iconAssignedCount, + 'data-icon-render-visible-count': iconStats?.iconRenderVisibleCount, + 'data-icon-assignment-signature': iconStats?.iconAssignmentSignature, + 'data-icon-first-visible-index': iconStats?.iconFirstVisibleIndex, + 'data-icon-last-visible-index': iconStats?.iconLastVisibleIndex, + 'data-icon-recycle-count': iconStats?.iconRecycleCount, + 'data-icon-window-revision': iconStats?.iconWindowRevision, + 'data-icon-overscan-rows': iconStats?.iconOverscanRows, + 'data-icon-overscan-columns': iconStats?.iconOverscanColumns, + 'data-icon-scroll-x': iconStats?.iconScrollX, + 'data-icon-scroll-y': iconStats?.iconScrollY, + 'data-icon-maximum-scroll-x': iconStats?.iconMaximumScrollX, + 'data-icon-maximum-scroll-y': iconStats?.iconMaximumScrollY, + 'data-line-count': stats?.lineCount, + 'data-median-gpu-ms': stats?.medianGpuMs, + 'data-median-submit-ms': stats?.medianSubmitMs, + 'data-missing-glyph-count': stats?.missingGlyphCount, + 'data-p95-gpu-ms': stats?.p95GpuMs, + 'data-p95-submit-ms': stats?.p95SubmitMs, + 'data-renderer-init-ms': stats?.rendererInitMs, + 'data-configuration-revision': stats?.configurationRevision, + 'data-camera-kind': stats?.cameraKind, + 'data-applied-font-size': stats?.appliedFontSize, + 'data-applied-workload-amount': stats?.appliedAmount, + 'data-layout-width-ratio': stats?.appliedLayoutWidthRatio, + 'data-paint-opacity': paintStats?.appliedPaintOpacity, + 'data-paint-shadow-enabled': paintStats === undefined ? undefined : String(paintStats.appliedPaintShadowEnabled), + 'data-paint-stroke-width': paintStats?.appliedPaintStrokeWidth, + 'data-paint-revision': paintStats?.paintRevision, + 'data-paint-update-ms': paintStats?.lastPaintUpdateMs, + 'data-presentation-pending': + stats !== undefined && + (stats.technique !== technique || + stats.workload !== workload || + stats.appliedFontFixture !== requestedFontFixture), + 'data-layout-bounds-visible': + stats?.workload === 'dynamic-layout' ? String(stats.appliedShowLayoutBounds) : undefined, + 'data-reflow-count': stats?.reflowCount, + 'data-reflow-ms': stats?.lastReflowMs, + 'data-rendered-device-px': stats?.renderedPpem, + 'data-raster-em-size': stats?.technique === 'mtsdf' ? stats.rasterEmSize : undefined, + 'data-raster-pixel-range': stats?.technique === 'mtsdf' ? stats.rasterPixelRange : undefined, + 'data-scale-ratio': stats?.technique === 'slug' ? undefined : stats?.scaleRatio, + 'data-slug-curve-gpu-bytes': stats?.technique === 'slug' ? stats.slugCurveGpuBytes : undefined, + 'data-slug-header-gpu-bytes': stats?.technique === 'slug' ? stats.slugHeaderGpuBytes : undefined, + 'data-slug-page-count': stats?.technique === 'slug' ? stats.slugPageCount : undefined, + 'data-slug-reference-gpu-bytes': stats?.technique === 'slug' ? stats.slugReferenceGpuBytes : undefined, + 'data-slug-gpu-bytes': stats?.technique === 'slug' ? stats.slugGpuBytes : undefined, + 'data-startup-ms': stats?.startupMs, + 'data-source-text-length': stats?.sourceTextLength, + 'data-submit-history-length': stats?.submitHistoryLength, + 'data-text-ready-ms': stats?.textReadyMs, + 'data-text-update-sample-count': stats?.textUpdateTimings.sampleCount, + 'data-technique': technique, + 'data-total-gpu-bytes': stats?.totalGpuBytes, + 'data-upload-frame-gpu-ms': stats?.uploadFrameGpuMs, + 'data-upload-frame-complete-ms': stats?.uploadFrameCompleteMs, + 'data-workload': stats?.workload, + 'data-zoom-text': zoomStats?.zoomText, + 'data-zoom-language': zoomStats?.zoomLanguage, + 'data-zoom-phrase-index': zoomStats?.zoomPhraseIndex, + 'data-zoom-phrase-revision': zoomStats?.zoomPhraseRevision, + 'data-zoom-base-css-px': zoomStats?.zoomBaseCssPx, + 'data-zoom-effective-css-px': zoomStats?.zoomEffectiveCssPx, + 'data-zoom-maximum-css-px': zoomStats?.zoomMaximumCssPx, + 'data-zoom-scale': zoomStats?.zoomScale, + 'data-zoom-maximum-scale': zoomStats?.zoomMaximumScale, + 'data-workload-amount': + stats === undefined || workloadAmountLabel(stats.workload, stats.appliedAmount) === undefined + ? undefined + : stats.appliedAmount, + 'data-animation-enabled': animatedStats === undefined ? undefined : String(animatedStats.appliedAnimationEnabled), + 'data-animation-speed': animatedStats?.appliedAnimationSpeed, + }; +} + +export function ComparisonWorkloadViewport({ + amount, + animationEnabled, + animationSpeed, + backend, + delivery, + demoMode, + dpr, + fontFixture, + fontSize, + grid, + layoutWidthRatio, + paintOpacity, + paintShadowEnabled, + paintStrokeWidth, + presentationPreset, + showLayoutBounds, + suppressLoading, + stats, + surfaceAnchorRef, + technique, + workload, + onStats, +}: ComparisonWorkloadViewportProps) { + const { activateSurface, configureSurface } = usePersistentRenderHost(); + const activatePersistentSurface = useEffectEvent(activateSurface); + const configurePersistentSurface = useEffectEvent(configureSurface); + const containerRef = useRef(null); + const previewRef = useRef(undefined); + const workloadDefinition = benchmarkWorkloadDefinition(workload); + const workloadFonts = liveWorkloadFontFixtures(workload, fontFixture); + const [error, setError] = useState(); + const { + active: bakeProgressActive, + finish: finishBakeProgress, + publish: publishBakeProgress, + value: bakeProgressValue, + } = useBakeProgress(techniqueLabel(technique)); + const publishStats = useEffectEvent((next: ComparisonWorkloadStats) => { + finishBakeProgress(); + onStats(next); + setError(undefined); + }); + const publishError = useEffectEvent((caught: unknown) => { + if (caught instanceof DOMException && caught.name === 'AbortError') return; + finishBakeProgress(); + setError(caught instanceof Error ? caught.message : String(caught)); + }); + const currentConfiguration = useEffectEvent( + (): ComparisonWorkloadConfiguration => ({ + amount, + animationEnabled, + animationSpeed, + fontFixture, + fontSize, + iconGridView: presentationPreset === 'icon-grid-return' ? 'alternate' : 'origin', + layoutWidthRatio, + paintOpacity, + paintShadowEnabled, + paintStrokeWidth, + showGrid: grid, + showLayoutBounds, + textLadderExitEnabled: demoMode && workload === 'text-ladder', + workload, + }), + ); + + useEffect(() => { + const container = containerRef.current; + const surfaceAnchor = surfaceAnchorRef.current; + if (container === null || surfaceAnchor === null) return; + const controller = new AbortController(); + let preview: ComparisonWorkloadPersistentScene | undefined; + let surfaceLease: Awaited> | undefined; + let cancelled = false; + const initialization = (async () => { + const { createComparisonWorkloadPersistentScene } = await preloadComparisonWorkload(); + if (cancelled) return; + const configuration = currentConfiguration(); + const interaction = benchmarkWorkloadDefinition(configuration.workload).interaction; + const created = createComparisonWorkloadPersistentScene({ + ...configuration, + backend, + delivery, + technique, + onError: publishError, + onStats: publishStats, + onBakeProgress: publishBakeProgress, + }); + preview = created; + previewRef.current = created; + surfaceLease = await activatePersistentSurface( + { + anchor: surfaceAnchor, + controller: previewRef, + label: `Live ${techniqueLabel(technique)} benchmark using ${backend}`, + pan: interaction.pan, + scene: created, + zoom: interaction.zoom, + }, + controller.signal, + ); + if (cancelled) await surfaceLease.release(); + })(); + void initialization.catch(publishError); + return () => { + cancelled = true; + controller.abort(); + void initialization.then( + async () => { + const current = preview; + preview = undefined; + if (previewRef.current === current) previewRef.current = undefined; + await surfaceLease?.release(); + }, + () => { + const current = preview; + preview = undefined; + if (previewRef.current === current) previewRef.current = undefined; + }, + ); + }; + }, [backend, delivery, publishBakeProgress, surfaceAnchorRef, technique]); + + useEffect(() => { + const interaction = benchmarkWorkloadDefinition(workload).interaction; + configurePersistentSurface({ + controller: previewRef, + label: `Live ${techniqueLabel(technique)} benchmark using ${backend}`, + pan: interaction.pan, + zoom: interaction.zoom, + }); + }, [backend, technique, workload]); + + useEffect(() => { + const preview = previewRef.current; + if (preview === undefined) return; + void preview.update(currentConfiguration()).catch(publishError); + }, [ + amount, + animationEnabled, + animationSpeed, + dpr, + demoMode, + fontFixture, + fontSize, + layoutWidthRatio, + paintOpacity, + paintShadowEnabled, + paintStrokeWidth, + presentationPreset, + grid, + showLayoutBounds, + workload, + ]); + + const rangeLabel = + workload === 'text-ladder' + ? '8–1024 CSS PX' + : workload === 'zoom-text' + ? '8 PT · 10.67 CSS PX → VIEWPORT FIT' + : workload === 'icon-grid' + ? `${fontSize} CSS PX ICONS` + : `${fontSize} CSS PX`; + return ( +
+
+ + {stats?.technique === 'mtsdf' + ? `MTSDF ${String(stats.rasterEmSize)} PX/EM` + : stats?.technique === 'slug' + ? `SLUG ANALYTIC · ${String(stats.slugPageCount)} PAGE${stats.slugPageCount === 1 ? '' : 'S'}` + : stats?.technique === 'bitmap' + ? `BITMAP ${String(stats.strikePpem)} PX STRIKE` + : technique === 'mtsdf' + ? 'MTSDF — PX/EM' + : technique === 'slug' + ? 'SLUG ANALYTIC · — PAGES' + : 'BITMAP — PX STRIKE'}{' '} + · {rangeLabel} + +
+
+ {workloadDefinition.interaction.zoom + ? 'PAN · PINCH/WHEEL ZOOM' + : workloadDefinition.interaction.pan + ? 'PAN' + : 'AUTO FIT'}{' '} + · {dpr}× DPR +
+ {!suppressLoading && (stats === undefined || bakeProgressActive) && error === undefined && ( + + )} + {error !== undefined && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/apps/benchmarks/src/surfaces/benchmark/labels.ts b/apps/benchmarks/src/surfaces/benchmark/labels.ts new file mode 100644 index 00000000..a169ffab --- /dev/null +++ b/apps/benchmarks/src/surfaces/benchmark/labels.ts @@ -0,0 +1,11 @@ +import type { RasterTechnique } from '../../benchmark/url-state'; +import { benchmarkWorkloadDefinition, type BenchmarkWorkloadId } from '../../workloads/catalog'; + +export function techniqueLabel(technique: RasterTechnique): 'Bitmap' | 'MSDF' | 'Slug' { + return technique === 'mtsdf' ? 'MSDF' : technique === 'slug' ? 'Slug' : 'Bitmap'; +} + +export function workloadAmountLabel(workload: BenchmarkWorkloadId, amount: number): string | undefined { + const range = benchmarkWorkloadDefinition(workload).controls.amount; + return range === undefined ? undefined : `${range.label} · ${amount}%`; +} diff --git a/apps/benchmarks/src/surfaces/benchmark/live-benchmark-surface.tsx b/apps/benchmarks/src/surfaces/benchmark/live-benchmark-surface.tsx index 936085d1..e54aa7f1 100644 --- a/apps/benchmarks/src/surfaces/benchmark/live-benchmark-surface.tsx +++ b/apps/benchmarks/src/surfaces/benchmark/live-benchmark-surface.tsx @@ -3,7 +3,7 @@ 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 type { AdvancedShapingFrame } from '../../workloads/advanced-shaping/scene'; import { benchmarkWorkloadDefinition, type BenchmarkWorkloadId } from '../../workloads/catalog'; export function LiveBenchmarkSurface({ diff --git a/apps/benchmarks/src/surfaces/benchmark/live-text-viewport-contracts.ts b/apps/benchmarks/src/surfaces/benchmark/live-text-viewport-contracts.ts index a6b34795..b37ba17f 100644 --- a/apps/benchmarks/src/surfaces/benchmark/live-text-viewport-contracts.ts +++ b/apps/benchmarks/src/surfaces/benchmark/live-text-viewport-contracts.ts @@ -1,4 +1,4 @@ -import type { BitmapTextSceneUpdate } from '../../renderer/bitmap-text'; +import type { BitmapTextSceneUpdate } from '../../techniques/bitmap/persistent-scene'; import type { BenchmarkWorkloadId } from '../../workloads/catalog'; import type { LiveTextScene } from '../../workloads/live-text-scene'; diff --git a/apps/benchmarks/src/surfaces/benchmark/scene-preload.ts b/apps/benchmarks/src/surfaces/benchmark/scene-preload.ts new file mode 100644 index 00000000..6a635268 --- /dev/null +++ b/apps/benchmarks/src/surfaces/benchmark/scene-preload.ts @@ -0,0 +1,51 @@ +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { FontDelivery, HarnessLocation, RasterTechnique } from '../../benchmark/url-state'; +import { benchmarkWorkloadDefinition, isBenchmarkWorkloadId } from '../../workloads/catalog'; + +let comparisonWorkloadModule: ReturnType | undefined; +const liveSceneAssetResources = new Map>(); + +function importComparisonWorkload() { + return import('./scenes/comparison-workload'); +} + +export function loadBenchmarkFontAssets() { + return import('../../workloads/font-assets'); +} + +export function preloadComparisonWorkload(): ReturnType { + comparisonWorkloadModule ??= importComparisonWorkload(); + return comparisonWorkloadModule; +} + +export function scheduleComparisonWorkloadPreload(): () => void { + if (globalThis.requestIdleCallback === undefined) return () => undefined; + const request = globalThis.requestIdleCallback(() => { + void preloadComparisonWorkload(); + }); + return () => globalThis.cancelIdleCallback(request); +} + +export function liveSceneAssetResource( + technique: RasterTechnique, + delivery: FontDelivery, + fontFixture: BenchmarkFontFixture, + workload: HarnessLocation['workload'], +): Promise { + const definition = isBenchmarkWorkloadId(workload) ? benchmarkWorkloadDefinition(workload) : undefined; + const fixtures = + definition?.fontPolicy.kind === 'icon-grid' ? [fontFixture, definition.fontPolicy.iconFixture] : [fontFixture]; + const comparison = definition?.surface === 'comparison'; + const key = `${technique}:${delivery}:${fixtures.join(',')}:${String(comparison)}`; + const existing = liveSceneAssetResources.get(key); + if (existing !== undefined) return existing; + const resource = (async () => { + if (comparison) await preloadComparisonWorkload(); + if (delivery !== 'baked') return; + const { preloadBenchmarkFontAssets } = await loadBenchmarkFontAssets(); + await preloadBenchmarkFontAssets({ technique, fixtures, bitmapDensity: 'live' }); + })(); + liveSceneAssetResources.set(key, resource); + void resource.catch(() => liveSceneAssetResources.delete(key)); + return resource; +} diff --git a/apps/benchmarks/src/workloads/comparison/scene.test.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.test.ts similarity index 99% rename from apps/benchmarks/src/workloads/comparison/scene.test.ts rename to apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.test.ts index e667927c..132b544e 100644 --- a/apps/benchmarks/src/workloads/comparison/scene.test.ts +++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.test.ts @@ -32,7 +32,7 @@ import { zoomTextMaximumScale, type ComparisonWorkloadConfiguration, type IconGridAutoPanState, -} from './scene'; +} from './comparison-workload'; const baseConfiguration: ComparisonWorkloadConfiguration = { amount: 50, @@ -172,12 +172,13 @@ describe('comparison workload updates', () => { expect(publications).toBe(1); }); - it('rolls every retained Text back before a failed candidate font can be released', () => { + it('rolls every retained Text back before a failed candidate font can be released', async () => { const previous = fakeFontFixture(); const next = fakeFontFixture(); const updates: Array> = [[], []]; const publications: number[] = []; const texts = updates.map((fontUpdates, index) => ({ + ready: Promise.resolve(), setProperties(properties: { readonly font: RegisteredFont; readonly raster: AnyRasterInput }) { fontUpdates.push(properties.font); if (properties.font === next.font && index === 1) throw new Error('candidate failed'); @@ -187,7 +188,7 @@ describe('comparison workload updates', () => { }, })); - expect(() => applyRetainedTextFontFixture(texts, previous, next)).toThrow('rolled back'); + await expect(applyRetainedTextFontFixture(texts, previous, next)).rejects.toThrow('rolled back'); expect(updates).toEqual([ [next.font, previous.font], [next.font, previous.font], diff --git a/apps/benchmarks/src/workloads/comparison/scene.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts similarity index 95% rename from apps/benchmarks/src/workloads/comparison/scene.ts rename to apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts index 00f91239..b932b24c 100644 --- a/apps/benchmarks/src/workloads/comparison/scene.ts +++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts @@ -2,16 +2,16 @@ import { FontRegistry, type AnyRasterInput, type ParagraphLayout, type Registere import * as THREE from 'three/webgpu'; import { selectBitmapStrikePpem } from '@pmndrs/text/raster/bitmap'; -import type { BenchmarkFontFixture, RasterConformanceSpecimen } from '../../benchmark/font-fixtures'; -import { ICON_GRID_FONT_FIXTURE } from '../../benchmark/font-fixtures'; -import type { RuntimeLiveStats } from '../../benchmark/runtime-world'; -import type { FontDelivery, RasterTechnique } from '../../benchmark/url-state'; +import type { BenchmarkFontFixture, RasterConformanceSpecimen } from '../../../benchmark/font-fixtures'; +import { ICON_GRID_FONT_FIXTURE } from '../../../benchmark/font-fixtures'; +import type { RuntimeLiveStats } from '../../../benchmark/runtime-world'; +import type { FontDelivery, RasterTechnique } from '../../../benchmark/url-state'; import { comparisonWorkloadDefinition, comparisonWorkloadRequiresIconWindowSuspension as registryRequiresIconWindowSuspension, comparisonWorkloadUpdateKind as registryUpdateKind, -} from '../registry'; -import { DYNAMIC_LAYOUT_TEXT, dynamicLayoutWidths } from '../dynamic-layout'; +} from '../../../workloads/comparison/registry'; +import { DYNAMIC_LAYOUT_TEXT, dynamicLayoutWidths } from '../../../workloads/dynamic-layout/scene'; import { ICON_GRID_ITEMS, ICON_GRID_LABEL_SIZE, @@ -22,47 +22,48 @@ import { resizeIconGridEntries, type IconGridEntryPool, type IconGridWorkloadInstance, -} from '../icon-grid'; -import type { MutableTextLadderScenePosition } from '../text-ladder'; -import { ZOOM_TEXT_BASE_CSS_PX } from '../zoom-text'; -import type { ZoomTextAnimationState } from '../zoom-text'; +} from '../../../workloads/icon-grid/scene'; +import type { MutableTextLadderScenePosition } from '../../../workloads/text-ladder/scene'; +import { ZOOM_TEXT_BASE_CSS_PX, type ZoomTextAnimationState } from '../../../workloads/zoom-text/scene'; import type { ComparisonWorkloadAnimationScratch, ComparisonWorkloadConfiguration, ComparisonWorkloadId, -} from '../contracts'; -import { committedTextLayout, type ComparisonWorkloadEntry } from '../factory-contracts'; -import { registeredBitmapAtlas, type BitmapAtlasPageStats } from '../../benchmark/low-level/raster/bitmap-atlas'; +} from '../../../workloads/comparison/contracts'; +import { committedTextLayout, type ComparisonWorkloadEntry } from '../../../workloads/shared/scene-entry'; +import { registeredBitmapAtlas, type BitmapAtlasPageStats } from '../../../techniques/bitmap/metadata'; +import { registeredMtsdfConfiguration, type MtsdfRasterConfiguration } from '../../../techniques/mtsdf/metadata'; +import { registeredSlugConfiguration, type SlugRasterConfiguration } from '../../../techniques/slug/metadata'; +import { createCanvasSurface } from '../../../renderer/canvas-surface'; +import type { LiveFrameTelemetrySnapshot } from '../../../renderer/live-frame-telemetry'; +import { createTextUpdateTelemetry } from '../../../renderer/text-update-telemetry'; import { - registeredMtsdfConfiguration, - type MtsdfRasterConfiguration, -} from '../../benchmark/low-level/raster/mtsdf-configuration'; -import { - registeredSlugConfiguration, - type SlugRasterConfiguration, -} from '../../benchmark/low-level/raster/slug-configuration'; -import { createCanvasSurface } from '../../renderer/canvas-surface'; -import type { LiveFrameTelemetrySnapshot } from '../../renderer/live-frame-telemetry'; -import { createTextUpdateTelemetry } from '../../renderer/text-update-telemetry'; -import { loadBenchmarkFontAsset, type BakedSlugArtifactSource, type FontDeliveryMetrics } from '../font-assets'; -import { benchmarkContentWidth } from '../shared/text-style'; -import type { RendererBackend } from '../../renderer/webgpu-renderer'; + loadBenchmarkFontAsset, + type BakedSlugArtifactSource, + type FontDeliveryMetrics, +} from '../../../workloads/font-assets'; +import { benchmarkContentWidth } from '../../../workloads/shared/text-style'; +import type { RendererBackend } from '../../../renderer/webgpu-renderer'; import type { PersistentRenderFrameContext, PersistentRenderScene, PersistentRenderSceneContext, PersistentRenderViewport, -} from '../../renderer/persistent-render-host'; -import { createPersistentSceneActivation } from '../../renderer/persistent-scene-activation'; +} from '../../../renderer/persistent-render-host'; +import { createPersistentSceneActivation } from '../../../renderer/persistent-scene-activation'; import { createRetainedFontFixtureController, type RetainedFontFixtureController, -} from '../../renderer/retained-font-fixture'; +} from '../../../renderer/retained-font-fixture'; type WorkloadEntry = ComparisonWorkloadEntry; -export type { ComparisonWorkloadConfiguration, ComparisonWorkloadId, IconGridView } from '../contracts'; -export type { MutableTextLadderScenePosition } from '../text-ladder'; +export type { + ComparisonWorkloadConfiguration, + ComparisonWorkloadId, + IconGridView, +} from '../../../workloads/comparison/contracts'; +export type { MutableTextLadderScenePosition } from '../../../workloads/text-ladder/scene'; export { advanceIconGridAutoPan, iconGridAssignmentSignature, @@ -72,23 +73,24 @@ export { iconGridVirtualWindow, iconGridViewportUpdateKind, smoothIconGridFrameDelta, -} from '../icon-grid'; -export type { IconGridAutoPanState } from '../icon-grid'; +} from '../../../workloads/icon-grid/scene'; +export type { IconGridAutoPanState } from '../../../workloads/icon-grid/scene'; +export { dynamicLayoutWidths } from '../../../workloads/dynamic-layout/scene'; export { - dynamicLayoutWidths, ladderCssSizes, - OFF_AXIS_SPANS, - OFF_AXIS_TEXT, - paintWordHue, setTextLadderScenePosition, - shuffleZoomTextPhrases, textLadderScenePosition, +} from '../../../workloads/text-ladder/scene'; +export { OFF_AXIS_SPANS, OFF_AXIS_TEXT } from '../../../workloads/off-axis-3d/scene'; +export { paintWordHue } from '../../../workloads/paint-effects/scene'; +export { + shuffleZoomTextPhrases, ZOOM_TEXT_BASE_CSS_PX, ZOOM_TEXT_CORPUS, ZOOM_TEXT_PHRASES, zoomTextAnimationState, zoomTextMaximumScale, -} from '../index'; +} from '../../../workloads/zoom-text/scene'; export type ComparisonWorkloadStats = RuntimeLiveStats & { readonly configurationRevision: number; @@ -504,7 +506,7 @@ async function createComparisonWorkloadRuntime( scheduledAt = performance.now(); fontFixtureCommitting = true; try { - applyRetainedTextFontFixture(targetTexts, activeSelectedFont.current.asset, nextFont); + await applyRetainedTextFontFixture(targetTexts, activeSelectedFont.current.asset, nextFont); } finally { fontFixtureCommitting = false; } @@ -1132,23 +1134,26 @@ interface RetainedWidthText { } interface RetainedFontText { + readonly ready: Promise; setProperties(properties: { readonly font: RegisteredFont; readonly raster: AnyRasterInput }): void; updateMatrixWorld(force?: boolean): void; } -export function applyRetainedTextFontFixture( +export async function applyRetainedTextFontFixture( texts: readonly RetainedFontText[], previous: Pick, next: Pick, -): void { +): Promise { try { for (const text of texts) text.setProperties({ font: next.font, raster: next.raster }); publishRetainedTexts(texts); + await Promise.all(texts.map(({ ready }) => ready)); } catch (error) { - // A synchronous staging failure may leave earlier siblings queued. Restore the complete fixture before the + // A staging or readiness failure may leave earlier siblings queued. Restore the complete fixture before the // candidate owner is released so no Text can retain a generation backed by a disposed font. for (const text of texts) text.setProperties({ font: previous.font, raster: previous.raster }); publishRetainedTexts(texts); + await Promise.allSettled(texts.map(({ ready }) => ready)); throw new Error('comparison font fixture update failed and was rolled back', { cause: error }); } } diff --git a/apps/benchmarks/src/surfaces/benchmark/sdf-text-viewports.tsx b/apps/benchmarks/src/surfaces/benchmark/sdf-text-viewports.tsx index 6f2bfce3..9627e668 100644 --- a/apps/benchmarks/src/surfaces/benchmark/sdf-text-viewports.tsx +++ b/apps/benchmarks/src/surfaces/benchmark/sdf-text-viewports.tsx @@ -2,9 +2,9 @@ import { useEffect, useEffectEvent, useRef, useState, type RefObject } from 'rea import type { FontDelivery, GraphicsBackend } from '../../benchmark/url-state'; import { createLatestAsyncQueue, type LatestAsyncQueue } from './latest-async-queue'; -import type { MtsdfTextLiveStats, MtsdfTextPersistentScene } from '../../renderer/mtsdf-text'; +import type { MtsdfTextLiveStats, MtsdfTextPersistentScene } from '../../techniques/mtsdf/persistent-scene'; import { usePersistentRenderHost } from '../../renderer/persistent-render-host-context'; -import type { SlugTextLiveStats, SlugTextPersistentScene } from '../../renderer/slug-text'; +import type { SlugTextLiveStats, SlugTextPersistentScene } from '../../techniques/slug/persistent-scene'; import { benchmarkContentWidth, BENCHMARK_CONTENT_INSET, @@ -16,11 +16,11 @@ import { BakeProgressOverlay, useBakeProgress } from './bake-progress-overlay'; import type { LiveTextConfiguration, RetainedLiveTextUpdate } from './live-text-viewport-contracts'; function loadMtsdfTextRenderer() { - return import('../../renderer/mtsdf-text'); + return import('../../techniques/mtsdf/persistent-scene'); } function loadSlugTextRenderer() { - return import('../../renderer/slug-text'); + return import('../../techniques/slug/persistent-scene'); } interface SdfTextViewportProps { diff --git a/apps/benchmarks/src/surfaces/benchmark/viewport-boundary.test.ts b/apps/benchmarks/src/surfaces/benchmark/viewport-boundary.test.ts index 38c4323c..9888c48c 100644 --- a/apps/benchmarks/src/surfaces/benchmark/viewport-boundary.test.ts +++ b/apps/benchmarks/src/surfaces/benchmark/viewport-boundary.test.ts @@ -2,28 +2,42 @@ import { readFile } from 'node:fs/promises'; import { describe, expect, it } from 'vitest'; -describe('live-text viewport renderer boundaries', () => { +describe('live-text viewport technique boundaries', () => { + it('keeps the retained comparison scene host-owned and renderer-borrowing', async () => { + const source = await readFile(new URL('./scenes/comparison-workload.ts', import.meta.url), 'utf8'); + + expect(source).not.toContain('createComparisonWorkloadPreview'); + expect(source).not.toContain('createConfiguredRenderer'); + expect(source).not.toContain('setAnimationLoop'); + expect(source).not.toContain('createGpuFrameTimer'); + }); + it.each([ - ['bitmap-text-viewport.tsx', '../../renderer/bitmap-text'], - ['sdf-text-viewports.tsx', '../../renderer/mtsdf-text'], - ['sdf-text-viewports.tsx', '../../renderer/slug-text'], - ] as const)('keeps %s references to %s lazy or type-only', async (file, rendererModule) => { + ['bitmap-text-viewport.tsx', '../../techniques/bitmap/persistent-scene'], + ['sdf-text-viewports.tsx', '../../techniques/mtsdf/persistent-scene'], + ['sdf-text-viewports.tsx', '../../techniques/slug/persistent-scene'], + ] as const)('keeps %s references to %s lazy or type-only', async (file, techniqueModule) => { const source = await readFile(new URL(file, import.meta.url), 'utf8'); const staticImports = source .split(';') .map((statement) => statement.trim()) - .filter((statement) => statement.startsWith('import ') && statement.includes(`'${rendererModule}'`)); + .filter((statement) => statement.startsWith('import ') && statement.includes(`'${techniqueModule}'`)); expect(staticImports).not.toHaveLength(0); expect(staticImports.every((statement) => statement.startsWith('import type '))).toBe(true); - expect(source).toContain(`import('${rendererModule}')`); + expect(source).toContain(`import('${techniqueModule}')`); }); - it('keeps live renderer modules on persistent-scene contracts', async () => { + it('keeps live technique modules on persistent-scene contracts', async () => { const renderers = await Promise.all( - ['bitmap-text.ts', 'mtsdf-text.ts', 'slug-text.ts'].map(async (file) => ({ + [ + ['bitmap', 'persistent-scene.ts'], + ['mtsdf', 'persistent-scene.ts'], + ['slug', 'persistent-scene.ts'], + ].map(async ([technique, file]) => ({ file, - source: await readFile(new URL(`../../renderer/${file}`, import.meta.url), 'utf8'), + technique, + source: await readFile(new URL(`../../techniques/${technique}/${file}`, import.meta.url), 'utf8'), })), ); @@ -34,10 +48,29 @@ describe('live-text viewport renderer boundaries', () => { expect(source).not.toContain('TextPreview'); } - expect(renderers.find(({ file }) => file === 'bitmap-text.ts')?.source).toContain( + expect(renderers.find(({ technique }) => technique === 'bitmap')?.source).toContain( 'createBitmapTextPersistentScene', ); - expect(renderers.find(({ file }) => file === 'mtsdf-text.ts')?.source).toContain('createMtsdfTextPersistentScene'); - expect(renderers.find(({ file }) => file === 'slug-text.ts')?.source).toContain('createSlugTextPersistentScene'); + expect(renderers.find(({ technique }) => technique === 'mtsdf')?.source).toContain( + 'createMtsdfTextPersistentScene', + ); + expect(renderers.find(({ technique }) => technique === 'slug')?.source).toContain('createSlugTextPersistentScene'); + }); + + it('keeps the harness separate from benchmark surface implementations', async () => { + const [sceneSource, surfaceSource, comparisonSource, preloadSource] = await Promise.all([ + readFile(new URL('../harness/scene.tsx', import.meta.url), 'utf8'), + readFile(new URL('./benchmark-surface.tsx', import.meta.url), 'utf8'), + readFile(new URL('./comparison-workload-viewport.tsx', import.meta.url), 'utf8'), + readFile(new URL('./scene-preload.ts', import.meta.url), 'utf8'), + ]); + + expect(sceneSource).toContain("import { BenchmarkSurface } from '../benchmark/benchmark-surface';"); + expect(sceneSource).not.toContain('function BenchmarkSurface('); + expect(sceneSource).not.toContain('function ComparisonWorkloadViewport('); + expect(surfaceSource).toContain("import { ComparisonWorkloadViewport } from './comparison-workload-viewport';"); + expect(comparisonSource).toContain("import { preloadComparisonWorkload } from './scene-preload';"); + expect(preloadSource).toContain('let comparisonWorkloadModule'); + expect(preloadSource).toContain('const liveSceneAssetResources = new Map>()'); }); }); diff --git a/apps/benchmarks/src/surfaces/conformance/conformance-surface.boundary.test.ts b/apps/benchmarks/src/surfaces/conformance/conformance-surface.boundary.test.ts index b4ffa698..5672be49 100644 --- a/apps/benchmarks/src/surfaces/conformance/conformance-surface.boundary.test.ts +++ b/apps/benchmarks/src/surfaces/conformance/conformance-surface.boundary.test.ts @@ -3,18 +3,22 @@ import { describe, expect, it } from 'vitest'; describe('conformance surface boundaries', () => { it('keeps route composition separate from retained and finite renderer ownership', async () => { - const [app, surface, finiteCapture, finiteSurface] = await Promise.all([ - readFile(new URL('../../app.tsx', import.meta.url), 'utf8'), + const [controller, harnessScene, surface, comparisonSurface, finiteCapture, finiteSurface] = await Promise.all([ + readFile(new URL('../../controllers/harness-controller.tsx', import.meta.url), 'utf8'), + readFile(new URL('../harness/scene.tsx', import.meta.url), 'utf8'), readFile(new URL('./conformance-surface.tsx', import.meta.url), 'utf8'), + readFile(new URL('./raster-technique-comparison-surface.tsx', import.meta.url), 'utf8'), readFile(new URL('./capture.ts', import.meta.url), 'utf8'), readFile(new URL('./finite-conformance-surface.tsx', import.meta.url), 'utf8'), ]); - expect(app).toContain("from './surfaces/conformance/conformance-surface'"); - expect(app).not.toContain('function ConformanceSurface('); - expect(app).not.toContain('function captureFiniteConformance('); + expect(controller).not.toContain('function ConformanceSurface('); + expect(controller).not.toContain('function captureFiniteConformance('); + expect(harnessScene).toContain("import { ConformanceSurface } from '../conformance/conformance-surface';"); expect(surface).toContain(' ({ fontDisposals: [] as string[], @@ -75,7 +75,7 @@ vi.mock('@pmndrs/text', async () => { return { Text: MockText }; }); -vi.mock('../../../../workloads/font-assets/mtsdf', () => ({ +vi.mock('../../../workloads/font-assets/mtsdf', () => ({ loadMtsdfFontAsset: async ({ signal }: { readonly signal?: AbortSignal }) => { signal?.throwIfAborted(); return { @@ -85,7 +85,7 @@ vi.mock('../../../../workloads/font-assets/mtsdf', () => ({ }, })); -vi.mock('../../../../workloads/font-assets/slug', () => ({ +vi.mock('../../../workloads/font-assets/slug', () => ({ loadSlugFontAsset: async ({ signal }: { readonly signal?: AbortSignal }) => { signal?.throwIfAborted(); return { diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/comparison-scene.ts b/apps/benchmarks/src/surfaces/conformance/scenes/raster-technique-comparison.ts similarity index 98% rename from apps/benchmarks/src/benchmark/targets/conformance/raster/comparison-scene.ts rename to apps/benchmarks/src/surfaces/conformance/scenes/raster-technique-comparison.ts index 42422442..d7b1c8bc 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/raster/comparison-scene.ts +++ b/apps/benchmarks/src/surfaces/conformance/scenes/raster-technique-comparison.ts @@ -3,17 +3,17 @@ import type { Node } from 'three/webgpu'; import * as THREE from 'three/webgpu'; import { mul, saturate, sub, texture, vec4 } from 'three/tsl'; -import { rasterConformanceSpecimen, type SelectableFontFixture } from '../../../font-fixtures'; -import { loadMtsdfFontAsset } from '../../../../workloads/font-assets/mtsdf'; +import { rasterConformanceSpecimen, type SelectableFontFixture } from '../../../benchmark/font-fixtures'; +import { loadMtsdfFontAsset } from '../../../workloads/font-assets/mtsdf'; import { createPersistentRenderHost, type PersistentRenderScene, type PersistentRenderSceneContext, type PersistentRenderSceneRenderer, type PersistentRenderViewport, -} from '../../../../renderer/persistent-render-host'; -import { loadSlugFontAsset } from '../../../../workloads/font-assets/slug'; -import type { RendererBackend } from '../../../../renderer/webgpu-renderer'; +} from '../../../renderer/persistent-render-host'; +import { loadSlugFontAsset } from '../../../workloads/font-assets/slug'; +import type { RendererBackend } from '../../../renderer/webgpu-renderer'; const BACKGROUND = 0x070709; const BASE_PHYSICAL_PPEM = 64; diff --git a/apps/benchmarks/src/surfaces/harness/persistent-layout.tsx b/apps/benchmarks/src/surfaces/harness/persistent-layout.tsx new file mode 100644 index 00000000..4f78756e --- /dev/null +++ b/apps/benchmarks/src/surfaces/harness/persistent-layout.tsx @@ -0,0 +1,43 @@ +import type { HarnessLocation } from '../../benchmark/url-state'; +import { HarnessLayout, type HarnessLayoutProps } from '../../components/harness-layout'; +import { PersistentRenderHostProvider, usePersistentRenderHost } from '../../renderer/persistent-render-host-context'; +import type { PersistentRenderJob } from '../../renderer/persistent-render-host'; + +type RunExclusiveJob = (job: PersistentRenderJob, signal?: AbortSignal) => Promise>; + +interface PersistentHarnessLayoutProps extends Omit { + readonly backend: HarnessLocation['backend']; + readonly dpr: 1 | 2; + readonly onBenchmarkAction: () => void; + readonly onConformanceAction: (runExclusiveJob: RunExclusiveJob) => void; + readonly onRendererError: (caught: unknown) => void; +} + +export function PersistentHarnessLayout({ + backend, + dpr, + onRendererError, + ...properties +}: PersistentHarnessLayoutProps) { + return ( + + + + ); +} + +function PersistentHarnessLayoutAdapter({ + onBenchmarkAction, + onConformanceAction, + ...properties +}: Omit) { + const { runExclusiveJob } = usePersistentRenderHost(); + return ( + onConformanceAction(runExclusiveJob) + } + /> + ); +} diff --git a/apps/benchmarks/src/surfaces/harness/scene.tsx b/apps/benchmarks/src/surfaces/harness/scene.tsx new file mode 100644 index 00000000..fc039082 --- /dev/null +++ b/apps/benchmarks/src/surfaces/harness/scene.tsx @@ -0,0 +1,227 @@ +import { Activity } from 'react'; + +import type { BenchmarkSummary, RunnerEvent } from '../../benchmark/contracts'; +import type { BenchmarkFontFixture, SelectableFontFixture } from '../../benchmark/font-fixtures'; +import type { LiveBenchmarkCapture } from '../../benchmark/product-result'; +import { + useRuntimeAnimationControls, + useRuntimeLayoutControls, + useRuntimePaintControls, + useRuntimeTelemetry, + useRuntimeViewControls, + type RuntimeLiveStats, +} from '../../benchmark/runtime-world'; +import type { HarnessLocation, RasterTechnique } from '../../benchmark/url-state'; +import { workloadById, type ConformanceWorkloadId, type WorkloadOption } from '../../benchmark/workloads'; +import type { PresentationPreset } from '../../benchmark/presentation-sequence'; +import type { ConformanceView } from '../../components/render-controls'; +import { Chip } from '../../components/ui'; +import type { AdvancedShapingFrame } from '../../workloads/advanced-shaping/scene'; +import type { BenchmarkWorkloadId } from '../../workloads/catalog'; +import { BenchmarkSurface } from '../benchmark/benchmark-surface'; +import { techniqueLabel } from '../benchmark/labels'; +import { ConformanceSurface } from '../conformance/conformance-surface'; + +type ActivityWorkloads = { + readonly benchmark: BenchmarkWorkloadId; + readonly conformance: ConformanceWorkloadId; +}; + +function formatMs(value: number | undefined): string { + return value === undefined ? '—' : `${value.toFixed(2)} ms`; +} + +export function SceneSuspenseFallback({ technique }: { readonly technique: RasterTechnique }) { + return ( +
+
+ Loading {techniqueLabel(technique)} scene… +
+
+ ); +} + +export function Scene({ + activeFontFixture, + activityWorkloads, + comparisonText, + conformanceView, + dpr, + error, + event, + fontFixture, + liveCapture, + location, + demoMode, + presentation, + presentationPreset, + showcaseFrame, + summary, + onConformancePan, + onConformanceZoom, + onLiveStats, +}: { + readonly activeFontFixture: BenchmarkFontFixture; + readonly activityWorkloads: ActivityWorkloads; + readonly comparisonText: string; + readonly conformanceView: ConformanceView; + readonly dpr: 1 | 2; + readonly error: string | undefined; + readonly event: RunnerEvent | undefined; + readonly fontFixture: SelectableFontFixture; + readonly liveCapture: LiveBenchmarkCapture | undefined; + readonly location: HarnessLocation; + readonly demoMode: boolean; + readonly presentation: 'main' | 'presentation'; + readonly presentationPreset: PresentationPreset | undefined; + readonly showcaseFrame: AdvancedShapingFrame; + readonly summary: BenchmarkSummary | undefined; + readonly onConformancePan: (deltaXPercent: number, deltaYPercent: number) => void; + readonly onConformanceZoom: (zoom: number) => void; + readonly onLiveStats: (stats: RuntimeLiveStats) => void; +}) { + const { showGrid: grid, showLayoutBounds } = useRuntimeViewControls(); + const { fontSize, layoutWidthPercent, workloadAmount } = useRuntimeLayoutControls(); + const { animationEnabled, animationSpeed } = useRuntimeAnimationControls(); + const { paintOpacityPercent, paintShadowEnabled, paintStrokePercent } = useRuntimePaintControls(); + const { stats: liveStats } = useRuntimeTelemetry(); + const workload = workloadById(location.mode, location.workload); + const benchmarkWorkload = workloadById('benchmark', activityWorkloads.benchmark); + const benchmarkStatus = benchmarkWorkload.techniques[location.technique]; + const conformanceWorkload = workloadById('conformance', activityWorkloads.conformance); + const conformanceStatus = conformanceWorkload.techniques[location.technique]; + const benchmarkSurface = + benchmarkStatus.kind === 'planned' ? ( + + ) : ( + + ); + + if (presentation === 'presentation') { + return ( +
+ {benchmarkSurface} + {error !== undefined && ( +
+ {error} +
+ )} +
+ ); + } + + return ( +
+
+
+

{location.mode === 'benchmark' ? 'Live benchmark' : 'Correctness inspection'}

+

{workload.label}

+

{workload.description}

+
+
+ {techniqueLabel(location.technique)} + {location.backend === 'webgpu' ? 'WebGPU' : 'WebGL'} + {location.delivery === 'runtime' ? 'Runtime bake' : 'Baked asset'} + {dpr}× DPR +
+
+ +
+ {benchmarkSurface} +
+
+ +
+ {conformanceStatus.kind === 'planned' ? ( + + ) : ( + + )} +
+
+ {error !== undefined && ( +
{error}
+ )} + {location.mode === 'benchmark' && liveCapture !== undefined && ( +
+ Captured the current rolling window at {liveCapture.capturedAt} ·{' '} + {liveCapture.stats.framesPerSecond.toFixed(1)} FPS · {formatMs(liveCapture.stats.medianSubmitMs)} CPU frame +
+ )} +
+ ); +} + +export function PlannedWorkloadSurface({ + milestone, + technique, + workload, +}: { + readonly milestone: 8 | 9; + readonly technique: RasterTechnique; + readonly workload: WorkloadOption; +}) { + return ( +
+
+

Milestone {milestone}

+

+ {techniqueLabel(technique)} · {workload.label} +

+

{workload.description}

+
+
+ ); +} diff --git a/apps/benchmarks/src/renderer/bitmap-line.ts b/apps/benchmarks/src/techniques/bitmap/line.ts similarity index 97% rename from apps/benchmarks/src/renderer/bitmap-line.ts rename to apps/benchmarks/src/techniques/bitmap/line.ts index ad7bb179..c3e13671 100644 --- a/apps/benchmarks/src/renderer/bitmap-line.ts +++ b/apps/benchmarks/src/techniques/bitmap/line.ts @@ -2,7 +2,7 @@ import { Text, type FontFeature, type ParagraphLayout, type RegisteredFont } fro import { bitmap, selectBitmapStrikePpem } from '@pmndrs/text/raster/bitmap'; import * as THREE from 'three/webgpu'; -import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../workloads/shared/text-style'; +import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../../workloads/shared/text-style'; export interface BitmapLine { readonly object: Text; diff --git a/apps/benchmarks/src/benchmark/low-level/raster/bitmap-atlas.ts b/apps/benchmarks/src/techniques/bitmap/metadata.ts similarity index 97% rename from apps/benchmarks/src/benchmark/low-level/raster/bitmap-atlas.ts rename to apps/benchmarks/src/techniques/bitmap/metadata.ts index 411571fd..070e2c42 100644 --- a/apps/benchmarks/src/benchmark/low-level/raster/bitmap-atlas.ts +++ b/apps/benchmarks/src/techniques/bitmap/metadata.ts @@ -1,7 +1,7 @@ import { type JsonValue, type RegisteredFont } from '@pmndrs/text'; import { bitmapRasterKey } from '@pmndrs/text/raster/bitmap'; -import type { BitmapFixtureDensity } from '../../../workloads/font-assets'; +import type { BitmapFixtureDensity } from '../../workloads/font-assets'; const CONFORMANCE_BITMAP_STRIKES = [16] as const; const LIVE_BITMAP_STRIKES = [16, 32] as const; diff --git a/apps/benchmarks/src/renderer/bitmap-text.ts b/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts similarity index 97% rename from apps/benchmarks/src/renderer/bitmap-text.ts rename to apps/benchmarks/src/techniques/bitmap/persistent-scene.ts index 627567c8..c31444fa 100644 --- a/apps/benchmarks/src/renderer/bitmap-text.ts +++ b/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts @@ -8,29 +8,29 @@ import { } from '@pmndrs/text/raster/bitmap'; import * as THREE from 'three/webgpu'; -import type { BenchmarkFontFixture } from '../benchmark/font-fixtures'; -import { registeredBitmapAtlas, type BitmapAtlasPageStats } from '../benchmark/low-level/raster/bitmap-atlas'; -import type { FontDelivery } from '../benchmark/url-state'; -import { createCanvasSurface } from './canvas-surface'; -import { finiteCanvasDelta } from './canvas-view'; -import { type LiveFrameHistoryCursor } from './live-frame-telemetry'; -import { createTextUpdateTelemetry, type TextUpdateTimingSummary } from './text-update-telemetry'; +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { FontDelivery } from '../../benchmark/url-state'; +import { createCanvasSurface } from '../../renderer/canvas-surface'; +import { finiteCanvasDelta } from '../../renderer/canvas-view'; +import { type LiveFrameHistoryCursor } from '../../renderer/live-frame-telemetry'; +import { createTextUpdateTelemetry, type TextUpdateTimingSummary } from '../../renderer/text-update-telemetry'; import { createRetainedFontFixtureController, type LiveFontFixtureUpdate, type RetainedFontFixtureController, -} from './retained-font-fixture'; -import { benchmarkContentWidth, liveTextPosition, type LiveTextAnchor } from '../workloads/shared/text-style'; -import { type RendererBackend } from './webgpu-renderer'; +} from '../../renderer/retained-font-fixture'; +import { benchmarkContentWidth, liveTextPosition, type LiveTextAnchor } from '../../workloads/shared/text-style'; +import { type RendererBackend } from '../../renderer/webgpu-renderer'; import { type PersistentRenderFrameContext, type PersistentRenderScene, type PersistentRenderSceneContext, type PersistentRenderViewport, -} from './persistent-render-host'; -import { createPersistentSceneActivation } from './persistent-scene-activation'; -import { loadBitmapFontAsset } from '../workloads/font-assets/bitmap'; -import { createBitmapLine, disposeBitmapLine, type BitmapLine } from './bitmap-line'; +} from '../../renderer/persistent-render-host'; +import { createPersistentSceneActivation } from '../../renderer/persistent-scene-activation'; +import { loadBitmapFontAsset } from '../../workloads/font-assets/bitmap'; +import { createBitmapLine, disposeBitmapLine, type BitmapLine } from './line'; +import { registeredBitmapAtlas, type BitmapAtlasPageStats } from './metadata'; export interface BitmapTextLiveStats { readonly technique: 'bitmap'; diff --git a/apps/benchmarks/src/benchmark/low-level/raster/mtsdf-configuration.ts b/apps/benchmarks/src/techniques/mtsdf/metadata.ts similarity index 100% rename from apps/benchmarks/src/benchmark/low-level/raster/mtsdf-configuration.ts rename to apps/benchmarks/src/techniques/mtsdf/metadata.ts diff --git a/apps/benchmarks/src/renderer/mtsdf-text.ts b/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts similarity index 96% rename from apps/benchmarks/src/renderer/mtsdf-text.ts rename to apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts index 12fefa26..32e9b432 100644 --- a/apps/benchmarks/src/renderer/mtsdf-text.ts +++ b/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts @@ -1,36 +1,33 @@ import { FontRegistry, Text, type FontFeature, type ParagraphLayout, type RegisteredFont } from '@pmndrs/text'; import * as THREE from 'three/webgpu'; -import type { BenchmarkFontFixture } from '../benchmark/font-fixtures'; -import { - registeredMtsdfConfiguration, - type MtsdfRasterConfiguration, -} from '../benchmark/low-level/raster/mtsdf-configuration'; -import type { FontDelivery } from '../benchmark/url-state'; -import { createCanvasSurface, type CanvasSurface } from './canvas-surface'; -import { finiteCanvasDelta } from './canvas-view'; -import type { LiveFrameHistoryCursor } from './live-frame-telemetry'; -import { createTextUpdateTelemetry, type TextUpdateTimingSummary } from './text-update-telemetry'; +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { FontDelivery } from '../../benchmark/url-state'; +import { createCanvasSurface, type CanvasSurface } from '../../renderer/canvas-surface'; +import { finiteCanvasDelta } from '../../renderer/canvas-view'; +import type { LiveFrameHistoryCursor } from '../../renderer/live-frame-telemetry'; +import { createTextUpdateTelemetry, type TextUpdateTimingSummary } from '../../renderer/text-update-telemetry'; import { createRetainedFontFixtureController, type LiveFontFixtureUpdate, type RetainedFontFixtureController, -} from './retained-font-fixture'; +} from '../../renderer/retained-font-fixture'; import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT, liveTextPosition, type LiveTextAnchor, -} from '../workloads/shared/text-style'; -import { type RendererBackend } from './webgpu-renderer'; +} from '../../workloads/shared/text-style'; +import { type RendererBackend } from '../../renderer/webgpu-renderer'; import { type PersistentRenderScene, type PersistentRenderSceneRenderer, type PersistentRenderViewport, -} from './persistent-render-host'; -import { createPersistentSceneActivation } from './persistent-scene-activation'; -import { loadMtsdfFontAsset, MTSDF_FIXTURE_ARTIFACT_BYTE_LIMIT } from '../workloads/font-assets/mtsdf'; +} from '../../renderer/persistent-render-host'; +import { createPersistentSceneActivation } from '../../renderer/persistent-scene-activation'; +import { loadMtsdfFontAsset, MTSDF_FIXTURE_ARTIFACT_BYTE_LIMIT } from '../../workloads/font-assets/mtsdf'; +import { registeredMtsdfConfiguration, type MtsdfRasterConfiguration } from './metadata'; export interface MtsdfTextLiveStats { readonly technique: 'mtsdf'; diff --git a/apps/benchmarks/src/benchmark/low-level/raster/slug-configuration.ts b/apps/benchmarks/src/techniques/slug/metadata.ts similarity index 100% rename from apps/benchmarks/src/benchmark/low-level/raster/slug-configuration.ts rename to apps/benchmarks/src/techniques/slug/metadata.ts diff --git a/apps/benchmarks/src/renderer/slug-text.ts b/apps/benchmarks/src/techniques/slug/persistent-scene.ts similarity index 96% rename from apps/benchmarks/src/renderer/slug-text.ts rename to apps/benchmarks/src/techniques/slug/persistent-scene.ts index 30e66537..593272e9 100644 --- a/apps/benchmarks/src/renderer/slug-text.ts +++ b/apps/benchmarks/src/techniques/slug/persistent-scene.ts @@ -8,32 +8,29 @@ import { } from '@pmndrs/text'; import * as THREE from 'three/webgpu'; -import type { BenchmarkFontFixture } from '../benchmark/font-fixtures'; -import { - registeredSlugConfiguration, - type SlugRasterConfiguration, -} from '../benchmark/low-level/raster/slug-configuration'; -import type { FontDelivery } from '../benchmark/url-state'; -import { createCanvasSurface } from './canvas-surface'; -import { finiteCanvasDelta } from './canvas-view'; -import { loadSlugFontAsset } from '../workloads/font-assets/slug'; -import type { LiveFrameHistoryCursor } from './live-frame-telemetry'; +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { FontDelivery } from '../../benchmark/url-state'; +import { createCanvasSurface } from '../../renderer/canvas-surface'; +import { finiteCanvasDelta } from '../../renderer/canvas-view'; +import { loadSlugFontAsset } from '../../workloads/font-assets/slug'; +import type { LiveFrameHistoryCursor } from '../../renderer/live-frame-telemetry'; import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT, liveTextPosition, type LiveTextAnchor, -} from '../workloads/shared/text-style'; -import { createTextUpdateTelemetry, type TextUpdateTimingSummary } from './text-update-telemetry'; -import { type PersistentRenderScene, type PersistentRenderViewport } from './persistent-render-host'; -import { createPersistentSceneActivation } from './persistent-scene-activation'; +} from '../../workloads/shared/text-style'; +import { createTextUpdateTelemetry, type TextUpdateTimingSummary } from '../../renderer/text-update-telemetry'; +import { type PersistentRenderScene, type PersistentRenderViewport } from '../../renderer/persistent-render-host'; +import { createPersistentSceneActivation } from '../../renderer/persistent-scene-activation'; import { createRetainedFontFixtureController, type LiveFontFixtureUpdate, type RetainedFontFixtureController, -} from './retained-font-fixture'; -import type { RendererBackend } from './webgpu-renderer'; +} from '../../renderer/retained-font-fixture'; +import type { RendererBackend } from '../../renderer/webgpu-renderer'; +import { registeredSlugConfiguration, type SlugRasterConfiguration } from './metadata'; export interface SlugTextLiveStats { readonly technique: 'slug'; diff --git a/apps/benchmarks/src/workloads/advanced-shaping.test.ts b/apps/benchmarks/src/workloads/advanced-shaping.test.ts index dc736c67..ad3447e4 100644 --- a/apps/benchmarks/src/workloads/advanced-shaping.test.ts +++ b/apps/benchmarks/src/workloads/advanced-shaping.test.ts @@ -13,7 +13,7 @@ import { advancedShapingFrames, initialAdvancedShapingState, updateAdvancedShaping, -} from './advanced-shaping'; +} from './advanced-shaping/scene'; describe('Advanced Shaping workload timeline', () => { it('starts the live showcase on CJK without publishing an empty playing frame', () => { @@ -44,6 +44,12 @@ describe('Advanced Shaping workload timeline', () => { }); }); + it('preserves an explicitly selected compatible font fixture', () => { + const frame = advancedShapingFrame(initialAdvancedShapingState()); + + expect(advancedShapingLiveTextScene(frame, 'dot-gothic-16').fontFixture).toBe('dot-gothic-16'); + }); + it('completes one presentation cycle at the authored duration', () => { const completed = advanceAdvancedShapingByTime( initialAdvancedShapingState(), diff --git a/apps/benchmarks/src/workloads/advanced-shaping/definition.ts b/apps/benchmarks/src/workloads/advanced-shaping/definition.ts new file mode 100644 index 00000000..4cdcc0d6 --- /dev/null +++ b/apps/benchmarks/src/workloads/advanced-shaping/definition.ts @@ -0,0 +1,20 @@ +import { + fontSizeControl, + noControls, + readyTechniques, + workloadDefaults, + type BenchmarkWorkloadDefinition, +} from '../shared/definition'; + +export const advancedShapingDefinition = { + controls: { ...noControls, fontSize: fontSizeControl }, + defaults: workloadDefaults(20, 48), + description: 'Tests whether complex text stays correct as it types and wraps.', + fontPolicy: { kind: 'advanced-case', defaultFixture: 'noto-sans-cjk-showcase' }, + id: 'advanced-shaping', + interaction: { pan: true, zoom: false }, + label: 'Advanced shaping', + preload: 'technique-module', + surface: 'advanced-shaping', + techniques: readyTechniques, +} as const satisfies BenchmarkWorkloadDefinition<'advanced-shaping'>; diff --git a/apps/benchmarks/src/workloads/advanced-shaping.ts b/apps/benchmarks/src/workloads/advanced-shaping/scene.ts similarity index 97% rename from apps/benchmarks/src/workloads/advanced-shaping.ts rename to apps/benchmarks/src/workloads/advanced-shaping/scene.ts index 8e64b1bf..73687457 100644 --- a/apps/benchmarks/src/workloads/advanced-shaping.ts +++ b/apps/benchmarks/src/workloads/advanced-shaping/scene.ts @@ -1,7 +1,8 @@ /** Authored Advanced Shaping workload state and timeline. */ import { graphemeSegments } from 'unicode-segmenter/grapheme'; -import type { LiveTextScene } from './live-text-scene'; +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { LiveTextScene } from '../shared/live-text-scene'; export type AdvancedShapingCaseId = | 'latin-features' @@ -290,14 +291,17 @@ export function advancedShapingFrame(state: AdvancedShapingState): AdvancedShapi } /** Projects a timeline frame into the live scene's public Text inputs. */ -export function advancedShapingLiveTextScene(frame: AdvancedShapingFrame): LiveTextScene { +export function advancedShapingLiveTextScene( + frame: AdvancedShapingFrame, + fontFixture: BenchmarkFontFixture = frame.caseDefinition.fontFixture, +): LiveTextScene { return { anchor: 'measure-center', animatePresentation: false, direction: frame.caseDefinition.direction, expectedGlyphCount: undefined, features: frame.caseDefinition.features, - fontFixture: frame.caseDefinition.fontFixture, + fontFixture, language: frame.caseDefinition.language, layoutWidthRatio: frame.widthPermille / 1_000, presentation: 'timeline', diff --git a/apps/benchmarks/src/workloads/benchmark-ipsum.test.ts b/apps/benchmarks/src/workloads/benchmark-ipsum.test.ts index 37b76b74..2660e3df 100644 --- a/apps/benchmarks/src/workloads/benchmark-ipsum.test.ts +++ b/apps/benchmarks/src/workloads/benchmark-ipsum.test.ts @@ -7,7 +7,7 @@ import { BENCHMARK_IPSUM_PARAGRAPHS, BENCHMARK_IPSUM_TEXT, benchmarkIpsumLiveTextScene, -} from './benchmark-ipsum'; +} from './benchmark-ipsum/scene'; describe('Benchmark Ipsum workload', () => { it('keeps five independently diagnosable conformance lanes', () => { diff --git a/apps/benchmarks/src/workloads/benchmark-ipsum/definition.ts b/apps/benchmarks/src/workloads/benchmark-ipsum/definition.ts new file mode 100644 index 00000000..3d2b72ce --- /dev/null +++ b/apps/benchmarks/src/workloads/benchmark-ipsum/definition.ts @@ -0,0 +1,21 @@ +import { + fontSizeControl, + layoutWidthControl, + noControls, + readyTechniques, + workloadDefaults, + type BenchmarkWorkloadDefinition, +} from '../shared/definition'; + +export const benchmarkIpsumDefinition = { + controls: { ...noControls, fontSize: fontSizeControl, layoutWidth: layoutWidthControl }, + defaults: workloadDefaults(20, 24), + description: 'Tests the everyday cost of rendering and reflowing a full paragraph.', + fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, + id: 'benchmark-ipsum', + interaction: { pan: true, zoom: false }, + label: 'Benchmark ipsum', + preload: 'technique-module', + surface: 'single-paragraph', + techniques: readyTechniques, +} as const satisfies BenchmarkWorkloadDefinition<'benchmark-ipsum'>; diff --git a/apps/benchmarks/src/workloads/benchmark-ipsum.ts b/apps/benchmarks/src/workloads/benchmark-ipsum/scene.ts similarity index 95% rename from apps/benchmarks/src/workloads/benchmark-ipsum.ts rename to apps/benchmarks/src/workloads/benchmark-ipsum/scene.ts index d6c66bcc..c16e67d5 100644 --- a/apps/benchmarks/src/workloads/benchmark-ipsum.ts +++ b/apps/benchmarks/src/workloads/benchmark-ipsum/scene.ts @@ -1,6 +1,6 @@ -import type { BenchmarkFontFixture } from '../benchmark/font-fixtures'; +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; -import type { LiveTextScene } from './live-text-scene'; +import type { LiveTextScene } from '../shared/live-text-scene'; /** * Stable Benchmark Ipsum workload corpus for native-strike rendering checks. diff --git a/apps/benchmarks/src/workloads/catalog.test.ts b/apps/benchmarks/src/workloads/catalog.test.ts index 8db75b9e..bce547b0 100644 --- a/apps/benchmarks/src/workloads/catalog.test.ts +++ b/apps/benchmarks/src/workloads/catalog.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { workloadsFor } from '../benchmark/workloads'; -import { COMPARISON_WORKLOAD_IDS } from './registry'; +import { COMPARISON_WORKLOAD_IDS } from './comparison/registry'; import { BENCHMARK_WORKLOAD_IDS, BENCHMARK_WORKLOADS, diff --git a/apps/benchmarks/src/workloads/catalog.ts b/apps/benchmarks/src/workloads/catalog.ts index 47dd295b..26e01ec7 100644 --- a/apps/benchmarks/src/workloads/catalog.ts +++ b/apps/benchmarks/src/workloads/catalog.ts @@ -1,357 +1,44 @@ -import { - ICON_GRID_FONT_FIXTURE, - type BenchmarkFontFixture, - type SelectableFontFixture, -} from '../benchmark/font-fixtures'; -import type { HarnessLayout, RasterTechnique } from '../benchmark/url-state'; - -import type { ComparisonWorkloadId } from './contracts'; +import { advancedShapingDefinition } from './advanced-shaping/definition'; +import { benchmarkIpsumDefinition } from './benchmark-ipsum/definition'; +import type { ComparisonWorkloadId } from './comparison/contracts'; +import { dynamicLayoutDefinition } from './dynamic-layout/definition'; +import { iconGridDefinition } from './icon-grid/definition'; +import { offAxis3dDefinition } from './off-axis-3d/definition'; +import { paintEffectsDefinition } from './paint-effects/definition'; +import { paragraphStressDefinition } from './paragraph-stress/definition'; +import { textLadderDefinition } from './text-ladder/definition'; +import { zoomTextDefinition } from './zoom-text/definition'; + +export type { + BenchmarkWorkloadDefinition, + LiveWorkloadPreloadKind, + LiveWorkloadSurfaceKind, + WorkloadControls, + WorkloadFontPolicy, + WorkloadInteraction, + WorkloadPaintControls, + WorkloadRange, + WorkloadRuntimeDefaults, +} from './shared/definition'; +import type { BenchmarkWorkloadDefinition } from './shared/definition'; /** Every runnable live example, including the two retained single-paragraph scenes. */ export type BenchmarkWorkloadId = 'benchmark-ipsum' | 'advanced-shaping' | ComparisonWorkloadId; -export type LiveWorkloadSurfaceKind = 'advanced-shaping' | 'comparison' | 'single-paragraph'; -export type LiveWorkloadPreloadKind = 'comparison-module' | 'technique-module'; - -export interface WorkloadRange { - readonly label: string; - readonly maximum: number; - readonly minimum: number; - readonly scale: 'linear' | 'logarithmic'; - readonly step: number; -} - -export interface WorkloadPaintControls { - readonly opacity: WorkloadRange; - readonly shadowTechniques: readonly RasterTechnique[]; - readonly strokeTechniques: readonly RasterTechnique[]; -} - -export interface WorkloadControls { - readonly animation: boolean; - readonly amount: WorkloadRange | undefined; - readonly fontSize: WorkloadRange | undefined; - readonly layoutBounds: boolean; - readonly layoutWidth: WorkloadRange | undefined; - readonly paint: WorkloadPaintControls | undefined; -} - -export interface WorkloadInteraction { - readonly pan: boolean; - readonly zoom: boolean; -} - -export interface WorkloadRuntimeDefaults { - readonly animationEnabled: boolean; - readonly animationSpeed: number; - readonly fontSize: number; - readonly layoutWidthPercent: number; - readonly paintOpacityPercent: number; - readonly paintShadowEnabled: boolean; - readonly paintStrokePercent: number; - readonly showGrid: boolean; - readonly showLayoutBounds: boolean; - readonly workloadAmount: number; -} - -export type WorkloadFontPolicy = - | { - readonly defaultFixture: BenchmarkFontFixture; - readonly kind: 'advanced-case'; - } - | { - readonly defaultFixture: SelectableFontFixture; - readonly kind: 'fixed'; - } - | { - readonly iconFixture: typeof ICON_GRID_FONT_FIXTURE; - readonly kind: 'icon-grid'; - readonly labelDefaultFixture: SelectableFontFixture; - } - | { - readonly defaultFixture: SelectableFontFixture; - readonly kind: 'selectable'; - }; - /** - * Declarative benchmark-app policy. Workload modules own the Text examples; - * the route/UI consume this metadata rather than recreating workload-ID lists. + * The one complete route catalog. Each value is declared beside the authored + * workload scene; this file only preserves application order and lookup. */ -export interface BenchmarkWorkloadDefinition { - readonly controls: WorkloadControls; - readonly defaults: Readonly>; - readonly description: string; - readonly fontPolicy: WorkloadFontPolicy; - readonly id: BenchmarkWorkloadId; - readonly interaction: WorkloadInteraction; - readonly label: string; - readonly preload: LiveWorkloadPreloadKind; - readonly surface: LiveWorkloadSurfaceKind; - readonly techniques: Readonly>; -} - -const READY_TECHNIQUES = { - bitmap: { kind: 'ready' }, - mtsdf: { kind: 'ready' }, - slug: { kind: 'ready' }, -} as const; - -const STANDARD_RUNTIME_DEFAULTS = { - animationEnabled: true, - animationSpeed: 50, - layoutWidthPercent: 82, - paintOpacityPercent: 100, - paintShadowEnabled: false, - paintStrokePercent: 0, - showGrid: true, - showLayoutBounds: true, - workloadAmount: 50, -} as const; - -const DEFAULT_FONT_SIZE = { - main: 20, - presentation: 24, -} as const; - -const FONT_SIZE = { - label: 'Rendered size', - maximum: 96, - minimum: 8, - scale: 'linear', - step: 1, -} as const satisfies WorkloadRange; - -const ICON_SIZE = { - label: 'Icon size', - maximum: 1_024, - minimum: 8, - scale: 'logarithmic', - step: 1, -} as const satisfies WorkloadRange; - -const LAYOUT_WIDTH = { - label: 'Layout width', - maximum: 100, - minimum: 40, - scale: 'linear', - step: 2, -} as const satisfies WorkloadRange; - -const OFF_AXIS_LAYOUT_WIDTH = { - ...LAYOUT_WIDTH, - maximum: 200, -} as const satisfies WorkloadRange; - -const PERSPECTIVE_AMOUNT = { - label: 'Perspective intensity', - maximum: 100, - minimum: 0, - scale: 'linear', - step: 1, -} as const satisfies WorkloadRange; - -const REFLOW_AMOUNT = { - label: 'Reflow amplitude', - maximum: 100, - minimum: 0, - scale: 'linear', - step: 1, -} as const satisfies WorkloadRange; - -const TEXT_VOLUME_AMOUNT = { - label: 'Text volume', - maximum: 100, - minimum: 0, - scale: 'linear', - step: 1, -} as const satisfies WorkloadRange; - -const HUE_SPREAD_AMOUNT = { - label: 'Hue spread', - maximum: 100, - minimum: 0, - scale: 'linear', - step: 1, -} as const satisfies WorkloadRange; - -const PAINT_CONTROLS = { - opacity: { - label: 'Opacity', - maximum: 100, - minimum: 0, - scale: 'linear', - step: 1, - }, - shadowTechniques: ['mtsdf'], - strokeTechniques: ['mtsdf'], -} as const satisfies WorkloadPaintControls; - -const NO_CONTROLS = { - animation: false, - amount: undefined, - fontSize: undefined, - layoutBounds: false, - layoutWidth: undefined, - paint: undefined, -} as const satisfies WorkloadControls; - -function defaults( - mainFontSize: number, - presentationFontSize: number, - options: Partial = {}, -): Readonly> { - return { - main: { ...STANDARD_RUNTIME_DEFAULTS, fontSize: mainFontSize, ...options }, - presentation: { ...STANDARD_RUNTIME_DEFAULTS, fontSize: presentationFontSize, ...options }, - }; -} - export const BENCHMARK_WORKLOADS = { - 'benchmark-ipsum': { - controls: { - ...NO_CONTROLS, - fontSize: FONT_SIZE, - layoutWidth: LAYOUT_WIDTH, - }, - defaults: defaults(DEFAULT_FONT_SIZE.main, DEFAULT_FONT_SIZE.presentation), - description: 'Tests the everyday cost of rendering and reflowing a full paragraph.', - fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, - id: 'benchmark-ipsum', - interaction: { pan: true, zoom: false }, - label: 'Benchmark ipsum', - preload: 'technique-module', - surface: 'single-paragraph', - techniques: READY_TECHNIQUES, - }, - 'advanced-shaping': { - controls: { - ...NO_CONTROLS, - fontSize: FONT_SIZE, - }, - defaults: defaults(DEFAULT_FONT_SIZE.main, 48), - description: 'Tests whether complex text stays correct as it types and wraps.', - fontPolicy: { kind: 'advanced-case', defaultFixture: 'noto-sans-cjk-showcase' }, - id: 'advanced-shaping', - interaction: { pan: true, zoom: false }, - label: 'Advanced shaping', - preload: 'technique-module', - surface: 'advanced-shaping', - techniques: READY_TECHNIQUES, - }, - 'text-ladder': { - controls: { ...NO_CONTROLS, animation: true }, - defaults: defaults(DEFAULT_FONT_SIZE.main, DEFAULT_FONT_SIZE.presentation), - description: 'Tests how text quality holds up from 8 to 1024 pixels.', - fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, - id: 'text-ladder', - interaction: { pan: true, zoom: false }, - label: 'Text ladder', - preload: 'comparison-module', - surface: 'comparison', - techniques: READY_TECHNIQUES, - }, - 'zoom-text': { - controls: { ...NO_CONTROLS, animation: true }, - defaults: defaults(DEFAULT_FONT_SIZE.main, DEFAULT_FONT_SIZE.presentation), - description: 'Cycles through translations of “Shape” while scaling from 8 pt to the largest size that fits.', - fontPolicy: { kind: 'fixed', defaultFixture: 'inter' }, - id: 'zoom-text', - interaction: { pan: false, zoom: false }, - label: 'Zoom text', - preload: 'comparison-module', - surface: 'comparison', - techniques: READY_TECHNIQUES, - }, - 'icon-grid': { - controls: { ...NO_CONTROLS, animation: true, fontSize: ICON_SIZE }, - defaults: defaults(56, 64), - description: 'Tests a labeled icon font across scale, movement, and raster techniques.', - fontPolicy: { - iconFixture: ICON_GRID_FONT_FIXTURE, - kind: 'icon-grid', - labelDefaultFixture: 'inter', - }, - id: 'icon-grid', - interaction: { pan: true, zoom: false }, - label: 'Icon grid', - preload: 'comparison-module', - surface: 'comparison', - techniques: READY_TECHNIQUES, - }, - 'off-axis-3d': { - controls: { - ...NO_CONTROLS, - amount: PERSPECTIVE_AMOUNT, - animation: true, - fontSize: FONT_SIZE, - layoutWidth: OFF_AXIS_LAYOUT_WIDTH, - }, - defaults: defaults(96, 96, { layoutWidthPercent: 120, workloadAmount: 100 }), - description: 'Tests text quality and cost at steep, moving viewing angles.', - fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, - id: 'off-axis-3d', - interaction: { pan: true, zoom: true }, - label: 'Off-axis / 3D', - preload: 'comparison-module', - surface: 'comparison', - techniques: READY_TECHNIQUES, - }, - 'dynamic-layout': { - controls: { - ...NO_CONTROLS, - amount: REFLOW_AMOUNT, - animation: true, - fontSize: FONT_SIZE, - layoutBounds: true, - layoutWidth: LAYOUT_WIDTH, - }, - defaults: defaults(28, 32), - description: 'Tests whether animated containers reflow text smoothly and correctly.', - fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, - id: 'dynamic-layout', - interaction: { pan: true, zoom: false }, - label: 'Dynamic layout', - preload: 'comparison-module', - surface: 'comparison', - techniques: READY_TECHNIQUES, - }, - 'paragraph-stress': { - controls: { - ...NO_CONTROLS, - amount: TEXT_VOLUME_AMOUNT, - animation: true, - fontSize: FONT_SIZE, - layoutWidth: LAYOUT_WIDTH, - }, - defaults: defaults(DEFAULT_FONT_SIZE.main, DEFAULT_FONT_SIZE.presentation, { workloadAmount: 100 }), - description: 'Tests rendering cost as paragraphs, glyphs, and atlas pressure grow.', - fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, - id: 'paragraph-stress', - interaction: { pan: true, zoom: false }, - label: 'Paragraph stress', - preload: 'comparison-module', - surface: 'comparison', - techniques: READY_TECHNIQUES, - }, - 'paint-effects': { - controls: { - ...NO_CONTROLS, - amount: HUE_SPREAD_AMOUNT, - animation: true, - fontSize: FONT_SIZE, - layoutWidth: LAYOUT_WIDTH, - paint: PAINT_CONTROLS, - }, - defaults: defaults(44, 52), - description: 'Tests the live cost and visual quality of animated text effects.', - fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, - id: 'paint-effects', - interaction: { pan: true, zoom: false }, - label: 'Paint & effects', - preload: 'comparison-module', - surface: 'comparison', - techniques: READY_TECHNIQUES, - }, + 'benchmark-ipsum': benchmarkIpsumDefinition, + 'advanced-shaping': advancedShapingDefinition, + 'text-ladder': textLadderDefinition, + 'zoom-text': zoomTextDefinition, + 'icon-grid': iconGridDefinition, + 'off-axis-3d': offAxis3dDefinition, + 'dynamic-layout': dynamicLayoutDefinition, + 'paragraph-stress': paragraphStressDefinition, + 'paint-effects': paintEffectsDefinition, } as const satisfies Record; export const BENCHMARK_WORKLOAD_IDS = Object.freeze(Object.keys(BENCHMARK_WORKLOADS) as readonly BenchmarkWorkloadId[]); diff --git a/apps/benchmarks/src/workloads/contracts.ts b/apps/benchmarks/src/workloads/comparison/contracts.ts similarity index 95% rename from apps/benchmarks/src/workloads/contracts.ts rename to apps/benchmarks/src/workloads/comparison/contracts.ts index 69c3f0e9..407876dd 100644 --- a/apps/benchmarks/src/workloads/contracts.ts +++ b/apps/benchmarks/src/workloads/comparison/contracts.ts @@ -1,9 +1,9 @@ import type { AnyRasterInput, RegisteredFont } from '@pmndrs/text'; import type * as THREE from 'three/webgpu'; -import type { RasterConformanceSpecimen, BenchmarkFontFixture } from '../benchmark/font-fixtures'; -import type { RasterTechnique } from '../benchmark/url-state'; -import type { ComparisonWorkloadEntry } from './factory-contracts'; +import type { RasterConformanceSpecimen, BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { RasterTechnique } from '../../benchmark/url-state'; +import type { ComparisonWorkloadEntry } from '../shared/scene-entry'; /** The comparison workloads that share the retained benchmark render host. */ export type ComparisonWorkloadId = diff --git a/apps/benchmarks/src/workloads/registry.ts b/apps/benchmarks/src/workloads/comparison/registry.ts similarity index 79% rename from apps/benchmarks/src/workloads/registry.ts rename to apps/benchmarks/src/workloads/comparison/registry.ts index 358ee71b..40e45fbf 100644 --- a/apps/benchmarks/src/workloads/registry.ts +++ b/apps/benchmarks/src/workloads/comparison/registry.ts @@ -1,11 +1,11 @@ +import { dynamicLayoutWorkload } from '../dynamic-layout/scene'; +import { iconGridWorkload } from '../icon-grid/scene'; +import { offAxis3dWorkload } from '../off-axis-3d/scene'; +import { paintEffectsWorkload } from '../paint-effects/scene'; +import { paragraphStressWorkload } from '../paragraph-stress/scene'; +import { textLadderWorkload } from '../text-ladder/scene'; +import { zoomTextWorkload } from '../zoom-text/scene'; import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition, ComparisonWorkloadId } from './contracts'; -import { dynamicLayoutWorkload } from './dynamic-layout'; -import { iconGridWorkload } from './icon-grid'; -import { offAxis3dWorkload } from './off-axis-3d'; -import { paintEffectsWorkload } from './paint-effects'; -import { paragraphStressWorkload } from './paragraph-stress'; -import { textLadderWorkload } from './text-ladder'; -import { zoomTextWorkload } from './zoom-text'; /** * The one complete map from a comparison workload ID to its canonical example. diff --git a/apps/benchmarks/src/workloads/dynamic-layout/definition.ts b/apps/benchmarks/src/workloads/dynamic-layout/definition.ts new file mode 100644 index 00000000..e9ae3559 --- /dev/null +++ b/apps/benchmarks/src/workloads/dynamic-layout/definition.ts @@ -0,0 +1,29 @@ +import { + fontSizeControl, + layoutWidthControl, + noControls, + readyTechniques, + reflowAmountControl, + workloadDefaults, + type BenchmarkWorkloadDefinition, +} from '../shared/definition'; + +export const dynamicLayoutDefinition = { + controls: { + ...noControls, + amount: reflowAmountControl, + animation: true, + fontSize: fontSizeControl, + layoutBounds: true, + layoutWidth: layoutWidthControl, + }, + defaults: workloadDefaults(28, 32), + description: 'Tests whether animated containers reflow text smoothly and correctly.', + fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, + id: 'dynamic-layout', + interaction: { pan: true, zoom: false }, + label: 'Dynamic layout', + preload: 'comparison-module', + surface: 'comparison', + techniques: readyTechniques, +} as const satisfies BenchmarkWorkloadDefinition<'dynamic-layout'>; diff --git a/apps/benchmarks/src/workloads/dynamic-layout.ts b/apps/benchmarks/src/workloads/dynamic-layout/scene.ts similarity index 98% rename from apps/benchmarks/src/workloads/dynamic-layout.ts rename to apps/benchmarks/src/workloads/dynamic-layout/scene.ts index 12b2cb25..0cbbffec 100644 --- a/apps/benchmarks/src/workloads/dynamic-layout.ts +++ b/apps/benchmarks/src/workloads/dynamic-layout/scene.ts @@ -1,14 +1,14 @@ import { Text } from '@pmndrs/text'; import * as THREE from 'three/webgpu'; -import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from './shared/text-style'; +import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; +import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; -import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from './contracts'; import { committedTextLayout, type ComparisonWorkloadEntry, type WorkloadTextFactoryContext, -} from './factory-contracts'; +} from '../shared/scene-entry'; export const DYNAMIC_LAYOUT_TEXT = [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Left-aligned lines narrow and open while every word reshapes into its changing measure.', diff --git a/apps/benchmarks/src/workloads/factories.test.ts b/apps/benchmarks/src/workloads/factories.test.ts index ce35c1ad..a2a621e5 100644 --- a/apps/benchmarks/src/workloads/factories.test.ts +++ b/apps/benchmarks/src/workloads/factories.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { setDynamicLayoutBoundsVisibility } from './dynamic-layout'; -import { iconGridEntryAssignment } from './icon-grid'; +import { setDynamicLayoutBoundsVisibility } from './dynamic-layout/scene'; +import { iconGridEntryAssignment } from './icon-grid/scene'; describe('comparison workload factory invariants', () => { it('keeps an unassigned Icon Grid pool slot hidden instead of assigning icon zero', () => { diff --git a/apps/benchmarks/src/workloads/font-assets/font-assets.boundary.test.ts b/apps/benchmarks/src/workloads/font-assets/font-assets.boundary.test.ts index e0cd20e1..1a71ee33 100644 --- a/apps/benchmarks/src/workloads/font-assets/font-assets.boundary.test.ts +++ b/apps/benchmarks/src/workloads/font-assets/font-assets.boundary.test.ts @@ -28,11 +28,13 @@ describe('benchmark font-asset boundaries', () => { expect(sources[1]).toContain("import('@pmndrs/text/runtime-bake')"); }); - it('keeps renderer scenes on the asset adapter rather than the runtime-bake entrypoint', async () => { + it('keeps technique scenes on the asset adapter rather than the runtime-bake entrypoint', async () => { const renderers = await Promise.all( - ['../../renderer/bitmap-text.ts', '../../renderer/mtsdf-text.ts', '../../renderer/slug-text.ts'].map((name) => - readFile(new URL(name, fontAssetsRoot), 'utf8'), - ), + [ + '../../techniques/bitmap/persistent-scene.ts', + '../../techniques/mtsdf/persistent-scene.ts', + '../../techniques/slug/persistent-scene.ts', + ].map((name) => readFile(new URL(name, fontAssetsRoot), 'utf8')), ); for (const renderer of renderers) { @@ -41,11 +43,13 @@ describe('benchmark font-asset boundaries', () => { } }); - it('keeps loading and raster metadata ownership out of retained renderer exports', async () => { + it('keeps loading and raster metadata ownership out of retained technique-scene exports', async () => { const renderers = await Promise.all( - ['../../renderer/bitmap-text.ts', '../../renderer/mtsdf-text.ts', '../../renderer/slug-text.ts'].map((name) => - readFile(new URL(name, fontAssetsRoot), 'utf8'), - ), + [ + '../../techniques/bitmap/persistent-scene.ts', + '../../techniques/mtsdf/persistent-scene.ts', + '../../techniques/slug/persistent-scene.ts', + ].map((name) => readFile(new URL(name, fontAssetsRoot), 'utf8')), ); for (const renderer of renderers) { diff --git a/apps/benchmarks/src/workloads/icon-grid-instance.test.ts b/apps/benchmarks/src/workloads/icon-grid-instance.test.ts index 490f4d96..d434ea38 100644 --- a/apps/benchmarks/src/workloads/icon-grid-instance.test.ts +++ b/apps/benchmarks/src/workloads/icon-grid-instance.test.ts @@ -2,14 +2,14 @@ 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 type { ComparisonWorkloadConfiguration } from './comparison/contracts'; +import type { ComparisonWorkloadEntry } from './shared/scene-entry'; import { createIconGridWorkloadInstance, iconGridContent, type IconGridEntryPool, type IconGridVirtualWindow, -} from './icon-grid'; +} from './icon-grid/scene'; const viewport = { height: 360, width: 720 }; const configuration: ComparisonWorkloadConfiguration = { diff --git a/apps/benchmarks/src/workloads/icon-grid/definition.ts b/apps/benchmarks/src/workloads/icon-grid/definition.ts new file mode 100644 index 00000000..37cd8f84 --- /dev/null +++ b/apps/benchmarks/src/workloads/icon-grid/definition.ts @@ -0,0 +1,21 @@ +import { + iconSizeControl, + noControls, + readyTechniques, + workloadDefaults, + type BenchmarkWorkloadDefinition, +} from '../shared/definition'; +import { ICON_GRID_FONT_FIXTURE } from '../../benchmark/font-fixtures'; + +export const iconGridDefinition = { + controls: { ...noControls, animation: true, fontSize: iconSizeControl }, + defaults: workloadDefaults(56, 64), + description: 'Tests a labeled icon font across scale, movement, and raster techniques.', + fontPolicy: { iconFixture: ICON_GRID_FONT_FIXTURE, kind: 'icon-grid', labelDefaultFixture: 'inter' }, + id: 'icon-grid', + interaction: { pan: true, zoom: false }, + label: 'Icon grid', + preload: 'comparison-module', + surface: 'comparison', + techniques: readyTechniques, +} as const satisfies BenchmarkWorkloadDefinition<'icon-grid'>; diff --git a/apps/benchmarks/src/workloads/icon-grid.ts b/apps/benchmarks/src/workloads/icon-grid/scene.ts similarity index 99% rename from apps/benchmarks/src/workloads/icon-grid.ts rename to apps/benchmarks/src/workloads/icon-grid/scene.ts index 0832b3d1..6d49dbec 100644 --- a/apps/benchmarks/src/workloads/icon-grid.ts +++ b/apps/benchmarks/src/workloads/icon-grid/scene.ts @@ -1,10 +1,10 @@ import { Text, type AnyRasterInput, type RegisteredFont } from '@pmndrs/text'; 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 { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from './contracts'; -import { committedTextLayout, type ComparisonWorkloadEntry } from './factory-contracts'; +import fontAwesomeIcons from '../../../fixtures/fonts/font-awesome-free-6.7.2/icons.json'; +import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; +import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; +import { committedTextLayout, type ComparisonWorkloadEntry } from '../shared/scene-entry'; export const ICON_GRID_LABEL_SIZE = 11; const ICON_GRID_LABEL_WIDTH = 112; diff --git a/apps/benchmarks/src/workloads/index.ts b/apps/benchmarks/src/workloads/index.ts deleted file mode 100644 index 12d4c582..00000000 --- a/apps/benchmarks/src/workloads/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type { - ComparisonWorkloadConfiguration, - ComparisonWorkloadDefinition, - ComparisonWorkloadId, - IconGridView, -} from './contracts'; -export { DYNAMIC_LAYOUT_TEXT, dynamicLayoutWidths } from './dynamic-layout'; -export { OFF_AXIS_SPANS, OFF_AXIS_TEXT } from './off-axis-3d'; -export { paintWordHue } from './paint-effects'; -export { setTextLadderScenePosition, textLadderScenePosition, ladderCssSizes } from './text-ladder'; -export { - ZOOM_TEXT_BASE_CSS_PX, - ZOOM_TEXT_CORPUS, - ZOOM_TEXT_PHRASES, - shuffleZoomTextPhrases, - zoomTextAnimationState, - zoomTextMaximumScale, -} from './zoom-text'; diff --git a/apps/benchmarks/src/workloads/live-text-scene.ts b/apps/benchmarks/src/workloads/live-text-scene.ts index 52b6eca4..f8429d0d 100644 --- a/apps/benchmarks/src/workloads/live-text-scene.ts +++ b/apps/benchmarks/src/workloads/live-text-scene.ts @@ -1,26 +1,2 @@ -import type { FontFeature } from '@pmndrs/text'; - -import type { BenchmarkFontFixture } from '../benchmark/font-fixtures'; - -import type { LiveTextAnchor } from './shared/text-style'; - -/** - * Authored Text input for one retained live benchmark scene. - * - * Renderer adapters consume this data, but do not define it: this keeps the - * example a direct record of the public Text properties it intends to exercise. - */ -export interface LiveTextScene { - readonly anchor: LiveTextAnchor; - readonly animatePresentation: boolean; - readonly direction: 'ltr' | 'rtl'; - readonly expectedGlyphCount: number | undefined; - readonly features: readonly FontFeature[]; - readonly fontFixture: BenchmarkFontFixture; - readonly language: string; - readonly layoutWidthRatio: number; - readonly presentation: 'static' | 'timeline'; - readonly text: string; - readonly textAlign: 'start' | 'center'; - readonly timelineTick: number | undefined; -} +/** @deprecated Import from `workloads/shared/live-text-scene` inside the benchmark application. */ +export type { LiveTextScene } from './shared/live-text-scene'; diff --git a/apps/benchmarks/src/workloads/live-text-scenes.test.ts b/apps/benchmarks/src/workloads/live-text-scenes.test.ts index f80252d5..87effd49 100644 --- a/apps/benchmarks/src/workloads/live-text-scenes.test.ts +++ b/apps/benchmarks/src/workloads/live-text-scenes.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { advancedShapingFrame, initialAdvancedShapingState } from './advanced-shaping'; +import { advancedShapingFrame, initialAdvancedShapingState } from './advanced-shaping/scene'; import { liveTextSceneForWorkload } from './live-text-scenes'; describe('live Text scene registry', () => { diff --git a/apps/benchmarks/src/workloads/live-text-scenes.ts b/apps/benchmarks/src/workloads/live-text-scenes.ts index e83a5f80..0ca23d58 100644 --- a/apps/benchmarks/src/workloads/live-text-scenes.ts +++ b/apps/benchmarks/src/workloads/live-text-scenes.ts @@ -1,10 +1,9 @@ import type { BenchmarkFontFixture } from '../benchmark/font-fixtures'; -import type { AdvancedShapingFrame } from './advanced-shaping'; -import { advancedShapingLiveTextScene } from './advanced-shaping'; -import { benchmarkIpsumLiveTextScene } from './benchmark-ipsum'; +import { advancedShapingLiveTextScene, type AdvancedShapingFrame } from './advanced-shaping/scene'; +import { benchmarkIpsumLiveTextScene } from './benchmark-ipsum/scene'; import type { BenchmarkWorkloadId } from './catalog'; -import type { LiveTextScene } from './live-text-scene'; +import type { LiveTextScene } from './shared/live-text-scene'; export interface LiveTextSceneInput { readonly fontFixture: BenchmarkFontFixture; @@ -24,7 +23,7 @@ const LIVE_TEXT_SCENES: Partial< Readonly LiveTextScene }>> > = { 'advanced-shaping': { - create: ({ showcaseFrame }) => advancedShapingLiveTextScene(showcaseFrame), + create: ({ fontFixture, showcaseFrame }) => advancedShapingLiveTextScene(showcaseFrame, fontFixture), }, 'benchmark-ipsum': { create: ({ fontFixture, layoutWidthRatio }) => benchmarkIpsumLiveTextScene(fontFixture, layoutWidthRatio), diff --git a/apps/benchmarks/src/workloads/off-axis-3d/definition.ts b/apps/benchmarks/src/workloads/off-axis-3d/definition.ts new file mode 100644 index 00000000..30c6fccb --- /dev/null +++ b/apps/benchmarks/src/workloads/off-axis-3d/definition.ts @@ -0,0 +1,28 @@ +import { + fontSizeControl, + noControls, + offAxisLayoutWidthControl, + perspectiveAmountControl, + readyTechniques, + workloadDefaults, + type BenchmarkWorkloadDefinition, +} from '../shared/definition'; + +export const offAxis3dDefinition = { + controls: { + ...noControls, + amount: perspectiveAmountControl, + animation: true, + fontSize: fontSizeControl, + layoutWidth: offAxisLayoutWidthControl, + }, + defaults: workloadDefaults(96, 96, { layoutWidthPercent: 120, workloadAmount: 100 }), + description: 'Tests text quality and cost at steep, moving viewing angles.', + fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, + id: 'off-axis-3d', + interaction: { pan: true, zoom: true }, + label: 'Off-axis / 3D', + preload: 'comparison-module', + surface: 'comparison', + techniques: readyTechniques, +} as const satisfies BenchmarkWorkloadDefinition<'off-axis-3d'>; diff --git a/apps/benchmarks/src/workloads/off-axis-3d.ts b/apps/benchmarks/src/workloads/off-axis-3d/scene.ts similarity index 96% rename from apps/benchmarks/src/workloads/off-axis-3d.ts rename to apps/benchmarks/src/workloads/off-axis-3d/scene.ts index 39eca202..4beb83a4 100644 --- a/apps/benchmarks/src/workloads/off-axis-3d.ts +++ b/apps/benchmarks/src/workloads/off-axis-3d/scene.ts @@ -1,16 +1,16 @@ import { Text } from '@pmndrs/text'; import * as THREE from 'three/webgpu'; -import { createOklabColorCycle } from './shared/oklab-color-cycle'; -import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from './shared/text-style'; +import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; +import { createOklabColorCycle } from '../shared/oklab-color-cycle'; +import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; -import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from './contracts'; import { committedTextLayout, type ComparisonWorkloadEntry, type MutablePaintSpan, type WorkloadTextFactoryContext, -} from './factory-contracts'; +} from '../shared/scene-entry'; export const OFF_AXIS_HORIZONTAL_BIAS_RATIO = 0.075; export const OFF_AXIS_TEXT = diff --git a/apps/benchmarks/src/workloads/paint-effects/definition.ts b/apps/benchmarks/src/workloads/paint-effects/definition.ts new file mode 100644 index 00000000..b9173be5 --- /dev/null +++ b/apps/benchmarks/src/workloads/paint-effects/definition.ts @@ -0,0 +1,30 @@ +import { + fontSizeControl, + hueSpreadAmountControl, + layoutWidthControl, + noControls, + paintControls, + readyTechniques, + workloadDefaults, + type BenchmarkWorkloadDefinition, +} from '../shared/definition'; + +export const paintEffectsDefinition = { + controls: { + ...noControls, + amount: hueSpreadAmountControl, + animation: true, + fontSize: fontSizeControl, + layoutWidth: layoutWidthControl, + paint: paintControls, + }, + defaults: workloadDefaults(44, 52), + description: 'Tests the live cost and visual quality of animated text effects.', + fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, + id: 'paint-effects', + interaction: { pan: true, zoom: false }, + label: 'Paint & effects', + preload: 'comparison-module', + surface: 'comparison', + techniques: readyTechniques, +} as const satisfies BenchmarkWorkloadDefinition<'paint-effects'>; diff --git a/apps/benchmarks/src/workloads/paint-effects.ts b/apps/benchmarks/src/workloads/paint-effects/scene.ts similarity index 97% rename from apps/benchmarks/src/workloads/paint-effects.ts rename to apps/benchmarks/src/workloads/paint-effects/scene.ts index 5013fb0e..15ebe141 100644 --- a/apps/benchmarks/src/workloads/paint-effects.ts +++ b/apps/benchmarks/src/workloads/paint-effects/scene.ts @@ -1,13 +1,13 @@ import { Text, type TextSpan } from '@pmndrs/text'; -import { benchmarkContentWidth, LIVE_TEXT_LINE_HEIGHT } from './shared/text-style'; -import type { RasterTechnique } from '../benchmark/url-state'; -import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from './contracts'; +import type { RasterTechnique } from '../../benchmark/url-state'; +import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; +import { benchmarkContentWidth, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { committedTextLayout, type ComparisonWorkloadEntry, type WorkloadTextFactoryContext, -} from './factory-contracts'; +} from '../shared/scene-entry'; export interface MutablePaintSpan extends TextSpan { color: number; diff --git a/apps/benchmarks/src/workloads/paragraph-stress/definition.ts b/apps/benchmarks/src/workloads/paragraph-stress/definition.ts new file mode 100644 index 00000000..e5dd2b4d --- /dev/null +++ b/apps/benchmarks/src/workloads/paragraph-stress/definition.ts @@ -0,0 +1,28 @@ +import { + fontSizeControl, + layoutWidthControl, + noControls, + readyTechniques, + textVolumeAmountControl, + workloadDefaults, + type BenchmarkWorkloadDefinition, +} from '../shared/definition'; + +export const paragraphStressDefinition = { + controls: { + ...noControls, + amount: textVolumeAmountControl, + animation: true, + fontSize: fontSizeControl, + layoutWidth: layoutWidthControl, + }, + defaults: workloadDefaults(20, 24, { workloadAmount: 100 }), + description: 'Tests rendering cost as paragraphs, glyphs, and atlas pressure grow.', + fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, + id: 'paragraph-stress', + interaction: { pan: true, zoom: false }, + label: 'Paragraph stress', + preload: 'comparison-module', + surface: 'comparison', + techniques: readyTechniques, +} as const satisfies BenchmarkWorkloadDefinition<'paragraph-stress'>; diff --git a/apps/benchmarks/src/workloads/paragraph-stress.ts b/apps/benchmarks/src/workloads/paragraph-stress/scene.ts similarity index 91% rename from apps/benchmarks/src/workloads/paragraph-stress.ts rename to apps/benchmarks/src/workloads/paragraph-stress/scene.ts index fbc52afa..9943dea6 100644 --- a/apps/benchmarks/src/workloads/paragraph-stress.ts +++ b/apps/benchmarks/src/workloads/paragraph-stress/scene.ts @@ -1,15 +1,15 @@ import { Text } from '@pmndrs/text'; import type * as THREE from 'three/webgpu'; -import { benchmarkIpsumText } from '../benchmark/font-fixtures'; -import { paragraphStressScrollProgress } from '../benchmark/paragraph-stress-motion'; -import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from './shared/text-style'; -import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from './contracts'; +import { benchmarkIpsumText } from '../../benchmark/font-fixtures'; +import { paragraphStressScrollProgress } from '../../benchmark/paragraph-stress-motion'; +import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; +import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { committedTextLayout, type ComparisonWorkloadEntry, type WorkloadTextFactoryContext, -} from './factory-contracts'; +} from '../shared/scene-entry'; export const paragraphStressWorkload = { animate(entries, configuration, elapsedMs, _viewportWidth, viewportHeight, scene) { diff --git a/apps/benchmarks/src/workloads/registry.test.ts b/apps/benchmarks/src/workloads/registry.test.ts index bc270120..ae780cdc 100644 --- a/apps/benchmarks/src/workloads/registry.test.ts +++ b/apps/benchmarks/src/workloads/registry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { COMPARISON_WORKLOAD_IDS, COMPARISON_WORKLOADS, comparisonWorkloadDefinition } from './registry'; +import { COMPARISON_WORKLOAD_IDS, COMPARISON_WORKLOADS, comparisonWorkloadDefinition } from './comparison/registry'; describe('comparison workload registry', () => { it('owns every retained comparison example exactly once', () => { diff --git a/apps/benchmarks/src/workloads/shared/definition.ts b/apps/benchmarks/src/workloads/shared/definition.ts new file mode 100644 index 00000000..36079f8d --- /dev/null +++ b/apps/benchmarks/src/workloads/shared/definition.ts @@ -0,0 +1,185 @@ +import type { + BenchmarkFontFixture, + ICON_GRID_FONT_FIXTURE, + SelectableFontFixture, +} from '../../benchmark/font-fixtures'; +import type { HarnessLayout, RasterTechnique } from '../../benchmark/url-state'; + +export type LiveWorkloadSurfaceKind = 'advanced-shaping' | 'comparison' | 'single-paragraph'; +export type LiveWorkloadPreloadKind = 'comparison-module' | 'technique-module'; + +export interface WorkloadRange { + readonly label: string; + readonly maximum: number; + readonly minimum: number; + readonly scale: 'linear' | 'logarithmic'; + readonly step: number; +} + +export interface WorkloadPaintControls { + readonly opacity: WorkloadRange; + readonly shadowTechniques: readonly RasterTechnique[]; + readonly strokeTechniques: readonly RasterTechnique[]; +} + +export interface WorkloadControls { + readonly animation: boolean; + readonly amount: WorkloadRange | undefined; + readonly fontSize: WorkloadRange | undefined; + readonly layoutBounds: boolean; + readonly layoutWidth: WorkloadRange | undefined; + readonly paint: WorkloadPaintControls | undefined; +} + +export interface WorkloadInteraction { + readonly pan: boolean; + readonly zoom: boolean; +} + +export interface WorkloadRuntimeDefaults { + readonly animationEnabled: boolean; + readonly animationSpeed: number; + readonly fontSize: number; + readonly layoutWidthPercent: number; + readonly paintOpacityPercent: number; + readonly paintShadowEnabled: boolean; + readonly paintStrokePercent: number; + readonly showGrid: boolean; + readonly showLayoutBounds: boolean; + readonly workloadAmount: number; +} + +export type WorkloadFontPolicy = + | { readonly defaultFixture: BenchmarkFontFixture; readonly kind: 'advanced-case' } + | { readonly defaultFixture: SelectableFontFixture; readonly kind: 'fixed' } + | { + readonly iconFixture: typeof ICON_GRID_FONT_FIXTURE; + readonly kind: 'icon-grid'; + readonly labelDefaultFixture: SelectableFontFixture; + } + | { readonly defaultFixture: SelectableFontFixture; readonly kind: 'selectable' }; + +/** Route policy colocated with the authored scene that it presents. */ +export interface BenchmarkWorkloadDefinition { + readonly controls: WorkloadControls; + readonly defaults: Readonly>; + readonly description: string; + readonly fontPolicy: WorkloadFontPolicy; + readonly id: Id; + readonly interaction: WorkloadInteraction; + readonly label: string; + readonly preload: LiveWorkloadPreloadKind; + readonly surface: LiveWorkloadSurfaceKind; + readonly techniques: Readonly>; +} + +export const readyTechniques = { + bitmap: { kind: 'ready' }, + mtsdf: { kind: 'ready' }, + slug: { kind: 'ready' }, +} as const; + +const standardRuntimeDefaults = { + animationEnabled: true, + animationSpeed: 50, + layoutWidthPercent: 82, + paintOpacityPercent: 100, + paintShadowEnabled: false, + paintStrokePercent: 0, + showGrid: true, + showLayoutBounds: true, + workloadAmount: 50, +} as const; + +export const fontSizeControl = { + label: 'Rendered size', + maximum: 96, + minimum: 8, + scale: 'linear', + step: 1, +} as const satisfies WorkloadRange; + +export const iconSizeControl = { + label: 'Icon size', + maximum: 1_024, + minimum: 8, + scale: 'logarithmic', + step: 1, +} as const satisfies WorkloadRange; + +export const layoutWidthControl = { + label: 'Layout width', + maximum: 100, + minimum: 40, + scale: 'linear', + step: 2, +} as const satisfies WorkloadRange; + +export const offAxisLayoutWidthControl = { + ...layoutWidthControl, + maximum: 200, +} as const satisfies WorkloadRange; + +export const perspectiveAmountControl = { + label: 'Perspective intensity', + maximum: 100, + minimum: 0, + scale: 'linear', + step: 1, +} as const satisfies WorkloadRange; + +export const reflowAmountControl = { + label: 'Reflow amplitude', + maximum: 100, + minimum: 0, + scale: 'linear', + step: 1, +} as const satisfies WorkloadRange; + +export const textVolumeAmountControl = { + label: 'Text volume', + maximum: 100, + minimum: 0, + scale: 'linear', + step: 1, +} as const satisfies WorkloadRange; + +export const hueSpreadAmountControl = { + label: 'Hue spread', + maximum: 100, + minimum: 0, + scale: 'linear', + step: 1, +} as const satisfies WorkloadRange; + +export const paintControls = { + opacity: { + label: 'Opacity', + maximum: 100, + minimum: 0, + scale: 'linear', + step: 1, + }, + shadowTechniques: ['mtsdf'], + strokeTechniques: ['mtsdf'], +} as const satisfies WorkloadPaintControls; + +export const noControls = { + animation: false, + amount: undefined, + fontSize: undefined, + layoutBounds: false, + layoutWidth: undefined, + paint: undefined, +} as const satisfies WorkloadControls; + +export function workloadDefaults( + mainFontSize: number, + presentationFontSize: number, + options: Partial = {}, +): Readonly> { + return { + main: { ...standardRuntimeDefaults, fontSize: mainFontSize, ...options }, + presentation: { ...standardRuntimeDefaults, fontSize: presentationFontSize, ...options }, + }; +} diff --git a/apps/benchmarks/src/workloads/shared/live-text-scene.ts b/apps/benchmarks/src/workloads/shared/live-text-scene.ts new file mode 100644 index 00000000..80800aa6 --- /dev/null +++ b/apps/benchmarks/src/workloads/shared/live-text-scene.ts @@ -0,0 +1,26 @@ +import type { FontFeature } from '@pmndrs/text'; + +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; + +import type { LiveTextAnchor } from './text-style'; + +/** + * Authored Text input for one retained live benchmark scene. + * + * Renderer adapters consume this data, but do not define it: this keeps the + * example a direct record of the public Text properties it intends to exercise. + */ +export interface LiveTextScene { + readonly anchor: LiveTextAnchor; + readonly animatePresentation: boolean; + readonly direction: 'ltr' | 'rtl'; + readonly expectedGlyphCount: number | undefined; + readonly features: readonly FontFeature[]; + readonly fontFixture: BenchmarkFontFixture; + readonly language: string; + readonly layoutWidthRatio: number; + readonly presentation: 'static' | 'timeline'; + readonly text: string; + readonly textAlign: 'start' | 'center'; + readonly timelineTick: number | undefined; +} diff --git a/apps/benchmarks/src/workloads/factory-contracts.ts b/apps/benchmarks/src/workloads/shared/scene-entry.ts similarity index 100% rename from apps/benchmarks/src/workloads/factory-contracts.ts rename to apps/benchmarks/src/workloads/shared/scene-entry.ts diff --git a/apps/benchmarks/src/workloads/text-ladder/definition.ts b/apps/benchmarks/src/workloads/text-ladder/definition.ts new file mode 100644 index 00000000..85ccb1e9 --- /dev/null +++ b/apps/benchmarks/src/workloads/text-ladder/definition.ts @@ -0,0 +1,14 @@ +import { noControls, readyTechniques, workloadDefaults, type BenchmarkWorkloadDefinition } from '../shared/definition'; + +export const textLadderDefinition = { + controls: { ...noControls, animation: true }, + defaults: workloadDefaults(20, 24), + description: 'Tests how text quality holds up from 8 to 1024 pixels.', + fontPolicy: { kind: 'selectable', defaultFixture: 'inter' }, + id: 'text-ladder', + interaction: { pan: true, zoom: false }, + label: 'Text ladder', + preload: 'comparison-module', + surface: 'comparison', + techniques: readyTechniques, +} as const satisfies BenchmarkWorkloadDefinition<'text-ladder'>; diff --git a/apps/benchmarks/src/workloads/text-ladder.ts b/apps/benchmarks/src/workloads/text-ladder/scene.ts similarity index 96% rename from apps/benchmarks/src/workloads/text-ladder.ts rename to apps/benchmarks/src/workloads/text-ladder/scene.ts index 50891c5b..e462e5ae 100644 --- a/apps/benchmarks/src/workloads/text-ladder.ts +++ b/apps/benchmarks/src/workloads/text-ladder/scene.ts @@ -1,14 +1,14 @@ import { Text } from '@pmndrs/text'; import type * as THREE from 'three/webgpu'; -import type { RasterConformanceSpecimen } from '../benchmark/font-fixtures'; -import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from './shared/text-style'; -import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from './contracts'; +import type { RasterConformanceSpecimen } from '../../benchmark/font-fixtures'; +import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; +import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { committedTextLayout, type ComparisonWorkloadEntry, type WorkloadTextFactoryContext, -} from './factory-contracts'; +} from '../shared/scene-entry'; export const LADDER_CSS_SIZES = [ 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 64, 80, 96, 128, 160, 192, 256, 512, 1024, diff --git a/apps/benchmarks/src/workloads/workload-boundary.test.ts b/apps/benchmarks/src/workloads/workload-boundary.test.ts index 052cf942..f6760114 100644 --- a/apps/benchmarks/src/workloads/workload-boundary.test.ts +++ b/apps/benchmarks/src/workloads/workload-boundary.test.ts @@ -14,6 +14,17 @@ const allowedRendererDependencies = new Set([ 'text-update-telemetry', 'webgpu-renderer', ]); +const authoredWorkloadIds = [ + 'advanced-shaping', + 'benchmark-ipsum', + 'text-ladder', + 'zoom-text', + 'icon-grid', + 'off-axis-3d', + 'dynamic-layout', + 'paragraph-stress', + 'paint-effects', +] as const; async function sourceFiles(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); @@ -33,11 +44,11 @@ function rendererDependencies(source: string): readonly string[] { describe('workload source boundaries', () => { it('recognizes direct, nested, and dynamic renderer imports', () => { - const renderer = 'renderer/bitmap-text'; - expect(rendererDependencies(`import { text } from '../${renderer}';`)).toEqual(['bitmap-text']); - expect(rendererDependencies(`import { text } from '../../${renderer}';`)).toEqual(['bitmap-text']); - expect(rendererDependencies(`await import('../${renderer}');`)).toEqual(['bitmap-text']); - expect(rendererDependencies(`import(/* eager */ '../../${renderer}');`)).toEqual(['bitmap-text']); + const renderer = 'renderer/persistent-render-host'; + expect(rendererDependencies(`import { text } from '../${renderer}';`)).toEqual(['persistent-render-host']); + expect(rendererDependencies(`import { text } from '../../${renderer}';`)).toEqual(['persistent-render-host']); + expect(rendererDependencies(`await import('../${renderer}');`)).toEqual(['persistent-render-host']); + expect(rendererDependencies(`import(/* eager */ '../../${renderer}');`)).toEqual(['persistent-render-host']); }); it('depends only on generic renderer host and scene primitives', async () => { @@ -57,12 +68,25 @@ describe('workload source boundaries', () => { expect(offenders.filter((file): file is string => file !== undefined)).toEqual([]); }); - it('keeps the retained comparison scene local and the standalone adapter target-owned', async () => { - const source = await readFile(new URL('./comparison/scene.ts', import.meta.url), 'utf8'); + it('colocates every authored route definition with its scene and keeps its Text imports public', async () => { + const directories = await Promise.all( + authoredWorkloadIds.map(async (id) => { + const directory = `${workloadDirectory}/${id}`; + const entries = await readdir(directory); + const [definition, scene] = await Promise.all([ + readFile(`${directory}/definition.ts`, 'utf8'), + readFile(`${directory}/scene.ts`, 'utf8'), + ]); + return { definition, entries, id, scene }; + }), + ); - expect(source).not.toContain('createComparisonWorkloadPreview'); - expect(source).not.toContain('createConfiguredRenderer'); - expect(source).not.toContain('setAnimationLoop'); - expect(source).not.toContain('createGpuFrameTimer'); + for (const { definition, entries, id, scene } of directories) { + expect(entries).toEqual(expect.arrayContaining(['definition.ts', 'scene.ts'])); + expect(definition).toContain(`id: '${id}'`); + expect(scene).not.toMatch(/@pmndrs\/text(?:-font-baker|\/internal)/); + expect(scene).not.toContain('.wasm?url'); + expect(rendererDependencies(scene)).toEqual([]); + } }); }); diff --git a/apps/benchmarks/src/workloads/zoom-text/definition.ts b/apps/benchmarks/src/workloads/zoom-text/definition.ts new file mode 100644 index 00000000..be2c0d24 --- /dev/null +++ b/apps/benchmarks/src/workloads/zoom-text/definition.ts @@ -0,0 +1,14 @@ +import { noControls, readyTechniques, workloadDefaults, type BenchmarkWorkloadDefinition } from '../shared/definition'; + +export const zoomTextDefinition = { + controls: { ...noControls, animation: true }, + defaults: workloadDefaults(20, 24), + description: 'Cycles through translations of “Shape” while scaling from 8 pt to the largest size that fits.', + fontPolicy: { kind: 'fixed', defaultFixture: 'inter' }, + id: 'zoom-text', + interaction: { pan: false, zoom: false }, + label: 'Zoom text', + preload: 'comparison-module', + surface: 'comparison', + techniques: readyTechniques, +} as const satisfies BenchmarkWorkloadDefinition<'zoom-text'>; diff --git a/apps/benchmarks/src/workloads/zoom-text.ts b/apps/benchmarks/src/workloads/zoom-text/scene.ts similarity index 98% rename from apps/benchmarks/src/workloads/zoom-text.ts rename to apps/benchmarks/src/workloads/zoom-text/scene.ts index b2386db9..af98d34b 100644 --- a/apps/benchmarks/src/workloads/zoom-text.ts +++ b/apps/benchmarks/src/workloads/zoom-text/scene.ts @@ -1,13 +1,13 @@ import { Text } from '@pmndrs/text'; import * as THREE from 'three/webgpu'; -import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from './shared/text-style'; -import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from './contracts'; +import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; +import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { committedTextLayout, type ComparisonWorkloadEntry, type WorkloadTextFactoryContext, -} from './factory-contracts'; +} from '../shared/scene-entry'; export const ZOOM_TEXT_BASE_CSS_PX = 8 * (96 / 72); export const ZOOM_TEXT_INSET_CSS_PX = 24; diff --git a/apps/benchmarks/vitest.config.ts b/apps/benchmarks/vitest.config.ts index b4f2ad12..a657c224 100644 --- a/apps/benchmarks/vitest.config.ts +++ b/apps/benchmarks/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, test: { environment: 'node', + globalSetup: ['./vitest.global-setup.ts'], include: ['src/**/*.test.ts'], }, }); diff --git a/apps/benchmarks/vitest.global-setup.ts b/apps/benchmarks/vitest.global-setup.ts new file mode 100644 index 00000000..2eca1b93 --- /dev/null +++ b/apps/benchmarks/vitest.global-setup.ts @@ -0,0 +1,20 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const executeFile = promisify(execFile); + +/** Builds the replay inspector before parallel test files can contend with its first compilation. */ +export default async function prepareBenchmarkUnitTests(): Promise { + await executeFile('cargo', [ + 'build', + '--manifest-path', + fileURLToPath(new URL('../../packages/font-baker/rust/Cargo.toml', import.meta.url)), + '--bin', + 'inspect-font-fixture', + '--features', + 'oracle', + '--locked', + '--quiet', + ]); +} diff --git a/apps/benchmarks/vitexec/advanced-shaping-performance.probe.ts b/apps/benchmarks/vitexec/advanced-shaping-performance.probe.ts index 468c9b09..b6d9d738 100644 --- a/apps/benchmarks/vitexec/advanced-shaping-performance.probe.ts +++ b/apps/benchmarks/vitexec/advanced-shaping-performance.probe.ts @@ -1,4 +1,4 @@ -const advancedShapingPath = '/src/workloads/advanced-shaping.ts'; +const advancedShapingPath = '/src/workloads/advanced-shaping/scene.ts'; const environmentPath = '/src/benchmark/environment.ts'; const STEADY_STATE_REPORT_COUNT = 12; const [{ ADVANCED_SHAPING_CASES }, { environmentResource }] = await Promise.all([ @@ -35,11 +35,7 @@ for (const definition of ADVANCED_SHAPING_CASES) { } console.log('advanced-shaping-performance-settled', definition.id); const previousFrameCount = numericAttribute(viewport, 'data-frame-count'); - await Promise.all([ - waitForGreaterNumericAttribute(viewport, 'data-frame-count', previousFrameCount), - waitForAtLeastNumericAttribute(viewport, 'data-gpu-history-length', STEADY_STATE_REPORT_COUNT), - waitForAtLeastNumericAttribute(viewport, 'data-fps-history-length', STEADY_STATE_REPORT_COUNT), - ]); + await waitForTelemetryReports(viewport, previousFrameCount, STEADY_STATE_REPORT_COUNT); console.log('advanced-shaping-performance-sampled', definition.id); cases.push({ id: definition.id, @@ -154,14 +150,10 @@ function waitForGreaterNumericAttribute(element: HTMLElement, name: string, prev return observeUntil(element, find, { attributes: true, attributeFilter: [name] }); } -function waitForAtLeastNumericAttribute(element: HTMLElement, name: string, minimum: number): Promise { - const find = (): number | undefined => { - const value = numericAttribute(element, name); - return value >= minimum ? value : undefined; - }; - const current = find(); - if (current !== undefined) return Promise.resolve(current); - return observeUntil(element, find, { attributes: true, attributeFilter: [name] }); +async function waitForTelemetryReports(element: HTMLElement, frameCount: number, count: number): Promise { + for (let index = 0; index < count; index += 1) { + frameCount = await waitForGreaterNumericAttribute(element, 'data-frame-count', frameCount); + } } function numericAttribute(element: HTMLElement, name: string): number { diff --git a/apps/benchmarks/vitexec/core-text-frame.probe.ts b/apps/benchmarks/vitexec/core-text-frame.probe.ts index 86f59374..b11e90d7 100644 --- a/apps/benchmarks/vitexec/core-text-frame.probe.ts +++ b/apps/benchmarks/vitexec/core-text-frame.probe.ts @@ -2,7 +2,7 @@ import { FontRegistry, Text } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/raster/bitmap'; import * as THREE from 'three/webgpu'; -const benchmarkIpsumPath = '/src/workloads/benchmark-ipsum.ts'; +const benchmarkIpsumPath = '/src/workloads/benchmark-ipsum/scene.ts'; const compactRgba8ReadbackPath = '/src/benchmark/low-level/raster/rgba-readback.ts'; const rendererPath = '/src/renderer/webgpu-renderer.ts'; const [{ BENCHMARK_IPSUM_CONFORMANCE_TEXT }, { compactRgba8Readback }, { createConfiguredRenderer }] = diff --git a/apps/benchmarks/vitexec/harness.probe.ts b/apps/benchmarks/vitexec/harness.probe.ts index 9349e4cc..5fbbcfc0 100644 --- a/apps/benchmarks/vitexec/harness.probe.ts +++ b/apps/benchmarks/vitexec/harness.probe.ts @@ -247,7 +247,7 @@ await waitForElementState('[data-testid="comparison-live-viewport"]', { 'data-technique': 'bitmap', }); -const advancedShapingPath = '/src/workloads/advanced-shaping.ts'; +const advancedShapingPath = '/src/workloads/advanced-shaping/scene.ts'; const { ADVANCED_SHAPING_CASES } = await import(/* @vite-ignore */ advancedShapingPath); const advancedShapingButton = [...document.querySelectorAll('button')].find( (candidate) => candidate.textContent?.includes('Advanced shaping') === true && !candidate.disabled, diff --git a/apps/benchmarks/vitexec/slug-adaptive32-performance.probe.ts b/apps/benchmarks/vitexec/slug-adaptive32-performance.probe.ts index 8a5f661d..57cc429e 100644 --- a/apps/benchmarks/vitexec/slug-adaptive32-performance.probe.ts +++ b/apps/benchmarks/vitexec/slug-adaptive32-performance.probe.ts @@ -1,14 +1,14 @@ import type { ComparisonWorkloadPreview, ComparisonWorkloadStats, -} from '../src/benchmark/targets/measurement/comparison-preview'; +} from '../src/benchmark/probes/comparison-workload-preview'; export {}; type SlugStats = Extract; type Variant = 'fixed16' | 'adaptive32'; -const comparisonWorkloadPath = '/src/benchmark/targets/measurement/comparison-preview.ts'; +const comparisonWorkloadPath = '/src/benchmark/probes/comparison-workload-preview.ts'; const environmentPath = '/src/benchmark/environment.ts'; const fontFixturesPath = '/src/benchmark/font-fixtures.ts'; const productResultPath = '/src/benchmark/product-result.ts'; diff --git a/apps/benchmarks/vitexec/slug-fixed32-performance.probe.ts b/apps/benchmarks/vitexec/slug-fixed32-performance.probe.ts index 4dc9cbab..a51621f6 100644 --- a/apps/benchmarks/vitexec/slug-fixed32-performance.probe.ts +++ b/apps/benchmarks/vitexec/slug-fixed32-performance.probe.ts @@ -1,14 +1,14 @@ import type { ComparisonWorkloadPreview, ComparisonWorkloadStats, -} from '../src/benchmark/targets/measurement/comparison-preview'; +} from '../src/benchmark/probes/comparison-workload-preview'; export {}; type SlugStats = Extract; type Variant = 'fixed16' | 'fixed32'; -const comparisonWorkloadPath = '/src/benchmark/targets/measurement/comparison-preview.ts'; +const comparisonWorkloadPath = '/src/benchmark/probes/comparison-workload-preview.ts'; const environmentPath = '/src/benchmark/environment.ts'; const fontFixturesPath = '/src/benchmark/font-fixtures.ts'; const productResultPath = '/src/benchmark/product-result.ts'; diff --git a/apps/benchmarks/vitexec/slug-performance-matrix.probe.ts b/apps/benchmarks/vitexec/slug-performance-matrix.probe.ts index c6856faa..4b1aa936 100644 --- a/apps/benchmarks/vitexec/slug-performance-matrix.probe.ts +++ b/apps/benchmarks/vitexec/slug-performance-matrix.probe.ts @@ -1,13 +1,13 @@ import type { ComparisonWorkloadPreview, ComparisonWorkloadStats, -} from '../src/benchmark/targets/measurement/comparison-preview'; +} from '../src/benchmark/probes/comparison-workload-preview'; export {}; type SlugComparisonWorkloadStats = Extract; -const comparisonWorkloadPath = '/src/benchmark/targets/measurement/comparison-preview.ts'; +const comparisonWorkloadPath = '/src/benchmark/probes/comparison-workload-preview.ts'; const environmentPath = '/src/benchmark/environment.ts'; const fontFixturesPath = '/src/benchmark/font-fixtures.ts'; const productResultPath = '/src/benchmark/product-result.ts'; diff --git a/docs/index.md b/docs/index.md index 0468c26f..a94fb872 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,8 @@ okf_version: '0.2' - [Project README](../README.md) — product overview, API preview, implementation order, and local setup. - [Project brief](planning/project-brief.md) — product outcome, scope, non-goals, and success criteria. - [Canonical roadmap](roadmap/roadmap.md) — implementation sequence, issue-sized milestones, dependencies, and exit gates. -- [Runtime and bake API V0](planning/api-shapes.md) — proposed public API and package boundaries. +- [Runtime and bake API V0](planning/api-shapes.md) — accepted V1 public API, package boundaries, and explicitly deferred additions. +- [Raster and baker plugin guide](planning/raster-baker-plugin.md) — build an external technique through the public runtime, baker, artifact, discovery, and lifecycle contracts. ## Architecture and data contracts diff --git a/docs/log.md b/docs/log.md index de1eb6da..e1599b3f 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,8 @@ ## 2026-08-03 +- **Milestone 10 release review closure** — Replaced stale “planned” renderer guidance with evidence-backed V1 Bitmap, MTSDF, and Slug roles while labeling color, expanded-effect, and mixed-raster work as additive rather than shipped. Added a from-scratch external raster and baker plugin how-to covering identity, descriptor normalization, companion artifacts, Node and runtime bakers, static discovery, renderer-neutral `stageBatch` transactions, Three.js attachment, cold preparation, and lifecycle proof. The public API reference now distinguishes accepted V1 surfaces from explicitly deferred proposals. The nine-workload consumer audit found no missing common API: bounded capacity stays raster-owned, matrix traversal remains the one warm publication path, and diagnostics stay out of production graphs. Complete deterministic and live browser gates, the corrected 59.84–60.17 FPS Advanced Shaping observation, exact package-size checks, zero-warning OKF validation, GitHub-verified signed history, reviewed PR descriptions, and green stacked CI close Milestone 10. +- **Benchmark public-API and ownership audit** — Traced all nine live workloads through their public `Text`, font loader/registry, raster, and runtime-bake entry points and found no package-internal import or missing common API. Each example now owns a `workloads//{definition,scene}.ts` directory; consumers import the exact file they need, the root catalog only aggregates definitions, and shared/comparison-only contracts have explicit homes. Direct Wasm remains isolated to ABI conformance. Moved benchmark-specific Bitmap, MTSDF, and Slug persistent scenes plus their metadata into explicit `techniques` subtrees; generic renderer infrastructure no longer depends on live technique or target code. The 1,300-line retained comparison host moved from `workloads` to `surfaces/benchmark/scenes`, leaving workload modules responsible only for authored construction, layout, animation, and retained configuration. The root `app.tsx` is now a 37-line route entry; one shared `HarnessRoute` identity delegates URL and transition state to `controllers`, renderer-aware composition to `surfaces/harness`, and application chrome to `components`. Boundary tests reject separate Main/Presentation component types and preserve one runtime-world and persistent-renderer generation. The API audit explicitly rejected public buffer-slack controls, a second `Text.flush()` publication path, and shipped update diagnostics because retained capacity, Three.js lifecycle publication, and benchmark-local telemetry already satisfy the observed consumers. Reclassified the retained raster comparison as a conformance surface scene and the isolated comparison preview as probe infrastructure rather than executable targets. The live lane exposed and fixed two real state/lifecycle faults: Advanced Shaping discarded an explicitly selected compatible font, and the retained comparison scene measured layouts before a font replacement generation became ready. The performance probe also mislabeled a pre-populated frame-history length as twelve steady reports; it now observes twelve distinct 250 ms telemetry publications per case. The isolated corrected run held every Advanced Shaping case at 59.84–60.17 FPS, with CJK at 60.02 FPS, 0.77 ms CPU p95, and 3.82 ms GPU p95. Vitest now builds the large-font replay inspector once in global setup, before parallel test-file contention, instead of allowing cold `cargo run` compilation to consume an individual test's deadline. All 321 unit tests pass. All nine workloads then completed sequentially across Bitmap, MTSDF, and Slug; the timed demo observed its complete schedule with one renderer, returned to Off-axis / 3D, and measured Icon Grid at 60.25 FPS. React Doctor 0.7.2 reported no findings; its remote score endpoint was unavailable for the final route-only pass. - **Final benchmark boundary locality** — Moved the deterministic TSL baseline and its tests from `renderer` into the conformance target tree, with the lazy registry and core-text browser probe resolving their true target and low-level readback owners. Moved the latest-value async queue beside the three React viewport controllers that exclusively consume it. Focused boundary/queue/TSL tests, strict type checking, the complete 317-test unit lane, and the real WebGPU/WebGL live browser probe passed. - **Workload-owned retained comparison scene** — Moved the remaining 1,573-line multi-technique workload implementation and its 661-line focused test beside the authored workload definitions under `workloads/comparison`. Removed its standalone renderer, RAF, GPU-timer, and telemetry branch. Three Slug performance probes now use an 80-line measurement-owned adapter that creates one `PersistentRenderHost` and activates the same retained scene, completing all 40 fixed-32 browser runs across WebGPU/WebGL2, Inter/CJK, and both candidates. The complete 317-test gate passed; all 42 Presentation cells stayed visible with one renderer, and both timed demos returned to Off-axis / 3D at 59.90/60.02 Icon Grid FPS. - **Canonical benchmark asset and raster metadata boundaries** — Removed renderer-owned font-loader, preload, baked-artifact, and raster-configuration compatibility facades. Live scenes and finite product/conformance targets now call the discriminated `workloads/font-assets` API directly, while Bitmap atlas, MTSDF extension, and Slug allocation inspection live under `benchmark/low-level/raster`. The complete 316-test gate and production build passed; all 19 isolated conformance/product scenarios remained deterministic, all 42 sequential Presentation cells rendered visible pixels with one renderer, and the retained comparison plus exclusive finite-job recovery probe passed on WebGPU and WebGL2. Live renderer chunks fell again to 9.26/3.38 kB minified/gzip for Bitmap, 7.77/2.85 for MTSDF, and 7.28/2.75 for Slug. diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 5d6b81cd..d0a5feb1 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:6c6d99db4edf86b72b37772cf56b37edee4c486f2af7275e3a1376c114c8b892' +source_digest: 'sha256:c21579722a9569b19c6a328add02fcdc3154fc2544ac7d44392e86a041a523f6' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -18,13 +18,13 @@ sources: resource: ../planning/benchmark-plan.md title: Benchmark plan - id: benchmark-ipsum - resource: ../../apps/benchmarks/src/workloads/benchmark-ipsum.ts + resource: ../../apps/benchmarks/src/workloads/benchmark-ipsum/scene.ts title: Canonical benchmark ipsum corpus - id: advanced-shaping-workload - resource: ../../apps/benchmarks/src/workloads/advanced-shaping.ts + resource: ../../apps/benchmarks/src/workloads/advanced-shaping/scene.ts title: Authored Advanced Shaping workload timeline - id: live-text-scene - resource: ../../apps/benchmarks/src/workloads/live-text-scene.ts + resource: ../../apps/benchmarks/src/workloads/shared/live-text-scene.ts title: Public-API-facing live Text scene contract - id: live-text-scene-registry resource: ../../apps/benchmarks/src/workloads/live-text-scenes.ts @@ -60,8 +60,8 @@ sources: resource: ../../apps/benchmarks/fixtures/results/icon-grid-retained-evidence-chromium149.json title: Retained complete icon-grid traversal evidence - id: raster-technique-compare - resource: ../../apps/benchmarks/src/benchmark/targets/conformance/raster/comparison-scene.ts - title: Target-owned realtime MSDF and Slug GPU comparison + resource: ../../apps/benchmarks/src/surfaces/conformance/scenes/raster-technique-comparison.ts + title: Surface-owned realtime MSDF and Slug GPU comparison - id: low-level-raster-reference resource: ../../apps/benchmarks/src/benchmark/low-level/raster/source-outline-reference.ts title: Shared low-level source-outline oracle @@ -90,14 +90,23 @@ sources: resource: ../../apps/benchmarks/src/benchmark/low-level/raster/rgba-readback.ts title: Renderer-neutral RGBA8 readback normalization - id: bitmap-atlas-metadata - resource: ../../apps/benchmarks/src/benchmark/low-level/raster/bitmap-atlas.ts - title: Renderer-neutral Bitmap atlas metadata inspection + resource: ../../apps/benchmarks/src/techniques/bitmap/metadata.ts + title: Bitmap technique atlas metadata inspection - id: mtsdf-raster-configuration - resource: ../../apps/benchmarks/src/benchmark/low-level/raster/mtsdf-configuration.ts - title: Renderer-neutral MTSDF configuration inspection + resource: ../../apps/benchmarks/src/techniques/mtsdf/metadata.ts + title: MTSDF technique configuration inspection - id: slug-raster-configuration - resource: ../../apps/benchmarks/src/benchmark/low-level/raster/slug-configuration.ts - title: Renderer-neutral Slug configuration inspection + resource: ../../apps/benchmarks/src/techniques/slug/metadata.ts + title: Slug technique allocation inspection + - id: bitmap-persistent-scene + resource: ../../apps/benchmarks/src/techniques/bitmap/persistent-scene.ts + title: Bitmap live Text technique adapter + - id: mtsdf-persistent-scene + resource: ../../apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts + title: MTSDF live Text technique adapter + - id: slug-persistent-scene + resource: ../../apps/benchmarks/src/techniques/slug/persistent-scene.ts + title: Slug live Text technique adapter - id: renderer-lifecycle resource: ../../apps/benchmarks/src/renderer/webgpu-renderer.ts title: Shared renderer creation and disposal lifecycle @@ -108,17 +117,38 @@ sources: 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 + resource: ../../apps/benchmarks/src/workloads/icon-grid/scene.ts title: Retained Icon Grid workload instance and public Text example + - id: application-routes + resource: ../../apps/benchmarks/src/app.tsx + title: Main and Presentation route entry + - id: harness-route + resource: ../../apps/benchmarks/src/routes/harness-route.tsx + title: Shared Main and Presentation provider identity + - id: harness-controller + resource: ../../apps/benchmarks/src/controllers/harness-controller.tsx + title: URL, transition, presentation, and execution state machine + - id: harness-scene + resource: ../../apps/benchmarks/src/surfaces/harness/scene.tsx + title: Benchmark and conformance scene composition + - id: persistent-harness-layout + resource: ../../apps/benchmarks/src/surfaces/harness/persistent-layout.tsx + title: Persistent renderer provider and exclusive action adapter - id: harness-layout resource: ../../apps/benchmarks/src/components/harness-layout.tsx - title: Main and Presentation route composition + title: Main and Presentation application chrome - id: bitmap-live-viewport resource: ../../apps/benchmarks/src/surfaces/benchmark/bitmap-text-viewport.tsx title: Bitmap persistent live-text viewport controller - id: sdf-live-viewports resource: ../../apps/benchmarks/src/surfaces/benchmark/sdf-text-viewports.tsx title: MTSDF and Slug persistent live-text viewport controllers + - id: benchmark-surface + resource: ../../apps/benchmarks/src/surfaces/benchmark/benchmark-surface.tsx + title: Authored workload and technique surface dispatcher + - id: comparison-workload-viewport + resource: ../../apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx + title: Retained comparison workload React viewport - id: raster-conformance-session resource: ../../apps/benchmarks/src/benchmark/targets/conformance/raster/contracts.ts title: Low-level raster conformance session contract @@ -129,10 +159,10 @@ sources: resource: ../../apps/benchmarks/src/benchmark/targets/conformance/raster/slug-capture.ts title: Target-owned finite Slug capture lifecycle and role proofs - id: comparison-workload - resource: ../../apps/benchmarks/src/workloads/comparison/scene.ts + resource: ../../apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts title: Retained multi-technique workload scene - id: comparison-measurement-preview - resource: ../../apps/benchmarks/src/benchmark/targets/measurement/comparison-preview.ts + resource: ../../apps/benchmarks/src/benchmark/probes/comparison-workload-preview.ts title: Isolated-canvas adapter for Slug performance measurements - id: tsl-conformance-target resource: ../../apps/benchmarks/src/benchmark/targets/conformance/tsl-baseline.ts @@ -154,7 +184,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-03T12:06:43Z' + at: '2026-08-03T15:14:20Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -192,7 +222,7 @@ Main and Presentation are exclusive URL-selected root presentations. Presentatio 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. They project their complete anchor, direction, feature, fixture, language, measure, text, alignment, glyph expectation, and timeline intent through the small `LiveTextScene` contract; the route only supplies runtime font size and selects a technique adapter. Advanced Shaping derives the font fixture from the authored case itself, preventing the displayed script and fixture from drifting. 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 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. The three persistent Bitmap, MTSDF, and Slug live-text viewport controllers also live under `surfaces/benchmark`, keeping their host lease, warm update queue, loading state, telemetry, and probe contract beside the rendered surface. Their renderer imports remain literal dynamic boundaries: type-only references use `import type`, so the production build retains separate technique chunks rather than pulling renderer implementations into the route entry. Authored scenes load fixtures through `workloads/font-assets`: one discriminated adapter selects only the requested Bitmap, MTSDF, or Slug lane through literal dynamic imports, while each lane uses the public `FontLoader`, `FontRegistry`, raster request, and `@pmndrs/text/runtime-bake` entrypoint. The adapter owns source-font URLs, baked transport URLs, gzip and SHA-256 authentication, runtime progress and delivery metrics, and bounded default registries; renderer modules retain only live GPU lifecycle, configuration, statistics, and compatibility delegates. Direct font-baker imports and Wasm URLs remain prohibited from this workload-facing path. Conformance React composition lives under `surfaces/conformance`: both the retained comparison and finite captures can receive only the host-owned renderer, while executable low-level work lives below `benchmark/targets/conformance`, `benchmark/targets/product`, and `benchmark/targets/measurement`. The realtime MTSDF/Slug comparison, runtime-fallback capture, external raster proof, React reconciliation target, and finite Bitmap/MTSDF/Slug product lifecycles are owned by those explicit target trees rather than `renderer`. The finite product targets accept the runner's renderer and abort signal, lazily load their public `Text` scenes, render deterministic frames, and dispose only resources they own. Shared Bitmap line construction, exact CPU-reference composition, renderer-state restoration, and RGBA8 readback normalization live below `benchmark/low-level/raster`; MTSDF and Slug product scenes remain target-owned because they are executable benchmark examples. Pure CPU raster and source-outline oracles live in the same low-level tree, so renderer-adjacent finite capture code can share primitives without importing executable targets; a source-boundary regression prohibits renderer-to-target dependencies. Advanced Shaping lives in the conformance target hierarchy behind the registry's literal selected-target dynamic import. Bitmap, MTSDF, and Slug conformance dispatch now enters technique-owned target modules rather than live renderer files. MTSDF and Slug sampling plus source-outline targets implement the same warm session contract, preserving `load → capture → dispose` reuse and forwarding the borrowed renderer and abort signal unchanged; Bitmap's thin target wrapper reuses its neutral low-level finite scene. The target modules own CPU comparison, renderer-state restoration, standard visual captures, and Slug role/external-resource proofs; renderer modules retain only live persistent-scene and font/configuration infrastructure. 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, reject renderer imports of executable targets, authenticate literal selected-target imports, preserve selected-technique asset chunks, and reject direct font-baker or Wasm URL imports outside the shared adapter, preventing raw tooling from leaking into the normal Presentation module graph. +The root `app.tsx` owns only runner detection, shell Suspense, and URL route selection. Both route branches render the same `routes/harness-route.tsx` component type, preserving one runtime-world identity while `controllers/harness-controller.tsx` owns URL revisions, post-preload transitions, presentation playback, shortcuts, and execution state. Persistent renderer provisioning and exclusive conformance-action adaptation live in `surfaces/harness/persistent-layout.tsx`; Benchmark/Conformance scene composition lives in `surfaces/harness/scene.tsx`; Main and Presentation chrome remains in `components/harness-layout.tsx`; and runtime control binding remains in `components/runtime-controls.tsx`. The three persistent Bitmap, MTSDF, and Slug live-text viewport controllers live under `surfaces/benchmark`, keeping their host lease, warm update queue, loading state, telemetry, and probe contract beside the rendered surface. Their renderer imports remain literal dynamic boundaries: type-only references use `import type`, so the production build retains separate technique chunks rather than pulling renderer implementations into the route entry. Authored scenes load fixtures through `workloads/font-assets`: one discriminated adapter selects only the requested Bitmap, MTSDF, or Slug lane through literal dynamic imports, while each lane uses the public `FontLoader`, `FontRegistry`, raster request, and `@pmndrs/text/runtime-bake` entrypoint. The adapter owns source-font URLs, baked transport URLs, gzip and SHA-256 authentication, runtime progress and delivery metrics, and bounded default registries; renderer modules retain only live GPU lifecycle, configuration, statistics, and compatibility delegates. Direct font-baker imports and Wasm URLs remain prohibited from this workload-facing path. Conformance React composition lives under `surfaces/conformance`: both the retained comparison and finite captures can receive only the host-owned renderer, while executable low-level work lives below `benchmark/targets/conformance`, `benchmark/targets/product`, and `benchmark/targets/measurement`. The realtime MTSDF/Slug comparison, runtime-fallback capture, external raster proof, React reconciliation target, and finite Bitmap/MTSDF/Slug product lifecycles are owned by those explicit target trees rather than `renderer`. The finite product targets accept the runner's renderer and abort signal, lazily load their public `Text` scenes, render deterministic frames, and dispose only resources they own. Shared Bitmap line construction, exact CPU-reference composition, renderer-state restoration, and RGBA8 readback normalization live below `benchmark/low-level/raster`; MTSDF and Slug product scenes remain target-owned because they are executable benchmark examples. Pure CPU raster and source-outline oracles live in the same low-level tree, so renderer-adjacent finite capture code can share primitives without importing executable targets; a source-boundary regression prohibits renderer-to-target dependencies. Advanced Shaping lives in the conformance target hierarchy behind the registry's literal selected-target dynamic import. Bitmap, MTSDF, and Slug conformance dispatch now enters technique-owned target modules rather than live renderer files. MTSDF and Slug sampling plus source-outline targets implement the same warm session contract, preserving `load → capture → dispose` reuse and forwarding the borrowed renderer and abort signal unchanged; Bitmap's thin target wrapper reuses its neutral low-level finite scene. The target modules own CPU comparison, renderer-state restoration, standard visual captures, and Slug role/external-resource proofs; renderer modules retain only live persistent-scene and font/configuration infrastructure. 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, reject renderer imports of executable targets, authenticate literal selected-target imports, preserve selected-technique asset chunks, 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. @@ -262,11 +292,17 @@ The first retained challenger doubles every glyph from 16 to 32 bands without ch Six authenticated full-font fixtures cover sans, serif, script, Arabic, Devanagari, and Japanese stress. A seventh deterministic Noto Sans CJK JP subset covers only the authored Advanced Shaping Japanese corpus so the visual default uses a conventional sans rather than DotGothic16's intentional pixel style. Its source is `38,092 B`; its Bitmap GLB is `65,996 B` on one page; and its MTSDF artifact is `1,044,110 B` gzip / `3,172,756 B` raw / one page / `3,162,112 B` exact padded base-level GPU memory. The full Noto CJK font remains the shaping oracle and Milestone 13 paging target. DotGothic16 remains an explicit full-face raster stress fixture, not the representative CJK visual default. A streaming test authenticates all fourteen artifacts, embedded source hashes, complete fixture-local glyph counts, page totals, and exact padded base-array allocations without rebaking them. The production build emits and links one exact font-notices artifact containing every redistributed license. -Bitmap, MTSDF, and Slug renderer modules expose only persistent-scene construction and scene-update contracts. The obsolete standalone preview constructors and their duplicate renderer, RAF, GPU-timer, telemetry, resize, and disposal lifecycles are removed; one source-boundary regression rejects their return. +Bitmap, MTSDF, and Slug technique modules expose only persistent-scene construction, scene-update contracts, and their own raster metadata. The obsolete standalone preview constructors and their duplicate renderer, RAF, GPU-timer, telemetry, resize, and disposal lifecycles are removed; one source-boundary regression rejects their return. -Canonical font loading is owned exclusively by `workloads/font-assets`, and renderer-neutral Bitmap atlas, MTSDF configuration, and Slug allocation inspection is owned by `benchmark/low-level/raster`. Product targets, conformance targets, retained comparison scenes, and live technique scenes call those owners directly; renderer modules no longer re-export loader, preload, baked-artifact, or raster-configuration compatibility facades. +Canonical font loading is owned exclusively by `workloads/font-assets`. Bitmap atlas, MTSDF configuration, and Slug allocation inspection live beside the corresponding persistent adapter under `techniques`; finite scenes, CPU references, readback normalization, and direct ABI work remain under `benchmark/low-level` and `benchmark/targets`. Generic `renderer` modules own only the persistent host, canvas/view state, activation, telemetry, timing, renderer configuration, and state restoration. -The multi-technique retained implementation lives beside its authored definitions under `workloads/comparison/scene`. It can use only an allowlisted set of generic host, canvas, telemetry, and activation primitives from `renderer`; it cannot create a renderer, animation loop, or GPU timer. Slug performance observations that require an isolated canvas enter through `benchmark/targets/measurement/comparison-preview`, which owns one `PersistentRenderHost`, activates the same workload scene, and guarantees host disposal after release. The app and workload rail preserve the literal lazy scene chunk boundary. +Each runnable example owns one `workloads//` directory. Its `definition.ts` declares route metadata, controls, font policy, interaction, and Main/Presentation defaults; `scene.ts` is the framework-neutral public `@pmndrs/text` and Three.js example. Consumers import the exact file they need rather than routing through workload barrels, and the root catalog only preserves order and lookup. Shared structural contracts live under `workloads/shared`, while comparison-only contracts and registry wiring live under `workloads/comparison`. + +The multi-technique retained implementation lives under `surfaces/benchmark/scenes/comparison-workload` and consumes those authored scene factories. It can use only an allowlisted set of generic host, canvas, telemetry, and activation primitives from `renderer`; it cannot create a renderer, animation loop, or GPU timer. Slug performance observations that require an isolated canvas enter through `benchmark/probes/comparison-workload-preview`, which owns one `PersistentRenderHost`, activates the same workload scene, and guarantees host disposal after release. The app and workload rail preserve the literal lazy scene chunk boundary. Keeping this 1,300-line host scene outside `workloads` leaves each workload directory as readable authored content and policy rather than mixing renderer borrowing, telemetry, font transactions, and route activation into the examples. + +True product, conformance, and measurement targets remain under `benchmark/targets`. Reusable finite scenes, CPU references, and readback primitives remain under `benchmark/low-level`. The realtime raster comparison is a route-owned retained scene under `surfaces/conformance/scenes`, not a benchmark target. + +`app.tsx` is a 37-line route entry with no controller or viewport implementation. `routes/harness-route` supplies the one shared component identity, `controllers/harness-controller` owns the route-independent state machine, and `surfaces/harness` owns renderer-aware layout and scene composition. `surfaces/benchmark/benchmark-surface` dispatches an authored workload to the selected technique, while `comparison-workload-viewport` owns the retained comparison activation, progress, errors, and causal probe attributes. Main and Presentation intentionally render the same `HarnessRoute` component type; separate Main and Presentation wrappers would remount the provider and violate canvas, renderer, and telemetry retention. The deterministic TSL renderer baseline is an executable conformance target under `benchmark/targets/conformance`, not renderer infrastructure. The latest-value async queue is local to `surfaces/benchmark`, where the three React viewport controllers use it to serialize scene commits and collapse obsolete pending inputs. @@ -284,7 +320,7 @@ The corpus is an executable fixture, not display copy. Its five lines isolate or Milestone 7.2 owns a product-facing advanced-shaping showcase over this same rendering path. It makes Arabic joining, Indic reordering, bidi, ligatures and marks, and CJK line breaking visible while paragraph-scale text streams into one stable measure. The showcase loads playing from its empty authored boundary. Play/Pause, Reset, and direct scrubbing provide deterministic transition points for Vitest, Vitexec, and visual evidence without sleeps or timer tolerances. Playback loops at the authored boundary; scrubbing pauses until the user explicitly resumes. -The showcase corpus is an immutable TypeScript discriminated union with exact integer timeline state. Each case now owns a focused conformance sequence and a longer live sequence. Grapheme segmentation keeps combining marks and complex-script clusters intact; seeking does not depend on JavaScript code-unit slicing. The live benchmark exposes edit, play/pause, reset, and scrub controls over the retained public `Text` object. Automatic playback reveals one grapheme on every application animation frame at a stable width, making continuous 60 Hz shaping and layout the default workload on a 60 Hz display rather than throttling updates to a slow typewriter cadence. Bitmap and MSDF center the paragraph measure in both viewport axes while keeping its text start-aligned, so the box has a stable horizontal origin and each line grows naturally before wrapping. Every update publishes its settled timeline tick only after `Text.ready`, giving Vitest and Vitexec a causal product signal instead of a timer cushion. The maintained GPU probe renders every authored case with zero missing glyphs, confirms the fixed anchor and measure, and requires multi-line wrapping; a separate exact conformance matrix deliberately retains its bounded source and historical hashes. +The showcase corpus is an immutable TypeScript discriminated union with exact integer timeline state. Each case now owns a focused conformance sequence and a longer live sequence. Grapheme segmentation keeps combining marks and complex-script clusters intact; seeking does not depend on JavaScript code-unit slicing. The live benchmark exposes edit, play/pause, reset, and scrub controls over the retained public `Text` object. Automatic playback reveals one grapheme on every application animation frame at a stable width, making continuous 60 Hz shaping and layout the default workload on a 60 Hz display rather than throttling updates to a slow typewriter cadence. Bitmap and MSDF center the paragraph measure in both viewport axes while keeping its text start-aligned, so the box has a stable horizontal origin and each line grows naturally before wrapping. Resident updates publish through ordinary Three.js matrix traversal; `Text.ready` is observed only to publish causal probe/telemetry completion and to report genuinely cold or failed preparation, not to make the renderer advance the text. The maintained GPU probe renders every authored case with zero missing glyphs, confirms the fixed anchor and measure, and requires multi-line wrapping; a separate exact conformance matrix deliberately retains its bounded source and historical hashes. The CI-safe advanced-shaping target derives all 68 finite frames from that same corpus and sends each through the public `Text` object and bitmap batch construction at an explicit 800 CSS-pixel viewport and 16 px font size. Its exact Chromium 149 record covers five cases, 709 laid-out glyphs, 625 rendered instances, 72 draws, zero missing glyphs, 17,362 normalized layout bytes, and composite hash `51ba1d14`; a wrong hash and a missing-glyph mutation are negative controls. The three recorded 8.5–11.3 ms durations describe this machine's end-to-end conformance execution only. They are neither live renderer costs nor portability thresholds. Hardware GPU pixels remain owned by the exact bitmap readback lane, while the admitted Vitexec product probe proves that each authenticated showcase fixture reaches the live WebGPU canvas. diff --git a/docs/planning/api-shapes.md b/docs/planning/api-shapes.md index 75514e0e..0a837572 100644 --- a/docs/planning/api-shapes.md +++ b/docs/planning/api-shapes.md @@ -1,7 +1,7 @@ --- type: API Reference title: Runtime and bake API fixture V0 -description: Defines the canonical proposed package, loader, baker, shaper, paragraph, raster, and cache interfaces. +description: Defines the canonical accepted V1 package, loader, baker, shaper, paragraph, raster, and cache interfaces plus explicitly deferred additions. tags: [api, loader, baker, shaping, paragraph, raster] sources: - id: 'citation-1' @@ -20,17 +20,17 @@ sources: resource: '../../apps/benchmarks/src/workloads/catalog.ts' title: 'Typed benchmark workload catalog' - id: 'raster-technique-comparison' - resource: '../../apps/benchmarks/src/benchmark/targets/conformance/raster/comparison-scene.ts' + resource: '../../apps/benchmarks/src/surfaces/conformance/scenes/raster-technique-comparison.ts' title: 'Retained MSDF and Slug comparison scene' generated: by: openai-codex/gpt-5.6 - at: '2026-08-03T07:33:52Z' + at: '2026-08-03T15:29:54Z' --- # Runtime and bake API fixture V0 -Status: contract candidate; names may be polished, ownership and data flow may not remain implicit +Status: accepted V1 surfaces are implemented; sections labeled deferred remain proposals Scope: baked-first loading, lazy Worker baking, HarfRust Wasm shaping, JavaScript paragraph layout, and explicit raster loading ## Milestone 0.1 acceptance evidence @@ -53,17 +53,35 @@ constraint has a distinguishing test, and runtime-size, Worker, renderer, and ty telemetry, fixture authentication, renderer ownership, and direct-ABI measurement do not become product APIs merely because the harness needs them. -| Consumer surface | Observed package path | Finding | Decision | -| --- | --- | --- | --- | -| Text Ladder, Zoom Text, Icon Grid, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects | Public `Text` with a `RegisteredFont` and typed raster input; retained changes use `Text.setProperties` | Every scene is expressible without direct Wasm, a private shaper, or renderer ownership | No API gap; finish the benchmark-local scene hierarchy | -| Baked and runtime font delivery | Public `FontLoader`, `FontRegistry`, `defineFont`, raster modules, and lazy `runtime-bake` | Manual manifest authentication and compressed-fixture accounting are benchmark evidence concerns | Keep instrumentation outside the thin runtime | -| Direct baker/shaper ABI targets | Explicit low-level package and Wasm imports behind one lazily selected shared adapter | The target measures the ABI rather than demonstrating ordinary product usage | Keep isolated under benchmark conformance/measurement targets | -| Retained MSDF / Slug comparison | Two independently transactional public `Text` objects coordinated by the scene | A deterministic delayed-peer test reproduced a mixed-generation target pair; pausing target refresh, publishing both retained objects in one task, and rolling back failures preserves the last complete pair | Confirmed benchmark-local coordination need; no public grouped transaction because no ordinary consumer requires cross-object atomicity | +| Consumer surface | Published API exercised | Observed application-owned concern | Decision | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Benchmark Ipsum | Technique adapter constructs public `Text` from a `RegisteredFont`, typed raster input, and authored paragraph properties | Corpus, expected Inter glyph count, and viewport anchor | No API gap | +| Advanced Shaping | Technique adapter updates public `Text` with language, direction, features, width, and grapheme-safe content | Authored case timeline, editing controls, and fixture selection | No API gap | +| Text Ladder | Public `Text` objects share one registered font/raster and retain Three.js transforms | Size corpus, marquee layout, and completion timing | No API gap | +| Zoom Text | Public `Text` objects retain pre-shaped phrases; opacity changes use `Text.setProperties` | Phrase order, viewport-fit scale, and animation sequencing | No API gap | +| Icon Grid | A fixed public `Text` pool changes icon and label content through `Text.setProperties` | Virtual window, recycling assignments, panning, smoothing, and metrics | No API gap; glyph replacement already uses retained raster capacity | +| Off-axis / 3D | Public `Text` uses styled spans and retained span updates under ordinary Three.js transforms | Camera, group transform, and color-cycle animation | No API gap | +| Dynamic Layout | Public `Text.setProperties` updates width and publishes through the Three.js matrix lifecycle | Three independent width curves and benchmark-only bounds geometry | No API gap | +| Paragraph Stress | Public `Text` renders a workload-sized paragraph and retains camera-only scrolling | Repeated corpus size and scroll motion | No API gap; text-volume changes correctly replace topology | +| Paint & Effects | Public `TextSpan` values and `Text.setProperties` update fill, opacity, MTSDF outline, and shadow | Color animation and technique-specific control policy | No API gap | +| Baked and runtime font delivery | Public `FontLoader`, `FontRegistry`, `defineRaster`, raster subpaths, and lazy `runtime-bake` | Fixture authentication, decompression, and payload accounting | Keep instrumentation outside the thin runtime | +| Direct baker/shaper ABI targets | Published Wasm/package entry points behind one lazily selected target adapter | Exact ABI timing and byte-level conformance | Keep isolated under benchmark conformance/measurement targets | +| Retained MSDF / Slug comparison | Two independently transactional public `Text` objects coordinated by the scene | Paired offscreen-target publication and rollback after a delayed peer | Keep coordination local; no ordinary consumer proves a grouped public transaction | + +The workload pass also tested three plausible additions and found no consumer failure that would justify shipping them: + +| Candidate | Evidence | Decision | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Public glyph-capacity or slack policy | All three raster modules reuse compatible batches through the required `stageBatch(previous, …)` transaction, reserve bounded internal capacity, and complete the 1,402-glyph Icon Grid recycle sweep without consumer allocation controls | Keep capacity package-owned; exposing the growth policy would couple callers to renderer storage without improving correctness | +| Public `Text.flush()` or `Text.commit()` | Resident `setProperties()` work publishes before child traversal through the ordinary Three.js `updateMatrixWorld()` lifecycle; authored workloads do not await readiness per frame, and Dynamic Layout can synchronously read the committed layout after matrix publication | Keep one lifecycle rather than adding a second publication path with ambiguous render ordering | +| Public retained-update diagnostics | Reuse, ranged upload, overflow, and replacement are covered by raster tests and benchmark-only telemetry; applications do not need those classifications to render correctly | Keep investigation/profiling signals outside the thin runtime so production builds retain zero diagnostic cost | This audit rejects new loader telemetry, generic raster-statistics, Three-specific, and React-specific APIs: each would add -coupling or shipped code without a demonstrated consumer failure. The delayed-peer failure is real, but its required -atomicity belongs to one comparison product over two independent render targets. The private retained-target solution closes -that consumer failure without adding renderer-wide grouped publication to the package. +coupling or shipped code without a demonstrated consumer failure. It also rejects exporting the first-party capacity and dirty- +range helpers: the portable `stageBatch` contract already lets an external raster own an equivalent policy without inheriting +Three-specific storage. The delayed-peer failure is real, but its required atomicity belongs to one comparison product over +two independent render targets. The private retained-target solution closes that consumer failure without adding renderer- +wide grouped publication to the package. ## Package boundaries @@ -963,6 +981,9 @@ uikit is the first required third-party integration, but its `CustomLayouting`, ## Raster module boundary +External package authors should follow the focused [raster and baker plugin guide](raster-baker-plugin.md); the signatures +and invariants below remain authoritative. + ```ts interface RuntimeRasterBakeRequest { source: Uint8Array; diff --git a/docs/planning/benchmark-ipsum.md b/docs/planning/benchmark-ipsum.md index 53f5afba..2c7aa93c 100644 --- a/docs/planning/benchmark-ipsum.md +++ b/docs/planning/benchmark-ipsum.md @@ -6,7 +6,7 @@ status: stable tags: [benchmarks, corpus, shaping, typography] sources: - id: corpus - resource: ../../apps/benchmarks/src/workloads/benchmark-ipsum.ts + resource: ../../apps/benchmarks/src/workloads/benchmark-ipsum/scene.ts title: Executable benchmark ipsum corpus - id: benchmark-plan resource: benchmark-plan.md diff --git a/docs/planning/index.md b/docs/planning/index.md index b1d346e4..c45f71c9 100644 --- a/docs/planning/index.md +++ b/docs/planning/index.md @@ -4,6 +4,7 @@ - [Project brief](project-brief.md) — product intent, current integration slice, V1, and later horizon. - [Runtime and bake API V0](api-shapes.md) — public and internal TypeScript contract fixture. +- [Raster and baker plugin guide](raster-baker-plugin.md) — build an external technique through the public runtime, baker, artifact, discovery, and lifecycle contracts. - [Architecture](architecture.md) — system ownership, import boundaries, and runtime flow. - [Canonical roadmap](../roadmap/roadmap.md) — authoritative implementation order and exit gates. - [uikit integration](uikit-integration.md) — third-party retained-layout integration boundary. diff --git a/docs/planning/raster-baker-plugin.md b/docs/planning/raster-baker-plugin.md new file mode 100644 index 00000000..61f0daa0 --- /dev/null +++ b/docs/planning/raster-baker-plugin.md @@ -0,0 +1,358 @@ +--- +type: How-to guide +title: Build a raster and baker plugin +description: Shows package authors how to add an external raster runtime, baker, artifact, optional runtime fallback, discovery metadata, and lifecycle tests through public pmndrs/text APIs. +tags: [raster, baker, plugins, transactions, threejs] +sources: + - id: 'public-raster-contract' + resource: '../../packages/text/src/raster.ts' + title: 'Public raster runtime contract' + - id: 'public-baker-contract' + resource: '../../packages/text/src/bake.ts' + title: 'Public raster baker contract' + - id: 'plugin-manifest' + resource: '../../packages/glyph-example-raster/package.json' + title: 'External plugin manifest and discovery mapping' + - id: 'plugin-contract' + resource: '../../packages/glyph-example-raster/src/contract.ts' + title: 'External plugin identity and descriptor' + - id: 'plugin-artifact' + resource: '../../packages/glyph-example-raster/src/artifact.ts' + title: 'External plugin artifact generator' + - id: 'plugin-baker' + resource: '../../packages/glyph-example-raster/src/baker.ts' + title: 'External plugin Node baker' + - id: 'plugin-runtime' + resource: '../../packages/glyph-example-raster/src/raster.ts' + title: 'External plugin raster runtime' + - id: 'plugin-runtime-baker' + resource: '../../packages/glyph-example-raster/src/runtime-baker.ts' + title: 'External plugin runtime baker' + - id: 'plugin-tests' + resource: '../../packages/glyph-example-raster/tests/glyph-example.test.ts' + title: 'External plugin lifecycle tests' + - id: 'plugin-browser-proof' + resource: '../../apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts' + title: 'External plugin browser proof' + +generated: + by: 'openai-codex/gpt-5.6' + at: '2026-08-04T01:17:43Z' +--- + +# Build a raster and baker plugin + +Use this guide to create an ESM package that adds a raster technique to `pmndrs/text` without changing or importing its +internals. The finished package will own: + +- one literal raster kind, extension name, format version, options type, and canonical descriptor; +- a package-owned companion artifact and baker; +- a runtime decoder and renderer adapter; +- an optional runtime-bake entry loaded only when source fallback is required; +- retained-update, abort, overflow, and disposal tests. + +The exact interfaces remain authoritative in the [API reference](api-shapes.md#raster-module-boundary). The private +[`@pmndrs/text-glyph-example-raster`](../../packages/glyph-example-raster) workspace package is a complete external proof using +only published entry points. + +## 1. Create separate runtime and baker entry points + +Keep the browser runtime at the package root and the Node baker in its own export. The root must not statically import the +baker or its generation dependencies. + +```json +{ + "name": "@example/text-raster", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./baker": { + "types": "./dist/baker.d.ts", + "import": "./dist/baker.js" + } + }, + "pmndrs": { + "text": { + "exampleRaster": "./baker" + } + }, + "peerDependencies": { + "@pmndrs/text": "^1.0.0", + "three": ">=0.185.1 <0.186" + } +} +``` + +The `pmndrs.text` key is the runtime factory export name. Its value is the package export containing the default baker. +Static project discovery can then connect `exampleRaster(...)` in a `defineFont(...)` call to this baker without executing +application modules. Omit the `three` peer when the plugin targets a different adapter and does not publish a Three.js draw +object. + +## 2. Define one shared identity and descriptor + +Put the values shared by runtime and baker in a dependency-light contract module: + +```ts +import type { JsonValue } from '@pmndrs/text'; + +export const EXAMPLE_KIND = 'exampleRaster' as const; +export const EXAMPLE_EXTENSION = 'VENDOR_text_example_raster' as const; +export const EXAMPLE_FORMAT_VERSION = 0 as const; +export const EXAMPLE_GENERATOR_VERSION = '1.0.0' as const; + +export interface ExampleOptions { + readonly quality?: number; +} + +export interface ExampleDescriptor { + readonly [key: string]: JsonValue; + readonly generatorVersion: typeof EXAMPLE_GENERATOR_VERSION; + readonly quality: number; +} + +export function exampleDescriptor(options: ExampleOptions = {}): ExampleDescriptor { + const quality = options.quality ?? 1; + if (!Number.isSafeInteger(quality) || quality < 1 || quality > 4) { + throw new RangeError('example raster quality must be an integer in 1..4'); + } + return { generatorVersion: EXAMPLE_GENERATOR_VERSION, quality }; +} +``` + +Descriptor normalization must be deterministic. The descriptor participates in the raster key, so semantically equal +options must produce the same JSON value and unsupported values must fail before baking or loading. + +## 3. Generate a package-owned companion artifact + +Implement one artifact function accepting `RasterBakeRequest` and returning +`RasterBakeArtifact`. It owns the extension JSON, binary records, optional page artifacts, hashes, and payload report. + +```ts +import type { RasterBakeArtifact, RasterBakeRequest } from '@pmndrs/text'; + +export async function bakeExampleArtifact( + request: RasterBakeRequest, +): Promise> { + request.signal?.throwIfAborted(); + const generated = await generateRecords(request.font, request.descriptor, request.signal); + const companion = await encodeCompanionGlb({ + rasterKey: request.rasterKey, + shapingHash: request.font.shapingHash, + glyphCount: request.font.glyphCount, + descriptor: request.descriptor, + packaging: request.packaging, + generated, + }); + request.signal?.throwIfAborted(); + return companion; +} +``` + +The returned artifact must satisfy these boundaries: + +- `kind`, extension name, format version, raster key, shaping hash, and glyph count agree everywhere; +- the companion uses the `raster` role and independently delivered payloads use `raster-page`; +- every artifact has deterministic bytes, ID, and SHA-256; +- the payload report states exact serialized, metadata, page, and decoded GPU bytes; +- embedded and external packaging preserve the same semantic extension data; +- external resources declare URI, byte length, and hash; +- abort is checked before expensive work, after awaited work, and before publication. + +A standalone companion is still an ordinary valid GLB. If the Node composer must embed it into the core font artifact, +integer extension fields referencing glTF buffer views must be named `bufferView` or end in `BufferView` so the generic host +can range-check and rebase them without understanding the plugin schema. + +## 4. Export the Node baker + +Use `defineRasterBaker` to bind options, descriptor, identity, and generation while preserving their exact types: + +```ts +import { defineRasterBaker } from '@pmndrs/text'; + +const exampleBaker = defineRasterBaker({ + kind: EXAMPLE_KIND, + extension: EXAMPLE_EXTENSION, + version: EXAMPLE_FORMAT_VERSION, + descriptor: exampleDescriptor, + bake: bakeExampleArtifact, +}); + +export default exampleBaker; +``` + +Keep this default export at the `./baker` path declared in the manifest. The literal baker kind must equal the runtime +factory export name used by static discovery. + +## 5. Implement the raster runtime + +Use `defineRaster` for decoding, cold preparation, transactional batch staging, paint validation, and resource disposal: + +```ts +import { + defineRaster, + defineRasterBatchStage, + type RasterObjectDrawBatch, +} from '@pmndrs/text'; +import type { Group } from 'three/webgpu'; + +interface ExampleBatch extends RasterObjectDrawBatch { + readonly capacity: number; + readonly glyphCount: number; +} + +export const exampleRasterModule = defineRaster({ + kind: EXAMPLE_KIND, + extension: EXAMPLE_EXTENSION, + version: EXAMPLE_FORMAT_VERSION, + runtimeBaker: () => import('./runtime-baker.js'), + descriptor: exampleDescriptor, + async decode(font, raster, signal) { + signal?.throwIfAborted(); + return decodeAndValidateResource(font, raster, signal); + }, + prepare(layout, resource, fontSlot, signal) { + return prepareRequiredPages(layout, resource, fontSlot, signal); + }, + stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio) { + const next = prepareInstanceValues(layout, resource, fontSlot, paint, rasterPixelRatio); + if (previous !== undefined && canRetain(previous, resource, fontSlot, next)) { + return defineRasterBatchStage( + previous, + () => publishRetainedValues(previous, next), + () => releasePreparedValues(next), + ); + } + const replacement = createReplacementBatch(resource, fontSlot, next); + return defineRasterBatchStage( + replacement, + () => undefined, + () => replacement.dispose(), + ); + }, + validatePaint: validateExamplePaint, + dispose: disposeExampleResource, +}); + +export function exampleRaster(options: ExampleOptions = {}) { + return { module: exampleRasterModule, options } as const; +} +``` + +`decode` must treat the artifact as untrusted: validate extension identity, descriptor, record sizes, buffer ranges, hashes, +formats, device limits, and GPU budgets before allocation. `prepare` owns genuinely cold page readiness. Once dependencies +are resident, `stageBatch` should complete synchronously. + +Staging may retain `previous` or create a replacement, but it must not mutate committed buffers, draw counts, scene +children, or ownership before `commit()`. Perform every fallible operation while staging; make commit synchronous and +infallible; make abort, batch disposal, and resource disposal idempotent. A retained implementation may own capacity slack +and dirty-range coalescing privately. Those policies are not public API. + +For Three.js, publish `RasterObjectDrawBatch` so `Text` can attach the object while the portable +`RasterDrawBatch` contract remains renderer-neutral. Another adapter may use a different batch type without adding that +renderer to core. + +## 6. Add optional runtime baking without growing the normal runtime + +The runtime module reaches its generator only through the literal dynamic import in `runtimeBaker`. Export a default +`RuntimeRasterBakerModule` from `runtime-baker.ts` and reuse the same artifact generator: + +```ts +import type { RuntimeRasterBakerModule } from '@pmndrs/text'; + +const runtimeBaker: RuntimeRasterBakerModule = { + kind: EXAMPLE_KIND, + bake(request) { + return bakeExampleArtifact({ + font: { + source: request.source, + fontFaceIndex: request.fontFaceIndex, + glyphCount: request.font.glyphCount, + shapingHash: request.font.shapingHash, + }, + rasterKey: request.rasterKey, + packaging: { artifact: 'embedded', pages: 'embedded' }, + descriptor: exampleDescriptor(request.options), + signal: request.signal, + onProgress: request.onProgress, + }); + }, +}; + +export default runtimeBaker; +``` + +The runtime-bake host loads this module in its selected module Worker. Keep Node-only modules, validators, profiling, and +unselected generators outside the browser runtime graph. If the plugin does not support runtime generation, omit +`runtimeBaker`; a missing compatible artifact will then reject instead of silently choosing another raster. + +## 7. Bake and consume the plugin + +For an explicit Node bake, pass the typed baker to the public host: + +```ts +import { rasterBake } from '@pmndrs/text'; +import { bakeFont } from '@pmndrs/text/bake'; +import exampleBaker from '@example/text-raster/baker'; + +await bakeFont({ + input: new URL('./Inter-Regular.ttf', import.meta.url), + output: new URL('./Inter.font.glb', import.meta.url), + font: { fontFaceIndex: 0 }, + rasters: [ + rasterBake(exampleBaker, { + packaging: { artifact: 'embedded', pages: 'embedded' }, + options: { quality: 2 }, + }), + ], +}); +``` + +At runtime, use the package factory like a first-party raster: + +```ts +import { FontRegistry, Text } from '@pmndrs/text'; +import { exampleRaster } from '@example/text-raster'; + +const registry = new FontRegistry(); +const font = await registry.load('/fonts/Inter.font.glb'); +const text = new Text({ + text: 'External raster', + font, + raster: exampleRaster({ quality: 2 }), + fontSize: 48, +}); + +await text.ready; // Cold font, shaper, artifact, and resource readiness only. +scene.add(text); +``` + +Resident text, layout, and paint changes use `text.setProperties(...)` and publish during ordinary Three.js +`updateMatrixWorld()` or `updateWorldMatrix()` traversal. Do not add a per-frame `await text.ready`. + +## 8. Prove the extension boundary + +Test the plugin at three layers: + +1. Artifact tests prove deterministic embedded/external bytes, hashes, identity, report accounting, semantic validation, + malformed-input rejection, and cancellation. +2. Runtime tests prove initial creation, same-capacity replacement of every glyph field, shrink, exact-capacity growth, + overflow or topology replacement, commit, abort, staging failure, stale-generation recovery, and repeated disposal. +3. A real browser target proves public loading, shaping, layout, renderer attachment, retained updates, visible pixels, and + cleanup on every supported backend. + +Add a package-boundary test that rejects private core paths and first-party raster/baker imports, plus a core search proving +the new kind and extension name require no registration edit or switch. + +The workspace proof runs with: + +```sh +mise exec -- pnpm --filter @pmndrs/text-glyph-example-raster test +mise exec -- pnpm benchmarks:test:external-raster-proof +``` + +Success means the package bakes, packages, authenticates, loads, renders, updates, overflows, aborts, and disposes through +public entry points while the normal runtime graph remains free of its baker. diff --git a/docs/planning/renderer-capabilities.md b/docs/planning/renderer-capabilities.md index 27b1ac9c..481cd163 100644 --- a/docs/planning/renderer-capabilities.md +++ b/docs/planning/renderer-capabilities.md @@ -1,7 +1,7 @@ --- type: Reference title: Renderer capability matrix -description: Compares planned game-text features and limitations across bitmap, MSDF, and Slug rasters. +description: Compares evidence-backed V1 raster roles and explicitly planned later capabilities across bitmap, MSDF, and Slug. tags: [rendering, bitmap, msdf, mtsdf, slug, games] sources: - id: 'citation-1-1' @@ -31,20 +31,30 @@ sources: - id: 'slug-outline-research' resource: 'slug-outline-research.md' title: 'Slug outline architecture' + - id: 'benchmark-evidence' + resource: '../packages/benchmarks.md' + title: 'Benchmark and presentation evidence' + - id: 'text-package-evidence' + resource: '../packages/text.md' + title: 'Text and raster package evidence' generated: by: 'openai-codex/gpt-5.6' - at: '2026-07-29T13:24:14Z' + at: '2026-08-03T15:29:54Z' --- # Renderer capability matrix -This is the intended `pmndrs/text` feature set, not a claim about implemented behavior. The public MSDF raster uses one MTSDF RGBA atlas; MTSDF is its encoding, not another selectable engine. +This matrix separates the evidence-backed V1 raster roles from explicitly planned later capabilities. The public MSDF +raster uses one MTSDF RGBA atlas; MTSDF is its encoding, not another selectable engine. The recommendation and current +scale/effect boundaries below are release claims. Rows describing later color, paging, mixed-raster, or expanded-effect +work remain intended capabilities rather than claims about the V1 implementation. | Symbol | Meaning | | :----: | ------------------------------------------------------------------------- | | ✅ | Natural, fully intended capability | | ⚠️ | Supported with a bounded range, extra pass/data, or documented constraint | +| 🟡 | Planned additive capability; not implemented in V1 | | ❌ | Not represented by this technique; choose another raster | ## Styling and effects @@ -53,19 +63,22 @@ This is the intended `pmndrs/text` feature set, not a claim about implemented be | --------------------------- | :----: | :--: | :--: | | Runtime solid fill/tint | ✅ | ✅ | ✅ | | Per-span or per-glyph color | ✅ | ✅ | ✅ | -| Gradient or texture fill | ✅ | ✅ | ✅ | +| Gradient or texture fill | 🟡 | 🟡 | 🟡 | | Runtime opacity/fade | ✅ | ✅ | ✅ | -| Adjustable outline | ⚠️ | ✅ | ❌ | -| Multiple outline bands | ⚠️ | ⚠️ | ❌ | -| Hard drop shadow | ✅ | ✅ | ❌ | -| Soft shadow or glow | ⚠️ | ✅ | ❌ | -| Cosmetic weight adjustment | ⚠️ | ⚠️ | ❌ | +| Adjustable outline | 🟡 | ✅ | 🟡 | +| Multiple outline bands | 🟡 | 🟡 | 🟡 | +| Hard drop shadow | 🟡 | ✅ | 🟡 | +| Soft shadow or glow | 🟡 | 🟡 | 🟡 | +| Cosmetic weight adjustment | 🟡 | 🟡 | 🟡 | | 3D extrusion/bevel | ❌ | ❌ | ❌ | Notes: -1. Bitmap outlines require dilation or another strike. MSDF outlines use the MTSDF alpha channel and are bounded by the encoded distance range. Slug V0 rejects outline and shadow paint while a bounded shared-traversal approximation remains research.[^slug-outline-research] -2. Soft effects may require extra samples, padding, or an offscreen blur. The API must report limits rather than imply identical output. +1. Bitmap and Slug V0 accept fill and opacity and reject outline or shadow instead of degrading silently. A future Bitmap + outline requires a separate dilated strike; the bounded Slug shared-traversal approximation remains research.[^slug-outline-research] + MSDF outlines use the MTSDF alpha channel and are bounded by the encoded distance range. +2. Planned soft effects require extra samples, padding, or an offscreen blur. Any admitted API must report limits rather + than imply identical output. 3. Cosmetic thickness is not a substitute for shaping a real font weight. 4. Extruded geometry is a separate mesh-generation feature, outside this 2D raster contract. @@ -74,21 +87,24 @@ Notes: | Feature | Bitmap | MSDF | Slug | | --------------------------------------------------- | :----: | :--: | :--: | | Monochrome OpenType outlines | ✅ | ✅ | ✅ | -| Standalone SVG icon manifest | ✅ | ⚠️ | ✅ | -| OpenType `SVG ` glyphs | ✅ | ⚠️ | ⚠️ | -| COLRv0 layered vectors | ✅ | ⚠️ | ✅ | -| COLRv1 paint graph | ✅ | ⚠️ | ⚠️ | -| Runtime palette selection | ❌ | ⚠️ | ✅ | -| CBDT/CBLC or `sbix` emoji | ✅ | ❌ | ❌ | -| Mixed vector/raster SVG artwork | ✅ | ❌ | ⚠️ | +| Standalone SVG icon manifest | 🟡 | 🟡 | 🟡 | +| OpenType `SVG ` glyphs | 🟡 | 🟡 | 🟡 | +| COLRv0 layered vectors | 🟡 | 🟡 | 🟡 | +| COLRv1 paint graph | 🟡 | 🟡 | 🟡 | +| Runtime palette selection | 🟡 | 🟡 | 🟡 | +| CBDT/CBLC or `sbix` emoji | 🟡 | ❌ | ❌ | +| Mixed vector/raster SVG artwork | 🟡 | ❌ | 🟡 | | SVG scripts, animation, filters, external resources | ❌ | ❌ | ❌ | Notes: -1. Bitmap can flatten supported source artwork to RGBA strikes, but loses vector palette behavior. -2. The MSDF raster accepts supported closed paths or layered masks; arbitrary SVG paint and embedded images are not distance fields. -3. Slug compiles a safe vector/paint subset into flat geometry, layer, palette, and image-reference records. It never executes source SVG at runtime. -4. Color bitmap emoji uses the bitmap/image raster for that glyph while preserving the paragraph's shaped glyph identity. +1. V1 bakes monochrome OpenType outlines only. The color and standalone-SVG rows are additive plans, not accepted input + paths in the current baker or renderer. +2. A future Bitmap color path can flatten supported source artwork to RGBA strikes, but loses vector palette behavior. +3. Arbitrary SVG paint and embedded images are not distance fields. Any future MSDF admission must define a supported + closed-path or layered-mask subset. +4. A future Slug color path may compile a safe vector/paint subset into package-owned records; it will never execute source + SVG scripts or external resources at runtime. ## Scale and workload fit @@ -108,7 +124,7 @@ Notes: Notes: 1. Bitmap quality is tied to available strikes; it is preferred for tiny hinted or deliberately pixel-authored text. -2. MSDF with MTSDF encoding is the proposed general-purpose default, subject to accepted scale, transform, atlas, and effects benchmarks. +2. MSDF with MTSDF encoding is the general-purpose default established by the accepted scale, transform, atlas, and effects benchmarks. 3. Slug is preferred when large-size or zoomed fill fidelity dominates. Its cost remains shape- and coverage-dependent. 4. Payload size depends on glyph coverage, strikes, atlas resolution, layer padding, outline complexity, and compression. It must be measured per corpus. @@ -125,9 +141,14 @@ Switching raster must never reshape text or change line breaks. V1 selects one r ## Recommendation -- Use **MSDF** for ordinary scalable game and UI text and inexpensive runtime outlines/effects. Its V1 atlas encoding is MTSDF. -- Use **bitmap strikes** for tiny or intentionally pixel-authored text and embedded color emoji. -- Use **Slug** for large or deeply zoomed text, high-fidelity vector layers, and SVG icon fonts. +- Use **MSDF** for ordinary scalable game and UI text and inexpensive runtime outlines/effects. Its V1 MTSDF encoding + passed the shared workload, DPR, transform, effects, source-outline error, atlas, and dual-backend gates recorded in the + [benchmark evidence](../packages/benchmarks.md). +- Use **bitmap strikes** for tiny, known-density, or intentionally pixel-authored text. The exact DPR-1/DPR-2 strike and + CPU/GPU frame oracles establish this role; bitmap does not silently approximate unsupported outline or shadow effects. +- Use **Slug** for large or deeply zoomed fill text and intricate monochrome outlines. The 36-cell dual-backend/DPR + release-role matrix covers large size, 1,024-ppem magnification, complex scripts, clipping, affine transforms, and + projection zoom against source outlines. Slug V0 deliberately rejects outline, shadow, and color-layer paint. - Keep the choice explicit. `pmndrs/text` may expose recommendations and capabilities, but it does not silently switch engines. [^slug-outline-research]: The rejected exact-distance design, retained measurements, and replacement gate are recorded separately from the capability table. diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index f0934030..c12ba9e9 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -19,7 +19,7 @@ sources: generated: by: openai-codex/gpt-5.6 - at: '2026-08-03T07:33:52Z' + at: '2026-08-03T15:34:13Z' --- # Canonical implementation roadmap @@ -56,9 +56,9 @@ Status key: ✅ complete · 🟡 in progress · ⬜ not started · ⛔ blocked | 7 | ✅ | Harden the integration proof | L | 1–6 | Identity, cancellation, limits, invalid data, package separation, and baselines pass review. | | 8 | ✅ | Implement and validate MSDF | XL | 7 | The MTSDF-backed general-purpose raster passes visual, payload, and GPU performance gates. | | 9 | ✅ | Port/rewrite and validate Slug | XL | 7 | Outline-accurate text passes correctness, packing, visual, and GPU performance gates. | -| 10 | 🟡 | Harden the first shippable release | L | 8–9 | Bitmap, MSDF, and Slug ship as independent modules over one shaping/layout result. | +| 10 | ✅ | Harden the first shippable release | L | 8–9 | Bitmap, MSDF, and Slug ship as independent modules over one shaping/layout result. | -Milestones 0–9 are closed. Milestone 10 is active. +Milestones 0–10 are closed. Milestone 11 is the next additive workstream. Do not start a milestone before its dependencies and exit evidence exist. @@ -126,7 +126,7 @@ These rows replace the former separate backlog. Each is intended to become one f | 10.3 | ✅ | Add bounded glyph-capacity slack, complete in-place field replacement, authoritative shrink counts, overflow replacement, and coalesced dirty uploads to all three rasters. | XL | 10.2 | | 10.4 | ✅ | Prove the public extension boundary with a private workspace raster/baker package that owns a new kind, artifact, adapter, retained updates, overflow, abort, and disposal. | L | 10.1, 10.3 | | 10.5 | ✅ | Remove benchmark recycling workarounds and prove Icon Grid plus every Presentation workload through sequential, timed, allocation, cadence, dual-backend, and React Doctor gates. | XL | 10.2–10.4 | -| 10.6 | 🟡 | Complete raster switching, release conformance, public API review, recommendations, migration material, package-size evidence, and signed stacked delivery. | L | 10.5 | +| 10.6 | ✅ | Complete raster switching, release conformance, public API review, recommendations, plugin authoring guidance, package-size evidence, and signed stacked delivery. | L | 10.5 | ## Milestone 0 — accept contracts and versions @@ -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 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. +Milestone 10 is closed. 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, item 10.5 removed benchmark workarounds and retained complete dual-backend Presentation evidence, and item 10.6 completed release review and signed stacked delivery. The release 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. Every example now owns `workloads//{definition,scene}.ts`: definitions carry exact Main/Presentation defaults and route policy, scenes show the public `@pmndrs/text` and Three.js usage, consumers import exact files rather than barrels, and the root catalog only preserves order and lookup. The workload audit records all nine live examples individually: each uses published `Text`, loader, registry, raster, and runtime-bake surfaces, while direct Wasm remains an explicit ABI target. No missing common package API is currently proven. Live Bitmap, MTSDF, and Slug adapters and metadata are isolated under `techniques`; generic renderer ownership and finite target machinery no longer depend on one another. Non-target retained comparison and isolated preview modules now live with conformance surfaces and probes, while true targets and reusable low-level references keep distinct hierarchies. Main/Presentation composition dispatches through named benchmark surfaces while preserving one shared `Harness` identity, canvas, renderer, and telemetry history across route changes. All seven retained comparison 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; explicit Advanced Shaping font selection now reaches the authored scene, and retained comparison font transactions keep layout measurement suspended until every replacement `Text` generation is ready. The complete live lane passed every sequential Bitmap/MTSDF/Slug workload, both renderer backends, Presentation exclusivity, the timed demo, and React Doctor at 100/100. 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. The public API is accepted for V1, external raster and baker authoring is documented, and renderer-wide batching across separate `Text` objects remains explicitly outside this milestone. ### 10.1–10.6 closure checklist @@ -711,11 +711,12 @@ Deliver: - an editable realtime MSDF / Slug comparison that renders equal offscreen targets and a GPU-only signed-delta heatmap, plus interactive comparison scenarios for all three techniques with correctness/visual gates and downloadable raw results; - second-font registration and raster-binding smoke fixtures; - release-level conformance, browser, GPU, memory, package-size, and malformed-input suites; -- reviewed public API, migration notes, and versioned extension schemas; +- reviewed public API, external raster and baker authoring guidance, and versioned extension schemas; - matching Three.js and React examples with no React-only font behavior; - renderer-neutral prepared raster batches and resource ownership extracted only after Slug proves the shared requirements, with the existing Three.js implementation retained as an adapter over the direct integration boundary. -The project cannot ship before this gate passes. +Milestone 10 passes this gate. Additive renderer and layout work begins with Milestone 11 and must not reopen the accepted +V1 contracts without new evidence. ## Additive work after the release renderer set