diff --git a/apps/benchmarks/src/app.tsx b/apps/benchmarks/src/app.tsx index 80624525..ee7c6305 100644 --- a/apps/benchmarks/src/app.tsx +++ b/apps/benchmarks/src/app.tsx @@ -111,16 +111,8 @@ 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 loadBenchmarkFontAssets() { + return import('./workloads/font-assets'); } function preloadComparisonWorkload(): ReturnType { @@ -152,16 +144,8 @@ async function preloadPresentationAssets( ): Promise { if (delivery !== 'baked') return; const fixtures = Array.from(new Set([selectedFont, ...PRESENTATION_FONT_FIXTURES])); - if (technique === 'bitmap') { - const { preloadBitmapFontAssets } = await loadBitmapTextRenderer(); - await preloadBitmapFontAssets(fixtures, signal); - } else if (technique === 'mtsdf') { - const { preloadMtsdfFontAssets } = await loadMtsdfTextRenderer(); - await preloadMtsdfFontAssets(fixtures, signal); - } else { - const { preloadSlugFontAssets } = await loadSlugTextRenderer(); - await preloadSlugFontAssets(fixtures, signal); - } + const { preloadBenchmarkFontAssets } = await loadBenchmarkFontAssets(); + await preloadBenchmarkFontAssets({ technique, fixtures, signal, bitmapDensity: 'live' }); } function liveSceneAssetResource( @@ -180,16 +164,8 @@ function liveSceneAssetResource( const resource = (async () => { if (comparison) await preloadComparisonWorkload(); if (delivery !== 'baked') return; - if (technique === 'bitmap') { - const { preloadBitmapFontAssets } = await loadBitmapTextRenderer(); - await preloadBitmapFontAssets(fixtures); - } else if (technique === 'mtsdf') { - const { preloadMtsdfFontAssets } = await loadMtsdfTextRenderer(); - await preloadMtsdfFontAssets(fixtures); - } else { - const { preloadSlugFontAssets } = await loadSlugTextRenderer(); - await preloadSlugFontAssets(fixtures); - } + const { preloadBenchmarkFontAssets } = await loadBenchmarkFontAssets(); + await preloadBenchmarkFontAssets({ technique, fixtures, bitmapDensity: 'live' }); })(); liveSceneAssetResources.set(key, resource); void resource.catch(() => liveSceneAssetResources.delete(key)); diff --git a/apps/benchmarks/src/renderer/bitmap-text.ts b/apps/benchmarks/src/renderer/bitmap-text.ts index 5b0d0297..e4c85978 100644 --- a/apps/benchmarks/src/renderer/bitmap-text.ts +++ b/apps/benchmarks/src/renderer/bitmap-text.ts @@ -18,22 +18,6 @@ import { } from '@pmndrs/text/raster/bitmap'; import * as THREE from 'three/webgpu'; -import amiriBitmapFontUrl from '../../fixtures/rendering/amiri-bitmap-16.font.glb?url'; -import amiriBitmapDensityFontUrl from '../../fixtures/rendering/amiri-bitmap-16-32.font.glb?url'; -import dotGothicBitmapFontUrl from '../../fixtures/rendering/dot-gothic-16-bitmap-16.font.glb?url'; -import dotGothicBitmapDensityFontUrl from '../../fixtures/rendering/dot-gothic-16-bitmap-16-32.font.glb?url'; -import dancingScriptBitmapFontUrl from '../../fixtures/rendering/dancing-script-bitmap-16.font.glb?url'; -import dancingScriptBitmapDensityFontUrl from '../../fixtures/rendering/dancing-script-bitmap-16-32.font.glb?url'; -import fontAwesomeBitmapFontUrl from '../../fixtures/rendering/font-awesome-free-6.7.2-bitmap-16.font.glb?url'; -import fontAwesomeBitmapDensityFontUrl from '../../fixtures/rendering/font-awesome-free-6.7.2-bitmap-16-32.font.glb?url'; -import bitmapFontUrl from '../../fixtures/rendering/inter-bitmap-16.font.glb?url'; -import bitmapDensityFontUrl from '../../fixtures/rendering/inter-bitmap-16-32.font.glb?url'; -import devanagariBitmapFontUrl from '../../fixtures/rendering/noto-sans-devanagari-bitmap-16.font.glb?url'; -import devanagariBitmapDensityFontUrl from '../../fixtures/rendering/noto-sans-devanagari-bitmap-16-32.font.glb?url'; -import notoCjkShowcaseBitmapFontUrl from '../../fixtures/rendering/noto-sans-cjk-showcase-bitmap-16.font.glb?url'; -import notoCjkShowcaseBitmapDensityFontUrl from '../../fixtures/rendering/noto-sans-cjk-showcase-bitmap-16-32.font.glb?url'; -import sourceSerifBitmapFontUrl from '../../fixtures/rendering/source-serif-4-bitmap-16.font.glb?url'; -import sourceSerifBitmapDensityFontUrl from '../../fixtures/rendering/source-serif-4-bitmap-16-32.font.glb?url'; import { conformanceText, type BenchmarkFontFixture, type SelectableFontFixture } from '../benchmark/font-fixtures'; import type { FontDelivery } from '../benchmark/url-state'; import type { BenchmarkTarget, TargetRunOutput } from '../benchmark/contracts'; @@ -71,12 +55,8 @@ import { } from './persistent-render-host'; import { createPersistentSceneActivation } from './persistent-scene-activation'; import { withRendererStateRestored } from './renderer-state-transaction'; -import { - createFontDeliveryMetrics, - loadRuntimeFont, - measuredBitmapRaster, - type FontDeliveryMetrics, -} from './font-delivery'; +import { loadBitmapFontAsset } from '../workloads/font-assets/bitmap'; +import type { BitmapFixtureDensity } from '../workloads/font-assets'; const WIDTH = 384; const HEIGHT = 128; @@ -86,38 +66,8 @@ const BITMAP_FONT_SIZE = 16; const CONFORMANCE_BITMAP_STRIKES = [16] as const; const LIVE_BITMAP_STRIKES = [16, 32] as const; const bitmapRequest = bitmap({ strikes: CONFORMANCE_BITMAP_STRIKES }); -const liveBitmapRequest = bitmap({ strikes: LIVE_BITMAP_STRIKES }); -export type BitmapFixtureDensity = 'conformance' | 'live'; -const bitmapFontUrls: Readonly> = { - inter: bitmapFontUrl, - amiri: amiriBitmapFontUrl, - 'noto-sans-devanagari': devanagariBitmapFontUrl, - 'noto-sans-cjk-showcase': notoCjkShowcaseBitmapFontUrl, - 'dot-gothic-16': dotGothicBitmapFontUrl, - 'font-awesome-free-6.7.2': fontAwesomeBitmapFontUrl, - 'source-serif-4': sourceSerifBitmapFontUrl, - 'dancing-script': dancingScriptBitmapFontUrl, -}; -const bitmapDensityFontUrls: Readonly> = { - inter: bitmapDensityFontUrl, - amiri: amiriBitmapDensityFontUrl, - 'noto-sans-devanagari': devanagariBitmapDensityFontUrl, - 'noto-sans-cjk-showcase': notoCjkShowcaseBitmapDensityFontUrl, - 'dot-gothic-16': dotGothicBitmapDensityFontUrl, - 'font-awesome-free-6.7.2': fontAwesomeBitmapDensityFontUrl, - 'source-serif-4': sourceSerifBitmapDensityFontUrl, - 'dancing-script': dancingScriptBitmapDensityFontUrl, -}; - -export async function preloadBitmapFontAssets( - fixtures: readonly BenchmarkFontFixture[], - signal?: AbortSignal, -): Promise { - await preloadFontUrls( - fixtures.map((fixture) => bitmapDensityFontUrls[fixture]), - signal, - ); -} +export { preloadBitmapFontAssets } from '../workloads/font-assets/bitmap'; +export type { BitmapFixtureDensity } from '../workloads/font-assets/bitmap'; interface BitmapTextResources { readonly backend: RendererBackend; @@ -489,50 +439,16 @@ export async function loadBitmapFont( density: BitmapFixtureDensity = 'conformance', onProgress?: import('@pmndrs/text').BakeProgressListener, registry = new FontRegistry(), -): Promise<{ - readonly artifactBytes: number; - readonly font: RegisteredFont; - readonly metrics: FontDeliveryMetrics; - readonly raster: ReturnType; -}> { - signal?.throwIfAborted(); - const metrics = createFontDeliveryMetrics(delivery); - const raster = density === 'live' ? liveBitmapRequest : bitmapRequest; - if (delivery === 'runtime') { - const loaded = await loadRuntimeFont(fixture, metrics, signal, onProgress, registry); - return { - artifactBytes: metrics.coreArtifactBytes, - font: loaded.font, - metrics, - raster: measuredBitmapRaster(metrics, density, onProgress), - }; - } - let font: RegisteredFont | undefined; - try { - const fontResponse = await fetch( - (density === 'live' ? bitmapDensityFontUrls : bitmapFontUrls)[fixture], - signal === undefined ? undefined : { signal }, - ); - if (!fontResponse.ok) throw new Error(`Unable to load bitmap font fixture (${fontResponse.status})`); - const fontBytes = await fontResponse.arrayBuffer(); - signal?.throwIfAborted(); - font = await registry.registerAsset(new Uint8Array(fontBytes)); - signal?.throwIfAborted(); - return { artifactBytes: fontBytes.byteLength, font, metrics, raster }; - } catch (error) { - font?.dispose(); - throw error; - } -} - -async function preloadFontUrls(urls: readonly string[], signal?: AbortSignal): Promise { - await Promise.all( - urls.map(async (url) => { - const response = await fetch(url, signal === undefined ? undefined : { signal }); - if (!response.ok) throw new Error(`Unable to preload bitmap font fixture (${response.status})`); - await response.arrayBuffer(); - }), - ); +): Promise>> { + return loadBitmapFontAsset({ + technique: 'bitmap', + fixture, + delivery, + bitmapDensity: density, + ...(registry === undefined ? {} : { registry }), + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }); } async function createBitmapLine( diff --git a/apps/benchmarks/src/renderer/comparison-workload.ts b/apps/benchmarks/src/renderer/comparison-workload.ts index 6bb47c05..56a193bb 100644 --- a/apps/benchmarks/src/renderer/comparison-workload.ts +++ b/apps/benchmarks/src/renderer/comparison-workload.ts @@ -31,19 +31,18 @@ import type { ComparisonWorkloadId, } from '../workloads/contracts'; import { committedTextLayout, type ComparisonWorkloadEntry } from '../workloads/factory-contracts'; -import { loadBitmapFont, registeredBitmapAtlas, type BitmapTextLiveStats } from './bitmap-text'; +import { registeredBitmapAtlas, type BitmapTextLiveStats } from './bitmap-text'; import { createCanvasSurface } from './canvas-surface'; import { createGpuFrameTimer, type GpuFrameTimer } from './gpu-frame-timer'; import { createLiveFrameTelemetry, type LiveFrameTelemetrySnapshot } from './live-frame-telemetry'; import { createTextUpdateTelemetry } from './text-update-telemetry'; -import type { FontDeliveryMetrics } from './font-delivery'; -import { benchmarkContentWidth } from '../workloads/shared/text-style'; import { - loadMtsdfFont, - registeredMtsdfConfiguration, - type MtsdfRasterConfiguration, - type MtsdfTextLiveStats, -} from './mtsdf-text'; + loadBenchmarkFontAsset, + type BakedSlugArtifactSource, + type FontDeliveryMetrics, +} from '../workloads/font-assets'; +import { benchmarkContentWidth } from '../workloads/shared/text-style'; +import { registeredMtsdfConfiguration, type MtsdfRasterConfiguration, type MtsdfTextLiveStats } from './mtsdf-text'; import type { SlugRasterConfiguration, SlugTextLiveStats } from './slug-text'; import { createConfiguredRenderer, @@ -170,7 +169,7 @@ export interface ComparisonWorkloadPreviewOptions { readonly showLayoutBounds: boolean; readonly textLadderExitEnabled: boolean; readonly signal?: AbortSignal; - readonly slugBakedArtifact?: import('./slug-text').SlugBakedArtifactSource; + readonly slugBakedArtifact?: BakedSlugArtifactSource; readonly technique: RasterTechnique; readonly textLadderSpecimen?: RasterConformanceSpecimen; readonly width: number; @@ -1429,12 +1428,20 @@ async function loadTechniqueFont( delivery: FontDelivery, signal?: AbortSignal, onBakeProgress?: import('@pmndrs/text').BakeProgressListener, - slugBakedArtifact?: import('./slug-text').SlugBakedArtifactSource, + slugBakedArtifact?: BakedSlugArtifactSource, registry?: FontRegistry, ): Promise { const startedAt = performance.now(); if (technique === 'bitmap') { - const loaded = await loadBitmapFont(signal, fontFixture, delivery, 'live', onBakeProgress, registry); + const loaded = await loadBenchmarkFontAsset({ + technique, + fixture: fontFixture, + delivery, + bitmapDensity: 'live', + ...(registry === undefined ? {} : { registry }), + signal, + onProgress: onBakeProgress, + }); const atlas = await registeredBitmapAtlas(loaded.font, 'live'); return { artifactBytes: loaded.artifactBytes, @@ -1448,7 +1455,14 @@ async function loadTechniqueFont( }; } if (technique === 'mtsdf') { - const loaded = await loadMtsdfFont(signal, fontFixture, delivery, onBakeProgress, registry); + const loaded = await loadBenchmarkFontAsset({ + technique, + fixture: fontFixture, + delivery, + ...(registry === undefined ? {} : { registry }), + signal, + onProgress: onBakeProgress, + }); const mtsdfConfiguration = await registeredMtsdfConfiguration(loaded.font, signal); return { artifactBytes: loaded.compressedBytes, @@ -1462,11 +1476,27 @@ async function loadTechniqueFont( raster: loaded.raster, }; } - const { loadSlugBakedArtifact, loadSlugFont, registeredSlugConfiguration } = await import('./slug-text'); - const loaded = - slugBakedArtifact === undefined - ? await loadSlugFont(signal, fontFixture, delivery, onBakeProgress, registry) - : await loadSlugBakedArtifact(slugBakedArtifact, signal, registry); + const { registeredSlugConfiguration } = await import('./slug-text'); + const loaded = await loadBenchmarkFontAsset( + delivery === 'runtime' + ? { + technique, + fixture: fontFixture, + delivery, + ...(registry === undefined ? {} : { registry }), + signal, + onProgress: onBakeProgress, + } + : { + technique, + fixture: fontFixture, + delivery, + ...(slugBakedArtifact === undefined ? {} : { bakedArtifact: slugBakedArtifact }), + ...(registry === undefined ? {} : { registry }), + signal, + onProgress: onBakeProgress, + }, + ); const slugConfiguration = await registeredSlugConfiguration(loaded.font, signal); return { artifactBytes: loaded.compressedBytes, diff --git a/apps/benchmarks/src/renderer/external-raster-proof.ts b/apps/benchmarks/src/renderer/external-raster-proof.ts index 0118795e..6d668eeb 100644 --- a/apps/benchmarks/src/renderer/external-raster-proof.ts +++ b/apps/benchmarks/src/renderer/external-raster-proof.ts @@ -1,10 +1,10 @@ -import { Text } from '@pmndrs/text'; +import { FontRegistry, Text } from '@pmndrs/text'; import { glyphExample } from '@pmndrs/text-glyph-example-raster'; import * as THREE from 'three/webgpu'; import type { BenchmarkTarget, TargetRunOutput } from '../benchmark/contracts'; import { compactRgba8Readback } from './tsl-baseline'; -import { createFontDeliveryMetrics, loadRuntimeFont } from './font-delivery'; +import { loadBenchmarkFontAsset } from '../workloads/font-assets'; import type { PersistentRenderSceneRenderer } from './persistent-render-host'; import { withRendererStateRestored } from './renderer-state-transaction'; import { createConfiguredRenderer, disposeConfiguredRenderer, type RendererBackend } from './webgpu-renderer'; @@ -97,7 +97,14 @@ async function createResources( }); target.texture.colorSpace = THREE.NoColorSpace; target.texture.generateMipmaps = false; - ({ font } = await loadRuntimeFont('inter', createFontDeliveryMetrics('runtime'), signal)); + ({ font } = await loadBenchmarkFontAsset({ + technique: 'bitmap', + fixture: 'inter', + delivery: 'runtime', + bitmapDensity: 'conformance', + registry: new FontRegistry(), + signal, + })); signal?.throwIfAborted(); text = new Text({ text: INITIAL_TEXT, diff --git a/apps/benchmarks/src/renderer/font-delivery.ts b/apps/benchmarks/src/renderer/font-delivery.ts deleted file mode 100644 index b3b32db7..00000000 --- a/apps/benchmarks/src/renderer/font-delivery.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - FontLoader, - FontRegistry, - defineRaster, - type RasterBakeArtifact, - type BakeProgressListener, - type RegisteredFont, - type RuntimeFontBakeRequest, - type RuntimeRasterBakerModule, -} from '@pmndrs/text'; -import { bitmap, type BitmapModule } from '@pmndrs/text/raster/bitmap'; -import { msdf, type MsdfModule } from '@pmndrs/text/raster/msdf'; - -import amiriSourceUrl from '../../fixtures/fonts/amiri-1.002/Amiri-Regular.ttf?url'; -import dancingScriptSourceUrl from '../../fixtures/fonts/dancing-script-3.000/DancingScript-Regular.otf?url'; -import dotGothicSourceUrl from '../../fixtures/fonts/dot-gothic-16/DotGothic16-Regular.ttf?url'; -import fontAwesomeSourceUrl from '../../fixtures/fonts/font-awesome-free-6.7.2/fa-solid-900.ttf?url'; -import interSourceUrl from '../../fixtures/fonts/inter-v4.1/Inter-Regular.ttf?url'; -import notoCjkSourceUrl from '../../fixtures/fonts/noto-sans-cjk-showcase-v0/NotoSansCJKjp-Showcase.otf?url'; -import devanagariSourceUrl from '../../fixtures/fonts/noto-sans-devanagari/NotoSansDevanagari.ttf?url'; -import sourceSerifSourceUrl from '../../fixtures/fonts/source-serif-4.005/SourceSerif4-Regular.ttf?url'; -import type { BenchmarkFontFixture } from '../benchmark/font-fixtures'; -import type { FontDelivery } from '../benchmark/url-state'; - -const sourceUrls: Readonly> = { - inter: interSourceUrl, - amiri: amiriSourceUrl, - 'noto-sans-devanagari': devanagariSourceUrl, - 'noto-sans-cjk-showcase': notoCjkSourceUrl, - 'dot-gothic-16': dotGothicSourceUrl, - 'font-awesome-free-6.7.2': fontAwesomeSourceUrl, - 'source-serif-4': sourceSerifSourceUrl, - 'dancing-script': dancingScriptSourceUrl, -}; - -export interface FontDeliveryMetrics { - readonly delivery: FontDelivery; - sourceFontBytes: number; - coreArtifactBytes: number; - coreBakeMs: number; - rasterArtifactBytes: number; - rasterBakeMs: number; - rasterGpuBytes: number; -} - -export interface RuntimeLoadedFont { - readonly font: RegisteredFont; - readonly metrics: FontDeliveryMetrics; -} - -export function createFontDeliveryMetrics(delivery: FontDelivery): FontDeliveryMetrics { - return { - delivery, - sourceFontBytes: 0, - coreArtifactBytes: 0, - coreBakeMs: 0, - rasterArtifactBytes: 0, - rasterBakeMs: 0, - rasterGpuBytes: 0, - }; -} - -export async function loadRuntimeFont( - fixture: BenchmarkFontFixture, - metrics: FontDeliveryMetrics, - signal?: AbortSignal, - onProgress?: BakeProgressListener, - registry = new FontRegistry(), -): Promise { - const loader = new FontLoader({ - registry, - runtimeBake: async (request: RuntimeFontBakeRequest) => { - metrics.sourceFontBytes = request.source.byteLength; - const started = performance.now(); - const { bakeFontInWorker } = await import('@pmndrs/text/runtime-bake'); - const artifact = await bakeFontInWorker({ - ...request, - ...(onProgress === undefined ? {} : { onProgress }), - }); - metrics.coreBakeMs = performance.now() - started; - metrics.coreArtifactBytes = artifact.byteLength; - return artifact; - }, - }); - return { - font: await loader.load( - { source: sourceUrls[fixture], baked: null }, - signal === undefined ? undefined : { signal }, - ), - metrics, - }; -} - -export function measuredBitmapRaster( - metrics: FontDeliveryMetrics, - density: 'conformance' | 'live' = 'conformance', - onProgress?: BakeProgressListener, -) { - const base = density === 'live' ? bitmap({ strikes: [16, 32] as const }) : bitmap({ strikes: [16] as const }); - const runtimeBaker = measuredRuntimeBaker(base.module.runtimeBaker, metrics, onProgress); - const module: BitmapModule = defineRaster({ - ...base.module, - ...(runtimeBaker === undefined ? {} : { runtimeBaker }), - }); - return { module, options: base.options }; -} - -export function measuredMsdfRaster(metrics: FontDeliveryMetrics, onProgress?: BakeProgressListener): MsdfModule { - const runtimeBaker = measuredRuntimeBaker(msdf.runtimeBaker, metrics, onProgress); - return defineRaster({ - ...msdf, - ...(runtimeBaker === undefined ? {} : { runtimeBaker }), - }); -} - -function measuredRuntimeBaker( - load: - | (() => Promise< - RuntimeRasterBakerModule | { readonly default: RuntimeRasterBakerModule } - >) - | undefined, - metrics: FontDeliveryMetrics, - onProgress?: BakeProgressListener, -) { - if (load === undefined) return undefined; - return async (): Promise> => { - const started = performance.now(); - const imported = await load(); - const baker = 'default' in imported ? imported.default : imported; - return { - kind: baker.kind, - async bake(request) { - const artifact = await baker.bake({ - ...request, - ...(onProgress === undefined ? {} : { onProgress }), - }); - metrics.rasterBakeMs = performance.now() - started; - metrics.rasterArtifactBytes = rasterArtifactBytes(artifact); - metrics.rasterGpuBytes = artifact.report.gpuBytes; - return artifact; - }, - }; - }; -} - -function rasterArtifactBytes(artifact: RasterBakeArtifact): number { - return artifact.artifacts.reduce((total, entry) => total + entry.bytes.byteLength, 0); -} diff --git a/apps/benchmarks/src/renderer/mtsdf-text.ts b/apps/benchmarks/src/renderer/mtsdf-text.ts index ec8cdb87..7be057fc 100644 --- a/apps/benchmarks/src/renderer/mtsdf-text.ts +++ b/apps/benchmarks/src/renderer/mtsdf-text.ts @@ -1,16 +1,7 @@ import { FontRegistry, Text, type FontFeature, type ParagraphLayout, type RegisteredFont } from '@pmndrs/text'; -import { msdf, msdfDescriptorRasterKey, type MsdfModule, type MsdfResource } from '@pmndrs/text/raster/msdf'; +import { msdf, msdfDescriptorRasterKey, type MsdfResource } from '@pmndrs/text/raster/msdf'; import * as THREE from 'three/webgpu'; -import amiriCompressedFontUrl from '../../fixtures/rendering/amiri-mtsdf.font.glb.gz?url'; -import dancingScriptCompressedFontUrl from '../../fixtures/rendering/dancing-script-mtsdf.font.glb.gz?url'; -import dotGothicCompressedFontUrl from '../../fixtures/rendering/dot-gothic-16-mtsdf.font.glb.gz?url'; -import fontAwesomeCompressedFontUrl from '../../fixtures/rendering/font-awesome-free-6.7.2-mtsdf.font.glb.gz?url'; -import interCompressedFontUrl from '../../fixtures/rendering/inter-mtsdf.font.glb.gz?url'; -import devanagariCompressedFontUrl from '../../fixtures/rendering/noto-sans-devanagari-mtsdf.font.glb.gz?url'; -import notoCjkShowcaseCompressedFontUrl from '../../fixtures/rendering/noto-sans-cjk-showcase-mtsdf.font.glb.gz?url'; -import sourceSerifCompressedFontUrl from '../../fixtures/rendering/source-serif-4-mtsdf.font.glb.gz?url'; -import showcaseManifest from '../../fixtures/rendering/showcase-mtsdf-fixtures-v0.json'; import { conformanceText, type BenchmarkFontFixture, type SelectableFontFixture } from '../benchmark/font-fixtures'; import type { BenchmarkTarget, TargetRunOutput } from '../benchmark/contracts'; import type { FontDelivery } from '../benchmark/url-state'; @@ -43,55 +34,13 @@ import { } from './persistent-render-host'; import { createPersistentSceneActivation } from './persistent-scene-activation'; import { withRendererStateRestored } from './renderer-state-transaction'; -import { - createFontDeliveryMetrics, - loadRuntimeFont, - measuredMsdfRaster, - type FontDeliveryMetrics, -} from './font-delivery'; +import { loadMtsdfFontAsset, MTSDF_FIXTURE_ARTIFACT_BYTE_LIMIT } from '../workloads/font-assets/mtsdf'; const WIDTH = 512; const HEIGHT = 320; const FLAT_CONFORMANCE_HEIGHT = 512; -interface MtsdfFixtureManifest { - readonly fontFixture: BenchmarkFontFixture; - readonly compressed: { readonly bytes: number; readonly sha256: string }; - readonly uncompressed: { readonly bytes: number; readonly sha256: string }; - readonly raster: { - readonly runtimeTextureArray: { readonly basePaddedGpuBytes: number }; - }; -} - -const compressedFontUrls: Readonly> = { - inter: interCompressedFontUrl, - amiri: amiriCompressedFontUrl, - 'noto-sans-devanagari': devanagariCompressedFontUrl, - 'noto-sans-cjk-showcase': notoCjkShowcaseCompressedFontUrl, - 'dot-gothic-16': dotGothicCompressedFontUrl, - 'font-awesome-free-6.7.2': fontAwesomeCompressedFontUrl, - 'source-serif-4': sourceSerifCompressedFontUrl, - 'dancing-script': dancingScriptCompressedFontUrl, -}; - -export async function preloadMtsdfFontAssets( - fixtures: readonly BenchmarkFontFixture[], - signal?: AbortSignal, -): Promise { - await Promise.all( - fixtures.map(async (fixture) => { - const response = await fetch(compressedFontUrls[fixture], signal === undefined ? undefined : { signal }); - if (!response.ok) throw new Error(`Unable to preload MTSDF font fixture (${response.status})`); - await response.arrayBuffer(); - }), - ); -} -const mtsdfFixtureManifests = new Map( - showcaseManifest.artifacts.map((artifact) => [artifact.fontFixture, artifact]), -) as ReadonlyMap; -const MTSDF_FIXTURE_ARTIFACT_BYTE_LIMIT = Math.max( - ...Array.from(mtsdfFixtureManifests.values(), ({ uncompressed }) => uncompressed.bytes), -); +export { preloadMtsdfFontAssets } from '../workloads/font-assets/mtsdf'; interface MtsdfTextResources { readonly backend: RendererBackend; @@ -866,46 +815,15 @@ export async function loadMtsdfFont( delivery: FontDelivery = 'baked', onProgress?: import('@pmndrs/text').BakeProgressListener, registry?: FontRegistry, -): Promise<{ - readonly artifactBytes: number; - readonly atlasGpuBytes: number; - readonly compressedBytes: number; - readonly font: RegisteredFont; - readonly metrics: FontDeliveryMetrics; - readonly raster: MsdfModule; -}> { - signal?.throwIfAborted(); - const metrics = createFontDeliveryMetrics(delivery); - const manifest = mtsdfFixtureManifests.get(fixture); - if (manifest === undefined) throw new RangeError(`Unknown MTSDF font fixture: ${fixture}`); - if (delivery === 'runtime') { - const loaded = await loadRuntimeFont(fixture, metrics, signal, onProgress, registry ?? new FontRegistry()); - return { - artifactBytes: metrics.coreArtifactBytes, - atlasGpuBytes: 0, - compressedBytes: metrics.sourceFontBytes, - font: loaded.font, - metrics, - raster: measuredMsdfRaster(metrics, onProgress), - }; - } - const response = await fetch(compressedFontUrls[fixture], signal === undefined ? undefined : { signal }); - if (!response.ok) throw new Error(`Unable to load MTSDF font fixture (${response.status})`); - const received = new Uint8Array(await response.arrayBuffer()); - signal?.throwIfAborted(); - const artifact = - received.byteLength === manifest.uncompressed.bytes ? received : await decompressFixture(received, manifest); - await assertFixtureBytes(artifact, manifest.uncompressed, 'uncompressed'); - signal?.throwIfAborted(); - const activeRegistry = registry ?? new FontRegistry({ maxArtifactBytes: manifest.uncompressed.bytes }); - return { - artifactBytes: artifact.byteLength, - atlasGpuBytes: manifest.raster.runtimeTextureArray.basePaddedGpuBytes, - compressedBytes: manifest.compressed.bytes, - font: await activeRegistry.registerAsset(artifact), - metrics, - raster: msdf, - }; +): Promise>> { + return loadMtsdfFontAsset({ + technique: 'mtsdf', + fixture, + delivery, + ...(registry === undefined ? {} : { registry }), + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }); } export interface MtsdfRasterConfiguration { @@ -973,31 +891,6 @@ function assertLayoutWidthRatio(value: number): void { } } -async function decompressFixture( - compressed: Uint8Array, - manifest: MtsdfFixtureManifest, -): Promise> { - await assertFixtureBytes(compressed, manifest.compressed, 'compressed'); - return decompressGzip(compressed); -} - -async function decompressGzip(compressed: Uint8Array): Promise> { - const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream('gzip')); - return new Uint8Array(await new Response(stream).arrayBuffer()); -} - -async function assertFixtureBytes( - bytes: Uint8Array, - expected: { readonly bytes: number; readonly sha256: string }, - label: string, -): Promise { - if (bytes.byteLength !== expected.bytes) { - throw new Error(`MTSDF ${label} fixture has ${bytes.byteLength} bytes; expected ${expected.bytes}`); - } - const hash = hex(await crypto.subtle.digest('SHA-256', bytes)); - if (hash !== expected.sha256) throw new Error(`MTSDF ${label} fixture failed SHA-256`); -} - async function renderMtsdfText(resources: MtsdfTextResources): Promise { const rendered = await renderMtsdfFrame(resources); const { bytes, renderMs, pixelEvidence } = rendered; diff --git a/apps/benchmarks/src/renderer/slug-text.ts b/apps/benchmarks/src/renderer/slug-text.ts index 6e61c4bb..e4ff62ec 100644 --- a/apps/benchmarks/src/renderer/slug-text.ts +++ b/apps/benchmarks/src/renderer/slug-text.ts @@ -2,34 +2,23 @@ import { FontLoader, FontRegistry, Text, - defineRaster, type BakeProgressListener, type FontFeature, type ParagraphLayout, - type RasterBakeArtifact, type RegisteredFont, - type RuntimeRasterBakerModule, type TextSpan, } from '@pmndrs/text'; import { slug, slugDescriptorRasterKey, type SlugModule, type SlugResource } from '@pmndrs/text/raster/slug'; import * as THREE from 'three/webgpu'; -import amiriCompressedFontUrl from '../../fixtures/rendering/amiri-slug.font.glb.gz?url'; -import dancingScriptCompressedFontUrl from '../../fixtures/rendering/dancing-script-slug.font.glb.gz?url'; -import dotGothicCompressedFontUrl from '../../fixtures/rendering/dot-gothic-16-slug.font.glb.gz?url'; -import fontAwesomeCompressedFontUrl from '../../fixtures/rendering/font-awesome-free-6.7.2-slug.font.glb.gz?url'; -import interCompressedFontUrl from '../../fixtures/rendering/inter-slug.font.glb.gz?url'; -import devanagariCompressedFontUrl from '../../fixtures/rendering/noto-sans-devanagari-slug.font.glb.gz?url'; -import notoCjkShowcaseCompressedFontUrl from '../../fixtures/rendering/noto-sans-cjk-showcase-slug.font.glb.gz?url'; -import sourceSerifCompressedFontUrl from '../../fixtures/rendering/source-serif-4-slug.font.glb.gz?url'; -import showcaseManifest from '../../fixtures/rendering/showcase-slug-fixtures-v0.json'; import { BENCHMARK_IPSUM_CONFORMANCE_TEXT } from '../workloads/benchmark-ipsum'; import type { BenchmarkTarget, TargetRunOutput } from '../benchmark/contracts'; import { rasterConformanceSpecimen, type BenchmarkFontFixture } from '../benchmark/font-fixtures'; import type { FontDelivery } from '../benchmark/url-state'; import { createCanvasSurface } from './canvas-surface'; import { finiteCanvasDelta } from './canvas-view'; -import { createFontDeliveryMetrics, loadRuntimeFont, type FontDeliveryMetrics } from './font-delivery'; +import { loadSlugFontAsset } from '../workloads/font-assets/slug'; +import type { BakedSlugArtifactSource as SlugBakedArtifactSource } from '../workloads/font-assets'; import type { LiveFrameHistoryCursor } from './live-frame-telemetry'; import { benchmarkContentWidth, @@ -72,48 +61,8 @@ const WIDTH = 512; const HEIGHT = 320; const FLAT_CONFORMANCE_HEIGHT = 512; -interface SlugFixtureManifest { - readonly fontFixture: BenchmarkFontFixture; - readonly compressed: { readonly bytes: number; readonly sha256: string }; - readonly uncompressed: { readonly bytes: number; readonly sha256: string }; -} - -export interface SlugBakedArtifactSource { - readonly url: string; - readonly compressed: { readonly bytes: number; readonly sha256: string }; - readonly uncompressed: { readonly bytes: number; readonly sha256: string }; -} - -const compressedFontUrls: Readonly> = { - inter: interCompressedFontUrl, - amiri: amiriCompressedFontUrl, - 'noto-sans-devanagari': devanagariCompressedFontUrl, - 'noto-sans-cjk-showcase': notoCjkShowcaseCompressedFontUrl, - 'dot-gothic-16': dotGothicCompressedFontUrl, - 'font-awesome-free-6.7.2': fontAwesomeCompressedFontUrl, - 'source-serif-4': sourceSerifCompressedFontUrl, - 'dancing-script': dancingScriptCompressedFontUrl, -}; - -export async function preloadSlugFontAssets( - fixtures: readonly BenchmarkFontFixture[], - signal?: AbortSignal, -): Promise { - await Promise.all( - fixtures.map(async (fixture) => { - const response = await fetch(compressedFontUrls[fixture], signal === undefined ? undefined : { signal }); - if (!response.ok) throw new Error(`Unable to preload Slug font fixture (${response.status})`); - await response.arrayBuffer(); - }), - ); -} - -const slugFixtureManifests = new Map( - (showcaseManifest as { readonly artifacts: readonly SlugFixtureManifest[] }).artifacts.map((artifact) => [ - artifact.fontFixture, - artifact, - ]), -) as ReadonlyMap; +export { preloadSlugFontAssets } from '../workloads/font-assets/slug'; +export type { BakedSlugArtifactSource as SlugBakedArtifactSource } from '../workloads/font-assets/slug'; export interface SlugRasterConfiguration { readonly planeUnitsPerEm: number; @@ -1707,29 +1656,26 @@ export async function loadSlugFont( delivery: FontDelivery = 'baked', onProgress?: BakeProgressListener, registry?: FontRegistry, -): Promise<{ - readonly artifactBytes: number; - readonly compressedBytes: number; - readonly font: RegisteredFont; - readonly metrics: FontDeliveryMetrics; - readonly raster: SlugModule; -}> { - signal?.throwIfAborted(); - const metrics = createFontDeliveryMetrics(delivery); - const manifest = slugFixtureManifests.get(fixture); - if (manifest === undefined) throw new RangeError(`Unknown Slug font fixture: ${fixture}`); - if (delivery === 'runtime') { - const loaded = await loadRuntimeFont(fixture, metrics, signal, onProgress, registry ?? new FontRegistry()); - return { - artifactBytes: metrics.coreArtifactBytes, - compressedBytes: metrics.sourceFontBytes, - font: loaded.font, - metrics, - raster: measuredSlugRaster(metrics, onProgress), - }; - } - const response = await fetch(compressedFontUrls[fixture], signal === undefined ? undefined : { signal }); - return loadSlugFontResponse(response, manifest, metrics, signal, registry); +): Promise>> { + return loadSlugFontAsset( + delivery === 'runtime' + ? { + technique: 'slug', + fixture, + delivery, + ...(registry === undefined ? {} : { registry }), + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + } + : { + technique: 'slug', + fixture, + delivery, + ...(registry === undefined ? {} : { registry }), + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }, + ); } /** Load a retained non-production Slug candidate through the ordinary registry boundary. */ @@ -1737,46 +1683,15 @@ export async function loadSlugBakedArtifact( source: SlugBakedArtifactSource, signal?: AbortSignal, registry?: FontRegistry, -): Promise<{ - readonly artifactBytes: number; - readonly compressedBytes: number; - readonly font: RegisteredFont; - readonly metrics: FontDeliveryMetrics; - readonly raster: SlugModule; -}> { - signal?.throwIfAborted(); - const response = await fetch(source.url, signal === undefined ? undefined : { signal }); - return loadSlugFontResponse(response, source, createFontDeliveryMetrics('baked'), signal, registry); -} - -async function loadSlugFontResponse( - response: Response, - manifest: Pick, - metrics: FontDeliveryMetrics, - signal?: AbortSignal, - registry?: FontRegistry, -): Promise<{ - readonly artifactBytes: number; - readonly compressedBytes: number; - readonly font: RegisteredFont; - readonly metrics: FontDeliveryMetrics; - readonly raster: SlugModule; -}> { - if (!response.ok) throw new Error(`Unable to load Slug font fixture (${response.status})`); - const received = new Uint8Array(await response.arrayBuffer()); - signal?.throwIfAborted(); - const artifact = - received.byteLength === manifest.uncompressed.bytes ? received : await decompressFixture(received, manifest); - await assertFixtureBytes(artifact, manifest.uncompressed, 'uncompressed'); - signal?.throwIfAborted(); - const activeRegistry = registry ?? new FontRegistry({ maxArtifactBytes: manifest.uncompressed.bytes }); - return { - artifactBytes: artifact.byteLength, - compressedBytes: manifest.compressed.bytes, - font: await activeRegistry.registerAsset(artifact), - metrics, - raster: slug, - }; +): Promise>> { + return loadSlugFontAsset({ + technique: 'slug', + fixture: 'inter', + delivery: 'baked', + bakedArtifact: source, + ...(registry === undefined ? {} : { registry }), + ...(signal === undefined ? {} : { signal }), + }); } export async function registeredSlugConfiguration( @@ -1828,48 +1743,6 @@ function slugResourceConfiguration(resource: SlugResource): SlugRasterConfigurat }; } -function measuredSlugRaster(metrics: FontDeliveryMetrics, onProgress?: BakeProgressListener): SlugModule { - const runtimeBaker = measuredRuntimeBaker(slug.runtimeBaker, metrics, onProgress); - return defineRaster({ - ...slug, - ...(runtimeBaker === undefined ? {} : { runtimeBaker }), - }); -} - -function measuredRuntimeBaker( - load: - | (() => Promise< - RuntimeRasterBakerModule | { readonly default: RuntimeRasterBakerModule } - >) - | undefined, - metrics: FontDeliveryMetrics, - onProgress?: BakeProgressListener, -) { - if (load === undefined) return undefined; - return async (): Promise> => { - const started = performance.now(); - const imported = await load(); - const baker = 'default' in imported ? imported.default : imported; - return { - kind: baker.kind, - async bake(request) { - const artifact = await baker.bake({ - ...request, - ...(onProgress === undefined ? {} : { onProgress }), - }); - metrics.rasterBakeMs = performance.now() - started; - metrics.rasterArtifactBytes = rasterArtifactBytes(artifact); - metrics.rasterGpuBytes = artifact.report.gpuBytes; - return artifact; - }, - }; - }; -} - -function rasterArtifactBytes(artifact: RasterBakeArtifact): number { - return artifact.artifacts.reduce((total, entry) => total + entry.bytes.byteLength, 0); -} - function positionLiveLine( line: Text, viewportWidth: number, @@ -1904,31 +1777,6 @@ function assertLayoutWidthRatio(value: number): void { } } -async function decompressFixture( - compressed: Uint8Array, - manifest: Pick, -): Promise> { - await assertFixtureBytes(compressed, manifest.compressed, 'compressed'); - return decompressGzip(compressed); -} - -async function decompressGzip(compressed: Uint8Array): Promise> { - const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream('gzip')); - return new Uint8Array(await new Response(stream).arrayBuffer()); -} - -async function assertFixtureBytes( - bytes: Uint8Array, - expected: { readonly bytes: number; readonly sha256: string }, - label: string, -): Promise { - if (bytes.byteLength !== expected.bytes) { - throw new Error(`Slug ${label} fixture has ${bytes.byteLength} bytes; expected ${expected.bytes}`); - } - const hash = hex(await crypto.subtle.digest('SHA-256', bytes)); - if (hash !== expected.sha256) throw new Error(`Slug ${label} fixture failed SHA-256`); -} - async function renderSlugText(resources: SlugTextResources): Promise { const rendered = await renderSlugFrame(resources); const { bytes, renderMs, pixelEvidence } = rendered; diff --git a/apps/benchmarks/src/workloads/font-assets/authenticated-gzip.ts b/apps/benchmarks/src/workloads/font-assets/authenticated-gzip.ts new file mode 100644 index 00000000..c003db31 --- /dev/null +++ b/apps/benchmarks/src/workloads/font-assets/authenticated-gzip.ts @@ -0,0 +1,62 @@ +import type { AuthenticatedArtifactSize } from './contracts'; + +export async function fetchAuthenticatedGzipAsset( + url: string, + manifest: { readonly compressed: AuthenticatedArtifactSize; readonly uncompressed: AuthenticatedArtifactSize }, + label: string, + signal?: AbortSignal, +): Promise> { + const response = await fetch(url, signal === undefined ? undefined : { signal }); + if (!response.ok) throw new Error(`Unable to load ${label} (${response.status})`); + const received = new Uint8Array(await response.arrayBuffer()); + signal?.throwIfAborted(); + const artifact = + received.byteLength === manifest.uncompressed.bytes ? received : await decompressFixture(received, manifest, label); + await assertFixtureBytes(artifact, manifest.uncompressed, label, 'uncompressed'); + signal?.throwIfAborted(); + return artifact; +} + +export async function preloadFontAssetUrls( + urls: readonly string[], + label: string, + signal?: AbortSignal, +): Promise { + await Promise.all( + urls.map(async (url) => { + const response = await fetch(url, signal === undefined ? undefined : { signal }); + if (!response.ok) throw new Error(`Unable to preload ${label} (${response.status})`); + await response.arrayBuffer(); + signal?.throwIfAborted(); + }), + ); +} + +async function decompressFixture( + compressed: Uint8Array, + manifest: { readonly compressed: AuthenticatedArtifactSize }, + label: string, +): Promise> { + await assertFixtureBytes(compressed, manifest.compressed, label, 'compressed'); + const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream('gzip')); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +async function assertFixtureBytes( + bytes: Uint8Array, + expected: AuthenticatedArtifactSize, + label: string, + stage: 'compressed' | 'uncompressed', +): Promise { + if (bytes.byteLength !== expected.bytes) { + throw new Error( + `${label} ${stage} fixture has ${String(bytes.byteLength)} bytes; expected ${String(expected.bytes)}`, + ); + } + const hash = hex(await crypto.subtle.digest('SHA-256', bytes)); + if (hash !== expected.sha256) throw new Error(`${label} ${stage} fixture failed SHA-256`); +} + +function hex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer), (value) => value.toString(16).padStart(2, '0')).join(''); +} diff --git a/apps/benchmarks/src/workloads/font-assets/bitmap.ts b/apps/benchmarks/src/workloads/font-assets/bitmap.ts new file mode 100644 index 00000000..61f545b4 --- /dev/null +++ b/apps/benchmarks/src/workloads/font-assets/bitmap.ts @@ -0,0 +1,130 @@ +import { defineRaster, FontRegistry } from '@pmndrs/text'; +import { bitmap, type BitmapModule } from '@pmndrs/text/raster/bitmap'; + +import amiriBitmapFontUrl from '../../../fixtures/rendering/amiri-bitmap-16.font.glb?url'; +import amiriBitmapDensityFontUrl from '../../../fixtures/rendering/amiri-bitmap-16-32.font.glb?url'; +import dancingScriptBitmapFontUrl from '../../../fixtures/rendering/dancing-script-bitmap-16.font.glb?url'; +import dancingScriptBitmapDensityFontUrl from '../../../fixtures/rendering/dancing-script-bitmap-16-32.font.glb?url'; +import dotGothicBitmapFontUrl from '../../../fixtures/rendering/dot-gothic-16-bitmap-16.font.glb?url'; +import dotGothicBitmapDensityFontUrl from '../../../fixtures/rendering/dot-gothic-16-bitmap-16-32.font.glb?url'; +import fontAwesomeBitmapFontUrl from '../../../fixtures/rendering/font-awesome-free-6.7.2-bitmap-16.font.glb?url'; +import fontAwesomeBitmapDensityFontUrl from '../../../fixtures/rendering/font-awesome-free-6.7.2-bitmap-16-32.font.glb?url'; +import interBitmapFontUrl from '../../../fixtures/rendering/inter-bitmap-16.font.glb?url'; +import interBitmapDensityFontUrl from '../../../fixtures/rendering/inter-bitmap-16-32.font.glb?url'; +import devanagariBitmapFontUrl from '../../../fixtures/rendering/noto-sans-devanagari-bitmap-16.font.glb?url'; +import devanagariBitmapDensityFontUrl from '../../../fixtures/rendering/noto-sans-devanagari-bitmap-16-32.font.glb?url'; +import notoCjkShowcaseBitmapFontUrl from '../../../fixtures/rendering/noto-sans-cjk-showcase-bitmap-16.font.glb?url'; +import notoCjkShowcaseBitmapDensityFontUrl from '../../../fixtures/rendering/noto-sans-cjk-showcase-bitmap-16-32.font.glb?url'; +import sourceSerifBitmapFontUrl from '../../../fixtures/rendering/source-serif-4-bitmap-16.font.glb?url'; +import sourceSerifBitmapDensityFontUrl from '../../../fixtures/rendering/source-serif-4-bitmap-16-32.font.glb?url'; +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import { preloadFontAssetUrls } from './authenticated-gzip'; +import type { BenchmarkFontAsset, BenchmarkFontAssetRequest, BitmapFixtureDensity } from './contracts'; +import { createFontDeliveryMetrics, loadRuntimeCoreFont, measuredRuntimeRaster, sourceUrlForFixture } from './runtime'; + +export type { BitmapFixtureDensity, FontDeliveryMetrics } from './contracts'; + +export type BitmapFontAsset = Omit & { + readonly technique: 'bitmap'; + readonly raster: ReturnType; +}; + +const bitmapRequest = bitmap({ strikes: [16] as const }); +const liveBitmapRequest = bitmap({ strikes: [16, 32] as const }); + +const bitmapFontUrls: Readonly> = { + inter: interBitmapFontUrl, + amiri: amiriBitmapFontUrl, + 'noto-sans-devanagari': devanagariBitmapFontUrl, + 'noto-sans-cjk-showcase': notoCjkShowcaseBitmapFontUrl, + 'dot-gothic-16': dotGothicBitmapFontUrl, + 'font-awesome-free-6.7.2': fontAwesomeBitmapFontUrl, + 'source-serif-4': sourceSerifBitmapFontUrl, + 'dancing-script': dancingScriptBitmapFontUrl, +}; + +const bitmapDensityFontUrls: Readonly> = { + inter: interBitmapDensityFontUrl, + amiri: amiriBitmapDensityFontUrl, + 'noto-sans-devanagari': devanagariBitmapDensityFontUrl, + 'noto-sans-cjk-showcase': notoCjkShowcaseBitmapDensityFontUrl, + 'dot-gothic-16': dotGothicBitmapDensityFontUrl, + 'font-awesome-free-6.7.2': fontAwesomeBitmapDensityFontUrl, + 'source-serif-4': sourceSerifBitmapDensityFontUrl, + 'dancing-script': dancingScriptBitmapDensityFontUrl, +}; + +export async function preloadBitmapFontAssets( + fixtures: readonly BenchmarkFontFixture[], + density: BitmapFixtureDensity = 'live', + signal?: AbortSignal, +): Promise { + const urls = density === 'live' ? bitmapDensityFontUrls : bitmapFontUrls; + await preloadFontAssetUrls( + fixtures.map((fixture) => urls[fixture]), + 'bitmap font fixture', + signal, + ); +} + +export async function loadBitmapFontAsset( + request: Extract, +): Promise { + const { bitmapDensity, delivery, fixture, onProgress, registry, signal } = request; + signal?.throwIfAborted(); + const metrics = createFontDeliveryMetrics(delivery); + const raster = bitmapDensity === 'live' ? liveBitmapRequest : bitmapRequest; + if (delivery === 'runtime') { + const font = await loadRuntimeCoreFont({ + source: sourceUrlForFixture(fixture), + metrics, + registry: registry ?? new FontRegistry(), + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }); + return { + technique: 'bitmap', + artifactBytes: metrics.coreArtifactBytes, + atlasGpuBytes: 0, + compressedBytes: metrics.sourceFontBytes, + font, + metrics, + raster: measuredBitmapRaster(raster, metrics, onProgress), + }; + } + let font: Awaited> | undefined; + try { + const urls = bitmapDensity === 'live' ? bitmapDensityFontUrls : bitmapFontUrls; + const response = await fetch(urls[fixture], signal === undefined ? undefined : { signal }); + if (!response.ok) throw new Error(`Unable to load bitmap font fixture (${response.status})`); + const bytes = new Uint8Array(await response.arrayBuffer()); + signal?.throwIfAborted(); + font = await (registry ?? new FontRegistry()).registerAsset(bytes); + signal?.throwIfAborted(); + return { + technique: 'bitmap', + artifactBytes: bytes.byteLength, + atlasGpuBytes: 0, + compressedBytes: bytes.byteLength, + font, + metrics, + raster, + }; + } catch (error) { + font?.dispose(); + throw error; + } +} + +function measuredBitmapRaster( + request: ReturnType, + metrics: BenchmarkFontAsset['metrics'], + onProgress?: Extract['onProgress'], +): ReturnType { + const runtimeBaker = measuredRuntimeRaster(request.module.runtimeBaker, metrics, onProgress); + const module: BitmapModule = defineRaster({ + ...request.module, + ...(runtimeBaker === undefined ? {} : { runtimeBaker }), + }); + return { module, options: request.options }; +} diff --git a/apps/benchmarks/src/workloads/font-assets/contracts.ts b/apps/benchmarks/src/workloads/font-assets/contracts.ts new file mode 100644 index 00000000..b880e555 --- /dev/null +++ b/apps/benchmarks/src/workloads/font-assets/contracts.ts @@ -0,0 +1,73 @@ +import type { AnyRasterInput, BakeProgressListener, FontRegistry, RegisteredFont } from '@pmndrs/text'; + +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { FontDelivery, RasterTechnique } from '../../benchmark/url-state'; + +export type BitmapFixtureDensity = 'conformance' | 'live'; + +export interface AuthenticatedArtifactSize { + readonly bytes: number; + readonly sha256: string; +} + +/** An authenticated non-production Slug fixture used only by the comparison candidate lane. */ +export interface BakedSlugArtifactSource { + readonly url: string; + readonly compressed: AuthenticatedArtifactSize; + readonly uncompressed: AuthenticatedArtifactSize; +} + +/** Mutable measurements populated by the selected public loader and optional runtime baker. */ +export interface FontDeliveryMetrics { + readonly delivery: FontDelivery; + sourceFontBytes: number; + coreArtifactBytes: number; + coreBakeMs: number; + rasterArtifactBytes: number; + rasterBakeMs: number; + rasterGpuBytes: number; +} + +interface CommonBenchmarkFontAssetRequest { + readonly fixture: BenchmarkFontFixture; + readonly registry?: FontRegistry | undefined; + readonly signal?: AbortSignal | undefined; + readonly onProgress?: BakeProgressListener | undefined; +} + +export type BenchmarkFontAssetRequest = + | (CommonBenchmarkFontAssetRequest & { + readonly technique: 'bitmap'; + readonly delivery: FontDelivery; + readonly bitmapDensity: BitmapFixtureDensity; + }) + | (CommonBenchmarkFontAssetRequest & { + readonly technique: 'mtsdf'; + readonly delivery: FontDelivery; + }) + | (CommonBenchmarkFontAssetRequest & { + readonly technique: 'slug'; + readonly delivery: 'runtime'; + }) + | (CommonBenchmarkFontAssetRequest & { + readonly technique: 'slug'; + readonly delivery: 'baked'; + readonly bakedArtifact?: BakedSlugArtifactSource; + }); + +export interface BenchmarkFontAsset { + readonly technique: RasterTechnique; + readonly artifactBytes: number; + readonly atlasGpuBytes: number; + readonly compressedBytes: number; + readonly font: RegisteredFont; + readonly metrics: FontDeliveryMetrics; + readonly raster: AnyRasterInput; +} + +export interface BenchmarkFontAssetPreloadRequest { + readonly technique: RasterTechnique; + readonly fixtures: readonly BenchmarkFontFixture[]; + readonly signal?: AbortSignal | undefined; + readonly bitmapDensity?: BitmapFixtureDensity; +} 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 new file mode 100644 index 00000000..0eb3a2c6 --- /dev/null +++ b/apps/benchmarks/src/workloads/font-assets/font-assets.boundary.test.ts @@ -0,0 +1,43 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +const fontAssetsRoot = new URL('.', import.meta.url); + +async function readAssetSource(name: string): Promise { + return readFile(new URL(name, fontAssetsRoot), 'utf8'); +} + +describe('benchmark font-asset boundaries', () => { + it('selects one technique lazily instead of statically importing every renderer asset lane', async () => { + const index = await readAssetSource('index.ts'); + + expect(index).toContain("import('./bitmap')"); + expect(index).toContain("import('./mtsdf')"); + expect(index).toContain("import('./slug')"); + expect(index).not.toMatch(/\bfrom ['"]\.\/(?:bitmap|mtsdf|slug)['"]/); + }); + + it('keeps direct font-baker and Wasm reach-through outside the workload asset adapter', async () => { + const files = ['index.ts', 'runtime.ts', 'bitmap.ts', 'mtsdf.ts', 'slug.ts']; + const sources = await Promise.all(files.map(readAssetSource)); + + for (const source of sources) { + expect(source).not.toMatch(/@pmndrs\/text-font-baker|text-shaper\.wasm\?url|font-baker\.wasm\?url/); + } + expect(sources[1]).toContain("import('@pmndrs/text/runtime-bake')"); + }); + + it('keeps renderer 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'), + ), + ); + + for (const renderer of renderers) { + expect(renderer).not.toContain('@pmndrs/text/runtime-bake'); + expect(renderer).not.toContain('@pmndrs/text-font-baker'); + } + }); +}); diff --git a/apps/benchmarks/src/workloads/font-assets/index.ts b/apps/benchmarks/src/workloads/font-assets/index.ts new file mode 100644 index 00000000..e18f08ad --- /dev/null +++ b/apps/benchmarks/src/workloads/font-assets/index.ts @@ -0,0 +1,39 @@ +import type { BenchmarkFontAsset, BenchmarkFontAssetPreloadRequest, BenchmarkFontAssetRequest } from './contracts'; + +export type { + AuthenticatedArtifactSize, + BakedSlugArtifactSource, + BenchmarkFontAsset, + BenchmarkFontAssetPreloadRequest, + BenchmarkFontAssetRequest, + BitmapFixtureDensity, + FontDeliveryMetrics, +} from './contracts'; + +/** Loads one fixture through public @pmndrs/text loader and raster entrypoints for the selected technique only. */ +export async function loadBenchmarkFontAsset(request: BenchmarkFontAssetRequest): Promise { + switch (request.technique) { + case 'bitmap': + return (await import('./bitmap')).loadBitmapFontAsset(request); + case 'mtsdf': + return (await import('./mtsdf')).loadMtsdfFontAsset(request); + case 'slug': + return (await import('./slug')).loadSlugFontAsset(request); + } +} + +/** Preloads only transport assets; runtime delivery intentionally has no prebuilt fixture payload to fetch. */ +export async function preloadBenchmarkFontAssets(request: BenchmarkFontAssetPreloadRequest): Promise { + switch (request.technique) { + case 'bitmap': + return (await import('./bitmap')).preloadBitmapFontAssets( + request.fixtures, + request.bitmapDensity ?? 'live', + request.signal, + ); + case 'mtsdf': + return (await import('./mtsdf')).preloadMtsdfFontAssets(request.fixtures, request.signal); + case 'slug': + return (await import('./slug')).preloadSlugFontAssets(request.fixtures, request.signal); + } +} diff --git a/apps/benchmarks/src/workloads/font-assets/mtsdf.ts b/apps/benchmarks/src/workloads/font-assets/mtsdf.ts new file mode 100644 index 00000000..054eaad5 --- /dev/null +++ b/apps/benchmarks/src/workloads/font-assets/mtsdf.ts @@ -0,0 +1,124 @@ +import { defineRaster, FontRegistry } from '@pmndrs/text'; +import { msdf, type MsdfModule } from '@pmndrs/text/raster/msdf'; + +import amiriCompressedFontUrl from '../../../fixtures/rendering/amiri-mtsdf.font.glb.gz?url'; +import dancingScriptCompressedFontUrl from '../../../fixtures/rendering/dancing-script-mtsdf.font.glb.gz?url'; +import dotGothicCompressedFontUrl from '../../../fixtures/rendering/dot-gothic-16-mtsdf.font.glb.gz?url'; +import fontAwesomeCompressedFontUrl from '../../../fixtures/rendering/font-awesome-free-6.7.2-mtsdf.font.glb.gz?url'; +import interCompressedFontUrl from '../../../fixtures/rendering/inter-mtsdf.font.glb.gz?url'; +import devanagariCompressedFontUrl from '../../../fixtures/rendering/noto-sans-devanagari-mtsdf.font.glb.gz?url'; +import notoCjkShowcaseCompressedFontUrl from '../../../fixtures/rendering/noto-sans-cjk-showcase-mtsdf.font.glb.gz?url'; +import sourceSerifCompressedFontUrl from '../../../fixtures/rendering/source-serif-4-mtsdf.font.glb.gz?url'; +import showcaseManifest from '../../../fixtures/rendering/showcase-mtsdf-fixtures-v0.json'; +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import { fetchAuthenticatedGzipAsset, preloadFontAssetUrls } from './authenticated-gzip'; +import type { AuthenticatedArtifactSize, BenchmarkFontAsset, BenchmarkFontAssetRequest } from './contracts'; +import { createFontDeliveryMetrics, loadRuntimeCoreFont, measuredRuntimeRaster, sourceUrlForFixture } from './runtime'; + +export type { FontDeliveryMetrics } from './contracts'; + +export type MtsdfFontAsset = Omit & { + readonly technique: 'mtsdf'; + readonly raster: MsdfModule; +}; + +interface MtsdfFixtureManifest { + readonly fontFixture: BenchmarkFontFixture; + readonly compressed: AuthenticatedArtifactSize; + readonly uncompressed: AuthenticatedArtifactSize; + readonly raster: { readonly runtimeTextureArray: { readonly basePaddedGpuBytes: number } }; +} + +const compressedFontUrls: Readonly> = { + inter: interCompressedFontUrl, + amiri: amiriCompressedFontUrl, + 'noto-sans-devanagari': devanagariCompressedFontUrl, + 'noto-sans-cjk-showcase': notoCjkShowcaseCompressedFontUrl, + 'dot-gothic-16': dotGothicCompressedFontUrl, + 'font-awesome-free-6.7.2': fontAwesomeCompressedFontUrl, + 'source-serif-4': sourceSerifCompressedFontUrl, + 'dancing-script': dancingScriptCompressedFontUrl, +}; + +const fixtureManifests = new Map( + (showcaseManifest as { readonly artifacts: readonly MtsdfFixtureManifest[] }).artifacts.map((artifact) => [ + artifact.fontFixture, + artifact, + ]), +) as ReadonlyMap; + +export const MTSDF_FIXTURE_ARTIFACT_BYTE_LIMIT = Math.max( + ...Array.from(fixtureManifests.values(), ({ uncompressed }) => uncompressed.bytes), +); + +export async function preloadMtsdfFontAssets( + fixtures: readonly BenchmarkFontFixture[], + signal?: AbortSignal, +): Promise { + await preloadFontAssetUrls( + fixtures.map((fixture) => compressedFontUrls[fixture]), + 'MTSDF font fixture', + signal, + ); +} + +export async function loadMtsdfFontAsset( + request: Extract, +): Promise { + const { delivery, fixture, onProgress, registry, signal } = request; + signal?.throwIfAborted(); + const metrics = createFontDeliveryMetrics(delivery); + const manifest = fixtureManifests.get(fixture); + if (manifest === undefined) throw new RangeError(`Unknown MTSDF font fixture: ${fixture}`); + if (delivery === 'runtime') { + const font = await loadRuntimeCoreFont({ + source: sourceUrlForFixture(fixture), + metrics, + registry: registry ?? new FontRegistry(), + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }); + return { + technique: 'mtsdf', + artifactBytes: metrics.coreArtifactBytes, + atlasGpuBytes: 0, + compressedBytes: metrics.sourceFontBytes, + font, + metrics, + raster: measuredMsdfRaster(metrics, onProgress), + }; + } + const artifact = await fetchAuthenticatedGzipAsset( + compressedFontUrls[fixture], + manifest, + 'MTSDF font fixture', + signal, + ); + let font: Awaited> | undefined; + try { + font = await (registry ?? new FontRegistry({ maxArtifactBytes: manifest.uncompressed.bytes })).registerAsset( + artifact, + ); + signal?.throwIfAborted(); + return { + technique: 'mtsdf', + artifactBytes: artifact.byteLength, + atlasGpuBytes: manifest.raster.runtimeTextureArray.basePaddedGpuBytes, + compressedBytes: manifest.compressed.bytes, + font, + metrics, + raster: msdf, + }; + } catch (error) { + font?.dispose(); + throw error; + } +} + +function measuredMsdfRaster( + metrics: BenchmarkFontAsset['metrics'], + onProgress?: Extract['onProgress'], +): MsdfModule { + const runtimeBaker = measuredRuntimeRaster(msdf.runtimeBaker, metrics, onProgress); + return defineRaster({ ...msdf, ...(runtimeBaker === undefined ? {} : { runtimeBaker }) }); +} diff --git a/apps/benchmarks/src/workloads/font-assets/runtime.ts b/apps/benchmarks/src/workloads/font-assets/runtime.ts new file mode 100644 index 00000000..a1ef8ae1 --- /dev/null +++ b/apps/benchmarks/src/workloads/font-assets/runtime.ts @@ -0,0 +1,124 @@ +import { + FontLoader, + FontRegistry, + type BakeProgressListener, + type RasterBakeArtifact, + type RuntimeFontBakeRequest, + type RuntimeRasterBakerModule, +} from '@pmndrs/text'; + +import type { FontDelivery } from '../../benchmark/url-state'; +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import type { FontDeliveryMetrics } from './contracts'; + +import amiriSourceUrl from '../../../fixtures/fonts/amiri-1.002/Amiri-Regular.ttf?url'; +import dancingScriptSourceUrl from '../../../fixtures/fonts/dancing-script-3.000/DancingScript-Regular.otf?url'; +import dotGothicSourceUrl from '../../../fixtures/fonts/dot-gothic-16/DotGothic16-Regular.ttf?url'; +import fontAwesomeSourceUrl from '../../../fixtures/fonts/font-awesome-free-6.7.2/fa-solid-900.ttf?url'; +import interSourceUrl from '../../../fixtures/fonts/inter-v4.1/Inter-Regular.ttf?url'; +import notoCjkSourceUrl from '../../../fixtures/fonts/noto-sans-cjk-showcase-v0/NotoSansCJKjp-Showcase.otf?url'; +import devanagariSourceUrl from '../../../fixtures/fonts/noto-sans-devanagari/NotoSansDevanagari.ttf?url'; +import sourceSerifSourceUrl from '../../../fixtures/fonts/source-serif-4.005/SourceSerif4-Regular.ttf?url'; + +const sourceUrls: Readonly> = { + inter: interSourceUrl, + amiri: amiriSourceUrl, + 'noto-sans-devanagari': devanagariSourceUrl, + 'noto-sans-cjk-showcase': notoCjkSourceUrl, + 'dot-gothic-16': dotGothicSourceUrl, + 'font-awesome-free-6.7.2': fontAwesomeSourceUrl, + 'source-serif-4': sourceSerifSourceUrl, + 'dancing-script': dancingScriptSourceUrl, +}; + +export function sourceUrlForFixture(fixture: BenchmarkFontFixture): string { + return sourceUrls[fixture]; +} + +export function createFontDeliveryMetrics(delivery: FontDelivery): FontDeliveryMetrics { + return { + delivery, + sourceFontBytes: 0, + coreArtifactBytes: 0, + coreBakeMs: 0, + rasterArtifactBytes: 0, + rasterBakeMs: 0, + rasterGpuBytes: 0, + }; +} + +/** Uses the published FontLoader and runtime-bake entrypoint; no Wasm URL is imported by benchmark scenes. */ +export async function loadRuntimeCoreFont({ + source, + metrics, + registry, + signal, + onProgress, +}: { + readonly source: string; + readonly metrics: FontDeliveryMetrics; + readonly registry: FontRegistry; + readonly signal?: AbortSignal | undefined; + readonly onProgress?: BakeProgressListener | undefined; +}) { + const loader = new FontLoader({ + registry, + runtimeBake: async (request: RuntimeFontBakeRequest) => { + metrics.sourceFontBytes = request.source.byteLength; + const started = performance.now(); + const { bakeFontInWorker } = await import('@pmndrs/text/runtime-bake'); + const artifact = await bakeFontInWorker({ + ...request, + ...(onProgress === undefined ? {} : { onProgress }), + }); + metrics.coreBakeMs = performance.now() - started; + metrics.coreArtifactBytes = artifact.byteLength; + return artifact; + }, + }); + return loader.load({ source, baked: null }, signal === undefined ? undefined : { signal }); +} + +export function measuredRuntimeRaster( + load: + | (() => Promise< + RuntimeRasterBakerModule | { readonly default: RuntimeRasterBakerModule } + >) + | undefined, + metrics: FontDeliveryMetrics, + onProgress?: BakeProgressListener, +) { + if (load === undefined) return undefined; + return async (): Promise> => { + const started = performance.now(); + const imported = await load(); + const baker = isDefaultRasterBaker(imported) ? imported.default : imported; + if (!isRuntimeRasterBaker(baker)) throw new TypeError('runtime raster baker module is invalid'); + return { + kind: baker.kind, + async bake(request) { + const artifact = await baker.bake({ ...request, ...(onProgress === undefined ? {} : { onProgress }) }); + metrics.rasterBakeMs = performance.now() - started; + metrics.rasterArtifactBytes = rasterArtifactBytes(artifact); + metrics.rasterGpuBytes = artifact.report.gpuBytes; + return artifact; + }, + }; + }; +} + +function isDefaultRasterBaker( + value: unknown, +): value is { readonly default: RuntimeRasterBakerModule } { + return typeof value === 'object' && value !== null && 'default' in value; +} + +function isRuntimeRasterBaker( + value: unknown, +): value is RuntimeRasterBakerModule { + return typeof value === 'object' && value !== null && 'kind' in value && 'bake' in value; +} + +function rasterArtifactBytes(artifact: RasterBakeArtifact): number { + return artifact.artifacts.reduce((total, entry) => total + entry.bytes.byteLength, 0); +} diff --git a/apps/benchmarks/src/workloads/font-assets/slug.ts b/apps/benchmarks/src/workloads/font-assets/slug.ts new file mode 100644 index 00000000..f8ae74aa --- /dev/null +++ b/apps/benchmarks/src/workloads/font-assets/slug.ts @@ -0,0 +1,124 @@ +import { FontRegistry, defineRaster } from '@pmndrs/text'; +import { slug, type SlugModule } from '@pmndrs/text/raster/slug'; + +import amiriCompressedFontUrl from '../../../fixtures/rendering/amiri-slug.font.glb.gz?url'; +import dancingScriptCompressedFontUrl from '../../../fixtures/rendering/dancing-script-slug.font.glb.gz?url'; +import dotGothicCompressedFontUrl from '../../../fixtures/rendering/dot-gothic-16-slug.font.glb.gz?url'; +import fontAwesomeCompressedFontUrl from '../../../fixtures/rendering/font-awesome-free-6.7.2-slug.font.glb.gz?url'; +import interCompressedFontUrl from '../../../fixtures/rendering/inter-slug.font.glb.gz?url'; +import devanagariCompressedFontUrl from '../../../fixtures/rendering/noto-sans-devanagari-slug.font.glb.gz?url'; +import notoCjkShowcaseCompressedFontUrl from '../../../fixtures/rendering/noto-sans-cjk-showcase-slug.font.glb.gz?url'; +import sourceSerifCompressedFontUrl from '../../../fixtures/rendering/source-serif-4-slug.font.glb.gz?url'; +import showcaseManifest from '../../../fixtures/rendering/showcase-slug-fixtures-v0.json'; +import type { BenchmarkFontFixture } from '../../benchmark/font-fixtures'; +import { fetchAuthenticatedGzipAsset, preloadFontAssetUrls } from './authenticated-gzip'; +import type { + AuthenticatedArtifactSize, + BakedSlugArtifactSource, + BenchmarkFontAsset, + BenchmarkFontAssetRequest, +} from './contracts'; +import { createFontDeliveryMetrics, measuredRuntimeRaster, loadRuntimeCoreFont, sourceUrlForFixture } from './runtime'; + +export type { BakedSlugArtifactSource, FontDeliveryMetrics } from './contracts'; + +export type SlugFontAsset = Omit & { + readonly technique: 'slug'; + readonly raster: SlugModule; +}; + +interface SlugFixtureManifest { + readonly fontFixture: BenchmarkFontFixture; + readonly compressed: AuthenticatedArtifactSize; + readonly uncompressed: AuthenticatedArtifactSize; +} + +const compressedFontUrls: Readonly> = { + inter: interCompressedFontUrl, + amiri: amiriCompressedFontUrl, + 'noto-sans-devanagari': devanagariCompressedFontUrl, + 'noto-sans-cjk-showcase': notoCjkShowcaseCompressedFontUrl, + 'dot-gothic-16': dotGothicCompressedFontUrl, + 'font-awesome-free-6.7.2': fontAwesomeCompressedFontUrl, + 'source-serif-4': sourceSerifCompressedFontUrl, + 'dancing-script': dancingScriptCompressedFontUrl, +}; + +const fixtureManifests = new Map( + (showcaseManifest as { readonly artifacts: readonly SlugFixtureManifest[] }).artifacts.map((artifact) => [ + artifact.fontFixture, + artifact, + ]), +) as ReadonlyMap; + +export async function preloadSlugFontAssets( + fixtures: readonly BenchmarkFontFixture[], + signal?: AbortSignal, +): Promise { + await preloadFontAssetUrls( + fixtures.map((fixture) => compressedFontUrls[fixture]), + 'Slug font fixture', + signal, + ); +} + +export async function loadSlugFontAsset( + request: Extract, +): Promise { + const { delivery, fixture, onProgress, registry, signal } = request; + signal?.throwIfAborted(); + const metrics = createFontDeliveryMetrics(delivery); + if (delivery === 'runtime') { + const font = await loadRuntimeCoreFont({ + source: sourceUrlForFixture(fixture), + metrics, + registry: registry ?? new FontRegistry(), + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }); + return { + technique: 'slug', + artifactBytes: metrics.coreArtifactBytes, + atlasGpuBytes: 0, + compressedBytes: metrics.sourceFontBytes, + font, + metrics, + raster: measuredSlugRaster(metrics, onProgress), + }; + } + const source = request.bakedArtifact ?? fixtureManifestSource(fixture); + const artifact = await fetchAuthenticatedGzipAsset(source.url, source, 'Slug font fixture', signal); + let font: Awaited> | undefined; + try { + font = await (registry ?? new FontRegistry({ maxArtifactBytes: source.uncompressed.bytes })).registerAsset( + artifact, + ); + signal?.throwIfAborted(); + return { + technique: 'slug', + artifactBytes: artifact.byteLength, + atlasGpuBytes: 0, + compressedBytes: source.compressed.bytes, + font, + metrics, + raster: slug, + }; + } catch (error) { + font?.dispose(); + throw error; + } +} + +function fixtureManifestSource(fixture: BenchmarkFontFixture): BakedSlugArtifactSource { + const manifest = fixtureManifests.get(fixture); + if (manifest === undefined) throw new RangeError(`Unknown Slug font fixture: ${fixture}`); + return { url: compressedFontUrls[fixture], compressed: manifest.compressed, uncompressed: manifest.uncompressed }; +} + +function measuredSlugRaster( + metrics: BenchmarkFontAsset['metrics'], + onProgress?: Extract['onProgress'], +): SlugModule { + const runtimeBaker = measuredRuntimeRaster(slug.runtimeBaker, metrics, onProgress); + return defineRaster({ ...slug, ...(runtimeBaker === undefined ? {} : { runtimeBaker }) }); +} diff --git a/docs/log.md b/docs/log.md index 88b26485..692599c4 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,7 @@ ## 2026-08-03 +- **Canonical benchmark font assets** — Moved source-font selection, baked fixture URLs, authenticated gzip transport, public `FontLoader` runtime fallback, raster requests, progress, and delivery metrics from renderer-owned files into `workloads/font-assets`. One discriminated adapter selects Bitmap, MTSDF, or Slug through literal dynamic imports; workload code cannot reach direct baker or Wasm URLs, while renderer modules retain live GPU lifecycle and compatibility delegates. Review restored post-registration abort checks and corrected runtime source-font selection. The complete 310-test benchmark gate, production chunk build, React Doctor, all 42 dual-backend Presentation workload cells, both timed demos at 60.02/60.47 Icon Grid FPS, all 19 headless conformance scenarios, and the dual-backend external raster proof passed. - **Canonical single-paragraph scenes** — Added one workload-owned `LiveTextScene` contract and registry so Benchmark Ipsum and Advanced Shaping project their complete public `Text` inputs beside their authored content instead of relying on a route-local workload switch. Advanced Shaping now derives its fixture from the selected authored case, while Benchmark Ipsum keeps its exact 1,151-glyph assertion scoped to Inter. Renderer adapters add only runtime font size and retain technique-specific lifecycle ownership. Exact projection, registry, and renderer-boundary tests passed within the complete 307-test benchmark gate. - **Persistent viewport hierarchy** — Moved the Bitmap, MTSDF, and Slug live-text viewport controllers, their warm update queues, loading chrome, telemetry attributes, and shared contracts from the route coordinator into `surfaces/benchmark`. Review caught mixed type imports collapsing the lazy renderer boundaries; explicit `import type` declarations restored separate Bitmap, MTSDF, and Slug production chunks. The complete deterministic benchmark check and React Doctor remained clean, all 42 retained-scene cells passed with visible pixels and one renderer, and both 60-second timed demos traversed Advanced Shaping and returned to Off-axis / 3D with one renderer at 60.02 WebGPU and 60.36 forced-WebGL2 Icon Grid FPS. - **Workload instance and low-level target isolation** — Moved Icon Grid's active virtual window, recycle epochs, pan/autopan smoothing, refresh suspension, visibility, and metrics into one per-mount workload instance while leaving renderer, RAF, font transactions, scene attachment, and telemetry in the persistent host. Extracted Main/Presentation composition, runtime control binding, benchmark surface chrome, and bake progress into named React modules. Added a common target-owned MTSDF/Slug conformance session that forwards the borrowed renderer and abort signal while retaining renderer-private resources behind adapters. The first browser pass exposed Text Ladder's offscreen scene transform leaking into Zoom Text; explicit per-workload scene initialization corrected the black frame. The repeated 42-cell dual-backend Presentation matrix then completed with visible pixels and one renderer per lane, all 19 isolated headless conformance scenarios passed, React Doctor reported zero diagnostics, and the complete deterministic benchmark gate passed 301 tests. diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 82d8791f..0d1715ba 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:be63a05eadf8689a15a610c2a9ea1e5a3ca032c70eccf995d8640bacd98d6cc1' +source_digest: 'sha256:a3249a57a4127fb5db7c88691b593af378c36902a26b22e43c6ea3cb6292c00d' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -29,6 +29,15 @@ sources: - id: live-text-scene-registry resource: ../../apps/benchmarks/src/workloads/live-text-scenes.ts title: Single-paragraph workload scene registry + - id: benchmark-font-assets + resource: ../../apps/benchmarks/src/workloads/font-assets/index.ts + title: Canonical selected-technique font-asset adapter + - id: benchmark-font-asset-contract + resource: ../../apps/benchmarks/src/workloads/font-assets/contracts.ts + title: Typed fixture delivery request and result contract + - id: benchmark-runtime-font-assets + resource: ../../apps/benchmarks/src/workloads/font-assets/runtime.ts + title: Public FontLoader runtime source-font path - id: slug-role-scenes resource: ../../apps/benchmarks/src/renderer/slug-role-scenes.ts title: Slug release-role scene definitions @@ -94,7 +103,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-03T10:00:11Z' + at: '2026-08-03T10:33:55Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -132,7 +141,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. Conformance React composition lives under `surfaces/conformance`: both the retained comparison and finite captures can receive only the host-owned renderer, while finite low-level dispatch stays below `benchmark/targets/conformance` and `benchmark/targets/measurement`. Advanced Shaping lives in that target hierarchy behind the registry's literal selected-target dynamic import. MTSDF and Slug sampling plus source-outline targets share a target-owned raster conformance session that preserves warm `load → run → dispose` reuse and forwards the borrowed renderer and abort signal unchanged; renderer-private capture resources remain inside their adapters. The targets share the explicitly named `targets/shared/direct-wasm.ts` dependency adapter only after target selection. The public missing-sibling loader Worker is conformance because it proves authenticated Worker bytes and loader fallback behavior; it is not a rendering product target. Boundary tests reject workload imports back into renderer implementation, isolate renderer imports to the raster adapters, authenticate the literal Advanced Shaping target import, and reject direct font-baker or Wasm URL imports outside the shared adapter, preventing raw tooling from leaking into the normal Presentation module graph. +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 finite low-level dispatch stays below `benchmark/targets/conformance` and `benchmark/targets/measurement`. Advanced Shaping lives in that target hierarchy behind the registry's literal selected-target dynamic import. MTSDF and Slug sampling plus source-outline targets share a target-owned raster conformance session that preserves warm `load → run → dispose` reuse and forwards the borrowed renderer and abort signal unchanged; renderer-private capture resources remain inside their adapters. The targets share the explicitly named `targets/shared/direct-wasm.ts` dependency adapter only after target selection. The public missing-sibling loader Worker is conformance because it proves authenticated Worker bytes and loader fallback behavior; it is not a rendering product target. Boundary tests reject workload imports back into renderer implementation, isolate renderer imports to the raster adapters, authenticate the literal Advanced Shaping target import, 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.