diff --git a/apps/benchmarks/doctor.config.json b/apps/benchmarks/doctor.config.json index f0e215c4..2cf2541a 100644 --- a/apps/benchmarks/doctor.config.json +++ b/apps/benchmarks/doctor.config.json @@ -1,6 +1,5 @@ { "$schema": "https://react.doctor/schema/config.json", - "noScore": true, "supplyChain": { "enabled": false }, diff --git a/apps/benchmarks/package.json b/apps/benchmarks/package.json index 4349d5b1..6f87c605 100644 --- a/apps/benchmarks/package.json +++ b/apps/benchmarks/package.json @@ -25,6 +25,8 @@ "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", + "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", "check:japanese-showcase-subset": "node ./scripts/provision-harfbuzz.mts --check && node ./scripts/generate-japanese-showcase-subset.mts --check", @@ -57,15 +59,18 @@ "profile:paragraph-font-size:bitmap": "vitexec --gpu --path '/presentation?mode=benchmark&technique=bitmap&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=paragraph-stress&profileControl=font-size' ./vitexec/paragraph-layout-profile.probe.ts", "profile:paragraph-font-size:bitmap:cpu": "vitexec --gpu --cpu-profile ./.cache/paragraph-font-size-bitmap.cpuprofile --path '/presentation?mode=benchmark&technique=bitmap&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=paragraph-stress&profileControl=font-size' ./vitexec/paragraph-layout-profile.probe.ts", "profile:inspect:paragraph-font-size:bitmap": "node ./scripts/summarize-cpu-profile.mts ./.cache/paragraph-font-size-bitmap.cpuprofile", - "profile:icon-grid:gc": "vitexec --gpu --cpu-profile ./.cache/icon-grid.cpuprofile --performance-trace ./.cache/icon-grid-performance.json --heap-snapshot ./.cache/icon-grid-heap.txt --path '/presentation?mode=benchmark&technique=bitmap&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=icon-grid' ./vitexec/icon-grid-gc.probe.ts", + "profile:icon-grid:gc": "vitexec --gpu --cpu-profile ./.cache/icon-grid.cpuprofile --performance-trace ./.cache/icon-grid-performance.json --path '/presentation?mode=benchmark&technique=bitmap&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=icon-grid' ./vitexec/icon-grid-gc.probe.ts", "profile:presentation-sweep": "vitexec --gpu --path '/presentation?mode=benchmark&technique=bitmap&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=benchmark-ipsum' ./vitexec/presentation-framerate-sweep.probe.ts", "test": "pnpm check:size && pnpm check:autoresearch-baseline && pnpm check:paragraph-bidi-contract && pnpm check:paragraph-cjk-contract && pnpm test:unit && pnpm test:headless && pnpm test:packed", "test:external-raster-proof": "vitexec --gpu ./vitexec/external-raster-proof.probe.ts", "test:headless": "pnpm --filter @pmndrs/text build && pnpm --filter @pmndrs/text-font-baker build && node ./scripts/run-headless.mts --suite conformance --dpr 1 --samples 3 --warmup 1 --port 5181", "test:packed": "pnpm --filter @pmndrs/text build && pnpm --filter @pmndrs/text-font-baker build && node ./scripts/run-packed-consumer.mts", "test:live": "node ./scripts/run-live-probe.mts", - "test:presentation-demo": "node ./scripts/run-presentation-demo-probe.mts", - "test:presentation-workloads": "node ./scripts/run-presentation-workload-probe.mts", + "test:presentation": "pnpm test:presentation-workloads && pnpm test:presentation-demo", + "test:presentation-demo": "node ./scripts/run-presentation-demo-matrix.mts", + "test:presentation-demo-case": "node ./scripts/run-presentation-demo-probe.mts", + "test:presentation-workload-case": "node ./scripts/run-presentation-workload-probe.mts", + "test:presentation-workloads": "node ./scripts/run-presentation-workload-matrix.mts", "test:presentation-controls": "vitexec --gpu --path '/presentation?mode=benchmark&technique=mtsdf&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=icon-grid' ./vitexec/presentation-controls.probe.ts", "test:raster-technique-compare": "pnpm test:raster-technique-compare:webgpu && pnpm test:raster-technique-compare:webgl", "test:raster-technique-compare:webgl": "vitexec --gpu --path '/?mode=conformance&technique=mtsdf&backend=webgl2&delivery=baked&dpr=1&font=inter&workload=mtsdf-slug-compare' ./vitexec/raster-technique-compare.probe.ts", diff --git a/apps/benchmarks/scripts/capture-presentation-workloads.mts b/apps/benchmarks/scripts/capture-presentation-workloads.mts new file mode 100644 index 00000000..ea5fa4c1 --- /dev/null +++ b/apps/benchmarks/scripts/capture-presentation-workloads.mts @@ -0,0 +1,12 @@ +process.env.PRESENTATION_SCREENSHOT_DIR ??= './.cache/presentation-screenshots'; + +const { runPresentationProbeMatrix } = await import('./support/run-presentation-probe-matrix.mts'); + +await runPresentationProbeMatrix({ + cases: [ + { backend: 'webgpu', technique: 'mtsdf' }, + { backend: 'webgl2', technique: 'mtsdf' }, + ], + label: 'presentation screenshot', + script: new URL('./run-presentation-workload-probe.mts', import.meta.url), +}); diff --git a/apps/benchmarks/scripts/run-presentation-demo-matrix.mts b/apps/benchmarks/scripts/run-presentation-demo-matrix.mts new file mode 100644 index 00000000..f4d8def5 --- /dev/null +++ b/apps/benchmarks/scripts/run-presentation-demo-matrix.mts @@ -0,0 +1,7 @@ +import { runPresentationProbeMatrix } from './support/run-presentation-probe-matrix.mts'; + +await runPresentationProbeMatrix({ + cases: [{ backend: 'webgpu' }, { backend: 'webgl2' }], + label: 'timed demo', + script: new URL('./run-presentation-demo-probe.mts', import.meta.url), +}); diff --git a/apps/benchmarks/scripts/run-presentation-demo-probe.mts b/apps/benchmarks/scripts/run-presentation-demo-probe.mts index 606ad547..4a560d4e 100644 --- a/apps/benchmarks/scripts/run-presentation-demo-probe.mts +++ b/apps/benchmarks/scripts/run-presentation-demo-probe.mts @@ -6,6 +6,7 @@ import { launchProjectChromium } from './support/project-chromium.mts'; const root = fileURLToPath(new URL('..', import.meta.url)); process.chdir(root); +const backend = presentationBackend(process.env.PRESENTATION_BACKEND); const server = await createServer({ root, server: { host: '127.0.0.1', port: 0 } }); await server.listen(); const address = server.httpServer?.address(); @@ -37,12 +38,12 @@ try { }); page.on('pageerror', (error) => consoleProblems.push(`pageerror: ${error.message}`)); await page.goto( - `http://127.0.0.1:${String(address.port)}/presentation?mode=benchmark&technique=mtsdf&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=off-axis-3d`, + `http://127.0.0.1:${String(address.port)}/presentation?mode=benchmark&technique=mtsdf&backend=${backend}&delivery=baked&dpr=2&font=inter&workload=off-axis-3d`, { waitUntil: 'domcontentloaded' }, ); const initialViewport = page.locator( - '[data-testid="comparison-live-viewport"][data-workload="off-axis-3d"][data-presentation-pending="false"]', + `[data-testid="comparison-live-viewport"][data-workload="off-axis-3d"][data-presentation-pending="false"][data-backend="${backend}"]`, ); await initialViewport.waitFor(); await page.waitForFunction(() => document.querySelector('canvas[data-configured-renderer-active="true"]') !== null); @@ -74,7 +75,7 @@ try { if (!(await animationSwitch.isChecked())) throw new Error('Presentation Space shortcut toggled the focused Animate control'); - await page.waitForFunction(() => { + await page.waitForFunction((expectedBackend) => { const viewport = document.querySelector( '[data-testid="comparison-live-viewport"][data-workload="icon-grid"]', ); @@ -83,6 +84,7 @@ try { Math.abs(Number(viewport.getAttribute(attribute)) - value) < 0.000_001; return ( viewport.dataset.cameraKind === 'orthographic' && + viewport.dataset.backend === expectedBackend && viewport.dataset.canvasGrid === 'true' && viewport.dataset.animationEnabled === 'true' && close('data-applied-font-size', 64) && @@ -93,7 +95,7 @@ try { Number(viewport.dataset.drawCount) > 0 && Number(viewport.dataset.framesPerSecond) > 0 ); - }); + }, backend); const retainedCanvas = await page.evaluate(() => { const scope = globalThis as typeof globalThis & { presentationDemoCanvas: Element | null }; @@ -118,8 +120,25 @@ try { const observedWorkloads = ['off-axis-3d', 'icon-grid']; const waitForWorkload = async (workload: string): Promise => { await page.waitForFunction( - (expected) => new URLSearchParams(location.search).get('workload') === expected, - workload, + ({ expectedBackend, expectedWorkload }) => { + if (new URLSearchParams(location.search).get('workload') !== expectedWorkload) return false; + const canvas = document.querySelector('canvas[data-configured-renderer-active="true"]'); + if (canvas === null || Number(document.documentElement.dataset.activeConfiguredRenderers) !== 1) return false; + if (expectedWorkload === 'advanced-shaping') { + return ( + document.querySelector('[data-testid="benchmark-surface"]')?.hasAttribute('data-advanced-case') === true + ); + } + const viewport = document.querySelector('[data-testid="comparison-live-viewport"]'); + return ( + viewport?.dataset.workload === expectedWorkload && + viewport.dataset.presentationPending === 'false' && + viewport.dataset.backend === expectedBackend && + Number(viewport.dataset.glyphCount) > 0 && + Number(viewport.dataset.drawCount) > 0 + ); + }, + { expectedBackend: backend, expectedWorkload: workload }, ); observedWorkloads.push(workload); }; @@ -177,6 +196,12 @@ try { if (new URLSearchParams(new URL(page.url()).search).get('workload') !== 'off-axis-3d') { throw new Error('Timed demo did not finish on Off-axis / 3D'); } + const finalBackend = await page + .locator('[data-testid="comparison-live-viewport"][data-workload="off-axis-3d"]') + .getAttribute('data-backend'); + if (finalBackend !== backend) { + throw new Error(`Timed demo changed the configured backend from ${backend} to ${finalBackend ?? 'missing'}`); + } const workloadControl = page.getByLabel('Live workload', { exact: true }); await workloadControl.focus(); @@ -201,6 +226,7 @@ try { 'presentation-demo-ready', JSON.stringify({ focusedControlCaptured: true, + backend, iconGridDefaults: true, iconGridFps, observedWorkloads, @@ -213,3 +239,11 @@ try { await browser?.close(); await server.close(); } + +function presentationBackend(value: string | undefined): PresentationBackend { + if (value === undefined || value === 'webgpu') return 'webgpu'; + if (value === 'webgl2') return value; + throw new RangeError(`PRESENTATION_BACKEND must be webgpu or webgl2; received ${value}`); +} + +type PresentationBackend = 'webgpu' | 'webgl2'; diff --git a/apps/benchmarks/scripts/run-presentation-workload-matrix.mts b/apps/benchmarks/scripts/run-presentation-workload-matrix.mts new file mode 100644 index 00000000..d7bfe39e --- /dev/null +++ b/apps/benchmarks/scripts/run-presentation-workload-matrix.mts @@ -0,0 +1,10 @@ +import { runPresentationProbeMatrix } from './support/run-presentation-probe-matrix.mts'; + +const backends = ['webgpu', 'webgl2'] as const; +const techniques = ['bitmap', 'mtsdf', 'slug'] as const; + +await runPresentationProbeMatrix({ + cases: backends.flatMap((backend) => techniques.map((technique) => ({ backend, technique }))), + label: 'sequential workloads', + script: new URL('./run-presentation-workload-probe.mts', import.meta.url), +}); diff --git a/apps/benchmarks/scripts/run-presentation-workload-probe.mts b/apps/benchmarks/scripts/run-presentation-workload-probe.mts index ee685237..38361bc2 100644 --- a/apps/benchmarks/scripts/run-presentation-workload-probe.mts +++ b/apps/benchmarks/scripts/run-presentation-workload-probe.mts @@ -1,3 +1,5 @@ +import { mkdir } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Browser, Page } from 'playwright'; import { createServer } from 'vite'; @@ -7,6 +9,9 @@ import { launchProjectChromium } from './support/project-chromium.mts'; const root = fileURLToPath(new URL('..', import.meta.url)); process.chdir(root); const technique = presentationTechnique(process.env.PRESENTATION_TECHNIQUE); +const backend = presentationBackend(process.env.PRESENTATION_BACKEND); +const screenshotDirectory = process.env.PRESENTATION_SCREENSHOT_DIR; +if (screenshotDirectory !== undefined) await mkdir(screenshotDirectory, { recursive: true }); const server = await createServer({ root, server: { host: '127.0.0.1', port: 0 } }); await server.listen(); const address = server.httpServer?.address(); @@ -77,23 +82,24 @@ try { }); page.on('pageerror', (error) => consoleProblems.push(`pageerror: ${error.message}`)); await page.goto( - `http://127.0.0.1:${String(address.port)}/presentation?mode=benchmark&technique=${technique}&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=text-ladder`, + `http://127.0.0.1:${String(address.port)}/presentation?mode=benchmark&technique=${technique}&backend=${backend}&delivery=baked&dpr=2&font=inter&workload=text-ladder`, { waitUntil: 'domcontentloaded' }, ); const workloadControl = page.getByLabel('Live workload', { exact: true }); await workloadControl.waitFor(); await page.waitForFunction(() => document.querySelector('canvas[data-configured-renderer-active="true"]') !== null); - await page.waitForFunction(() => { + await page.waitForFunction((expectedBackend) => { const viewport = document.querySelector( '[data-testid="comparison-live-viewport"][data-workload="text-ladder"]', ); return ( viewport?.dataset.presentationPending === 'false' && + viewport.dataset.backend === expectedBackend && Number(viewport.dataset.glyphCount) > 0 && Number(viewport.dataset.drawCount) > 0 && Number(viewport.dataset.framesPerSecond) > 0 ); - }); + }, backend); await page.evaluate(() => { const canvas = document.querySelector('canvas[data-configured-renderer-active="true"]'); const scope = globalThis as typeof globalThis & { @@ -152,7 +158,7 @@ try { for (const workload of workloads) { await workloadControl.click(); await page.getByRole('option', { name: workload.label, exact: true }).click(); - await assertPresentationRemainsVisible(page, workload.id); + await assertPresentationRemainsVisible(page, workload.id, backend); await page.waitForFunction( ({ expected }) => { const viewport = document.querySelector('[data-testid="comparison-live-viewport"]'); @@ -164,6 +170,7 @@ try { return ( viewport.dataset.workload === expected.id && viewport.dataset.presentationPending === 'false' && + viewport.dataset.backend === expected.backend && viewport.dataset.cameraKind === expected.camera && viewport.dataset.canvasGrid === 'true' && viewport.dataset.animationEnabled === 'true' && @@ -177,7 +184,7 @@ try { zoomReady ); }, - { expected: workload }, + { expected: { ...workload, backend } }, ); const retainedCanvas = await page.evaluate(() => { const scope = globalThis as typeof globalThis & { presentationProbeCanvas: Element | undefined }; @@ -187,6 +194,11 @@ try { if (Number(await page.locator('html').getAttribute('data-active-configured-renderers')) !== 1) { throw new Error(`${workload.id} did not retain exactly one configured renderer`); } + if (screenshotDirectory !== undefined) { + await page.screenshot({ + path: resolvePath(screenshotDirectory, `${backend}-${technique}-${workload.id}.png`), + }); + } } if (technique === 'bitmap') { const dprMenu = page.getByRole('button', { name: 'Device pixel ratio: 2×', exact: true }); @@ -195,13 +207,13 @@ try { .getByRole('button', { name: '1×', exact: true }) .evaluate((element) => (element as HTMLButtonElement).click()); await page.waitForFunction(() => new URLSearchParams(location.search).get('dpr') === '1'); - await assertCanvasHandoff(page, 'DPR 2→1'); + await assertCanvasHandoff(page, 'DPR 2→1', backend); await page.getByRole('button', { name: 'Device pixel ratio: 1×', exact: true }).click(); await page .getByRole('button', { name: '2×', exact: true }) .evaluate((element) => (element as HTMLButtonElement).click()); await page.waitForFunction(() => new URLSearchParams(location.search).get('dpr') === '2'); - await assertCanvasHandoff(page, 'DPR 1→2'); + await assertCanvasHandoff(page, 'DPR 1→2', backend); const mtsdfControl = page.getByRole('button', { name: 'MSDF', exact: true }); await mtsdfControl.evaluate((element) => (element as HTMLButtonElement).click()); @@ -209,7 +221,7 @@ try { const viewport = document.querySelector('[data-testid="comparison-live-viewport"]'); return viewport?.dataset.technique === 'mtsdf' && viewport.dataset.presentationPending === 'false'; }); - await assertCanvasHandoff(page, 'Bitmap→MTSDF'); + await assertCanvasHandoff(page, 'Bitmap→MTSDF', backend); await page .getByRole('button', { name: 'Bitmap', exact: true }) .evaluate((element) => (element as HTMLButtonElement).click()); @@ -217,34 +229,41 @@ try { const viewport = document.querySelector('[data-testid="comparison-live-viewport"]'); return viewport?.dataset.technique === 'bitmap' && viewport.dataset.presentationPending === 'false'; }); - await assertCanvasHandoff(page, 'MTSDF→Bitmap'); + await assertCanvasHandoff(page, 'MTSDF→Bitmap', backend); } if (consoleProblems.length > 0) { throw new Error(`Presentation emitted browser warnings or errors: ${consoleProblems.join(' | ')}`); } console.log( 'presentation-workloads-ready', - JSON.stringify({ workloads: workloads.length, rendererCount: 1, technique }), + JSON.stringify({ backend, workloads: workloads.length, rendererCount: 1, technique }), ); } finally { await browser?.close(); await server.close(); } -async function assertCanvasHandoff(page: Page, label: string): Promise { +async function assertCanvasHandoff(page: Page, label: string, expectedBackend: PresentationBackend): Promise { const evidence = await page.evaluate(() => { const scope = globalThis as typeof globalThis & { presentationProbeCanvas: HTMLCanvasElement | undefined; presentationProbeCanvasEvents: string[]; }; const current = document.querySelector('canvas[data-configured-renderer-active="true"]'); + const viewport = document.querySelector('[data-testid="comparison-live-viewport"]'); return { + backend: viewport?.dataset.backend, disconnected: scope.presentationProbeCanvasEvents.some((event) => event.endsWith('disconnected')), rendererCount: Number(document.documentElement.dataset.activeConfiguredRenderers), retained: current !== null && current === scope.presentationProbeCanvas && current.isConnected, }; }); - if (!evidence.retained || evidence.disconnected || evidence.rendererCount !== 1) { + if ( + !evidence.retained || + evidence.disconnected || + evidence.rendererCount !== 1 || + evidence.backend !== expectedBackend + ) { throw new Error(`${label} did not retain one continuously attached renderer canvas: ${JSON.stringify(evidence)}`); } } @@ -255,11 +274,21 @@ function presentationTechnique(value: string | undefined): 'bitmap' | 'mtsdf' | throw new RangeError(`PRESENTATION_TECHNIQUE must be bitmap, mtsdf, or slug; received ${value}`); } -async function assertPresentationRemainsVisible(page: Page, workload: string): Promise { +function presentationBackend(value: string | undefined): PresentationBackend { + if (value === undefined || value === 'webgpu') return 'webgpu'; + if (value === 'webgl2') return value; + throw new RangeError(`PRESENTATION_BACKEND must be webgpu or webgl2; received ${value}`); +} + +async function assertPresentationRemainsVisible( + page: Page, + workload: string, + expectedBackend: PresentationBackend, +): Promise { const minimumRequiredInkPixels = workload === 'zoom-text' ? 32 : 300; const startedAt = await page.evaluate(() => performance.now()); while (true) { - const canvasFailure = await page.evaluate(() => { + const canvasFailure = await page.evaluate((reportedBackend) => { const scope = globalThis as typeof globalThis & { presentationProbeCanvas: HTMLCanvasElement | undefined; presentationProbeCanvasEvidence: CanvasEvidence | undefined; @@ -286,11 +315,14 @@ async function assertPresentationRemainsVisible(page: Page, workload: string): P const rendererCount = Number(document.documentElement.dataset.activeConfiguredRenderers); if (rendererCount !== 1) return `the active renderer count changed to ${String(rendererCount)}`; const viewport = document.querySelector('[data-testid="comparison-live-viewport"]'); + if (viewport?.dataset.backend !== reportedBackend) { + return `the active backend changed to ${viewport?.dataset.backend ?? 'missing'}`; + } if (Number(viewport?.dataset.glyphCount) <= 0 || Number(viewport?.dataset.drawCount) <= 0) { return 'the active workload stopped publishing glyphs or draw calls'; } return undefined; - }); + }, expectedBackend); if (canvasFailure !== undefined) throw new Error(`${workload}: ${canvasFailure}`); const elapsedMs = await page.evaluate((start) => performance.now() - start, startedAt); if (elapsedMs >= presentationIntervalMs) break; @@ -326,6 +358,8 @@ interface CanvasEvidence { readonly cssWidth: number; } +type PresentationBackend = 'webgpu' | 'webgl2'; + async function visiblePresentationInkPixels(page: Page, screenshotBase64: string): Promise { return page.evaluate(async (encoded) => { const image = new Image(); diff --git a/apps/benchmarks/scripts/support/run-presentation-probe-matrix.mts b/apps/benchmarks/scripts/support/run-presentation-probe-matrix.mts new file mode 100644 index 00000000..c8c4c317 --- /dev/null +++ b/apps/benchmarks/scripts/support/run-presentation-probe-matrix.mts @@ -0,0 +1,47 @@ +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; + +export interface PresentationProbeCase { + readonly backend: 'webgpu' | 'webgl2'; + readonly technique?: 'bitmap' | 'mtsdf' | 'slug'; +} + +export interface PresentationProbeMatrixOptions { + readonly cases: readonly PresentationProbeCase[]; + readonly label: string; + readonly script: URL; +} + +export async function runPresentationProbeMatrix(options: PresentationProbeMatrixOptions): Promise { + for (const probeCase of options.cases) { + const environment = { + ...process.env, + PRESENTATION_BACKEND: probeCase.backend, + ...(probeCase.technique === undefined ? {} : { PRESENTATION_TECHNIQUE: probeCase.technique }), + }; + const description = [probeCase.backend, probeCase.technique].filter((value) => value !== undefined).join(' / '); + console.log(`presentation-probe-case ${options.label}: ${description}`); + await runPresentationProbe(options.script, environment, `${options.label}: ${description}`); + } +} + +async function runPresentationProbe(script: URL, environment: NodeJS.ProcessEnv, description: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fileURLToPath(script)], { + env: environment, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (exitCode, signal) => { + if (exitCode === 0) { + resolve(); + return; + } + reject( + new Error( + `${description} exited with ${exitCode === null ? `signal ${signal ?? 'unknown'}` : `status ${String(exitCode)}`}`, + ), + ); + }); + }); +} diff --git a/apps/benchmarks/src/app.tsx b/apps/benchmarks/src/app.tsx index 44e07e77..42bfcef5 100644 --- a/apps/benchmarks/src/app.tsx +++ b/apps/benchmarks/src/app.tsx @@ -3,7 +3,6 @@ import { lazy, Suspense, use, - useCallback, useEffect, useEffectEvent, useLayoutEffect, @@ -104,7 +103,7 @@ import type { import type { MtsdfTextConformanceCapture, MtsdfTextLiveStats, MtsdfTextPersistentScene } from './renderer/mtsdf-text'; import type { SlugTextConformanceCapture, SlugTextLiveStats, SlugTextPersistentScene } from './renderer/slug-text'; import type { RasterTechniqueComparisonPersistentScene } from './renderer/raster-technique-compare'; -import type { PersistentRenderJob } from './renderer/persistent-render-host'; +import type { PersistentRenderJob, PersistentRenderSceneRenderer } from './renderer/persistent-render-host'; import { createLatestAsyncQueue, type LatestAsyncQueue } from './renderer/latest-async-queue'; import type { ComparisonWorkloadConfiguration, @@ -125,6 +124,22 @@ import { Route, Switch, useLocation } from 'wouter'; type LiveTextStats = RuntimeLiveStats; type RunExclusiveJob = (job: PersistentRenderJob, signal?: AbortSignal) => Promise>; +type FiniteConformanceCapture = + | { readonly kind: 'bitmap'; readonly value: BitmapTextConformanceCapture } + | { readonly kind: 'mtsdf'; readonly value: MtsdfTextConformanceCapture } + | { readonly kind: 'slug'; readonly value: SlugTextConformanceCapture } + | { readonly kind: 'source-outline'; readonly value: SourceOutlineFidelityCapture } + | { readonly kind: 'runtime-fallback'; readonly value: RuntimeFallbackCapture }; + +interface FiniteConformanceCaptureOptions { + readonly backend: GraphicsBackend; + readonly dpr: 1 | 2; + readonly fontFixture: SelectableFontFixture; + readonly renderer: PersistentRenderSceneRenderer; + readonly signal: AbortSignal; + readonly technique: RasterTechnique; + readonly workload: string; +} let comparisonWorkloadModule: ReturnType | undefined; const liveSceneAssetResources = new Map>(); @@ -133,6 +148,97 @@ function importComparisonWorkload() { return import('./renderer/comparison-workload'); } +function loadBitmapTextRenderer() { + return import('./renderer/bitmap-text'); +} + +function loadMtsdfTextRenderer() { + return import('./renderer/mtsdf-text'); +} + +function loadSlugTextRenderer() { + return import('./renderer/slug-text'); +} + +function loadRasterTechniqueComparison() { + return import('./renderer/raster-technique-compare'); +} + +function loadRuntimeFallbackConformance() { + return import('./renderer/runtime-fallback-conformance'); +} + +async function reportInitializationFailure( + cleanup: () => Promise, + caught: unknown, + onError: (error: unknown) => void, +): Promise { + try { + await cleanup(); + onError(caught); + } catch (cleanupError) { + onError(cleanupError); + } +} + +async function captureFiniteConformance({ + backend, + dpr, + fontFixture, + renderer, + signal, + technique, + workload, +}: FiniteConformanceCaptureOptions): Promise { + if (workload === 'runtime-fallback') { + const { captureRuntimeFallbackConformance } = await loadRuntimeFallbackConformance(); + return { + kind: 'runtime-fallback', + value: await captureRuntimeFallbackConformance({ backend, dpr, fontFixture, renderer, signal, technique }), + }; + } + if (workload === 'cross-technique-fidelity') { + if (technique === 'slug') { + const { captureSlugSourceOutlineFidelity } = await loadSlugTextRenderer(); + return { + kind: 'source-outline', + value: await captureSlugSourceOutlineFidelity({ backend, dpr, fontFixture, renderer, signal }), + }; + } + if (technique === 'mtsdf') { + const { captureMtsdfSourceOutlineFidelity } = await loadMtsdfTextRenderer(); + return { + kind: 'source-outline', + value: await captureMtsdfSourceOutlineFidelity({ backend, dpr, fontFixture, renderer, signal }), + }; + } + const { captureBitmapSourceOutlineFidelity } = await loadBitmapTextRenderer(); + return { + kind: 'source-outline', + value: await captureBitmapSourceOutlineFidelity({ backend, dpr, fontFixture, renderer, signal }), + }; + } + if (technique === 'slug') { + const { captureSlugTextConformance } = await loadSlugTextRenderer(); + return { + kind: 'slug', + value: await captureSlugTextConformance({ backend, dpr, fontFixture, renderer, signal }), + }; + } + if (technique === 'mtsdf') { + const { captureMtsdfTextConformance } = await loadMtsdfTextRenderer(); + return { + kind: 'mtsdf', + value: await captureMtsdfTextConformance({ backend, dpr, fontFixture, renderer, signal }), + }; + } + const { captureBitmapTextConformance } = await loadBitmapTextRenderer(); + return { + kind: 'bitmap', + value: await captureBitmapTextConformance({ backend, dpr, fontFixture, renderer, signal }), + }; +} + function preloadComparisonWorkload(): ReturnType { comparisonWorkloadModule ??= importComparisonWorkload(); return comparisonWorkloadModule; @@ -163,13 +269,13 @@ async function preloadPresentationAssets( if (delivery !== 'baked') return; const fixtures = Array.from(new Set([selectedFont, ...PRESENTATION_FONT_FIXTURES])); if (technique === 'bitmap') { - const { preloadBitmapFontAssets } = await import('./renderer/bitmap-text'); + const { preloadBitmapFontAssets } = await loadBitmapTextRenderer(); await preloadBitmapFontAssets(fixtures, signal); } else if (technique === 'mtsdf') { - const { preloadMtsdfFontAssets } = await import('./renderer/mtsdf-text'); + const { preloadMtsdfFontAssets } = await loadMtsdfTextRenderer(); await preloadMtsdfFontAssets(fixtures, signal); } else { - const { preloadSlugFontAssets } = await import('./renderer/slug-text'); + const { preloadSlugFontAssets } = await loadSlugTextRenderer(); await preloadSlugFontAssets(fixtures, signal); } } @@ -189,13 +295,13 @@ function liveSceneAssetResource( if (comparison) await preloadComparisonWorkload(); if (delivery !== 'baked') return; if (technique === 'bitmap') { - const { preloadBitmapFontAssets } = await import('./renderer/bitmap-text'); + const { preloadBitmapFontAssets } = await loadBitmapTextRenderer(); await preloadBitmapFontAssets(fixtures); } else if (technique === 'mtsdf') { - const { preloadMtsdfFontAssets } = await import('./renderer/mtsdf-text'); + const { preloadMtsdfFontAssets } = await loadMtsdfTextRenderer(); await preloadMtsdfFontAssets(fixtures); } else { - const { preloadSlugFontAssets } = await import('./renderer/slug-text'); + const { preloadSlugFontAssets } = await loadSlugTextRenderer(); await preloadSlugFontAssets(fixtures); } })(); @@ -539,20 +645,22 @@ function useHarnessController(routeLayout: HarnessLayout): ReactNode { resetRuntimeControlsForWorkload(runtimeWorld, value.workload, value.layout); }; if (transitionsScene) { - startTransition(async () => { - try { - await liveSceneAssetResource(value.technique, value.delivery, value.fontFixture, value.workload); - // 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(); - }); - } catch (caught) { - if (requestRevision !== locationRequestRevisionRef.current) return; - requestedLocationRef.current = committedLocationRef.current; - setError(caught instanceof Error ? caught.message : String(caught)); - } + 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(); @@ -697,9 +805,9 @@ function useHarnessController(routeLayout: HarnessLayout): ReactNode { conformanceRunController.current = controller; const revision = ++conformanceRunRevision.current; setError(undefined); - startTransition(async () => { - try { - const value = await runExclusiveJob( + startTransition(() => { + const request = Promise.resolve().then(() => + runExclusiveJob( ({ renderer, signal }) => runRegisteredBenchmark({ targetId: @@ -731,20 +839,25 @@ function useHarnessController(routeLayout: HarnessLayout): ReactNode { }, }), controller.signal, - ); - if (revision === conformanceRunRevision.current) { - startTransition(() => setSummary(value)); - } - } catch (caught) { - if ( - revision === conformanceRunRevision.current && - !(caught instanceof DOMException && caught.name === 'AbortError') - ) { - setError(caught instanceof Error ? caught.message : String(caught)); - } - } finally { - if (conformanceRunController.current === controller) conformanceRunController.current = undefined; - } + ), + ); + 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; + }); }); } @@ -895,10 +1008,9 @@ function useHarnessController(routeLayout: HarnessLayout): ReactNode { /> ); - const reportRendererError = useCallback( - (caught: unknown) => setError(caught instanceof Error ? caught.message : String(caught)), - [], - ); + const reportRendererError = (caught: unknown): void => { + setError(caught instanceof Error ? caught.message : String(caught)); + }; return ( > | undefined; let cancelled = false; + const releaseComparison = async (): Promise => { + const current = comparison; + comparison = undefined; + if (comparisonRef.current === current) comparisonRef.current = undefined; + await surfaceLease?.release(); + surfaceLease = undefined; + }; const initialization = (async () => { - try { - if (cancelled) return; - const { createRasterTechniqueComparisonPersistentScene } = await import('./renderer/raster-technique-compare'); - if (cancelled) return; - const optionsText = initialText(); - const created = createRasterTechniqueComparisonPersistentScene({ - backend, - fontFixture, - onError: publishError, - onPan: publishPan, - onZoom: publishZoom, - text: optionsText, - }); - comparison = created; - comparisonRef.current = created; - surfaceLease = await activatePersistentSurface( - { - anchor: container, - controller: comparisonRef, - label: 'Live MSDF and Slug GPU comparison', - pan: true, - scene: created, - zoom: true, - }, - controller.signal, - ); - if (cancelled) { - await surfaceLease.release(); - return; - } - let text = optionsText; - let latestText = initialText(); - while (latestText !== text) { - text = latestText; - await created.setText(text); - latestText = initialText(); - } - if (cancelled) return; - const view = initialView(); - created.setView(view.zoom, view.panXPercent, view.panYPercent); - setCommittedText(text); - setReady(true); - setError(undefined); - } catch (caught) { - const failedComparison = comparison; - comparison = undefined; - if (comparisonRef.current === failedComparison) comparisonRef.current = undefined; - await surfaceLease?.release(); - surfaceLease = undefined; - throw caught; + if (cancelled) return; + const { createRasterTechniqueComparisonPersistentScene } = await loadRasterTechniqueComparison(); + if (cancelled) return; + const optionsText = initialText(); + const created = createRasterTechniqueComparisonPersistentScene({ + backend, + fontFixture, + onError: publishError, + onPan: publishPan, + onZoom: publishZoom, + text: optionsText, + }); + comparison = created; + comparisonRef.current = created; + surfaceLease = await activatePersistentSurface( + { + anchor: container, + controller: comparisonRef, + label: 'Live MSDF and Slug GPU comparison', + pan: true, + scene: created, + zoom: true, + }, + controller.signal, + ); + if (cancelled) { + await surfaceLease.release(); + return; } + let text = optionsText; + let latestText = initialText(); + while (latestText !== text) { + text = latestText; + await created.setText(text); + latestText = initialText(); + } + if (cancelled) return; + const view = initialView(); + created.setView(view.zoom, view.panXPercent, view.panYPercent); + setCommittedText(text); + setReady(true); + setError(undefined); })(); - void initialization.catch(publishError); + void initialization.catch((caught: unknown) => + reportInitializationFailure(releaseComparison, caught, publishError), + ); return () => { cancelled = true; controller.abort(); - void initialization.then( - async () => { - const current = comparison; - comparison = undefined; - if (comparisonRef.current === current) comparisonRef.current = undefined; - await surfaceLease?.release(); - surfaceLease = undefined; - }, - () => undefined, - ); + void initialization.then(releaseComparison, () => undefined); }; }, [backend, fontFixture]); @@ -1946,27 +2049,12 @@ function FiniteConformanceSurface({ }) { const { runExclusiveJob } = usePersistentRenderHost(); const runExclusiveCapture = useEffectEvent(runExclusiveJob); - const [capture, setCapture] = useState< - | { readonly kind: 'bitmap'; readonly value: BitmapTextConformanceCapture } - | { readonly kind: 'mtsdf'; readonly value: MtsdfTextConformanceCapture } - | { readonly kind: 'slug'; readonly value: SlugTextConformanceCapture } - | { readonly kind: 'source-outline'; readonly value: SourceOutlineFidelityCapture } - | { readonly kind: 'runtime-fallback'; readonly value: RuntimeFallbackCapture } - >(); + const [capture, setCapture] = useState(); const [error, setError] = useState(); - const publishCapture = useEffectEvent( - ( - value: - | { readonly kind: 'bitmap'; readonly value: BitmapTextConformanceCapture } - | { readonly kind: 'mtsdf'; readonly value: MtsdfTextConformanceCapture } - | { readonly kind: 'slug'; readonly value: SlugTextConformanceCapture } - | { readonly kind: 'source-outline'; readonly value: SourceOutlineFidelityCapture } - | { readonly kind: 'runtime-fallback'; readonly value: RuntimeFallbackCapture }, - ) => { - setCapture(value); - setError(undefined); - }, - ); + const publishCapture = useEffectEvent((value: FiniteConformanceCapture) => { + setCapture(value); + setError(undefined); + }); const publishError = useEffectEvent((caught: unknown) => { if (caught instanceof DOMException && caught.name === 'AbortError') return; setError(caught instanceof Error ? caught.message : String(caught)); @@ -1976,84 +2064,8 @@ function FiniteConformanceSurface({ const controller = new AbortController(); let cancelled = false; const request = runExclusiveCapture( - async ({ renderer, signal }) => - workload === 'runtime-fallback' - ? import('./renderer/runtime-fallback-conformance').then(async ({ captureRuntimeFallbackConformance }) => ({ - kind: 'runtime-fallback' as const, - value: await captureRuntimeFallbackConformance({ - backend, - dpr, - fontFixture, - renderer, - signal, - technique, - }), - })) - : workload === 'cross-technique-fidelity' - ? technique === 'slug' - ? import('./renderer/slug-text').then(async ({ captureSlugSourceOutlineFidelity }) => ({ - kind: 'source-outline' as const, - value: await captureSlugSourceOutlineFidelity({ - backend, - dpr, - fontFixture, - renderer, - signal, - }), - })) - : technique === 'mtsdf' - ? import('./renderer/mtsdf-text').then(async ({ captureMtsdfSourceOutlineFidelity }) => ({ - kind: 'source-outline' as const, - value: await captureMtsdfSourceOutlineFidelity({ - backend, - dpr, - fontFixture, - renderer, - signal, - }), - })) - : import('./renderer/bitmap-text').then(async ({ captureBitmapSourceOutlineFidelity }) => ({ - kind: 'source-outline' as const, - value: await captureBitmapSourceOutlineFidelity({ - backend, - dpr, - fontFixture, - renderer, - signal, - }), - })) - : technique === 'slug' - ? import('./renderer/slug-text').then(async ({ captureSlugTextConformance }) => ({ - kind: 'slug' as const, - value: await captureSlugTextConformance({ - backend, - dpr, - fontFixture, - renderer, - signal, - }), - })) - : technique === 'mtsdf' - ? import('./renderer/mtsdf-text').then(async ({ captureMtsdfTextConformance }) => ({ - kind: 'mtsdf' as const, - value: await captureMtsdfTextConformance({ - backend, - dpr, - fontFixture, - renderer, - signal, - }), - })) - : import('./renderer/bitmap-text').then(async ({ captureBitmapTextConformance }) => ({ - kind: 'bitmap' as const, - value: await captureBitmapTextConformance({ - backend, - dpr, - fontFixture, - renderer, - signal, - }), - })), + ({ renderer, signal }) => + captureFiniteConformance({ backend, dpr, fontFixture, renderer, signal, technique, workload }), controller.signal, ); void request @@ -2691,7 +2703,7 @@ function BitmapTextViewport({ let surfaceLease: Awaited> | undefined; let cancelled = false; const initialization = (async () => { - const { createBitmapTextPersistentScene } = await import('./renderer/bitmap-text'); + const { createBitmapTextPersistentScene } = await loadBitmapTextRenderer(); if (cancelled) return; const created = createBitmapTextPersistentScene({ anchor: configuration.anchor, @@ -3005,7 +3017,7 @@ function MtsdfTextViewport({ let surfaceLease: Awaited> | undefined; let cancelled = false; const initialization = (async () => { - const { createMtsdfTextPersistentScene } = await import('./renderer/mtsdf-text'); + const { createMtsdfTextPersistentScene } = await loadMtsdfTextRenderer(); if (cancelled) return; const created = createMtsdfTextPersistentScene({ anchor: configuration.anchor, @@ -3264,7 +3276,7 @@ function SlugTextViewport({ let surfaceLease: Awaited> | undefined; let cancelled = false; const initialization = (async () => { - const { createSlugTextPersistentScene } = await import('./renderer/slug-text'); + const { createSlugTextPersistentScene } = await loadSlugTextRenderer(); if (cancelled) return; const created = createSlugTextPersistentScene({ anchor: configuration.anchor, @@ -3810,21 +3822,18 @@ function useBakeProgress(label: string): { const [value, setValue] = useState(); const [active, setActive] = useState(false); const lastConsoleKey = useRef(''); - const publish = useCallback( - (progress: BakeProgress) => { - setValue(progress); - setActive(true); - if (!import.meta.env.DEV) return; - const percentage = Math.round((progress.completed / progress.total) * 100); - const bucket = Math.floor(percentage / 10) * 10; - const key = `${progress.stage}:${progress.phase}:${String(bucket)}`; - if (key === lastConsoleKey.current) return; - lastConsoleKey.current = key; - console.info(`[pmndrs/text] ${label} ${progress.stage} bake: ${progress.phase} ${String(percentage)}%`); - }, - [label], - ); - const finish = useCallback(() => setActive(false), []); + const publish = (progress: BakeProgress): void => { + setValue(progress); + setActive(true); + if (!import.meta.env.DEV) return; + const percentage = Math.round((progress.completed / progress.total) * 100); + const bucket = Math.floor(percentage / 10) * 10; + const key = `${progress.stage}:${progress.phase}:${String(bucket)}`; + if (key === lastConsoleKey.current) return; + lastConsoleKey.current = key; + console.info(`[pmndrs/text] ${label} ${progress.stage} bake: ${progress.phase} ${String(percentage)}%`); + }; + const finish = (): void => setActive(false); return { value, active, publish, finish }; } diff --git a/apps/benchmarks/src/benchmark/paragraph-stress-motion.test.ts b/apps/benchmarks/src/benchmark/paragraph-stress-motion.test.ts index 441ccf75..0635d3ad 100644 --- a/apps/benchmarks/src/benchmark/paragraph-stress-motion.test.ts +++ b/apps/benchmarks/src/benchmark/paragraph-stress-motion.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { paragraphStressMotionFrame } from './paragraph-stress-motion'; +import { + paragraphStressMotionFrame, + paragraphStressScrollProgress, + setParagraphStressMotionFrame, +} from './paragraph-stress-motion'; describe('paragraph stress presentation motion', () => { it('shrinks width, then font size, then scrolls', () => { @@ -25,4 +29,13 @@ describe('paragraph stress presentation motion', () => { expect(() => paragraphStressMotionFrame(0, 101, 24)).toThrow('animation speed must be in [0, 100]'); expect(() => paragraphStressMotionFrame(0, 50, 7)).toThrow('start font size must be at least 8'); }); + + it('updates caller-owned state and keeps scalar scroll output aligned with the complete frame', () => { + const reusable = { fontSize: -1, layoutWidthPercent: -1, scrollProgress: -1 }; + const result = setParagraphStressMotionFrame(reusable, 5_500, 50, 24); + + expect(result).toBeUndefined(); + expect(reusable).toEqual(paragraphStressMotionFrame(5_500, 50, 24)); + expect(paragraphStressScrollProgress(5_500, 50)).toBe(reusable.scrollProgress); + }); }); diff --git a/apps/benchmarks/src/benchmark/paragraph-stress-motion.ts b/apps/benchmarks/src/benchmark/paragraph-stress-motion.ts index f761ec40..c938bfc5 100644 --- a/apps/benchmarks/src/benchmark/paragraph-stress-motion.ts +++ b/apps/benchmarks/src/benchmark/paragraph-stress-motion.ts @@ -9,11 +9,53 @@ export interface ParagraphStressMotionFrame { readonly scrollProgress: number; } +export interface MutableParagraphStressMotionFrame { + fontSize: number; + layoutWidthPercent: number; + scrollProgress: number; +} + export function paragraphStressMotionFrame( elapsedMs: number, animationSpeed: number, startFontSize: number, ): ParagraphStressMotionFrame { + const frame: MutableParagraphStressMotionFrame = { fontSize: 0, layoutWidthPercent: 0, scrollProgress: 0 }; + setParagraphStressMotionFrame(frame, elapsedMs, animationSpeed, startFontSize); + return frame; +} + +/** Updates caller-owned motion state so a render loop does not allocate a frame record. */ +export function setParagraphStressMotionFrame( + frame: MutableParagraphStressMotionFrame, + elapsedMs: number, + animationSpeed: number, + startFontSize: number, +): void { + validateParagraphStressMotion(elapsedMs, animationSpeed, startFontSize); + const cycle = paragraphStressCycle(elapsedMs, animationSpeed); + const widthProgress = easeOutCubic(clamp01(cycle / 0.22)); + const fontProgress = easeInOutCubic(clamp01((cycle - 0.22) / 0.3)); + + frame.fontSize = Math.round(startFontSize + (END_FONT_SIZE - startFontSize) * fontProgress); + frame.layoutWidthPercent = Math.round( + START_WIDTH_PERCENT + (END_WIDTH_PERCENT - START_WIDTH_PERCENT) * widthProgress, + ); + frame.scrollProgress = paragraphStressScrollProgressForCycle(cycle); +} + +/** Computes the render-loop-only scroll component without creating a complete motion frame. */ +export function paragraphStressScrollProgress(elapsedMs: number, animationSpeed: number): number { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + throw new RangeError('paragraph stress elapsed time must be finite and nonnegative'); + } + if (!Number.isFinite(animationSpeed) || animationSpeed < 0 || animationSpeed > 100) { + throw new RangeError('paragraph stress animation speed must be in [0, 100]'); + } + return paragraphStressScrollProgressForCycle(paragraphStressCycle(elapsedMs, animationSpeed)); +} + +function validateParagraphStressMotion(elapsedMs: number, animationSpeed: number, startFontSize: number): void { if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { throw new RangeError('paragraph stress elapsed time must be finite and nonnegative'); } @@ -23,18 +65,15 @@ export function paragraphStressMotionFrame( if (!Number.isFinite(startFontSize) || startFontSize < END_FONT_SIZE) { throw new RangeError(`paragraph stress start font size must be at least ${END_FONT_SIZE}`); } +} +function paragraphStressCycle(elapsedMs: number, animationSpeed: number): number { const animationRate = 0.25 + animationSpeed * 0.0175; - const cycle = ((elapsedMs / PARAGRAPH_STRESS_CYCLE_MS) * animationRate) % 1; - const widthProgress = easeOutCubic(clamp01(cycle / 0.22)); - const fontProgress = easeInOutCubic(clamp01((cycle - 0.22) / 0.3)); - const scrollProgress = easeInOutCubic(clamp01((cycle - 0.52) / 0.4)); + return ((elapsedMs / PARAGRAPH_STRESS_CYCLE_MS) * animationRate) % 1; +} - return { - fontSize: Math.round(startFontSize + (END_FONT_SIZE - startFontSize) * fontProgress), - layoutWidthPercent: Math.round(START_WIDTH_PERCENT + (END_WIDTH_PERCENT - START_WIDTH_PERCENT) * widthProgress), - scrollProgress, - }; +function paragraphStressScrollProgressForCycle(cycle: number): number { + return easeInOutCubic(clamp01((cycle - 0.52) / 0.4)); } function clamp01(value: number): number { diff --git a/apps/benchmarks/src/components/workload-rail.tsx b/apps/benchmarks/src/components/workload-rail.tsx index 4e196ca5..ade381af 100644 --- a/apps/benchmarks/src/components/workload-rail.tsx +++ b/apps/benchmarks/src/components/workload-rail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'; import type { AdvancedShapingFrame } from '../benchmark/advanced-shaping'; import { ADVANCED_FONT_FIXTURES, @@ -72,6 +72,14 @@ function workloadRailDescription(workload: WorkloadOption, technique: RasterTech return status.kind === 'ready' ? workload.description : `M${status.milestone} · ${workload.description}`; } +function synchronizeScrollEdges( + element: HTMLDivElement, + setEdges: Dispatch>, +): void { + const next = workloadScrollEdges(element); + setEdges((current) => (current.before === next.before && current.after === next.after ? current : next)); +} + export function WorkloadRail({ activeFontFixture, className = '', @@ -112,38 +120,27 @@ export function WorkloadRail({ const [scrollEdges, setScrollEdges] = useState({ before: false, after: false }); const fixtureScrollRef = useRef(null); const [fixtureScrollEdges, setFixtureScrollEdges] = useState({ before: false, after: false }); - const syncScrollEdges = useCallback((element: HTMLDivElement) => { - const next = workloadScrollEdges(element); - setScrollEdges((current) => (current.before === next.before && current.after === next.after ? current : next)); - }, []); - const syncFixtureScrollEdges = useCallback((element: HTMLDivElement) => { - const next = workloadScrollEdges(element); - setFixtureScrollEdges((current) => - current.before === next.before && current.after === next.after ? current : next, - ); - }, []); - useEffect(() => { const element = workloadScrollRef.current; if (element === null) return; - const observer = new ResizeObserver(() => syncScrollEdges(element)); + const observer = new ResizeObserver(() => synchronizeScrollEdges(element, setScrollEdges)); observer.observe(element); const content = element.firstElementChild; if (content !== null) observer.observe(content); - syncScrollEdges(element); + synchronizeScrollEdges(element, setScrollEdges); return () => observer.disconnect(); - }, [location.mode, syncScrollEdges]); + }, [location.mode]); useEffect(() => { const element = fixtureScrollRef.current; if (element === null) return; - const observer = new ResizeObserver(() => syncFixtureScrollEdges(element)); + const observer = new ResizeObserver(() => synchronizeScrollEdges(element, setFixtureScrollEdges)); observer.observe(element); const content = element.firstElementChild; if (content !== null) observer.observe(content); - syncFixtureScrollEdges(element); + synchronizeScrollEdges(element, setFixtureScrollEdges); return () => observer.disconnect(); - }, [activeFontFixture, location.technique, location.workload, syncFixtureScrollEdges]); + }, [activeFontFixture, location.technique, location.workload]); const fixtureOptions: readonly FontFixtureButtonOption[] = location.workload === 'icon-grid' @@ -197,7 +194,7 @@ export function WorkloadRail({ data-scroll-before={String(scrollEdges.before)} data-testid="workload-scroll" ref={workloadScrollRef} - onScroll={(event) => syncScrollEdges(event.currentTarget)} + onScroll={(event) => synchronizeScrollEdges(event.currentTarget, setScrollEdges)} >