Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ if (address === null || address === undefined || typeof address === 'string') {
}

const workloads = [
{ id: 'editorial', label: 'Editorial', fontSize: 24, layoutWidthRatio: 0.82, amount: 50, camera: 'orthographic' },
{ id: 'text-ladder', label: 'Text ladder', fontSize: 24, layoutWidthRatio: 0.82, amount: 50, camera: 'orthographic' },
{
id: 'zoom-text',
Expand Down
2 changes: 2 additions & 0 deletions apps/benchmarks/src/workloads/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { advancedShapingDefinition } from './advanced-shaping/definition';
import { benchmarkIpsumDefinition } from './benchmark-ipsum/definition';
import type { ComparisonWorkloadId } from './comparison/contracts';
import { dynamicLayoutDefinition } from './dynamic-layout/definition';
import { editorialDefinition } from './editorial/definition';
import { iconGridDefinition } from './icon-grid/definition';
import { offAxis3dDefinition } from './off-axis-3d/definition';
import { paintEffectsDefinition } from './paint-effects/definition';
Expand Down Expand Up @@ -41,6 +42,7 @@ export const BENCHMARK_WORKLOADS = {
'paragraph-stress': paragraphStressDefinition,
'paint-effects': paintEffectsDefinition,
'rich-text': richTextDefinition,
editorial: editorialDefinition,
} as const satisfies Record<BenchmarkWorkloadId, BenchmarkWorkloadDefinition>;

export const BENCHMARK_WORKLOAD_IDS = Object.freeze(Object.keys(BENCHMARK_WORKLOADS) as readonly BenchmarkWorkloadId[]);
Expand Down
3 changes: 2 additions & 1 deletion apps/benchmarks/src/workloads/comparison/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export type ComparisonWorkloadId =
| 'dynamic-layout'
| 'paragraph-stress'
| 'paint-effects'
| 'rich-text';
| 'rich-text'
| 'editorial';

export type IconGridView = 'alternate' | 'origin';

Expand Down
2 changes: 2 additions & 0 deletions apps/benchmarks/src/workloads/comparison/registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { dynamicLayoutWorkload } from '../dynamic-layout/scene';
import { editorialWorkload } from '../editorial/scene';
import { iconGridWorkload } from '../icon-grid/scene';
import { offAxis3dWorkload } from '../off-axis-3d/scene';
import { paintEffectsWorkload } from '../paint-effects/scene';
Expand All @@ -22,6 +23,7 @@ export const COMPARISON_WORKLOADS = {
'paragraph-stress': paragraphStressWorkload,
'paint-effects': paintEffectsWorkload,
'rich-text': richTextWorkload,
editorial: editorialWorkload,
} satisfies Record<ComparisonWorkloadId, ComparisonWorkloadDefinition>;

export const COMPARISON_WORKLOAD_IDS = Object.freeze(
Expand Down
28 changes: 28 additions & 0 deletions apps/benchmarks/src/workloads/editorial/definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
fontSizeControl,
layoutWidthControl,
noControls,
readyTechniques,
textVolumeAmountControl,
workloadDefaults,
type BenchmarkWorkloadDefinition,
} from '../shared/definition';

export const editorialDefinition = {
controls: {
...noControls,
amount: textVolumeAmountControl,
animation: true,
fontSize: fontSizeControl,
layoutWidth: layoutWidthControl,
},
defaults: workloadDefaults(20, 24),
description: 'Justified editorial columns exercising indent, paragraph spacing, and word-space bounds.',
fontPolicy: { kind: 'selectable', defaultFixture: 'inter' },
id: 'editorial',
interaction: { pan: true, zoom: false },
label: 'Editorial',
preload: 'comparison-module',
surface: 'comparison',
techniques: readyTechniques,
} as const satisfies BenchmarkWorkloadDefinition<'editorial'>;
167 changes: 167 additions & 0 deletions apps/benchmarks/src/workloads/editorial/scene.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Text } from '@pmndrs/glyph/three';
import type * as THREE from 'three/webgpu';

import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts';
import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style';
import {
committedTextMetrics,
exactWidth,
paintColor,
publishWorkloadTexts,
type ComparisonWorkloadEntry,
type WorkloadTextFactoryContext,
} from '../shared/scene-entry';

/** One editorial column: every paragraph justifies with bounded elasticity. */
export const EDITORIAL_TEXT = [
'The pull of a justified column is older than the press that made it common. A page holds its measure, both edges true, while every line negotiates its own interior: word spaces widen and narrow inside declared bounds, and the letters lend a fraction of a unit when the words alone cannot settle the difference.',
'Typography is the craft of endowing human language with a durable visual form. The paragraph opens with a small indent, carries its own space before and after, and asks the composer for restraint: expansion capped near a third of a space, compression never past three quarters, and the last line left to fall where it may.',
'A tight measure is the honest test. When the column narrows, the breaker may borrow back the declared shrink to seat one more word; when it widens, capped word growth spills into hair-fine letter spacing rather than rivers. The reader should notice none of this — only that the page sits quietly.',
] as const;

const EDITORIAL_JUSTIFY = {
minWordSpaceRatio: 0.75,
maxWordSpaceRatio: 1.35,
letterSpaceExpansion: 0.4,
} as const;

export const editorialWorkload = {
animate(entries, configuration, elapsedMs, viewportWidth, viewportHeight, scene, _scratch, onError, onReflow) {
animateEditorialEntries(entries, configuration, elapsedMs, viewportWidth, viewportHeight, scene, onError, onReflow);
},
applyRetainedConfiguration() {},
batching: 'group',
cameraKind: 'orthographic',
contentWidth: { maximumWidth: 860 },
create(context) {
return createEditorialEntries({
...context.configuration,
animationElapsedMs: context.animationElapsedMs,
dpr: context.dpr,
font: context.font,
viewportWidth: context.viewportWidth,
});
},
id: 'editorial',
layout(entries, context) {
layoutEditorialEntries(entries, context.viewportWidth, context.viewportHeight);
},
suspendsIconWindow: false,
updateKind: () => 'retained',
} satisfies ComparisonWorkloadDefinition;

/** The animated editorial measure: one shared column width breathing slowly. */
export function editorialColumnWidth(
configuration: Pick<ComparisonWorkloadConfiguration, 'animationSpeed' | 'layoutWidthRatio'>,
viewportWidth: number,
animationElapsedMs: number,
): number {
const phase = animationElapsedMs * 0.00035 * animationRate(configuration.animationSpeed);
const baseWidth = benchmarkContentWidth(viewportWidth, configuration.layoutWidthRatio, 860);
return Math.max(220, baseWidth * (0.82 + Math.sin(phase) * 0.16));
}

export function createEditorialEntries(
context: WorkloadTextFactoryContext &
Pick<ComparisonWorkloadConfiguration, 'amount' | 'animationSpeed' | 'fontSize' | 'layoutWidthRatio'> & {
readonly animationElapsedMs: number;
readonly viewportWidth: number;
},
): readonly ComparisonWorkloadEntry[] {
const width = editorialColumnWidth(context, context.viewportWidth, context.animationElapsedMs);
// The amount control repeats the editorial cycle: 50 keeps one full column,
// and larger volumes stack additional justified pages onto the same measure.
const paragraphCount = Math.max(EDITORIAL_TEXT.length, Math.round((context.amount / 50) * EDITORIAL_TEXT.length));
const sourceTexts = Array.from(
{ length: paragraphCount },
(_, index) => EDITORIAL_TEXT[index % EDITORIAL_TEXT.length]!,
);
return sourceTexts.map((sourceText, index) => {
const text = new Text({
font: context.font,
rasterPixelRatio: context.dpr,
text: sourceText,
style: {
fontSize: context.fontSize,
lineHeight: LIVE_TEXT_LINE_HEIGHT,
wordSpacing: index % EDITORIAL_TEXT.length === 1 ? context.fontSize * 0.05 : 0,
},
paint: { color: paintColor(LIVE_TEXT_COLOR) },
contentBox: {
width: exactWidth(width),
wrap: 'word',
align: 'justify',
firstLineIndent: index % EDITORIAL_TEXT.length === 0 && index === 0 ? 0 : context.fontSize * 1.5,
spaceBefore: index === 0 ? 0 : context.fontSize * 0.6,
spaceAfter: context.fontSize * 0.4,
justify: EDITORIAL_JUSTIFY,
lastLine: index === sourceTexts.length - 1 ? 'justify' : 'auto',
},
});
return {
node: text,
role: index === 0 ? 'primary' : 'secondary',
sourceText,
text,
lastWidth: width,
};
});
}

export function animateEditorialEntries(
entries: readonly ComparisonWorkloadEntry[],
configuration: Pick<ComparisonWorkloadConfiguration, 'animationEnabled' | 'animationSpeed' | 'layoutWidthRatio'>,
timestamp: number,
viewportWidth: number,
viewportHeight: number,
scene: THREE.Scene,
onError: (error: unknown) => void,
onReflow: (duration: number) => void,
): void {
if (!configuration.animationEnabled) return;
const width = editorialColumnWidth(configuration, viewportWidth, timestamp);
if (entries.every((entry) => entry.lastWidth !== undefined && Math.abs(width - entry.lastWidth) < 1)) {
return;
}
const reflowStarted = performance.now();
try {
for (const entry of entries) {
entry.lastWidth = width;
entry.text.set({ contentBox: { ...entry.text.contentBox, width: exactWidth(width) } });
}
publishWorkloadTexts(scene, entries);
layoutEditorialEntries(entries, viewportWidth, viewportHeight);
onReflow(performance.now() - reflowStarted);
} catch (error) {
onError(error);
}
}

/** Paragraphs stack from their measured extents: space-after is part of the block size. */
export function layoutEditorialEntries(
entries: readonly ComparisonWorkloadEntry[],
viewportWidth: number,
viewportHeight: number,
): void {
const inset = 24;
let block = inset;
let columnWidth = 0;
for (const entry of entries) {
columnWidth = Math.max(columnWidth, committedTextMetrics(entry.text).width);
}
const left = Math.max(inset, (viewportWidth - columnWidth) / 2);
let totalHeight = 0;
for (const entry of entries) {
totalHeight += committedTextMetrics(entry.text).height;
}
block = Math.max(inset, (viewportHeight - totalHeight) / 2);
for (const entry of entries) {
const layout = committedTextMetrics(entry.text);
entry.text.position.set(left, -block, 0);
block += layout.height;
}
}

function animationRate(animationSpeed: number): number {
return 0.25 + animationSpeed * 0.0175;
}
10 changes: 10 additions & 0 deletions docs/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@

## 2026-08-12

- **Editorial workload (11.14, layer 5)** — The typography tier's product proof: three justified paragraphs on one
animated measure — first-line indents after the opening paragraph, paragraph space before and after, per-span
word spacing, word-space ratios bounded to [0.75, 1.35] with a 0.4-unit letter-gap budget, and a justified last
line — registered as the `editorial` comparison workload and added to the presentation workload probe. The probe
settles all nine workloads on Bitmap/WebGPU with editorial batching to one draw over 768 glyphs at 119 FPS. The
probe's extended paint-effects soak still reproduces the known metric-topology session poisoning on this stack's
base — that defect's fix is PR #66 off main, and a composition run with the fix patch applied locally settles all
nine workloads with zero engine errors, so the tier and the fix compose cleanly once #66 merges. Roadmap 11.14
closes; the decision register records the tier as D-252.

- **SIMD kernels reach their consumers (11.14, layer 4 / D-245)** — Two lab-admitted kernels graduated into
`engine/line_kernels.rs` production consumers: transition masks now drive the bidi run scan (sixteen levels per
step instead of a per-unit compare) and flag masks drive the justification space scan over the new
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur
resource: ../../apps/benchmarks
workspace_package: '@pmndrs/glyph-benchmarks'
documentation_type: reference
source_digest: 'sha256:defe604551e49dcd3d34cdf17e31c1b448a5b4b20efd62ee42c71cfc38c7afac'
source_digest: 'sha256:e43e7329cbf1dfa14cf7d049a9b558d4f588d650a47ca5da17c159f83efcdb56'
tags: [package, benchmarks, react, vite, product-e2e]
sources:
- id: manifest
Expand Down
1 change: 1 addition & 0 deletions docs/planning/decision-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules.
| D-239 | A known local font is directly expressible through `text bake --input --output` without authoring a discovery module or custom baking script. The package exposes one `text` executable with command-specific help and version output. First-party `--bitmap`, `--msdf`, and `--slug` flags select embedded raster resources; `--unicodes` invokes the package-owned Fontations/Skera baker Wasm before the shared `bakeFont` path so one prepared source feeds the shaping font and every raster; and `--check` performs a temporary byte-exact rebuild. `text glyphs` uses the same Wasm and Skrifa to surface Unicode mappings, exact glyph IDs, and retained `post`/CFF names as JSON or a bake-ready Unicode set without inventing semantic names. Product baking has no HarfBuzz executable dependency; pinned HarfBuzz remains an internal correctness oracle only. Runtime and R3F loading accept one nonempty tuple of raster requests for one input and return a position-preserving typed tuple of `LoadedFont` values. The font artifact is fetched and registered once while each declared raster still performs its required independent decode. Required per-technique options remain compile-time enforced. | Accepted |
| D-240 | CLI, Node, and runtime Worker baking share one prepare-once pipeline. A runtime request carries normalized Unicode ranges and the complete ordered raster plan; the Worker feeds the exact prepared source to the shaping bake and every selected first-party raster, composes one canonical GLB, validates it, and transfers one artifact. Only that final artifact is eligible for Worker-owned `CacheStorage`, keyed by source, face, ranges, exact raster descriptors/keys, and contract versions. Persistence inherits the source response's reusable freshness (`max-age` or `Expires`); `no-store`, `no-cache`, missing freshness, and expired responses remain memory-only. Browser quota eviction owns storage pressure, and storage failures remain transparent misses. Every GLB producer records `asset.generator` as the publishing package identity `@pmndrs/glyph`. | Accepted |
| D-241 | The package exposes its React integration as `@pmndrs/glyph/react`, matching the original public API, roadmap, and ecosystem convention. React Three Fiber remains the internal reconciler and a peer dependency, but is not encoded into the public subpath name. The stale `/r3f` export and generated entry are removed rather than retained as a second alias before publication. | Accepted |
| D-252 | The 11.14 typography tier lands as four stacked layers over one grown constraint record (56 → 84 bytes): wire carriage with strict validation and zero-filled backward equivalence; paragraph `spaceBefore`/`spaceAfter` and `firstLineIndent` steering composition, positioning, and measurement (indent narrows the first line's break width and shifts the pen on the paragraph-direction side; space-after rides block measurements so consumers stack paragraphs from reported extents); justification bounds where word spaces grow uniformly to a declared maximum ratio of their natural advance sum, overflow spills into per-gap-bounded letter expansion, a declared minimum ratio makes spaces elastic both ways — the breaker lends the shrinkable fraction back to admit the word that would otherwise just overflow via the new `CLUSTER_SPACE` flag, and positioning compresses to exactly the same bound — and a closed last-line policy (`auto`/`justify`) covering final and hard-broken lines; and the D-245 kernel graduation. Zero-valued controls reproduce pre-tier layout bit-for-bit. The `editorial` workload is the product proof: three justified paragraphs with indent, paragraph spacing, per-span word spacing, and bounded elasticity over an animated measure, registered in the catalog and the presentation workload probe. | Accepted |
| D-251 | Each raster technique's physical shape is declared once by a colocated, exported schema (`defineTechniqueSchema` in core; `bitmapSchema`/`msdfSchema`/`slugSchema` beside their techniques, `decorationSchema` and the Three policy's system buffers in the Three policy): buffer ids, scalar kinds, lane meanings, binding field names, and resource kinds. Policy programs build with `techniqueProgram(schema)` and store through schema buffer handles; the plan executor looks buffers up by declared id; a repository gate rejects any literal buffer lookup, literal attribute name, or parallel id const outside the declaration sites. The compiled policy bytes are proven byte-identical across the change — the schema layer is pure naming with an owner. The full cleanup plan, including shader-interface derivation, the data-origin axis with the reserved `pmndrs.pretext` fallback technique, and the tsdown build change, is recorded in [the technique contract plan](raster-technique-contract.md). | Accepted |
| D-250 | Policy programs are authored through a compile-time expression DSL in `@pmndrs/glyph/core` (`policyProgram`, `addF32`/`subtractF32`/`multiplyF32`/`u32ToF32`, typed constants) instead of hand-numbered registers. Authors reference named semantic handles (`inlineOrigin`, `fontSize`, `color.red`) and declared binding fields (`bearingX`, `uvOriginX`, `page`); `compile()` lowers the expression graph to the same forward-only `PolicyOperation` records, allocating registers automatically with use-before-write and exhaustion as errors and deduplicating reused values. The wire format, Rust validator, and interpreter are untouched, and the u32/f32 distinction remains wire-level per operation and buffer schema — the DSL's branded value types exist only at authoring time. The four Three programs are ported with per-technique named buffer ids; a semantic-equivalence test decodes old and new bytes and proves identical input tables, buffer schemas, metadata, and per-lane store dataflow against the hand-numbered fixtures, and the byte goldens are re-pinned once over that proof. | Accepted |
| D-249 | The renderer-neutral core publishes as `@pmndrs/glyph/core` and the technique shader library as `@pmndrs/glyph/tsl`. Core carries runtime shaper creation, the engine host and sessions, frame-wire serialization, render-plan and layout views, font-binding compilation, the versioned ABI, and the policy-authoring toolkit; the runtime-to-shaper bridge is public. Three-specific policy — per-technique programs, capability set, first-party buffer ids — moves from core internals to `three/render-policy.ts` and is built with the same public toolkit a third party uses. The four technique TSL node graphs, including the Slug shader tree formerly in core internals, move to `src/tsl/` under Tsl-prefixed names; the Three entry stops re-exporting shader symbols. First-party integration rigor is enforced by a scoped `no-restricted-imports` lint denying the three, tsl, and react surfaces any import from `internal/` or `generated/`. Wire contracts and behavior are unchanged; the moves are type-level, pinned by subpath type tests. | Accepted |
Expand Down
Loading
Loading