From c8cc91f7dc87c685f45bb9cf31caf6665ffb44a2 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 10 Sep 2026 23:40:22 +0200 Subject: [PATCH 01/15] fix(site): repair the benchmark page's lead profile, ratio bars and absent-arm figures The page led with whichever profile was measured last, which is a profile that may carry one domain: a physics-only run finished three hours after a rendering-only one buried every rendering comparison in the collapsed list below. Coverage now outranks the measurement date, so a profile carrying both domains leads. A comparison cell declared a ten-rem text track beside a four-rem minimum plot, whose intrinsic width exceeded the column a five-pair table gives it: the table pushed past the viewport and the log axis collapsed to about sixty pixels, on which every ratio short of 10x drew a few pixels. Text and bar each take a lane now, and the centre line the lengths are read against is visible. Column widths were declared on the header cells, which a fixed layout ignores when the first row is the group row and carries colspans; they move to a colgroup, and the count column stops holding a few hundred pixels of white space beside a four-digit number. An arm that sat a comparison out is stored as a zero rather than as a missing value, so the medians, p95, frame share and GPU rows printed 0.000 ms - the fastest figure on the page - for the arm that never ran. Archetype descriptions stay with the page rather than travelling in every profile: they are prose for a reader, identical on every machine, and two published profiles could otherwise disagree about what one archetype means. Schema 7 therefore adds only the GPU frame time, and reads version 6 unchanged. The local probe drained its timer queries immediately after ending them, so no sample was ever available and its untimed fallback reported the submission time under a GPU-inclusive label. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- .../exojs-bench/src/comparison/aggregate.ts | 8 + packages/exojs-bench/src/comparison/build.ts | 8 + packages/exojs-bench/src/profile/schema.ts | 8 +- .../src/components/BenchComparisonTable.astro | 95 +++++--- site/src/components/BenchLocalProbe.astro | 206 ++++++++++++++++++ .../src/components/pages/BenchmarksPage.astro | 3 + site/src/lib/bench-profiles.ts | 91 +++++++- site/src/lib/bench-tables.ts | 12 +- 8 files changed, 392 insertions(+), 39 deletions(-) create mode 100644 site/src/components/BenchLocalProbe.astro diff --git a/packages/exojs-bench/src/comparison/aggregate.ts b/packages/exojs-bench/src/comparison/aggregate.ts index 694972e86..a5ebf7ba1 100644 --- a/packages/exojs-bench/src/comparison/aggregate.ts +++ b/packages/exojs-bench/src/comparison/aggregate.ts @@ -242,6 +242,12 @@ const NO_SPREAD: RunSpread = { minMs: Number.NaN, maxMs: Number.NaN, ratio: Numb const measured = (value: number | null): value is number => value !== null && Number.isFinite(value); +const pooledOptional = (values: ReadonlyArray): number | null => { + const measuredValues = values.filter((value): value is number => value !== null && value !== undefined && Number.isFinite(value)); + + return measuredValues.length > 0 ? median(measuredValues) : null; +}; + /** * Pool one arm pair across the runs that produced it. * @@ -272,12 +278,14 @@ const aggregateCell = (perRun: readonly ComparisonCell[], runCount: number): Agg // pooled across runs, not a tail across the pooled samples, which no // published artifact retains. referenceP95Ms: referenceP95s.length > 0 ? median(referenceP95s) : null, + referenceGpuMs: pooledOptional(perRun.map(cell => cell.referenceGpuMs)), // Recomputed from the POOLED median rather than carried over from a run: the // published number is the one the mark has to describe, and a run's own mark // can disagree with it near the line. referenceOverFrameBudget: exceedsFrameBudget(referenceMs), competitorMs, competitorP95Ms: competitorP95s.length > 0 ? median(competitorP95s) : null, + competitorGpuMs: pooledOptional(perRun.map(cell => cell.competitorGpuMs)), competitorOverFrameBudget: exceedsFrameBudget(competitorMs), verdict: stable ? pooled : UNSTABLE_VERDICT, mechanism: first.mechanism, diff --git a/packages/exojs-bench/src/comparison/build.ts b/packages/exojs-bench/src/comparison/build.ts index 168c065b3..fab1819bd 100644 --- a/packages/exojs-bench/src/comparison/build.ts +++ b/packages/exojs-bench/src/comparison/build.ts @@ -78,6 +78,8 @@ export interface ComparisonCell { readonly referenceMs: number | null; /** Reference arm's 95th-percentile CPU time (ms) over the same timed window, or `null`. */ readonly referenceP95Ms: number | null; + /** Reference arm's measured GPU frame time, when the backend exposed a timer. */ + readonly referenceGpuMs?: number | null; /** * True when {@link referenceMs} is past a whole 60 fps frame; see * {@link '../shared/frameBudget'.FRAME_BUDGET_MS}. @@ -91,6 +93,8 @@ export interface ComparisonCell { readonly competitorMs: number | null; /** Competitor's 95th-percentile CPU time (ms) over the same timed window, or `null`. */ readonly competitorP95Ms: number | null; + /** Competitor arm's measured GPU frame time, when the backend exposed a timer. */ + readonly competitorGpuMs?: number | null; /** True when {@link competitorMs} is past a whole 60 fps frame; see {@link referenceOverFrameBudget}. */ readonly competitorOverFrameBudget: boolean; /** Computed ladder outcome, from the two medians. */ @@ -292,9 +296,11 @@ const buildBackend = (backend: Backend, results: readonly CellResult[]): Backend competitor, referenceMs: reference.cpuMsMedian, referenceP95Ms: reference.cpuMsP95, + referenceGpuMs: reference.frameMsMedian, referenceOverFrameBudget: exceedsFrameBudget(reference.cpuMsMedian), competitorMs: competitorCell.cpuMsMedian, competitorP95Ms: competitorCell.cpuMsP95, + competitorGpuMs: competitorCell.frameMsMedian, competitorOverFrameBudget: exceedsFrameBudget(competitorCell.cpuMsMedian), verdict: compareMedians(reference.cpuMsMedian, competitorCell.cpuMsMedian), mechanism, @@ -354,9 +360,11 @@ const buildBackend = (backend: Backend, results: readonly CellResult[]): Backend competitor, referenceMs: reference.cpuMsMedian, referenceP95Ms: reference.cpuMsP95, + referenceGpuMs: reference.frameMsMedian, referenceOverFrameBudget: exceedsFrameBudget(reference.cpuMsMedian), competitorMs: competitorCell.cpuMsMedian, competitorP95Ms: competitorCell.cpuMsP95, + competitorGpuMs: competitorCell.frameMsMedian, competitorOverFrameBudget: exceedsFrameBudget(competitorCell.cpuMsMedian), verdict: compareMedians(reference.cpuMsMedian, competitorCell.cpuMsMedian), mechanism: null, diff --git a/packages/exojs-bench/src/profile/schema.ts b/packages/exojs-bench/src/profile/schema.ts index be3352803..662ce03a1 100644 --- a/packages/exojs-bench/src/profile/schema.ts +++ b/packages/exojs-bench/src/profile/schema.ts @@ -29,7 +29,7 @@ import type { PlatformVersionStamp, PrereleaseStamp, RenderingBrowser } from '.. */ /** Schema version `bench:compare` stamps into a new document. */ -export const BENCH_PROFILE_SCHEMA_VERSION = 6; +export const BENCH_PROFILE_SCHEMA_VERSION = 7; /** * Schema versions a reader accepts. A document carrying anything else is @@ -53,8 +53,12 @@ export const BENCH_PROFILE_SCHEMA_VERSION = 6; * shared body count; its physics numbers were additionally taken on ladders that * have since moved, and because the per-cell seed folds the body count in, a * moved rung is a different scene rather than the same one measured again. + * + * Version 6 is still read. Version 7 only adds the GPU frame time beside each + * arm's CPU time, so every figure a version 6 document publishes still means + * what it meant; such a cell reports no GPU time rather than a wrong one. */ -export const SUPPORTED_BENCH_PROFILE_SCHEMA_VERSIONS: readonly number[] = [BENCH_PROFILE_SCHEMA_VERSION]; +export const SUPPORTED_BENCH_PROFILE_SCHEMA_VERSIONS: readonly number[] = [6, BENCH_PROFILE_SCHEMA_VERSION]; /** Characters a slug may be built from. */ const SLUG_CHARACTERS = /^[a-z0-9-]+$/; diff --git a/site/src/components/BenchComparisonTable.astro b/site/src/components/BenchComparisonTable.astro index b55efdf0d..44dee272f 100644 --- a/site/src/components/BenchComparisonTable.astro +++ b/site/src/components/BenchComparisonTable.astro @@ -45,6 +45,7 @@ import { frameShare, FRAME_BUDGET_MS, isWideSpread, + measuredMs, outcomeOf, pooledFactor, ratioBand, @@ -132,6 +133,14 @@ const winnerOf = (cell: ProfileCell): string => {
+ {/* A fixed layout takes its widths from the first row, which is the group row and carries colspans rather than columns; declared on the cells, every width here would be ignored. */} + + + {countColumn && } + {columns.map(() => ( + + ))} + {groups.length > 0 && ( @@ -168,6 +177,7 @@ const winnerOf = (cell: ProfileCell): string => {
{row.archetype} + {row.description !== undefined && {row.description}} {row.count !== null && !countColumn && ( {row.count} {unit} @@ -207,6 +217,9 @@ const winnerOf = (cell: ProfileCell): string => { const factor = mixed ? pooledFactor(cell) : cell.verdict.factor; const ratio = cell.verdict.ratio; const plotted = !mixed && ratio !== null && Number.isFinite(ratio) && ratio > 0; + const referenceMs = measuredMs(cell, cell.referenceMs); + const competitorMs = measuredMs(cell, cell.competitorMs); + const hasGpuTiming = cell.referenceGpuMs !== undefined || cell.competitorGpuMs !== undefined; return ( @@ -232,9 +245,9 @@ const winnerOf = (cell: ProfileCell): string => { {mixed ? 'no clear lead' : winnerOf(cell)} - {formatMs(cell.referenceMs)} + {formatMs(referenceMs)} vs - {formatMs(cell.competitorMs)} ms + {formatMs(competitorMs)} ms
@@ -243,7 +256,7 @@ const winnerOf = (cell: ProfileCell): string => {
p95
- {formatMs(cell.referenceP95Ms)} vs {formatMs(cell.competitorP95Ms)} ms + {formatMs(measuredMs(cell, cell.referenceP95Ms))} vs {formatMs(measuredMs(cell, cell.competitorP95Ms))} ms
@@ -275,8 +288,8 @@ const winnerOf = (cell: ProfileCell): string => {
60 fps frame
{[ - { name: 'ExoJS', share: frameShare(cell.referenceMs) }, - { name: armLabel(cell.competitor), share: frameShare(cell.competitorMs) }, + { name: 'ExoJS', share: frameShare(referenceMs) }, + { name: armLabel(cell.competitor), share: frameShare(competitorMs) }, ].map(entry => ( {entry.name} @@ -293,6 +306,16 @@ const winnerOf = (cell: ProfileCell): string => { ))}
+ {hasGpuTiming && ( +
+
CPU / GPU
+
+ CPU submission {formatMs(referenceMs)} vs {formatMs(competitorMs)} ms; GPU execution{' '} + {formatMs(measuredMs(cell, cell.referenceGpuMs ?? null))} vs{' '} + {formatMs(measuredMs(cell, cell.competitorGpuMs ?? null))} ms +
+
+ )}
runs
@@ -400,15 +423,16 @@ const winnerOf = (cell: ProfileCell): string => { } .col-archetype { - width: 12rem; + width: 14rem; } .col-count { width: 5rem; } + /* Left to share what the two fixed columns do not take, so the slack lands on the comparisons rather than beside a four-digit count. */ .col-arm { - width: 20rem; + width: auto; } tbody th { @@ -445,12 +469,20 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--fg-faint); } - /* The text track is sized to its longest line; the plot takes whatever the column has left, so the axis is as wide as the layout allows. */ + /* + * Text and bar each take a full-width lane rather than sharing a row. + * Beside a ten-rem text track, a column of a five-pair table leaves the plot + * about sixty pixels, on which every ratio short of 10x is a few pixels of + * bar and the axis stops carrying meaning; the intrinsic minimum of that + * two-column layout is also wider than such a column, so the table pushed + * past the viewport. Stacked, the axis is as wide as the column, and since + * the columns are equal width the axes still line up down the table. + */ .tracks { display: grid; - grid-template-columns: 10.5rem minmax(4rem, 1fr); + grid-template-columns: minmax(0, 1fr); align-items: start; - gap: var(--s-3); + gap: var(--s-2); } .track { @@ -472,13 +504,14 @@ const winnerOf = (cell: ProfileCell): string => { margin-top: 0.2rem; } + /* Every bar grows outward from this line, so a bar means nothing without it: it is the 1.00x the length is read against. */ .axis { position: absolute; left: 50%; - top: -0.55rem; - bottom: -0.85rem; + top: -5px; + bottom: -5px; width: 1px; - background: var(--line); + background: color-mix(in oklab, var(--fg-faint), transparent 45%); } .fill { @@ -600,10 +633,24 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--fg-muted); } + .row-description { + display: block; + max-width: 34rem; + margin-top: var(--s-1); + color: var(--fg-muted); + font-family: var(--f-sans); + font-size: 0.72rem; + font-weight: 400; + line-height: 1.35; + } + + /* + * The term sits above its value rather than beside it. A label column costs + * a comparison column six and a half rem of the little it has, and the two + * widest values here - the frame-budget rows and the run strip - are + * themselves multi-column and stop fitting long before the prose does. + */ .more dl { - display: grid; - grid-template-columns: 6.5rem minmax(0, 1fr); - column-gap: var(--s-3); margin: 0.4rem 0 0; font-family: var(--f-mono); font-size: 0.68rem; @@ -612,7 +659,7 @@ const winnerOf = (cell: ProfileCell): string => { } .more dl > div { - display: contents; + margin-bottom: 0.45rem; } .more dt { @@ -620,7 +667,7 @@ const winnerOf = (cell: ProfileCell): string => { } .more dd { - margin: 0 0 0.35rem; + margin: 0; overflow-wrap: anywhere; } @@ -788,20 +835,10 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--fg-faint); } - /* Stacked, the bar leads the comparison full width: there is no column of bars left to align down. */ - .tracks { - grid-template-columns: minmax(0, 1fr); - gap: var(--s-2); - } - + /* Stacked into cards there is no column of bars left to align down, so the bar leads its comparison instead of trailing it. */ .plot { order: 0; margin-top: 0; } - - .axis { - top: -2px; - bottom: -2px; - } } diff --git a/site/src/components/BenchLocalProbe.astro b/site/src/components/BenchLocalProbe.astro new file mode 100644 index 000000000..abd881c84 --- /dev/null +++ b/site/src/components/BenchLocalProbe.astro @@ -0,0 +1,206 @@ +--- +/** A deliberately separate, browser-only probe. It never changes published profile data. */ +--- + +
+
+

Local, not published

+

Run on this device

+

+ Run a short WebGL2 workload in this browser. It reports this device's local frame cost and capabilities; it is not mixed into the published + cross-library comparisons. +

+
+
+ + Idle +
+ + +
+ + + + diff --git a/site/src/components/pages/BenchmarksPage.astro b/site/src/components/pages/BenchmarksPage.astro index 33d435486..730728ec2 100644 --- a/site/src/components/pages/BenchmarksPage.astro +++ b/site/src/components/pages/BenchmarksPage.astro @@ -17,6 +17,7 @@ */ import BenchProfileReport from '../BenchProfileReport.astro'; +import BenchLocalProbe from '../BenchLocalProbe.astro'; import DocsLayout from '../../layouts/DocsLayout.astro'; import EnglishFallbackNotice from '../EnglishFallbackNotice.astro'; import { appInfo } from '../../lib/app-info'; @@ -93,6 +94,8 @@ const sections = [ )} + + {furtherProfiles.length > 0 && (

Further machines

diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts index 7f5ee81fd..c9a08d32c 100644 --- a/site/src/lib/bench-profiles.ts +++ b/site/src/lib/bench-profiles.ts @@ -21,7 +21,7 @@ */ /** Schema version this reader understands; anything else is refused. */ -const SUPPORTED_SCHEMA_VERSION = 6; +const SUPPORTED_SCHEMA_VERSIONS = new Set([6, 7]); /** * Arms that stand as a reference ceiling rather than as a peer. @@ -86,12 +86,16 @@ export interface ProfileCell { readonly referenceMs: number | null; /** Median of the per-run p95s. */ readonly referenceP95Ms: number | null; + /** GPU frame median when the published rendering run exposed a hardware timer. */ + readonly referenceGpuMs?: number | null; /** True when `referenceMs` is past a whole 60 fps frame; see `FRAME_BUDGET_MS`. */ readonly referenceOverFrameBudget: boolean; /** Median of the per-run medians. */ readonly competitorMs: number | null; /** Median of the per-run p95s. */ readonly competitorP95Ms: number | null; + /** GPU frame median when the published rendering run exposed a hardware timer. */ + readonly competitorGpuMs?: number | null; /** True when `competitorMs` is past a whole 60 fps frame; see `FRAME_BUDGET_MS`. */ readonly competitorOverFrameBudget: boolean; readonly verdict: ProfileVerdict; @@ -321,16 +325,26 @@ const byVersionDescending = (a: string, b: string): number => { return 0; }; -/** Newest engine version first, then newest measurement first. */ +/** How many of the two measured domains a profile carries. */ +const domainCount = (document: BenchProfileDocument): number => (document.rendering === undefined ? 0 : 1) + (document.physics === undefined ? 0 : 1); + +/** + * Newest engine version first, then widest coverage, then newest measurement. + * + * Coverage outranks the measurement date because the first profile leads the + * page: a partial run finished an hour later than a complete one would + * otherwise bury a whole domain in the collapsed list below, and the page would + * silently stop showing measurements it holds. + */ const byRecency = (a: BenchProfileDocument, b: BenchProfileDocument): number => - byVersionDescending(a.profile.engineVersion, b.profile.engineVersion) || b.profile.measuredAt.localeCompare(a.profile.measuredAt); + byVersionDescending(a.profile.engineVersion, b.profile.engineVersion) || + domainCount(b) - domainCount(a) || + b.profile.measuredAt.localeCompare(a.profile.measuredAt); const loaded = Object.entries(documents) .map(([path, document]) => { - if (document.schemaVersion !== SUPPORTED_SCHEMA_VERSION) { - throw new Error( - `Benchmark profile '${path}' declares schema version ${String(document.schemaVersion)}, but this site reads version ${String(SUPPORTED_SCHEMA_VERSION)}.`, - ); + if (!SUPPORTED_SCHEMA_VERSIONS.has(document.schemaVersion)) { + throw new Error(`Benchmark profile '${path}' declares unsupported schema version ${String(document.schemaVersion)}.`); } return document; @@ -370,6 +384,51 @@ const ARM_LABELS: Readonly> = { /** An arm's published name, or its slug where none is known. */ export const armLabel = (arm: string): string => ARM_LABELS[arm] ?? arm; +/** + * What each archetype's workload is, in one line. + * + * These live with the page rather than in a profile: they are prose for a + * reader, identical on every machine, and a measurement artifact that carried + * them would repeat them per run and let two published profiles disagree about + * what the same archetype means. The archetype id is the contract between the + * harness and this page; an id with no line here simply prints without one. + */ +const ARCHETYPE_DESCRIPTIONS: Readonly> = { + 'static-heavy': 'Mostly unchanged sprites; stresses retained scene reuse.', + 'dynamic-heavy': 'A lightly mutating sprite field; stresses transform and update work.', + 'deep-hierarchy': 'Deep parent-child nesting; stresses world-transform propagation.', + overdraw: 'Full-viewport sprites; stresses fragment fill and overdraw.', + 'batch-breaking': 'Many texture changes; stresses batch breaks and state submission.', + 'batch-breaking-atlased': 'Atlased texture changes; isolates batching without texture uploads.', + 'split-screen': 'Several simultaneous views; stresses multi-viewport traversal.', + 'mixed-blend': 'Long runs of blend modes; stresses state changes and batching.', + 'mixed-material': 'Several custom materials; stresses shader/material switches.', + 'mixed-material-atlased': 'Custom materials over atlased sprites; combines material and texture variety.', + 'instanced-batch': 'Explicit instance batches; stresses immediate submission cost.', + 'mixed-sprite-mesh-array': 'Sprites interleaved with mesh-array leaves; stresses renderer path switches.', + 'mixed-sprite-mesh-static': 'Sprites interleaved with static meshes; stresses mixed draw paths.', + 'scrolling-world': 'A moving camera over mostly off-screen content; stresses culling and retained reuse.', + 'text-static': 'Static labels with repeated glyphs; stresses text layout and glyph generation.', + 'text-dynamic': 'Changing labels; stresses per-frame text invalidation and layout.', + 'lifecycle-churn': 'A small fraction of leaves rebuilt each frame; stresses resource lifecycle work.', + 'filter-chain-1': 'One filter pass per scene; stresses offscreen composition.', + 'filter-chain-2': 'Two filter passes per scene; stresses chained offscreen composition.', + 'filter-chain-4': 'Four filter passes per scene; stresses deep filter composition.', + 'mask-clip': 'Clipped content; stresses mask setup and compositing.', + 'mask-clip-animated': 'Animated clipped content; stresses mask invalidation.', + composite: 'Nested render targets; stresses multi-pass composition.', + 'box-stack': 'Dense resting contacts; stresses collision detection, solving and sleeping.', + 'many-dynamic': 'Many active bodies in a bounded field; stresses broad-phase and live contacts.', + 'mixed-static-dynamic': 'Dynamic bodies falling onto static level geometry; models a common game mix.', + raycast: 'A mixed scene plus repeated rays; isolates query throughput.', + 'body-churn': 'Bodies rebuilt every step; stresses broad-phase repair and lifecycle work.', + joints: 'Constraint chains; stresses impulse propagation through joints.', + 'settling-pile': 'A dissipating pile; exposes steady-state settling and sleeping behavior.', +}; + +/** The one-line workload description for an archetype, or `undefined` where none is written. */ +export const archetypeDescription = (archetype: string): string | undefined => ARCHETYPE_DESCRIPTIONS[archetype]; + /** * Spread factor at which a measurement's own noise is called out. * @@ -400,6 +459,24 @@ export const formatFactor = (factor: number | null): string => (factor === null /** A median in milliseconds, or a dash when the arm produced no comparable number. */ export const formatMs = (ms: number | null): string => (ms === null || !Number.isFinite(ms) ? '-' : ms.toFixed(3)); +/** + * True when the pair produced a comparison at all. + * + * A pair the harness could not compare - an arm that does not implement the + * archetype, or that reported nothing there - carries neither a ratio nor a + * factor. + */ +export const isComparable = (cell: ProfileCell): boolean => cell.verdict.ratio !== null || cell.verdict.factor !== null; + +/** + * One arm's measurement inside a cell, or `null` where that arm produced none. + * + * An arm that sat a comparison out is stored as a zero rather than as a missing + * value, so a reader would otherwise be shown `0.000 ms` - the fastest number on + * the page - for the arm that did not run. + */ +export const measuredMs = (cell: ProfileCell, ms: number | null): number | null => (!isComparable(cell) && ms === 0 ? null : ms); + /** An ISO timestamp reduced to a calendar day. */ export const formatDay = (timestamp: string): string => timestamp.slice(0, 10); diff --git a/site/src/lib/bench-tables.ts b/site/src/lib/bench-tables.ts index 6546fdd01..9da9e7e50 100644 --- a/site/src/lib/bench-tables.ts +++ b/site/src/lib/bench-tables.ts @@ -22,6 +22,7 @@ */ import { + archetypeDescription, armLabel, armsOfSection, BACKEND_LABELS, @@ -59,6 +60,7 @@ export interface ComparisonRow { readonly section: string | null; /** The size every column measured this row at, or `null` where they differ. */ readonly count: number | null; + readonly description?: string; readonly entries: readonly ComparisonEntry[]; } @@ -116,7 +118,14 @@ export const renderingComparison = (document: BenchProfileDocument): ComparisonT }); const counts = [...new Set(entries.map(entry => entry.count).filter((count): count is number => count !== null))]; - return { key: archetype, archetype, section, count: counts.length === 1 ? (counts[0] ?? null) : null, entries }; + return { + key: archetype, + archetype, + section, + count: counts.length === 1 ? (counts[0] ?? null) : null, + description: archetypeDescription(archetype), + entries, + }; }); return { columns, rows, unit: 'nodes', countColumn: false }; @@ -135,6 +144,7 @@ const singleBlockTable = ( archetype: row.archetype, section: null, count: row.count, + description: archetypeDescription(row.archetype), entries: arms.map(arm => entryOf(arm, row, arm)), })), unit, From 11e7c9bd0406197c8d3c21a4c042b7f9727ed352 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 10 Sep 2026 23:59:47 +0200 Subject: [PATCH 02/15] feat(site): split the benchmarks into a rendering view and a physics view The page put both measured domains one under the other, so a reader after a renderer scrolled past twenty-one physics rows to reach the tables they came for and the two scoreboards, legends, methodology notes and provenance blocks were paid for twice. The domains share no row and answer different questions, so each gets its own page and the section's own URL serves rendering. The two views are ordinary links drawn as tabs: two static pages need no panel, no selection state and no arrow-key model, and either can be linked to. The machine profile is not swapped when a view is opened - a profile that measured one domain and not the other says so, because two machines' numbers do not belong in one reading. A comparison cell carried its own disclosure, so a five-pair row offered five toggles that each opened the same six terms; they become one disclosure per archetype, which also gives the terms room to be laid out as a list rather than wrapped into a column. Stacked into cards, an arm with no cell no longer takes a labelled block with a rule and the height of a result: the gap is visible in the matrix, where the columns beside it show it, and the detail row names the arms that were not compared either way. A pair inside the noise band now reads as "similar" rather than as 1.00x. The ladder pins the factor of a level rung to exactly one, so the cell printed a two-decimal ratio above two medians that work out to a different one - a precision the verdict never measured. Nothing about the ladder, its thresholds or the stored numbers changes; only what the cell prints. Where a table's columns fall into groups, a filter narrows it to one of them. It is an enhancement, not a gate: with no script every column is shown, which is what the indexed and printed page carries. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- .../src/components/BenchComparisonTable.astro | 336 ++++++++++++------ site/src/components/BenchProfileReport.astro | 44 +-- .../src/components/pages/BenchmarksPage.astro | 149 ++++---- site/src/lib/bench-profiles.ts | 22 +- site/src/pages/de/benchmarks/index.astro | 2 +- .../pages/de/benchmarks/physics/index.astro | 5 + site/src/pages/en/benchmarks/index.astro | 2 +- .../pages/en/benchmarks/physics/index.astro | 5 + 8 files changed, 372 insertions(+), 193 deletions(-) create mode 100644 site/src/pages/de/benchmarks/physics/index.astro create mode 100644 site/src/pages/en/benchmarks/physics/index.astro diff --git a/site/src/components/BenchComparisonTable.astro b/site/src/components/BenchComparisonTable.astro index 44dee272f..7888d7e07 100644 --- a/site/src/components/BenchComparisonTable.astro +++ b/site/src/components/BenchComparisonTable.astro @@ -10,12 +10,12 @@ * 700px of room inside a 1400px window, and a matrix squeezed into that is a * matrix nobody can read. Measurements never scroll sideways at any width. * - * A cell's first glance carries three things and no more: which way the pair - * went and by how much, the two medians, and a disclosure. Everything a reader - * needs only when verifying a row - p95, the range the pooled runs observed, - * the spread factor, the structural evidence, what each run concluded - sits - * behind that disclosure. It is not hidden, it is ranked: printing all of it in - * primary type is what made the previous table unreadable. + * A cell carries three things and no more: which way the pair went and by how + * much, the two medians, and the bar. Everything a reader needs only when + * verifying a row - p95, the range the pooled runs observed, the spread factor, + * the structural evidence, what each run concluded - sits behind one disclosure + * per archetype rather than one per comparison: a row of five pairs otherwise + * carried five toggles that each opened the same six terms. * * The bar is a diverging one on a fixed log axis shared by every column and * every table on the page, so a length means the same thing wherever it is @@ -132,13 +132,28 @@ const winnerOf = (cell: ProfileCell): string => { ---
+ {/* + * Hiding a group narrows the matrix to one question at a time. It is an + * enhancement and never a gate: without the script every column is shown, + * which is also what the printed and indexed page carries. + */} + {groups.length > 1 && ( +
+ + {groups.map(group => ( + + ))} +
+ )} {/* A fixed layout takes its widths from the first row, which is the group row and carries colspans rather than columns; declared on the cells, every width here would be ignored. */} {countColumn && } - {columns.map(() => ( - + {columns.map(column => ( + ))} @@ -146,7 +161,7 @@ const winnerOf = (cell: ProfileCell): string => { ))} @@ -156,7 +171,7 @@ const winnerOf = (cell: ProfileCell): string => { {countColumn && } {columns.map(column => ( - ); } const outcome = outcomeOf(cell); const mixed = outcome === 'unstable'; + const level = outcome === 'level'; const band = mixed ? ratioBand(cell) : null; const factor = mixed ? pooledFactor(cell) : cell.verdict.factor; const ratio = cell.verdict.ratio; const plotted = !mixed && ratio !== null && Number.isFinite(ratio) && ratio > 0; const referenceMs = measuredMs(cell, cell.referenceMs); const competitorMs = measuredMs(cell, cell.competitorMs); - const hasGpuTiming = cell.referenceGpuMs !== undefined || cell.competitorGpuMs !== undefined; return ( - + ); + })} + + {/* One disclosure for the archetype, not one per comparison: a matrix row carried five toggles that each opened the same six terms. */} + + - ); - })} + + + ); + })} + + + ))}
{groups.map(group => ( - + {group.label} Archetype{unit === 'bodies' ? 'Bodies' : 'Nodes'} + {column.overline !== '' && {column.overline}} vs @@ -199,30 +214,26 @@ const winnerOf = (cell: ProfileCell): string => { : `No shared cell at ${String(entry.count)} ${unit}: the arm sits this archetype out, or exceeded the run budget there.`; return ( - + - -
- Details -

{reason}

-
+ @@ -241,8 +252,9 @@ const winnerOf = (cell: ProfileCell): string => { - {mixed ? (factor === null ? '-' : formatApproximate(factor)) : formatFactor(factor)} - {mixed ? 'no clear lead' : winnerOf(cell)} + {/* A pair inside the noise band is named, not scored: its factor is pinned to 1 by the ladder, so printing 1.00x beside two medians that differ would claim a precision the ladder never measured. */} + {mixed ? (factor === null ? '-' : formatApproximate(factor)) : level ? 'similar' : formatFactor(factor)} + {mixed ? 'no clear lead' : level ? 'inside the noise band' : winnerOf(cell)} {formatMs(referenceMs)} @@ -250,8 +262,43 @@ const winnerOf = (cell: ProfileCell): string => { {formatMs(competitorMs)} ms -
- Details + +
+
+ + How {row.archetype} was measured + +
+ {row.entries.map((entry, column) => { + const cell = entry.cell; + + if (cell === null) { + return ( +
+

{labelOf(column)}

+

+ {entry.count === null + ? 'Not measured on this backend.' + : `No shared cell at ${String(entry.count)} ${unit}: the arm sits this archetype out, or exceeded the run budget there.`} +

+
+ ); + } + + const band = outcomeOf(cell) === 'unstable' ? ratioBand(cell) : null; + const referenceMs = measuredMs(cell, cell.referenceMs); + const competitorMs = measuredMs(cell, cell.competitorMs); + const hasGpuTiming = cell.referenceGpuMs !== undefined || cell.competitorGpuMs !== undefined; + + return ( +
+

{labelOf(column)}

p95
@@ -269,13 +316,7 @@ const winnerOf = (cell: ProfileCell): string => { {rangeOf(cell.aggregate.competitor)} {' '} - ms - -
-
-
spread
-
- {formatSpread(cell.aggregate.reference)} vs {formatSpread(cell.aggregate.competitor)} + ms, spread {formatSpread(cell.aggregate.reference)} vs {formatSpread(cell.aggregate.competitor)}
{band !== null && ( @@ -285,22 +326,16 @@ const winnerOf = (cell: ProfileCell): string => {
)}
-
60 fps frame
+
{FRAME_BUDGET_MS} ms frame
{[ { name: 'ExoJS', share: frameShare(referenceMs) }, { name: armLabel(cell.competitor), share: frameShare(competitorMs) }, - ].map(entry => ( + ].map(share => ( - {entry.name} - - 100 && 'over']} - style={`width:${String(Math.min(100, entry.share ?? 0))}%`} - /> - - 100 && 'warn']}> - {entry.share === null ? '-' : `${entry.share.toFixed(1)}%`} + {share.name} + 100 && 'warn']}> + {share.share === null ? '-' : `${share.share.toFixed(1)}%`} ))} @@ -308,11 +343,10 @@ const winnerOf = (cell: ProfileCell): string => {
{hasGpuTiming && (
-
CPU / GPU
+
GPU
- CPU submission {formatMs(referenceMs)} vs {formatMs(competitorMs)} ms; GPU execution{' '} {formatMs(measuredMs(cell, cell.referenceGpuMs ?? null))} vs{' '} - {formatMs(measuredMs(cell, cell.competitorGpuMs ?? null))} ms + {formatMs(measuredMs(cell, cell.competitorGpuMs ?? null))} ms of GPU execution
)} @@ -333,17 +367,45 @@ const winnerOf = (cell: ProfileCell): string => {
{cell.mechanism}
)} - -
- -
+ +
Bars share one log axis across the whole page: the centre is 1.00x and each side reaches 100x, with anything past that drawn cut. A hollow bar is a pair whose pooled runs landed on different rungs; it spans the interval they produced, publishes no verdict, and prints the two medians' own factor @@ -422,6 +484,42 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--fg-faint); } + .filters { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + margin-bottom: var(--s-3); + } + + .filters button { + padding: 0.2rem 0.65rem; + border: 1px solid var(--line-soft); + border-radius: var(--r-pill); + background: none; + font-family: var(--f-mono); + font-size: 0.7rem; + color: var(--fg-muted); + cursor: pointer; + } + + .filters button:hover { + color: var(--fg); + border-color: var(--line); + } + + /* The chosen filter is filled as well as tinted, so it is not colour alone that says which one is on. */ + .filters button[aria-pressed='true'] { + border-color: var(--line); + background: var(--bg-elevated); + color: var(--fg); + font-weight: 600; + } + + /* Stacked cards give td a display of their own, which the UA rule behind `hidden` does not outrank. */ + [data-group][hidden] { + display: none !important; + } + .col-archetype { width: 14rem; } @@ -490,12 +588,6 @@ const winnerOf = (cell: ProfileCell): string => { order: 1; } - /* The disclosure spans the cell rather than the text track: its rows are prose and wrap badly in ten rem. */ - .more { - grid-column: 1 / -1; - order: 3; - } - .plot { position: relative; display: block; @@ -622,17 +714,52 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--bench-mark); } - .more summary { + /* The detail row is a full-width strip under its archetype, not a cell: it belongs to the row, and its terms read as prose rather than as a column. */ + tr.detail > td { + padding: 0 0.7rem 0.5rem; + border-bottom: 1px solid var(--line-soft); + } + + .row-more summary { cursor: pointer; font-family: var(--f-mono); - font-size: 0.68rem; + font-size: 0.7rem; color: var(--fg-faint); } - .more summary:hover { + .row-more summary:hover { + color: var(--fg-muted); + } + + .row-more summary code { + font-size: inherit; + } + + .arms { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--s-3) var(--s-5); + margin-top: var(--s-3); + } + + .arm-detail h4 { + margin: 0 0 0.3rem; + font-family: var(--f-mono); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; color: var(--fg-muted); } + .arm-detail.absent p { + margin: 0; + font-family: var(--f-mono); + font-size: 0.68rem; + line-height: 1.5; + color: var(--fg-faint); + } + .row-description { display: block; max-width: 34rem; @@ -644,52 +771,39 @@ const winnerOf = (cell: ProfileCell): string => { line-height: 1.35; } - /* - * The term sits above its value rather than beside it. A label column costs - * a comparison column six and a half rem of the little it has, and the two - * widest values here - the frame-budget rows and the run strip - are - * themselves multi-column and stop fitting long before the prose does. - */ - .more dl { - margin: 0.4rem 0 0; + .arm-detail dl { + display: grid; + grid-template-columns: 5.5rem minmax(0, 1fr); + column-gap: var(--s-3); + margin: 0; font-family: var(--f-mono); font-size: 0.68rem; line-height: 1.5; color: var(--fg-muted); } - .more dl > div { - margin-bottom: 0.45rem; + .arm-detail dl > div { + display: contents; } - .more dt { + .arm-detail dt { color: var(--fg-faint); } - .more dd { - margin: 0; + .arm-detail dd { + margin: 0 0 0.25rem; overflow-wrap: anywhere; } - .empty-more p { - max-width: 32rem; - margin: 0.4rem 0 0; - font-family: var(--f-mono); - font-size: 0.68rem; - line-height: 1.5; - color: var(--fg-muted); - } - /* * A millisecond figure only means something against the frame it has to fit * in. Nothing is derived from the share - no verdict, no capacity figure - * it restates the published time in the unit a reader already thinks in. */ .budget-row { - display: grid; - grid-template-columns: 4.5rem minmax(3rem, 1fr) 3rem; - align-items: center; - gap: 0.4rem; + display: inline-flex; + gap: 0.35rem; + margin-right: var(--s-3); } .budget-row em { @@ -697,27 +811,8 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--fg-faint); } - .budget { - display: block; - height: 5px; - border-radius: 3px; - background: color-mix(in oklab, var(--fg-faint), transparent 85%); - } - - .budget i { - display: block; - height: 100%; - border-radius: 3px; - background: var(--fg-muted); - } - - .budget i.over { - background: var(--bench-mark); - } - .budget-row b { font-weight: 400; - text-align: right; } /* @@ -780,12 +875,22 @@ const winnerOf = (cell: ProfileCell): string => { display: none; } - tbody tr:not(.section) { + /* An archetype and its detail row are two table rows but one card, so the border opens on the first and closes on the second. */ + tbody tr:not(.section):not(.detail) { + border: 1px solid var(--line-soft); + border-bottom: 0; + border-radius: var(--r-3) var(--r-3) 0 0; + background: var(--bg-elevated); + padding: var(--s-3) var(--s-4) 0; + } + + tbody tr.detail { margin-bottom: var(--s-3); border: 1px solid var(--line-soft); - border-radius: var(--r-3); + border-top: 0; + border-radius: 0 0 var(--r-3) var(--r-3); background: var(--bg-elevated); - padding: var(--s-3) var(--s-4); + padding: 0 var(--s-4) var(--s-3); } tbody tr.section td { @@ -840,5 +945,26 @@ const winnerOf = (cell: ProfileCell): string => { order: 0; margin-top: 0; } + + /* + * An arm with no cell keeps its place in the matrix, where the gap is + * visible against the columns beside it. Stacked, that same gap becomes + * a labelled block with a rule and a dash and the height of a result - + * so the card lists only what was measured, and the detail row below + * still names every arm that was not. + */ + .cell.empty { + display: none; + } + + /* The card carries the row's border; a second one under the disclosure would draw a line across the middle of it. */ + tr.detail > td { + padding: var(--s-2) 0 0; + border-bottom: 0; + } + + .arms { + grid-template-columns: minmax(0, 1fr); + } } diff --git a/site/src/components/BenchProfileReport.astro b/site/src/components/BenchProfileReport.astro index 7c38076d4..125933b0a 100644 --- a/site/src/components/BenchProfileReport.astro +++ b/site/src/components/BenchProfileReport.astro @@ -20,6 +20,7 @@ import BenchComparisonTable from './BenchComparisonTable.astro'; import BenchComparisonSummary from './BenchComparisonSummary.astro'; import { BACKEND_LABELS, + type BenchDomain, comparisonTallies, formatDay, formatRunTime, @@ -31,6 +32,12 @@ import { physicsComparison, renderingComparison, webgl1Comparison } from '../lib interface Props { document: BenchProfileDocument; + /** + * The domain this view shows. Rendering and physics answer different + * questions and share no row, so putting both on one page only made a + * reader scroll past the one they did not come for. + */ + domain: BenchDomain; /** Heading level the block starts at, so a collapsed further machine does not outrank the reference. */ headingLevel?: 2 | 3; /** Set false where the surrounding markup already names the machine, such as a collapsed further machine. */ @@ -39,15 +46,15 @@ interface Props { primary?: boolean; } -const { document: profileDocument, headingLevel = 2, showTitle = true, primary = false } = Astro.props; +const { document: profileDocument, domain, headingLevel = 2, showTitle = true, primary = false } = Astro.props; const { profile, rendering, physics } = profileDocument; const Heading = `h${String(headingLevel)}` as 'h2' | 'h3'; -const SubHeading = `h${String(headingLevel + 1)}` as 'h3' | 'h4'; -const tallies = comparisonTallies(profileDocument); +const isRendering = domain === 'rendering'; +const tallies = comparisonTallies(profileDocument, domain); const renderingTable = renderingComparison(profileDocument); const physicsTable = physicsComparison(profileDocument); -const backends = rendering?.backends ?? []; +const backends = isRendering ? rendering?.backends ?? [] : []; const webgl1Blocks = backends.flatMap(backend => { const table = webgl1Comparison(backend); @@ -72,8 +79,8 @@ const renderingCountNote = const excluded = backends.flatMap(backend => backend.excluded.map(entry => ({ backend: backend.backend, ...entry }))); /** The machine is one across the pooled runs, so the first run's stamps name it for all of them. */ -const renderingStamps = rendering?.runs[0]?.provenance ?? []; -const physicsHost = physics?.runs[0]; +const renderingStamps = isRendering ? rendering?.runs[0]?.provenance ?? [] : []; +const physicsHost = isRendering ? undefined : physics?.runs[0]; /** * When each pooled run was taken, per domain. @@ -84,14 +91,15 @@ const physicsHost = physics?.runs[0]; * them is a median over several measurements and not a single lucky one. */ const runTimes = [ - ...(rendering === undefined ? [] : [{ domain: 'Rendering', times: rendering.runs.map(run => run.provenance[0]?.timestamp ?? null) }]), - ...(physics === undefined ? [] : [{ domain: 'Physics', times: physics.runs.map(run => run.timestamp) }]), + ...(!isRendering || rendering === undefined ? [] : [{ domain: 'Rendering', times: rendering.runs.map(run => run.provenance[0]?.timestamp ?? null) }]), + ...(isRendering || physics === undefined ? [] : [{ domain: 'Physics', times: physics.runs.map(run => run.timestamp) }]), ].map(entry => ({ domain: entry.domain, times: entry.times.filter((time): time is string => time !== null).map(formatRunTime), })); -const libraries = [...(rendering?.libraries ?? []), ...(physics?.libraries ?? [])].map(library => `${library.name} ${library.version}`); +/** Only the libraries this view's numbers came from: a physics table is not evidence about the renderer's opponents. */ +const libraries = (isRendering ? rendering?.libraries ?? [] : physics?.libraries ?? []).map(library => `${library.name} ${library.version}`); ---
@@ -113,23 +121,19 @@ const libraries = [...(rendering?.libraries ?? []), ...(physics?.libraries ?? [] bars and no bar is ranked against another: the arms answer different questions, and this page publishes no score and no overall winner.

- {renderingTable.rows.length > 0 && ( + {/* The view already names its domain and its unit, so the block carries only what the tab cannot say: the size the rows were measured at. */} + {isRendering && renderingTable.rows.length > 0 && (
- Rendering -

- {renderingCountNote}, chosen from the archetype ladders before any timing was read. CPU milliseconds per frame, pooled over {profile.runs} - {' '}separate runs as the median of their per-run medians; lower is better. -

+

{renderingCountNote}, chosen from the archetype ladders before any timing was read.

)} - {physicsTable !== null && physicsTable.rows.length > 0 && ( + {!isRendering && physicsTable !== null && physicsTable.rows.length > 0 && (
- Physics

Each archetype sits on its own body-count ladder, so every row states the count it was measured at and rows are not comparable with one - another - only the arms within one row are. CPU milliseconds per fixed step, pooled the same way. + another - only the arms within one row are.

@@ -141,7 +145,7 @@ const libraries = [...(rendering?.libraries ?? []), ...(physics?.libraries ?? []

These arms render through a WebGL1 context, so a gap against them can be caused by the backend generation as much as by the engine, and the structural probe cannot attach to say which. The rows compare CPU time and carry no mechanism, which makes them an observation rather than a - finding. + finding, and the comparison count above therefore leaves them out.

@@ -160,7 +164,7 @@ const libraries = [...(rendering?.libraries ?? []), ...(physics?.libraries ?? [] )} - {physicsHost !== undefined && physicsHost.caveats.length > 0 && ( + {!isRendering && physicsHost !== undefined && physicsHost.caveats.length > 0 && (
Caveats the physics runs disclosed ({physicsHost.caveats.length})
    diff --git a/site/src/components/pages/BenchmarksPage.astro b/site/src/components/pages/BenchmarksPage.astro index 730728ec2..6ad252592 100644 --- a/site/src/components/pages/BenchmarksPage.astro +++ b/site/src/components/pages/BenchmarksPage.astro @@ -21,14 +21,46 @@ import BenchLocalProbe from '../BenchLocalProbe.astro'; import DocsLayout from '../../layouts/DocsLayout.astro'; import EnglishFallbackNotice from '../EnglishFallbackNotice.astro'; import { appInfo } from '../../lib/app-info'; -import { formatDay, FRAME_BUDGET_MS, furtherProfiles, olderThanReference, profileScope, referenceProfile } from '../../lib/bench-profiles'; +import { + type BenchDomain, + coversDomain, + formatDay, + FRAME_BUDGET_MS, + furtherProfiles, + olderThanReference, + profileScope, + referenceProfile, +} from '../../lib/bench-profiles'; interface Props { locale: 'en' | 'de'; + domain: BenchDomain; } -const { locale } = Astro.props; -const scope = referenceProfile === undefined ? '' : profileScope(referenceProfile); +const { locale, domain } = Astro.props; +const scope = referenceProfile === undefined ? '' : profileScope(referenceProfile, domain); + +/** + * The two views, as ordinary links. + * + * They are styled as tabs but they are navigation, not a tab widget: two static + * pages need no panel, no selection state and no arrow-key model, and a reader + * can link to either one. Rendering is the page the section's own URL serves, + * so there is no third URL that only forwards to it. + */ +const base = import.meta.env.BASE_URL; +const views: readonly { id: BenchDomain; label: string; href: string }[] = [ + { id: 'rendering', label: 'Rendering', href: `${base}${locale}/benchmarks/` }, + { id: 'physics', label: 'Physics', href: `${base}${locale}/benchmarks/physics/` }, +]; + +const covered = referenceProfile !== undefined && coversDomain(referenceProfile, domain); + +/** Only the further machines that measured this domain: an empty disclosure is a promise the profile cannot keep. */ +const furtherMachines = furtherProfiles.filter(document => coversDomain(document, domain)); + +/** What this view measures, stated once beside the numbers rather than in the methodology alone. */ +const metric = domain === 'rendering' ? 'CPU milliseconds per frame' : 'CPU milliseconds per fixed step'; /** * Runs the reproduction below pools. A published profile states the number it @@ -40,22 +72,15 @@ const REPRODUCTION_RUNS = 3; const pooledRuns = referenceProfile?.profile.runs ?? REPRODUCTION_RUNS; const machine = referenceProfile?.profile; -/** - * The sections the on-page navigation offers. Only the ones that exist are - * listed: a link to an empty anchor is worse than a shorter bar. - */ -const sections = [ - ...(referenceProfile === undefined ? [] : [{ id: 'summary', label: 'Overview' }]), - ...(referenceProfile?.rendering === undefined ? [] : [{ id: 'rendering', label: 'Rendering' }]), - ...(referenceProfile?.physics === undefined ? [] : [{ id: 'physics', label: 'Physics' }]), - ...(furtherProfiles.length === 0 ? [] : [{ id: 'further', label: 'Other machines' }]), - { id: 'practices', label: 'Methodology' }, -]; ---
    @@ -64,45 +89,55 @@ const sections = [

    Benchmarks

    - Cross-library measurements for rendering and physics. Lower is better. Every published value is pooled from {pooledRuns} independent runs. + {metric}, lower is better. Every published value is the median of {pooledRuns} independent runs.

    - {machine !== undefined && ( -

    - - {machine.gpu} · {machine.os} · {machine.browser} - - - ExoJS {machine.engineVersion} · measured {formatDay(machine.measuredAt)} · {machine.runs} pooled runs - - {scope !== '' && {scope}} -

    - )}
    -
); })} @@ -346,38 +381,32 @@ pnpm bench:compare -- \ * reachable. Horizontal scrolling is fine for navigation - it is exactly * what the measurements below must never need. */ - .section-nav { - position: sticky; - top: 68px; - z-index: 2; + /* Drawn as tabs, but two ordinary links: each view is its own page, so there is nothing here to select, focus or arrow between. */ + .views { display: flex; - gap: var(--s-2); - overflow-x: auto; - margin: var(--s-5) 0 var(--s-6); - padding: var(--s-2) 0; - background: var(--bg-canvas); + gap: var(--s-4); + margin: var(--s-5) 0 var(--s-4); border-bottom: 1px solid var(--line-soft); - scrollbar-width: none; } - .section-nav::-webkit-scrollbar { - display: none; - } - - .section-nav a { - flex: none; - padding: 0.25rem 0.7rem; - border: 1px solid var(--line-soft); - border-radius: var(--r-pill); - font-size: 0.76rem; + .views a { + padding: 0.35rem 0.1rem; + margin-bottom: -1px; + border-bottom: 2px solid transparent; + font-size: 0.95rem; + font-weight: 600; color: var(--fg-muted); text-decoration: none; - white-space: nowrap; } - .section-nav a:hover { + .views a:hover { + color: var(--fg); + } + + /* The rule's presence, not its hue, is what marks the current view; `aria-current` carries the same fact where the rule is not seen. */ + .views a[aria-current='page'] { color: var(--fg); - border-color: var(--line); + border-bottom-color: var(--accent); } .empty { diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts index c9a08d32c..51aa88183 100644 --- a/site/src/lib/bench-profiles.ts +++ b/site/src/lib/bench-profiles.ts @@ -759,6 +759,9 @@ const tally = (key: string, group: string, label: string, arm: string, meta: str return { key, group, label, arm, meta, counts, summary, measured, total: cells.length }; }; +/** Which measured domain a view is showing. */ +export type BenchDomain = 'rendering' | 'physics'; + /** * The scoreboard, one line per arm a domain was measured against. * @@ -766,9 +769,12 @@ const tally = (key: string, group: string, label: string, arm: string, meta: str * different opponents into one strip, so a reader would see a mix that belongs * to neither of them. Nothing is summed across lines and no line is ranked * against another, because the arms answer different questions. + * + * Pass `domain` to keep the lines of one domain only; without it the document's + * whole scoreboard is returned. */ -export const comparisonTallies = (document: BenchProfileDocument): readonly ComparisonTally[] => [ - ...(document.rendering?.backends ?? []).flatMap(backend => +export const comparisonTallies = (document: BenchProfileDocument, domain?: BenchDomain): readonly ComparisonTally[] => [ + ...(domain === 'physics' ? [] : (document.rendering?.backends ?? [])).flatMap(backend => backend.competitors.map(arm => tally( `${backend.backend}-${arm}`, @@ -780,7 +786,7 @@ export const comparisonTallies = (document: BenchProfileDocument): readonly Comp ), ), ), - ...(document.physics === undefined + ...(document.physics === undefined || domain === 'rendering' ? [] : armsOfSection(document.physics.section).map(arm => tally( @@ -803,9 +809,9 @@ export const comparisonTallies = (document: BenchProfileDocument): readonly Comp * longer support. A profile carrying only one domain yields a shorter line * instead of a padded one. */ -export const profileScope = (document: BenchProfileDocument): string => { - const rendering = renderingCells(document); - const physics = physicsCells(document); +export const profileScope = (document: BenchProfileDocument, domain?: BenchDomain): string => { + const rendering = domain === 'physics' ? [] : renderingCells(document); + const physics = domain === 'rendering' ? [] : physicsCells(document); const parts: string[] = []; if (rendering.length > 0) parts.push(`${String(rendering.length)} rendering comparisons against ${listOf(armsIn(rendering).map(armLabel))}`); @@ -813,3 +819,7 @@ export const profileScope = (document: BenchProfileDocument): string => { return parts.join(' · '); }; + +/** True when the profile carries measurements for this domain. */ +export const coversDomain = (document: BenchProfileDocument, domain: BenchDomain): boolean => + domain === 'rendering' ? document.rendering !== undefined : document.physics !== undefined; diff --git a/site/src/pages/de/benchmarks/index.astro b/site/src/pages/de/benchmarks/index.astro index cdc06c4ed..6e4251f0a 100644 --- a/site/src/pages/de/benchmarks/index.astro +++ b/site/src/pages/de/benchmarks/index.astro @@ -2,4 +2,4 @@ import BenchmarksPage from '../../../components/pages/BenchmarksPage.astro'; --- - + diff --git a/site/src/pages/de/benchmarks/physics/index.astro b/site/src/pages/de/benchmarks/physics/index.astro new file mode 100644 index 000000000..a0f0994b4 --- /dev/null +++ b/site/src/pages/de/benchmarks/physics/index.astro @@ -0,0 +1,5 @@ +--- +import BenchmarksPage from '../../../../components/pages/BenchmarksPage.astro'; +--- + + diff --git a/site/src/pages/en/benchmarks/index.astro b/site/src/pages/en/benchmarks/index.astro index 09ef67f34..c6b87fd9d 100644 --- a/site/src/pages/en/benchmarks/index.astro +++ b/site/src/pages/en/benchmarks/index.astro @@ -2,4 +2,4 @@ import BenchmarksPage from '../../../components/pages/BenchmarksPage.astro'; --- - + diff --git a/site/src/pages/en/benchmarks/physics/index.astro b/site/src/pages/en/benchmarks/physics/index.astro new file mode 100644 index 000000000..cac7e3252 --- /dev/null +++ b/site/src/pages/en/benchmarks/physics/index.astro @@ -0,0 +1,5 @@ +--- +import BenchmarksPage from '../../../../components/pages/BenchmarksPage.astro'; +--- + + From b900281c2a0bb83d7cf556ab4b0e00e31ffcad34 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 00:11:05 +0200 Subject: [PATCH 03/15] fix(site): print benchmark figures to three significant digits A fixed two decimals on a factor and three on a millisecond value printed digits the measurement never resolved: 669.00x for a ratio whose denominator is one clock tick, and 13.380 ms for a value whose pooled runs spanned 12.73 to 14.10. The decimals now follow the magnitude, so 669x, 13.4 and 8.67 replace them and a sub-millisecond value keeps the three decimals its column aligns on. Trailing zeros are kept rather than stripped. 0.80 beside 0.12 is a column a reader scans down, and the zero is a digit the clock did resolve; what the rule removes is the one it did not. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- site/src/lib/bench-profiles.ts | 37 +++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts index 51aa88183..b977c6a80 100644 --- a/site/src/lib/bench-profiles.ts +++ b/site/src/lib/bench-profiles.ts @@ -453,11 +453,38 @@ export const WIDE_SPREAD_RATIO = 1.2; */ export const FRAME_BUDGET_MS = 16.7; +/** + * How many digits of a published figure are worth printing. + * + * Three, and never a fixed number of decimals. A fixed three decimals prints + * `13.380` for a value whose pooled runs spanned 12.73 to 14.10, and a fixed two + * prints `669.00x` for a ratio whose denominator is a single clock tick - digits + * the measurement never resolved, in the position a reader trusts most. + */ +const SIGNIFICANT_DIGITS = 3; + +/** + * A number at {@link SIGNIFICANT_DIGITS}, as a fixed number of decimals for its + * magnitude. + * + * Trailing zeros are kept rather than stripped: `0.80` printed as `0.8` beside + * `0.12` ragged a column of figures a reader is scanning down, and the zero is + * a digit the timer did resolve. What the magnitude rule removes is the digit + * it did not - the third decimal of a two-figure millisecond value. + */ +const significant = (value: number): string => { + const magnitude = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value))); + + // Capped at three: below a tenth of a millisecond the significant-figure rule + // would keep adding decimals to values the clock delivers in fixed steps. + return value.toFixed(Math.max(0, Math.min(3, SIGNIFICANT_DIGITS - 1 - magnitude))); +}; + /** A ratio in the form the verdict labels print it, or a dash when the pair produced none. */ -export const formatFactor = (factor: number | null): string => (factor === null || !Number.isFinite(factor) ? '-' : `${factor.toFixed(2)}x`); +export const formatFactor = (factor: number | null): string => (factor === null || !Number.isFinite(factor) ? '-' : `${significant(factor)}x`); /** A median in milliseconds, or a dash when the arm produced no comparable number. */ -export const formatMs = (ms: number | null): string => (ms === null || !Number.isFinite(ms) ? '-' : ms.toFixed(3)); +export const formatMs = (ms: number | null): string => (ms === null || !Number.isFinite(ms) ? '-' : significant(ms)); /** * True when the pair produced a comparison at all. @@ -665,7 +692,7 @@ export const ratioBand = (cell: ProfileCell): RatioBand | null => { }; /** A ratio band as the details print it. */ -export const formatBand = (band: RatioBand): string => `${band.low.toFixed(2)}x-${band.high.toFixed(2)}x`; +export const formatBand = (band: RatioBand): string => `${significant(band.low)}x-${significant(band.high)}x`; /** * The factor a pair's two pooled medians work out to. @@ -688,10 +715,10 @@ export const pooledFactor = (cell: ProfileCell): number | null => { }; /** A factor the runs did not settle, marked as such. */ -export const formatApproximate = (factor: number): string => `~${factor.toFixed(2)}x`; +export const formatApproximate = (factor: number): string => `~${significant(factor)}x`; /** How far the pooled runs moved, as the single factor the profile stores. */ -export const formatSpread = (spread: ProfileSpread): string => (spread.ratio === null || !Number.isFinite(spread.ratio) ? '' : `${spread.ratio.toFixed(2)}x`); +export const formatSpread = (spread: ProfileSpread): string => (spread.ratio === null || !Number.isFinite(spread.ratio) ? '' : `${significant(spread.ratio)}x`); /** * What a measured comparison came out as, once the ladder's five settled rungs From aa6a4e12f8787c3b60f5fe098d0c46c3e08f989e Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 00:34:32 +0200 Subject: [PATCH 04/15] feat(site): lead the benchmark views with the results, not with the tally The scoreboard opened every view: coverage, five outcome states, a strip whose unfilled part is not a result, and a paragraph on how not to read any of it - all of it asked before a reader had seen a single number. It moves under the tables, where it summarises rows already met, and the key above them keeps only what a bar's colour means. Stacked into cards, an archetype carried every one of its comparisons one under the other. A picker now names the pair on screen, and the narrow view opens on the arm ExoJS leads on fewest rows rather than the one it leads on most - the choice decides which comparison is visible first and publishes no figure, so nothing is summed, ranked or counted from it. Wide, the picker steps aside for the matrix, which shows the pairs side by side by design. A factor is read for its size rather than its digits, so it drops to whole numbers from ten up and one decimal below: 72x and 2x say what 72.25x and 2.00x said. Milliseconds keep their own rule - a time is compared against a frame budget, not against another factor. A pair inside the noise band prints the word alone, and what the word means is stated once in the key. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- .../src/components/BenchComparisonTable.astro | 148 +++++++++++++++--- site/src/components/BenchProfileReport.astro | 29 ++-- site/src/lib/bench-profiles.ts | 47 +++--- 3 files changed, 170 insertions(+), 54 deletions(-) diff --git a/site/src/components/BenchComparisonTable.astro b/site/src/components/BenchComparisonTable.astro index 7888d7e07..982cf8691 100644 --- a/site/src/components/BenchComparisonTable.astro +++ b/site/src/components/BenchComparisonTable.astro @@ -129,6 +129,25 @@ const winnerOf = (cell: ProfileCell): string => { return side === 'exojs' ? 'ExoJS' : side === 'competitor' ? armLabel(cell.competitor) : ''; }; + +/** + * The comparison a narrow reader is shown before choosing one. + * + * The arm ExoJS leads on fewest rows, so the view a phone opens on is the + * hardest opponent this machine found rather than the most flattering one. + * It selects which comparison is on screen first and publishes no figure, so + * nothing here is summed, ranked or counted anywhere else on the page. + */ +const defaultColumn = columns + .map((_, index) => ({ + index, + leads: rows.filter(row => { + const outcome = outcomeOf(row.entries[index]?.cell ?? null); + + return outcome === 'lead' || outcome === 'clear-lead'; + }).length, + })) + .reduce((fewest, candidate) => (candidate.leads < fewest.leads ? candidate : fewest), { index: 0, leads: Number.POSITIVE_INFINITY }).index; ---
@@ -137,14 +156,28 @@ const winnerOf = (cell: ProfileCell): string => { * enhancement and never a gate: without the script every column is shown, * which is also what the printed and indexed page carries. */} - {groups.length > 1 && ( -
- - {groups.map(group => ( - - ))} + {columns.length > 1 && ( +
+ {groups.length > 1 && ( +
+ + {groups.map(group => ( + + ))} +
+ )} + {/* Stacked, a card would otherwise carry every comparison of its archetype one under the other; picked, it carries one. */} +
)} @@ -152,8 +185,8 @@ const winnerOf = (cell: ProfileCell): string => { {countColumn && } - {columns.map(column => ( - + {columns.map((column, index) => ( + ))} @@ -170,8 +203,8 @@ const winnerOf = (cell: ProfileCell): string => { {countColumn && } - {columns.map(column => ( -
Archetype{unit === 'bodies' ? 'Bodies' : 'Nodes'} + {columns.map((column, index) => ( + {column.overline !== '' && {column.overline}} vs @@ -214,7 +247,7 @@ const winnerOf = (cell: ProfileCell): string => { : `No shared cell at ${String(entry.count)} ${unit}: the arm sits this archetype out, or exceeded the run budget there.`; return ( - + - @@ -233,7 +266,7 @@ const winnerOf = (cell: ProfileCell): string => { const competitorMs = measuredMs(cell, cell.competitorMs); return ( - + @@ -254,7 +287,8 @@ const winnerOf = (cell: ProfileCell): string => { {/* A pair inside the noise band is named, not scored: its factor is pinned to 1 by the ladder, so printing 1.00x beside two medians that differ would claim a precision the ladder never measured. */} {mixed ? (factor === null ? '-' : formatApproximate(factor)) : level ? 'similar' : formatFactor(factor)} - {mixed ? 'no clear lead' : level ? 'inside the noise band' : winnerOf(cell)} + {/* The word alone; what it means is stated once in the legend rather than in every cell that carries it. */} + {mixed ? 'no clear lead' : level ? '' : winnerOf(cell)} {formatMs(referenceMs)} @@ -271,9 +305,8 @@ const winnerOf = (cell: ProfileCell): string => {
- - How {row.archetype} was measured - + {/* The row already names its archetype; the label repeats it only where a reader reaches the summary without that context. */} + Measurement detail
{row.entries.map((entry, column) => { const cell = entry.cell; @@ -380,29 +413,57 @@ const winnerOf = (cell: ProfileCell): string => {
@@ -487,10 +548,33 @@ const winnerOf = (cell: ProfileCell): string => { .filters { display: flex; flex-wrap: wrap; - gap: var(--s-2); + align-items: center; + gap: var(--s-2) var(--s-4); margin-bottom: var(--s-3); } + .chips { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + } + + /* The matrix shows a column per comparison already, so picking one there would hide the reading the matrix exists for. */ + .pick { + display: none; + } + + .pick select { + margin-left: var(--s-2); + padding: 0.2rem 0.4rem; + border: 1px solid var(--line-soft); + border-radius: var(--r-2); + background: var(--bg-elevated); + color: var(--fg); + font-family: var(--f-mono); + font-size: 0.7rem; + } + .filters button { padding: 0.2rem 0.65rem; border: 1px solid var(--line-soft); @@ -966,5 +1050,17 @@ const winnerOf = (cell: ProfileCell): string => { .arms { grid-template-columns: minmax(0, 1fr); } + + /* One comparison at a time is the readable unit here, and the picker names the pair the chips could only narrow to a group. */ + .pick { + display: block; + font-family: var(--f-mono); + font-size: 0.7rem; + color: var(--fg-muted); + } + + .chips { + display: none; + } } diff --git a/site/src/components/BenchProfileReport.astro b/site/src/components/BenchProfileReport.astro index 125933b0a..8be39d672 100644 --- a/site/src/components/BenchProfileReport.astro +++ b/site/src/components/BenchProfileReport.astro @@ -105,21 +105,14 @@ const libraries = (isRendering ? rendering?.libraries ?? [] : physics?.libraries
{showTitle && {profile.gpu} / {profile.os} / {profile.browser}} -
- -
- + {/* Only what a bar's colour means, which the table beside it needs. Everything the key had to explain about itself moved into the tally below the results. */}

ExoJS ahead the arm ahead - level, inside the noise band - no clear lead: the pooled runs landed on different verdict bands + similar + no clear lead past the {FRAME_BUDGET_MS} ms frame, or a spread of {WIDE_SPREAD_RATIO}x and up

-

- A bar is as wide as the arm's row count, so the unfilled part is coverage the matrix does not have rather than a result. Nothing is summed across - bars and no bar is ranked against another: the arms answer different questions, and this page publishes no score and no overall winner. -

{/* The view already names its domain and its unit, so the block carries only what the tab cannot say: the size the rows were measured at. */} {isRendering && renderingTable.rows.length > 0 && ( @@ -139,6 +132,22 @@ const libraries = (isRendering ? rendering?.libraries ?? [] : physics?.libraries
)} + {/* + * The tally sits under the results rather than over them. Read first it is + * the hardest thing on the page - coverage, five outcome states, a strip + * whose unfilled part is not a result, and a paragraph on how not to read + * it - and it asks all of that before a reader has seen a single number. + * Read after, it is a summary of rows already met. + */} +
+ How the {tallies.length} comparisons came out, per arm +

+ A bar is as wide as the arm's row count, so the unfilled part is coverage the matrix does not have rather than a result. Nothing is summed + across bars and no bar is ranked against another: the arms answer different questions, and this page publishes no score and no overall winner. +

+ +
+ {webgl1Blocks.map(block => (
WebGL1 arms, CPU time only ({block.table.rows.length} rows) diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts index b977c6a80..93d740098 100644 --- a/site/src/lib/bench-profiles.ts +++ b/site/src/lib/bench-profiles.ts @@ -456,22 +456,19 @@ export const FRAME_BUDGET_MS = 16.7; /** * How many digits of a published figure are worth printing. * - * Three, and never a fixed number of decimals. A fixed three decimals prints - * `13.380` for a value whose pooled runs spanned 12.73 to 14.10, and a fixed two - * prints `669.00x` for a ratio whose denominator is a single clock tick - digits - * the measurement never resolved, in the position a reader trusts most. + * Three, and never a fixed number of decimals: a fixed three prints `13.380` for + * a value whose pooled runs spanned 12.73 to 14.10, which is a digit past what + * the measurement separated. + * + * The rule is about how many digits a figure of this size can carry, and it says + * nothing about the clock behind it. A value that reaches the page as `0.020` + * has three digits because the formatter counts places, not because the timer + * resolved a thousandth of a millisecond - see {@link ProfileAggregate} for what + * the runs actually separated. */ const SIGNIFICANT_DIGITS = 3; -/** - * A number at {@link SIGNIFICANT_DIGITS}, as a fixed number of decimals for its - * magnitude. - * - * Trailing zeros are kept rather than stripped: `0.80` printed as `0.8` beside - * `0.12` ragged a column of figures a reader is scanning down, and the zero is - * a digit the timer did resolve. What the magnitude rule removes is the digit - * it did not - the third decimal of a two-figure millisecond value. - */ +/** A number at {@link SIGNIFICANT_DIGITS}, as a fixed number of decimals for its magnitude. */ const significant = (value: number): string => { const magnitude = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value))); @@ -480,8 +477,22 @@ const significant = (value: number): string => { return value.toFixed(Math.max(0, Math.min(3, SIGNIFICANT_DIGITS - 1 - magnitude))); }; -/** A ratio in the form the verdict labels print it, or a dash when the pair produced none. */ -export const formatFactor = (factor: number | null): string => (factor === null || !Number.isFinite(factor) ? '-' : `${significant(factor)}x`); +/** + * A ratio as the cell prints it, or a dash when the pair produced none. + * + * A factor is read for its size, not for its digits, so it carries fewer than a + * millisecond value: whole numbers from ten up, one decimal below that, and no + * trailing zero. `72x` and `2x` say what `72.25x` and `2.00x` said, without + * offering four figures of a ratio the ladder rounds to a rung anyway. The + * unrounded figure stays in the row's detail. + */ +export const formatFactor = (factor: number | null): string => { + if (factor === null || !Number.isFinite(factor)) return '-'; + + const rounded = factor >= 10 ? factor.toFixed(0) : factor.toFixed(1).replace(/\.0$/, ''); + + return `${rounded}x`; +}; /** A median in milliseconds, or a dash when the arm produced no comparable number. */ export const formatMs = (ms: number | null): string => (ms === null || !Number.isFinite(ms) ? '-' : significant(ms)); @@ -692,7 +703,7 @@ export const ratioBand = (cell: ProfileCell): RatioBand | null => { }; /** A ratio band as the details print it. */ -export const formatBand = (band: RatioBand): string => `${significant(band.low)}x-${significant(band.high)}x`; +export const formatBand = (band: RatioBand): string => `${formatFactor(band.low)}-${formatFactor(band.high)}`; /** * The factor a pair's two pooled medians work out to. @@ -715,10 +726,10 @@ export const pooledFactor = (cell: ProfileCell): number | null => { }; /** A factor the runs did not settle, marked as such. */ -export const formatApproximate = (factor: number): string => `~${significant(factor)}x`; +export const formatApproximate = (factor: number): string => `~${formatFactor(factor)}`; /** How far the pooled runs moved, as the single factor the profile stores. */ -export const formatSpread = (spread: ProfileSpread): string => (spread.ratio === null || !Number.isFinite(spread.ratio) ? '' : `${significant(spread.ratio)}x`); +export const formatSpread = (spread: ProfileSpread): string => (spread.ratio === null || !Number.isFinite(spread.ratio) ? '' : formatFactor(spread.ratio)); /** * What a measured comparison came out as, once the ladder's five settled rungs From 8975c9c840e39d95c32bda23137b7e88678f4815 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 00:56:16 +0200 Subject: [PATCH 05/15] feat(bench): measure the page's clock resolution in a shared probe A benchmark that reports milliseconds without recording the grid its clock delivers them on cannot tell a fast cell from an unresolved one. The physics harness already measured that grid; the probe moves to the shared layer so the rendering harness can take the same reading in its own page, which is where it has to be taken - a value read in the driver process describes Node's clock. An unobserved resolution is reported as absent rather than as zero. The previous probe returned 0 when its loop saw no positive step, which reads downstream as a perfectly fine clock - the opposite of what the failed observation established. The report states what it is: the smallest step the probe saw, not a calibrated error bound. Engines may coarsen and jitter timestamps, so one observed minimum bounds neither the error of a sample nor the confidence of a comparison. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- packages/exojs-bench/src/shared/clock.ts | 61 +++++++++++++++++++ .../src/components/BenchComparisonTable.astro | 21 +------ site/src/lib/bench-tables.ts | 31 +++++++++- 3 files changed, 92 insertions(+), 21 deletions(-) create mode 100644 packages/exojs-bench/src/shared/clock.ts diff --git a/packages/exojs-bench/src/shared/clock.ts b/packages/exojs-bench/src/shared/clock.ts new file mode 100644 index 000000000..b4eb953c6 --- /dev/null +++ b/packages/exojs-bench/src/shared/clock.ts @@ -0,0 +1,61 @@ +/** + * What the page's own clock resolved, measured in the page that will be timed. + * + * `performance.now()` is coarsened by the engine, and by how much depends on + * the browsing context rather than on the workload - notably on whether the + * page reached a cross-origin-isolated context. A benchmark that reports + * milliseconds without recording that grid cannot tell a fast cell from an + * unresolved one, so the grid is measured alongside the cells and travels with + * them into the profile. + * + * The probe must run in the page under measurement. A value read in the driver + * process describes Node's clock and says nothing about the browser's. + */ + +/** How many readings the probe takes looking for the smallest positive step. */ +const PROBE_ITERATIONS = 10_000; + +/** What one page's clock was observed to do. */ +export interface ClockReport { + /** + * Smallest positive difference the probe observed between two consecutive + * readings, or `null` where it observed none. + * + * This is the step size the probe saw, not a calibrated error bound on any + * later measurement: engines may coarsen and jitter their timestamps, so a + * single observed minimum bounds neither the error of one sample nor the + * confidence of a comparison built from many. `null` means the probe + * established nothing and must never be read as a fine clock - it is the + * absence of the observation, not an observation of zero. + */ + readonly resolutionMs: number | null; + /** Whether the page reached a cross-origin-isolated context, which is what lifts the coarsest clamping. */ + readonly crossOriginIsolated: boolean; +} + +/** Measure the clock's observed grid in the current page. */ +export const probeClock = (): ClockReport => { + let smallest = Number.POSITIVE_INFINITY; + let previous = performance.now(); + + for (let index = 0; index < PROBE_ITERATIONS; index += 1) { + const now = performance.now(); + const delta = now - previous; + + if (delta > 0) { + smallest = Math.min(smallest, delta); + previous = now; + } + } + + return { + resolutionMs: Number.isFinite(smallest) ? smallest : null, + crossOriginIsolated: globalThis.crossOriginIsolated === true, + }; +}; + +/** How a clock report reads in a caveat or a provenance line. */ +export const describeClock = (clock: ClockReport): string => + clock.resolutionMs === null + ? `The page's performance.now() resolution could not be established (cross-origin isolated: ${String(clock.crossOriginIsolated)}); readings near it cannot be told apart from it.` + : `The page's performance.now() resolved to ${(clock.resolutionMs * 1000).toFixed(1)}us (cross-origin isolated: ${String(clock.crossOriginIsolated)}).`; diff --git a/site/src/components/BenchComparisonTable.astro b/site/src/components/BenchComparisonTable.astro index 982cf8691..a2c624d1d 100644 --- a/site/src/components/BenchComparisonTable.astro +++ b/site/src/components/BenchComparisonTable.astro @@ -62,7 +62,7 @@ interface Props { } const { table, label } = Astro.props; -const { columns, rows, unit, countColumn } = table; +const { columns, rows, unit, countColumn, defaultColumn } = table; /** * Half-width of the axis in decades, fixed for every column and every table. @@ -129,25 +129,6 @@ const winnerOf = (cell: ProfileCell): string => { return side === 'exojs' ? 'ExoJS' : side === 'competitor' ? armLabel(cell.competitor) : ''; }; - -/** - * The comparison a narrow reader is shown before choosing one. - * - * The arm ExoJS leads on fewest rows, so the view a phone opens on is the - * hardest opponent this machine found rather than the most flattering one. - * It selects which comparison is on screen first and publishes no figure, so - * nothing here is summed, ranked or counted anywhere else on the page. - */ -const defaultColumn = columns - .map((_, index) => ({ - index, - leads: rows.filter(row => { - const outcome = outcomeOf(row.entries[index]?.cell ?? null); - - return outcome === 'lead' || outcome === 'clear-lead'; - }).length, - })) - .reduce((fewest, candidate) => (candidate.leads < fewest.leads ? candidate : fewest), { index: 0, leads: Number.POSITIVE_INFINITY }).index; ---
diff --git a/site/src/lib/bench-tables.ts b/site/src/lib/bench-tables.ts index 9da9e7e50..166e0fb80 100644 --- a/site/src/lib/bench-tables.ts +++ b/site/src/lib/bench-tables.ts @@ -72,8 +72,36 @@ export interface ComparisonTable { readonly unit: string; /** True where the rows were measured at different sizes, so the size belongs in a column of its own. */ readonly countColumn: boolean; + /** Index of the column a narrow reader is shown first; see {@link PREFERRED_COLUMN_KEYS}. */ + readonly defaultColumn: number; } +/** + * Which comparison a stacked view opens on, most preferred first. + * + * An editorial choice about the entry point, fixed here rather than computed + * from the measurements. A rule such as "the arm ExoJS leads on fewest rows" + * reads as fairness but is not one: an arm can lead on few rows because it was + * compared on few, or because most of its runs disagreed - and a later + * correction to how a comparison is judged would then silently move the view a + * reader lands on, without anything about the navigation having changed. + * + * The list names keys, so a profile that never measured the first entry falls + * through to the next and finally to the leftmost column that exists. + */ +const PREFERRED_COLUMN_KEYS: readonly string[] = ['webgl2-pixi', 'webgpu-pixi', 'webgl2-phaser', 'matter-js', 'planck']; + +/** The first preferred column this table actually has, or its leftmost one. */ +const preferredColumn = (columns: readonly ComparisonColumn[]): number => { + for (const key of PREFERRED_COLUMN_KEYS) { + const index = columns.findIndex(column => column.key === key); + + if (index !== -1) return index; + } + + return 0; +}; + const entryOf = (key: string, row: ProfileRow | undefined, arm: string): ComparisonEntry => ({ key, cell: row?.cells.find(cell => cell.competitor === arm) ?? null, @@ -128,7 +156,7 @@ export const renderingComparison = (document: BenchProfileDocument): ComparisonT }; }); - return { columns, rows, unit: 'nodes', countColumn: false }; + return { columns, rows, unit: 'nodes', countColumn: false, defaultColumn: preferredColumn(columns) }; }; const singleBlockTable = ( @@ -139,6 +167,7 @@ const singleBlockTable = ( countColumn: boolean, ): ComparisonTable => ({ columns, + defaultColumn: preferredColumn(columns), rows: rows.map(row => ({ key: row.archetype, archetype: row.archetype, From 7afff285760b07521e7a9f487b39d61ba38bff5f Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 01:10:50 +0200 Subject: [PATCH 06/15] feat(site): withhold a comparison the clock did not resolve A duration a step or two above the grid performance.now() delivers on carries no ratio worth printing: measured again the same scene lands on the neighbouring step, and the factor built from it moves by a whole multiple. One guard now decides that, and the cell, the bar, the label and the scoreboard all read it from `outcomeOf` rather than each testing their own condition. Both durations are checked on their own against the step observed in the context that measured them - how large a reading is against the grid it was read on, not how far the two arms are apart - and a pooled figure inherits the coarsest step of the runs behind it, because a repetition measured on a coarse clock is not repaired by one measured on a fine one. The threshold is a guard set clear of the one- and two-step readings a coarse clock produces, and it is the same for every library, browser and profile. It is not a standard and not a precision claim: clearing it establishes that this one check did not trip, never that a comparison is accurate. A profile written before the step was recorded yields neither a pass nor a failure but the absence of the check, and reads exactly as it did before. A cell the guard stops keeps both measured times and loses the factor, the bar and the winner, and the scoreboard counts it on no summary line. That is a refusal to publish a comparison, not a claim that the two libraries are equally fast; a rule for keeping a direction where the gap is wide would need evidence of its own and is deliberately not attempted here. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- .../src/components/BenchComparisonTable.astro | 12 +- site/src/lib/bench-profiles.ts | 69 ++++++++++-- site/src/lib/bench-tables.ts | 32 +++++- test/site/bench-timer-check.test.ts | 103 ++++++++++++++++++ 4 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 test/site/bench-timer-check.test.ts diff --git a/site/src/components/BenchComparisonTable.astro b/site/src/components/BenchComparisonTable.astro index a2c624d1d..e0e59c8ea 100644 --- a/site/src/components/BenchComparisonTable.astro +++ b/site/src/components/BenchComparisonTable.astro @@ -236,13 +236,15 @@ const winnerOf = (cell: ProfileCell): string => { ); } - const outcome = outcomeOf(cell); + const outcome = outcomeOf(cell, entry.resolutionMs); const mixed = outcome === 'unstable'; const level = outcome === 'level'; + // A pair the clock never separated publishes neither a figure nor a bar; only its two times survive. + const unresolved = outcome === 'timer-limited'; const band = mixed ? ratioBand(cell) : null; const factor = mixed ? pooledFactor(cell) : cell.verdict.factor; const ratio = cell.verdict.ratio; - const plotted = !mixed && ratio !== null && Number.isFinite(ratio) && ratio > 0; + const plotted = !mixed && !unresolved && ratio !== null && Number.isFinite(ratio) && ratio > 0; const referenceMs = measuredMs(cell, cell.referenceMs); const competitorMs = measuredMs(cell, cell.competitorMs); @@ -251,7 +253,7 @@ const winnerOf = (cell: ProfileCell): string => { - {band !== null && ( + {!unresolved && band !== null && ( { {/* A pair inside the noise band is named, not scored: its factor is pinned to 1 by the ladder, so printing 1.00x beside two medians that differ would claim a precision the ladder never measured. */} - {mixed ? (factor === null ? '-' : formatApproximate(factor)) : level ? 'similar' : formatFactor(factor)} + {unresolved ? 'not resolved' : mixed ? (factor === null ? '-' : formatApproximate(factor)) : level ? 'similar' : formatFactor(factor)} {/* The word alone; what it means is stated once in the legend rather than in every cell that carries it. */} - {mixed ? 'no clear lead' : level ? '' : winnerOf(cell)} + {unresolved ? 'below the timer' : mixed ? 'no clear lead' : level ? '' : winnerOf(cell)} {formatMs(referenceMs)} diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts index 93d740098..b87f21865 100644 --- a/site/src/lib/bench-profiles.ts +++ b/site/src/lib/bench-profiles.ts @@ -180,10 +180,19 @@ export interface PlatformVersionStamp { readonly evidence: string; } +/** What a page's clock was observed to do, where the run recorded it. */ +export interface ProfileClock { + /** Smallest positive step the probe saw, or `null` where it established none. */ + readonly resolutionMs: number | null; + readonly crossOriginIsolated: boolean; +} + /** Rendering provenance for one backend. */ export interface RenderingStamp { readonly backend: ProfileBackendName; readonly adapter: string; + /** The clock this backend's page was measured on; absent in profiles written before it was recorded. */ + readonly clock?: ProfileClock; /** Browser engine the run was measured in. */ readonly browser: string; /** Browser build the run was measured in. */ @@ -643,10 +652,10 @@ const listOf = (items: readonly string[]): string => (items.length < 2 ? (items[ * absence of one: the runs reached different rungs, so the pair carries numbers * and no conclusion. `absent` is an arm that produced no comparable cell at all. */ -export type CellOutcome = 'clear-lead' | 'lead' | 'level' | 'loss' | 'clear-loss' | 'unstable' | 'absent'; +export type CellOutcome = 'clear-lead' | 'lead' | 'level' | 'loss' | 'clear-loss' | 'unstable' | 'timer-limited' | 'absent'; -/** Outcomes in reading order: the widest lead first, the widest loss last, then the two that carry no verdict. */ -export const OUTCOME_ORDER: readonly CellOutcome[] = ['clear-lead', 'lead', 'level', 'loss', 'clear-loss', 'unstable', 'absent']; +/** Outcomes in reading order: the widest lead first, the widest loss last, then the three that carry no verdict. */ +export const OUTCOME_ORDER: readonly CellOutcome[] = ['clear-lead', 'lead', 'level', 'loss', 'clear-loss', 'unstable', 'timer-limited', 'absent']; /** The word a summary and a legend print for each outcome. */ export const OUTCOME_LABELS: Readonly> = { @@ -656,18 +665,61 @@ export const OUTCOME_LABELS: Readonly> = { loss: 'loss', 'clear-loss': 'clear loss', unstable: 'no clear lead', + 'timer-limited': 'below the timer', absent: 'no shared cell', }; +/** + * How many of the clock's observed steps a duration has to span before a + * comparison built from it publishes a factor. + * + * A guard, chosen to be safely clear of the one- and two-step readings that a + * coarse clock produces, and applied to every library, browser and profile + * alike. It is not a standard and not a precision claim: clearing it means this + * one check did not trip, never that the comparison is accurate or + * statistically established. Every other check a cell passes still applies. + */ +const MIN_RESOLVED_STEPS = 10; + +/** What the timer check established about a comparison. */ +export type TimerCheck = 'resolved' | 'limited' | 'unknown'; + +/** + * Whether both of a pair's durations stand far enough above the clock's + * observed step to carry a ratio. + * + * Each duration is checked on its own against the step observed in the context + * that measured it - the question is how big a reading is relative to the grid + * it was read on, not how far the two arms are apart. A profile written before + * the step was recorded yields `unknown`, which is the absence of the check and + * never a pass. + */ +export const timerCheck = (cell: ProfileCell, resolutionMs: number | null): TimerCheck => { + if (resolutionMs === null || !Number.isFinite(resolutionMs) || resolutionMs <= 0) return 'unknown'; + + const floor = resolutionMs * MIN_RESOLVED_STEPS; + const durations = [cell.referenceMs, cell.competitorMs].filter((ms): ms is number => ms !== null && Number.isFinite(ms)); + + return durations.some(ms => ms < floor) ? 'limited' : 'resolved'; +}; + /** * Which outcome a cell publishes. * - * This is the only place a comparison is turned into one of the seven words, so - * no table or scoreboard can invent an outcome for a cell whose runs did not - * agree on one. + * This is the only place a comparison is turned into one of the words, so no + * table or scoreboard can invent an outcome for a cell whose runs did not agree + * on one - or whose durations the clock did not separate. Pass `resolutionMs` + * as the coarsest step observed across the runs behind this cell: a pooled + * figure inherits the limit of the least resolved run that produced it. + * + * `timer-limited` outranks every verdict because it is about whether a + * comparison could be drawn at all. The cell keeps both measured times; what it + * loses is the factor, the bar and the winner. That is a refusal to publish a + * comparison, not a claim that the two libraries are equally fast. */ -export const outcomeOf = (cell: ProfileCell | null): CellOutcome => { +export const outcomeOf = (cell: ProfileCell | null, resolutionMs: number | null = null): CellOutcome => { if (cell === null) return 'absent'; + if (timerCheck(cell, resolutionMs) === 'limited') return 'timer-limited'; if (!cell.aggregate.stable) return 'unstable'; if (cell.verdict.side === 'neither') return 'level'; if (cell.verdict.side === 'exojs') return cell.verdict.structural ? 'clear-lead' : 'lead'; @@ -751,6 +803,9 @@ export const SUMMARY_OF: Readonly> = { loss: 'behind', 'clear-loss': 'behind', unstable: 'unclear', + // Counted on its own line rather than folded into `unclear`: those runs + // disagreed about a comparison that was made, while these never resolved one. + 'timer-limited': null, absent: null, }; diff --git a/site/src/lib/bench-tables.ts b/site/src/lib/bench-tables.ts index 166e0fb80..b6f53f4cc 100644 --- a/site/src/lib/bench-tables.ts +++ b/site/src/lib/bench-tables.ts @@ -29,6 +29,7 @@ import { type BenchProfileDocument, isWasmReferenceArm, type ProfileBackend, + type ProfileBackendName, type ProfileCell, type ProfileRow, } from './bench-profiles'; @@ -50,6 +51,13 @@ export interface ComparisonEntry { readonly cell: ProfileCell | null; /** Scene size this column measured the row at; `null` where the column does not carry the row at all. */ readonly count: number | null; + /** + * Coarsest clock step observed across the runs behind this cell, or `null` + * where the profile records none. A pooled figure inherits the limit of the + * least resolved run that produced it, so the widest step is the one the + * comparison has to clear. + */ + readonly resolutionMs: number | null; } /** One archetype, across every column of a table. */ @@ -102,12 +110,30 @@ const preferredColumn = (columns: readonly ComparisonColumn[]): number => { return 0; }; -const entryOf = (key: string, row: ProfileRow | undefined, arm: string): ComparisonEntry => ({ +const entryOf = (key: string, row: ProfileRow | undefined, arm: string, resolutionMs: number | null = null): ComparisonEntry => ({ key, cell: row?.cells.find(cell => cell.competitor === arm) ?? null, count: row?.count ?? null, + resolutionMs, }); +/** + * The coarsest step any run of one backend observed, or `null` where no run + * recorded one. + * + * A single run without the stamp leaves the whole backend unknown rather than + * borrowing a finer neighbour's figure: a repetition measured on a coarse clock + * is not repaired by one measured on a fine one, and the missing observation + * does not come back by averaging. + */ +const coarsestResolution = (document: BenchProfileDocument, backend: ProfileBackendName): number | null => { + const stamps = (document.rendering?.runs ?? []).map(run => run.provenance.find(stamp => stamp.backend === backend)); + + if (stamps.length === 0 || stamps.some(stamp => stamp?.clock?.resolutionMs == null)) return null; + + return Math.max(...stamps.map(stamp => stamp?.clock?.resolutionMs ?? 0)); +}; + /** Every row of a backend, flattened out of its categories. */ const rowsOf = (backend: ProfileBackend): readonly ProfileRow[] => backend.sections.flatMap(section => section.rows); @@ -142,7 +168,9 @@ export const renderingComparison = (document: BenchProfileDocument): ComparisonT const entries = backends.flatMap(backend => { const row = rowsOf(backend).find(candidate => candidate.archetype === archetype); - return backend.competitors.map(arm => entryOf(`${backend.backend}-${arm}`, row, arm)); + const resolutionMs = coarsestResolution(document, backend.backend); + + return backend.competitors.map(arm => entryOf(`${backend.backend}-${arm}`, row, arm, resolutionMs)); }); const counts = [...new Set(entries.map(entry => entry.count).filter((count): count is number => count !== null))]; diff --git a/test/site/bench-timer-check.test.ts b/test/site/bench-timer-check.test.ts new file mode 100644 index 000000000..ca2db9341 --- /dev/null +++ b/test/site/bench-timer-check.test.ts @@ -0,0 +1,103 @@ +/** + * The published-comparison guard against a clock that did not separate the two + * arms. + * + * A duration only a step or two above the grid `performance.now()` delivers on + * carries no ratio worth printing: the same scene measured again lands on the + * neighbouring step, and the factor built from it moves by a whole multiple. The + * check therefore refuses the comparison rather than the measurement - both + * times stay readable, and only the figure, the bar and the winner go. + * + * Clearing the check is not a precision claim. It establishes that this one + * guard did not trip; every other check a cell passes still applies. + */ + +import { describe, expect, it } from 'vitest'; + +import { type CellOutcome, outcomeOf, type ProfileCell, SUMMARY_OF, timerCheck } from '../../site/src/lib/bench-profiles'; + +/** A stable comparison with two given medians; every other field is the uninteresting default. */ +const cellOf = (referenceMs: number | null, competitorMs: number | null, stable = true): ProfileCell => ({ + competitor: 'pixi', + referenceMs, + referenceP95Ms: referenceMs, + referenceOverFrameBudget: false, + competitorMs, + competitorP95Ms: competitorMs, + competitorOverFrameBudget: false, + verdict: { + side: 'exojs', + ratio: referenceMs !== null && competitorMs !== null && competitorMs !== 0 ? referenceMs / competitorMs : null, + factor: 2, + label: 'ExoJS leads (2.00x)', + structural: false, + }, + mechanism: null, + aggregate: { + runs: 3, + reference: { minMs: referenceMs, maxMs: referenceMs, ratio: 1 }, + competitor: { minMs: competitorMs, maxMs: competitorMs, ratio: 1 }, + stable, + rungs: ['exojs-leads', 'exojs-leads', 'exojs-leads'], + }, +}); + +/** The step the WebKit build behind the published macOS profiles was observed to deliver. */ +const COARSE_STEP = 0.02; + +describe('timerCheck', () => { + it('refuses a pair whose two durations sit on the same single step', () => { + expect(timerCheck(cellOf(COARSE_STEP, COARSE_STEP), COARSE_STEP)).toBe('limited'); + }); + + it('refuses a pair of one step against two, which is a rounding artefact rather than a doubling', () => { + expect(timerCheck(cellOf(COARSE_STEP, COARSE_STEP * 2), COARSE_STEP)).toBe('limited'); + }); + + it('refuses a pair where only one arm sits near the step, however far the other is above it', () => { + expect(timerCheck(cellOf(COARSE_STEP, 13.38), COARSE_STEP)).toBe('limited'); + expect(timerCheck(cellOf(13.38, COARSE_STEP), COARSE_STEP)).toBe('limited'); + }); + + it('reports an unrecorded step as unknown rather than as a pass', () => { + expect(timerCheck(cellOf(COARSE_STEP, COARSE_STEP), null)).toBe('unknown'); + expect(timerCheck(cellOf(0.5, 1.5), null)).toBe('unknown'); + }); + + it('treats a zero or negative step as unknown, never as a clock of unlimited precision', () => { + expect(timerCheck(cellOf(0.001, 0.002), 0)).toBe('unknown'); + expect(timerCheck(cellOf(0.001, 0.002), -1)).toBe('unknown'); + }); + + it('passes a pair whose durations both stand clear of the step', () => { + expect(timerCheck(cellOf(0.5, 1.5), COARSE_STEP)).toBe('resolved'); + }); +}); + +describe('outcomeOf', () => { + it('reports a timer-limited pair as such, ahead of any verdict the ladder reached', () => { + expect(outcomeOf(cellOf(COARSE_STEP, COARSE_STEP * 2), COARSE_STEP)).toBe('timer-limited'); + }); + + it('reports a timer-limited pair as such even where the runs also disagreed', () => { + expect(outcomeOf(cellOf(COARSE_STEP, COARSE_STEP, false), COARSE_STEP)).toBe('timer-limited'); + }); + + it('leaves a resolved pair on the verdict the ladder reached', () => { + expect(outcomeOf(cellOf(0.5, 1.5), COARSE_STEP)).toBe('lead'); + }); + + it('leaves a pair with no recorded step exactly as it read before the check existed', () => { + expect(outcomeOf(cellOf(COARSE_STEP, COARSE_STEP * 2), null)).toBe(outcomeOf(cellOf(COARSE_STEP, COARSE_STEP * 2))); + }); +}); + +describe('the scoreboard', () => { + it('counts a timer-limited pair on no summary line, so it is neither a win, a loss nor a level row', () => { + expect(SUMMARY_OF['timer-limited']).toBeNull(); + }); + + it('keeps it apart from the pairs whose runs disagreed, which did produce a comparison', () => { + expect(SUMMARY_OF.unstable).toBe('unclear'); + }); +}); From 89fe3f7acc91363d796451a97f7333348dc68e86 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 01:30:40 +0200 Subject: [PATCH 07/15] fix(site): judge the timer per run, and withhold a comparison no clock backs The check ran against the pooled medians and one coarsest step, which lets a well-resolved repetition carry a limited one past the threshold: three runs stepping 0.020, 0.005 and 0.005 ms with durations of 0.180, 0.300 and 0.300 pool to 0.300 against 0.020 - fifteen steps, a pass - while the first run stood nine steps above its own clock and did not. The published profile carries no per-run durations, so the site cannot make that judgement at all; the check moves to a per-run primitive and a merge rule, and the cell reads the verdict the harness will publish beside it. Merging is deliberately asymmetric: a limitation any run established stands, and a run whose clock was never recorded cannot lift it, because missing information does not cancel an established finding. Only a comparison whose every run cleared the check is reported as resolved. A profile that recorded no clock no longer keeps its factors. It was the state every published profile is in, so the exemption would have left exactly the figures the check exists to withhold - and the page is not emptied by removing them: scenarios, both measured times, the machine and the detail all stay, and the reason is stated once for the profile rather than in each of its cells. `timer-unknown` stays apart from `timer-limited`, because a check that could not be made and a check that tripped are different statements, and the scoreboard counts neither as a win, a loss or a level row. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- .../src/components/BenchComparisonTable.astro | 8 +- site/src/components/BenchProfileReport.astro | 24 ++++ site/src/lib/bench-profiles.ts | 71 ++++++++--- site/src/lib/bench-tables.ts | 32 +---- test/site/bench-timer-check.test.ts | 114 ++++++++++++------ 5 files changed, 159 insertions(+), 90 deletions(-) diff --git a/site/src/components/BenchComparisonTable.astro b/site/src/components/BenchComparisonTable.astro index e0e59c8ea..979e4b9f9 100644 --- a/site/src/components/BenchComparisonTable.astro +++ b/site/src/components/BenchComparisonTable.astro @@ -236,11 +236,11 @@ const winnerOf = (cell: ProfileCell): string => { ); } - const outcome = outcomeOf(cell, entry.resolutionMs); + const outcome = outcomeOf(cell); const mixed = outcome === 'unstable'; const level = outcome === 'level'; // A pair the clock never separated publishes neither a figure nor a bar; only its two times survive. - const unresolved = outcome === 'timer-limited'; + const unresolved = outcome === 'timer-limited' || outcome === 'timer-unknown'; const band = mixed ? ratioBand(cell) : null; const factor = mixed ? pooledFactor(cell) : cell.verdict.factor; const ratio = cell.verdict.ratio; @@ -269,9 +269,9 @@ const winnerOf = (cell: ProfileCell): string => { {/* A pair inside the noise band is named, not scored: its factor is pinned to 1 by the ladder, so printing 1.00x beside two medians that differ would claim a precision the ladder never measured. */} - {unresolved ? 'not resolved' : mixed ? (factor === null ? '-' : formatApproximate(factor)) : level ? 'similar' : formatFactor(factor)} + {unresolved ? '-' : mixed ? (factor === null ? '-' : formatApproximate(factor)) : level ? 'similar' : formatFactor(factor)} {/* The word alone; what it means is stated once in the legend rather than in every cell that carries it. */} - {unresolved ? 'below the timer' : mixed ? 'no clear lead' : level ? '' : winnerOf(cell)} + {outcome === 'timer-limited' ? 'below the timer' : outcome === 'timer-unknown' ? '' : mixed ? 'no clear lead' : level ? '' : winnerOf(cell)} {formatMs(referenceMs)} diff --git a/site/src/components/BenchProfileReport.astro b/site/src/components/BenchProfileReport.astro index 8be39d672..46f0324d2 100644 --- a/site/src/components/BenchProfileReport.astro +++ b/site/src/components/BenchProfileReport.astro @@ -52,6 +52,9 @@ const Heading = `h${String(headingLevel)}` as 'h2' | 'h3'; const isRendering = domain === 'rendering'; const tallies = comparisonTallies(profileDocument, domain); + +/** True where no cell of this view carries a timer verdict, which is what a profile written before the clock was recorded looks like. */ +const timerUnrecorded = tallies.length > 0 && tallies.every(entry => entry.counts['timer-unknown'] === entry.total); const renderingTable = renderingComparison(profileDocument); const physicsTable = physicsComparison(profileDocument); const backends = isRendering ? rendering?.backends ?? [] : []; @@ -114,6 +117,17 @@ const libraries = (isRendering ? rendering?.libraries ?? [] : physics?.libraries past the {FRAME_BUDGET_MS} ms frame, or a spread of {WIDE_SPREAD_RATIO}x and up

+ {/* + * Stated once for the profile rather than in each of its cells: it is a + * fact about how the run was recorded, identical in every row. + */} + {timerUnrecorded && ( +

+ This profile was measured before the harness recorded what its clock resolved, so no comparison here is published as a factor. The times each + arm produced are shown as measured; what cannot be established is whether the clock separated them well enough for a ratio to mean anything. +

+ )} + {/* The view already names its domain and its unit, so the block carries only what the tab cannot say: the size the rows were measured at. */} {isRendering && renderingTable.rows.length > 0 && (
@@ -251,6 +265,16 @@ const libraries = (isRendering ? rendering?.libraries ?? [] : physics?.libraries margin-top: var(--s-7); } + .notice { + margin: 0 0 var(--s-4); + padding: var(--s-3) var(--s-4); + border: 1px solid var(--line-soft); + border-left: 3px solid var(--bench-mark); + border-radius: var(--r-3); + color: var(--fg-muted); + font-size: 0.85rem; + } + .note { max-width: none; margin: 0 0 var(--s-3); diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts index b87f21865..0f76c2024 100644 --- a/site/src/lib/bench-profiles.ts +++ b/site/src/lib/bench-profiles.ts @@ -98,6 +98,8 @@ export interface ProfileCell { readonly competitorGpuMs?: number | null; /** True when `competitorMs` is past a whole 60 fps frame; see `FRAME_BUDGET_MS`. */ readonly competitorOverFrameBudget: boolean; + /** What the timer check made of this comparison across the runs behind it; absent in profiles written before the clock was recorded. */ + readonly timer?: TimerCheck; readonly verdict: ProfileVerdict; /** Structural evidence behind the difference, or `null` when the counters carry none. */ readonly mechanism: string | null; @@ -652,10 +654,20 @@ const listOf = (items: readonly string[]): string => (items.length < 2 ? (items[ * absence of one: the runs reached different rungs, so the pair carries numbers * and no conclusion. `absent` is an arm that produced no comparable cell at all. */ -export type CellOutcome = 'clear-lead' | 'lead' | 'level' | 'loss' | 'clear-loss' | 'unstable' | 'timer-limited' | 'absent'; +export type CellOutcome = 'clear-lead' | 'lead' | 'level' | 'loss' | 'clear-loss' | 'unstable' | 'timer-limited' | 'timer-unknown' | 'absent'; /** Outcomes in reading order: the widest lead first, the widest loss last, then the three that carry no verdict. */ -export const OUTCOME_ORDER: readonly CellOutcome[] = ['clear-lead', 'lead', 'level', 'loss', 'clear-loss', 'unstable', 'timer-limited', 'absent']; +export const OUTCOME_ORDER: readonly CellOutcome[] = [ + 'clear-lead', + 'lead', + 'level', + 'loss', + 'clear-loss', + 'unstable', + 'timer-limited', + 'timer-unknown', + 'absent', +]; /** The word a summary and a legend print for each outcome. */ export const OUTCOME_LABELS: Readonly> = { @@ -666,6 +678,7 @@ export const OUTCOME_LABELS: Readonly> = { 'clear-loss': 'clear loss', unstable: 'no clear lead', 'timer-limited': 'below the timer', + 'timer-unknown': 'timer not recorded', absent: 'no shared cell', }; @@ -685,22 +698,42 @@ const MIN_RESOLVED_STEPS = 10; export type TimerCheck = 'resolved' | 'limited' | 'unknown'; /** - * Whether both of a pair's durations stand far enough above the clock's - * observed step to carry a ratio. + * Whether both durations of one run stand far enough above the step that run's + * clock was observed to deliver. * - * Each duration is checked on its own against the step observed in the context - * that measured it - the question is how big a reading is relative to the grid - * it was read on, not how far the two arms are apart. A profile written before - * the step was recorded yields `unknown`, which is the absence of the check and + * Each duration is checked on its own - the question is how large a reading is + * against the grid it was read on, not how far the two arms are apart. A run + * with no recorded step yields `unknown`, which is the absence of the check and * never a pass. + * + * This evaluates ONE run. A pooled comparison must merge the runs' results with + * {@link mergeTimerChecks} rather than run this against pooled medians: pooling + * the durations first lets a well-resolved repetition carry a limited one past + * the threshold, which is the reading the per-run check exists to prevent. */ -export const timerCheck = (cell: ProfileCell, resolutionMs: number | null): TimerCheck => { +export const timerCheckOfRun = (durations: readonly (number | null)[], resolutionMs: number | null): TimerCheck => { if (resolutionMs === null || !Number.isFinite(resolutionMs) || resolutionMs <= 0) return 'unknown'; const floor = resolutionMs * MIN_RESOLVED_STEPS; - const durations = [cell.referenceMs, cell.competitorMs].filter((ms): ms is number => ms !== null && Number.isFinite(ms)); + const measured = durations.filter((ms): ms is number => ms !== null && Number.isFinite(ms)); + + return measured.some(ms => ms < floor) ? 'limited' : 'resolved'; +}; + +/** + * One verdict for a comparison from the verdicts of the runs behind it. + * + * A limitation any run established stands for the pooled figure, and a run + * whose clock was never recorded cannot lift it: missing information does not + * cancel an established one. Only a comparison whose every run cleared the + * check is reported as resolved. + */ +export const mergeTimerChecks = (checks: readonly TimerCheck[]): TimerCheck => { + if (checks.length === 0) return 'unknown'; + if (checks.includes('limited')) return 'limited'; + if (checks.includes('unknown')) return 'unknown'; - return durations.some(ms => ms < floor) ? 'limited' : 'resolved'; + return 'resolved'; }; /** @@ -712,14 +745,16 @@ export const timerCheck = (cell: ProfileCell, resolutionMs: number | null): Time * as the coarsest step observed across the runs behind this cell: a pooled * figure inherits the limit of the least resolved run that produced it. * - * `timer-limited` outranks every verdict because it is about whether a - * comparison could be drawn at all. The cell keeps both measured times; what it - * loses is the factor, the bar and the winner. That is a refusal to publish a - * comparison, not a claim that the two libraries are equally fast. + * The two timer states outrank every verdict because they are about whether a + * comparison could be drawn at all. Either way the cell keeps both measured + * times and loses the factor, the bar and the winner - a refusal to publish a + * comparison, not a claim that the libraries are equally fast. They stay apart + * because they say different things: `timer-limited` is a check that tripped, + * `timer-unknown` is a check that could not be made. */ -export const outcomeOf = (cell: ProfileCell | null, resolutionMs: number | null = null): CellOutcome => { +export const outcomeOf = (cell: ProfileCell | null): CellOutcome => { if (cell === null) return 'absent'; - if (timerCheck(cell, resolutionMs) === 'limited') return 'timer-limited'; + if (cell.timer !== 'resolved') return cell.timer === 'limited' ? 'timer-limited' : 'timer-unknown'; if (!cell.aggregate.stable) return 'unstable'; if (cell.verdict.side === 'neither') return 'level'; if (cell.verdict.side === 'exojs') return cell.verdict.structural ? 'clear-lead' : 'lead'; @@ -806,6 +841,8 @@ export const SUMMARY_OF: Readonly> = { // Counted on its own line rather than folded into `unclear`: those runs // disagreed about a comparison that was made, while these never resolved one. 'timer-limited': null, + // Not judged rather than judged inconclusive, and counted as neither. + 'timer-unknown': null, absent: null, }; diff --git a/site/src/lib/bench-tables.ts b/site/src/lib/bench-tables.ts index b6f53f4cc..166e0fb80 100644 --- a/site/src/lib/bench-tables.ts +++ b/site/src/lib/bench-tables.ts @@ -29,7 +29,6 @@ import { type BenchProfileDocument, isWasmReferenceArm, type ProfileBackend, - type ProfileBackendName, type ProfileCell, type ProfileRow, } from './bench-profiles'; @@ -51,13 +50,6 @@ export interface ComparisonEntry { readonly cell: ProfileCell | null; /** Scene size this column measured the row at; `null` where the column does not carry the row at all. */ readonly count: number | null; - /** - * Coarsest clock step observed across the runs behind this cell, or `null` - * where the profile records none. A pooled figure inherits the limit of the - * least resolved run that produced it, so the widest step is the one the - * comparison has to clear. - */ - readonly resolutionMs: number | null; } /** One archetype, across every column of a table. */ @@ -110,30 +102,12 @@ const preferredColumn = (columns: readonly ComparisonColumn[]): number => { return 0; }; -const entryOf = (key: string, row: ProfileRow | undefined, arm: string, resolutionMs: number | null = null): ComparisonEntry => ({ +const entryOf = (key: string, row: ProfileRow | undefined, arm: string): ComparisonEntry => ({ key, cell: row?.cells.find(cell => cell.competitor === arm) ?? null, count: row?.count ?? null, - resolutionMs, }); -/** - * The coarsest step any run of one backend observed, or `null` where no run - * recorded one. - * - * A single run without the stamp leaves the whole backend unknown rather than - * borrowing a finer neighbour's figure: a repetition measured on a coarse clock - * is not repaired by one measured on a fine one, and the missing observation - * does not come back by averaging. - */ -const coarsestResolution = (document: BenchProfileDocument, backend: ProfileBackendName): number | null => { - const stamps = (document.rendering?.runs ?? []).map(run => run.provenance.find(stamp => stamp.backend === backend)); - - if (stamps.length === 0 || stamps.some(stamp => stamp?.clock?.resolutionMs == null)) return null; - - return Math.max(...stamps.map(stamp => stamp?.clock?.resolutionMs ?? 0)); -}; - /** Every row of a backend, flattened out of its categories. */ const rowsOf = (backend: ProfileBackend): readonly ProfileRow[] => backend.sections.flatMap(section => section.rows); @@ -168,9 +142,7 @@ export const renderingComparison = (document: BenchProfileDocument): ComparisonT const entries = backends.flatMap(backend => { const row = rowsOf(backend).find(candidate => candidate.archetype === archetype); - const resolutionMs = coarsestResolution(document, backend.backend); - - return backend.competitors.map(arm => entryOf(`${backend.backend}-${arm}`, row, arm, resolutionMs)); + return backend.competitors.map(arm => entryOf(`${backend.backend}-${arm}`, row, arm)); }); const counts = [...new Set(entries.map(entry => entry.count).filter((count): count is number => count !== null))]; diff --git a/test/site/bench-timer-check.test.ts b/test/site/bench-timer-check.test.ts index ca2db9341..774bdacac 100644 --- a/test/site/bench-timer-check.test.ts +++ b/test/site/bench-timer-check.test.ts @@ -14,29 +14,32 @@ import { describe, expect, it } from 'vitest'; -import { type CellOutcome, outcomeOf, type ProfileCell, SUMMARY_OF, timerCheck } from '../../site/src/lib/bench-profiles'; - -/** A stable comparison with two given medians; every other field is the uninteresting default. */ -const cellOf = (referenceMs: number | null, competitorMs: number | null, stable = true): ProfileCell => ({ +import { + type CellOutcome, + mergeTimerChecks, + outcomeOf, + type ProfileCell, + SUMMARY_OF, + type TimerCheck, + timerCheckOfRun, +} from '../../site/src/lib/bench-profiles'; + +/** A stable comparison carrying a timer verdict; every other field is the uninteresting default. */ +const cellOf = (timer: TimerCheck | undefined, stable = true): ProfileCell => ({ competitor: 'pixi', - referenceMs, - referenceP95Ms: referenceMs, + referenceMs: 0.5, + referenceP95Ms: 0.6, referenceOverFrameBudget: false, - competitorMs, - competitorP95Ms: competitorMs, + competitorMs: 1.5, + competitorP95Ms: 1.7, competitorOverFrameBudget: false, - verdict: { - side: 'exojs', - ratio: referenceMs !== null && competitorMs !== null && competitorMs !== 0 ? referenceMs / competitorMs : null, - factor: 2, - label: 'ExoJS leads (2.00x)', - structural: false, - }, + ...(timer !== undefined && { timer }), + verdict: { side: 'exojs', ratio: 1 / 3, factor: 3, label: 'ExoJS leads (3.00x)', structural: false }, mechanism: null, aggregate: { runs: 3, - reference: { minMs: referenceMs, maxMs: referenceMs, ratio: 1 }, - competitor: { minMs: competitorMs, maxMs: competitorMs, ratio: 1 }, + reference: { minMs: 0.5, maxMs: 0.5, ratio: 1 }, + competitor: { minMs: 1.5, maxMs: 1.5, ratio: 1 }, stable, rungs: ['exojs-leads', 'exojs-leads', 'exojs-leads'], }, @@ -45,59 +48,92 @@ const cellOf = (referenceMs: number | null, competitorMs: number | null, stable /** The step the WebKit build behind the published macOS profiles was observed to deliver. */ const COARSE_STEP = 0.02; -describe('timerCheck', () => { - it('refuses a pair whose two durations sit on the same single step', () => { - expect(timerCheck(cellOf(COARSE_STEP, COARSE_STEP), COARSE_STEP)).toBe('limited'); +describe('timerCheckOfRun', () => { + it('refuses a run whose two durations sit on the same single step', () => { + expect(timerCheckOfRun([COARSE_STEP, COARSE_STEP], COARSE_STEP)).toBe('limited'); }); - it('refuses a pair of one step against two, which is a rounding artefact rather than a doubling', () => { - expect(timerCheck(cellOf(COARSE_STEP, COARSE_STEP * 2), COARSE_STEP)).toBe('limited'); + it('refuses one step against two, which is a rounding artefact rather than a doubling', () => { + expect(timerCheckOfRun([COARSE_STEP, COARSE_STEP * 2], COARSE_STEP)).toBe('limited'); }); - it('refuses a pair where only one arm sits near the step, however far the other is above it', () => { - expect(timerCheck(cellOf(COARSE_STEP, 13.38), COARSE_STEP)).toBe('limited'); - expect(timerCheck(cellOf(13.38, COARSE_STEP), COARSE_STEP)).toBe('limited'); + it('refuses a run where only one arm sits near the step, however far the other is above it', () => { + expect(timerCheckOfRun([COARSE_STEP, 13.38], COARSE_STEP)).toBe('limited'); + expect(timerCheckOfRun([13.38, COARSE_STEP], COARSE_STEP)).toBe('limited'); }); it('reports an unrecorded step as unknown rather than as a pass', () => { - expect(timerCheck(cellOf(COARSE_STEP, COARSE_STEP), null)).toBe('unknown'); - expect(timerCheck(cellOf(0.5, 1.5), null)).toBe('unknown'); + expect(timerCheckOfRun([0.5, 1.5], null)).toBe('unknown'); }); it('treats a zero or negative step as unknown, never as a clock of unlimited precision', () => { - expect(timerCheck(cellOf(0.001, 0.002), 0)).toBe('unknown'); - expect(timerCheck(cellOf(0.001, 0.002), -1)).toBe('unknown'); + expect(timerCheckOfRun([0.001, 0.002], 0)).toBe('unknown'); + expect(timerCheckOfRun([0.001, 0.002], -1)).toBe('unknown'); }); - it('passes a pair whose durations both stand clear of the step', () => { - expect(timerCheck(cellOf(0.5, 1.5), COARSE_STEP)).toBe('resolved'); + it('passes a run whose durations both stand clear of its own step', () => { + expect(timerCheckOfRun([0.5, 1.5], COARSE_STEP)).toBe('resolved'); + }); +}); + +describe('mergeTimerChecks', () => { + /** + * Pooling the durations first would hide this: the pooled median is 0.300 ms + * and the coarsest step 0.020 ms, which clears the threshold, while the first + * run stood nine steps above its own clock and did not. + */ + it('refuses a comparison whose first run was limited even though the pooled figure would clear the coarsest step', () => { + const perRun = [timerCheckOfRun([0.18, 5], 0.02), timerCheckOfRun([0.3, 5], 0.005), timerCheckOfRun([0.3, 5], 0.005)]; + + expect(perRun).toStrictEqual(['limited', 'resolved', 'resolved']); + expect(mergeTimerChecks(perRun)).toBe('limited'); + }); + + it('does not let a run with no recorded step lift a limitation another run established', () => { + expect(mergeTimerChecks(['limited', 'unknown'])).toBe('limited'); + expect(mergeTimerChecks(['unknown', 'limited', 'resolved'])).toBe('limited'); + }); + + it('reports a comparison with any unrecorded run as unknown rather than resolved', () => { + expect(mergeTimerChecks(['resolved', 'unknown', 'resolved'])).toBe('unknown'); + }); + + it('reports resolved only where every run cleared the check', () => { + expect(mergeTimerChecks(['resolved', 'resolved', 'resolved'])).toBe('resolved'); + }); + + it('reports no runs at all as unknown', () => { + expect(mergeTimerChecks([])).toBe('unknown'); }); }); describe('outcomeOf', () => { it('reports a timer-limited pair as such, ahead of any verdict the ladder reached', () => { - expect(outcomeOf(cellOf(COARSE_STEP, COARSE_STEP * 2), COARSE_STEP)).toBe('timer-limited'); + expect(outcomeOf(cellOf('limited'))).toBe('timer-limited'); }); it('reports a timer-limited pair as such even where the runs also disagreed', () => { - expect(outcomeOf(cellOf(COARSE_STEP, COARSE_STEP, false), COARSE_STEP)).toBe('timer-limited'); + expect(outcomeOf(cellOf('limited', false))).toBe('timer-limited'); }); - it('leaves a resolved pair on the verdict the ladder reached', () => { - expect(outcomeOf(cellOf(0.5, 1.5), COARSE_STEP)).toBe('lead'); + it('withholds the comparison of a profile that recorded no clock, and keeps that state apart', () => { + expect(outcomeOf(cellOf(undefined))).toBe('timer-unknown'); + expect(outcomeOf(cellOf('unknown'))).toBe('timer-unknown'); }); - it('leaves a pair with no recorded step exactly as it read before the check existed', () => { - expect(outcomeOf(cellOf(COARSE_STEP, COARSE_STEP * 2), null)).toBe(outcomeOf(cellOf(COARSE_STEP, COARSE_STEP * 2))); + it('leaves a resolved pair on the verdict the ladder reached', () => { + expect(outcomeOf(cellOf('resolved'))).toBe('lead'); + expect(outcomeOf(cellOf('resolved', false))).toBe('unstable'); }); }); describe('the scoreboard', () => { - it('counts a timer-limited pair on no summary line, so it is neither a win, a loss nor a level row', () => { + it('counts neither timer state on a summary line, so neither is a win, a loss nor a level row', () => { expect(SUMMARY_OF['timer-limited']).toBeNull(); + expect(SUMMARY_OF['timer-unknown']).toBeNull(); }); - it('keeps it apart from the pairs whose runs disagreed, which did produce a comparison', () => { + it('keeps them apart from the pairs whose runs disagreed, which did produce a comparison', () => { expect(SUMMARY_OF.unstable).toBe('unclear'); }); }); From 181b6f090f5ecf0f5b57585c84c42dbc7e292467 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 04:53:31 +0200 Subject: [PATCH 08/15] feat(bench): resolve runs against a published workload catalog A benchmark run had one shape: every archetype's full ladder against every arm. That is the development matrix, and publishing a comparison meant paying for it in full even though most of its rungs answer a scaling question no reader of the comparison asks. Runs now resolve against a named plan. `reference` selects the loads the project publishes - one headline load per scenario, a short ladder only where the scaling is itself the finding - and `full` keeps the whole development matrix, including the ExoJS-internal probes the published catalog deliberately omits. `--extreme` admits the named million-scale loads, and only together with `full`: an extreme load exists to find where a scenario stops being viable, which is not a published claim. Every load carries the unit it is counted in, because the number alone is ambiguous across the catalog - a million world tiles and a million visible particles are not the same measurement. A plan carries a hash over its semantic content only, so two runs can be checked for having measured the same contract without a path or a timestamp deciding it. `--domain=all` runs both domains serially into separate subdirectories, and `--dry-run` reports the resolved workload without starting a browser. It names the catalogued scenarios that have no archetype behind them yet rather than quietly planning around them. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- packages/exojs-bench/package.json | 1 + packages/exojs-bench/src/physics/driver.ts | 21 +- packages/exojs-bench/src/rendering/driver.ts | 42 +- .../exojs-bench/src/rendering/selection.ts | Bin 2688 -> 4004 bytes packages/exojs-bench/src/run.ts | 157 ++++++- packages/exojs-bench/src/suite/catalog.ts | 428 ++++++++++++++++++ packages/exojs-bench/src/suite/plan.ts | 216 +++++++++ packages/exojs-bench/test/suite-plan.test.ts | 203 +++++++++ 8 files changed, 1045 insertions(+), 23 deletions(-) create mode 100644 packages/exojs-bench/src/suite/catalog.ts create mode 100644 packages/exojs-bench/src/suite/plan.ts create mode 100644 packages/exojs-bench/test/suite-plan.test.ts diff --git a/packages/exojs-bench/package.json b/packages/exojs-bench/package.json index 5f6e6760a..a062fd8aa 100644 --- a/packages/exojs-bench/package.json +++ b/packages/exojs-bench/package.json @@ -7,6 +7,7 @@ "scripts": { "bench:setup": "pnpm install --dir competitors --frozen-lockfile && node competitors/link.ts", "bench": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts", + "bench:reference": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts --suite=reference --domain=all", "perf:baseline": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts", "gate:timing": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/runTimingGate.ts", "gate:structural": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/runStructuralGate.ts", diff --git a/packages/exojs-bench/src/physics/driver.ts b/packages/exojs-bench/src/physics/driver.ts index 24dc7405a..87f503e82 100644 --- a/packages/exojs-bench/src/physics/driver.ts +++ b/packages/exojs-bench/src/physics/driver.ts @@ -15,6 +15,7 @@ import { } from '../shared/provenance'; import type { ViteDevServer } from '../shared/viteServer'; import { PHYSICS_LIBRARY_ARMS, startViteServer as startPageServer } from '../shared/viteServer'; +import type { RunPlan } from '../suite/plan'; import { buildPhysicsMatrix, STEP_DELTA } from './archetypes'; import type { PhysicsArmReport, PhysicsClockReport } from './page/contract'; import type { PhysicsCellResult, PhysicsCellSpec } from './PhysicsAdapter'; @@ -127,6 +128,21 @@ const applyFilter = (cells: readonly PhysicsCellSpec[], filter: Partial entries.every(([key, value]) => cell[key as keyof PhysicsCellSpec] === value)); }; +/** + * Keeps only the cells one resolved suite plan selects. + * + * A pure filter, unlike the rendering side's re-emitting counterpart: physics + * seeds fold the body count in (`seedFor`), so a rung outside the archetype's + * own ladder would be a DIFFERENT world under the same name. The catalog's + * physics loads are therefore always ladder rungs, and a plan naming one that is + * not simply selects nothing for that scenario rather than inventing a scene. + */ +const applyPhysicsPlan = (cells: readonly PhysicsCellSpec[], plan: RunPlan): PhysicsCellSpec[] => { + const selected = new Set(plan.workloads.map(workload => `${workload.scenarioId}/${String(workload.value)}`)); + + return cells.filter(cell => selected.has(`${cell.archetype}/${String(cell.bodyCount)}`)); +}; + /** A cell that could not be measured: zeroed timings/structure, `unavailable` status, and an explanatory note. */ const unavailableCell = (spec: PhysicsCellSpec, note: string): PhysicsCellResult => ({ spec, @@ -261,6 +277,8 @@ export const runPhysicsMatrix = async ( * every platform, and both are part of a published profile's file name. */ platform?: PlatformDeclaration; + /** Resolved suite plan restricting the matrix to the loads it selects, applied before `filter`. */ + plan?: RunPlan; filter?: Partial; /** Forces every selected cell's timed-step count to this value (smoke/spot-check knob; never a reportable run). */ timedStepsOverride?: number; @@ -290,7 +308,8 @@ export const runPhysicsMatrix = async ( const reasons = new Map(arms.map(arm => [`${arm.engine}/${arm.config}`, arm.reason])); const allCells = buildPhysicsMatrix(arms); - const filtered = options.filter ? applyFilter(allCells, options.filter) : allCells; + const planned = options.plan ? applyPhysicsPlan(allCells, options.plan) : allCells; + const filtered = options.filter ? applyFilter(planned, options.filter) : planned; const cells = options.timedStepsOverride === undefined ? filtered : filtered.map(cell => ({ ...cell, timedSteps: options.timedStepsOverride! })); if (cells.length === 0) { diff --git a/packages/exojs-bench/src/rendering/driver.ts b/packages/exojs-bench/src/rendering/driver.ts index 4b6b6d553..a40408de7 100644 --- a/packages/exojs-bench/src/rendering/driver.ts +++ b/packages/exojs-bench/src/rendering/driver.ts @@ -15,10 +15,11 @@ import { } from '../shared/provenance'; import type { ViteDevServer } from '../shared/viteServer'; import { readEngineVersion, RENDERING_LIBRARY_ARMS, startViteServer as startPageServer } from '../shared/viteServer'; +import type { RunPlan } from '../suite/plan'; import { buildMatrix } from './archetypes'; import type { ArchetypeSpec, Backend, CellResult, CellSpec, EngineAdapter } from './EngineAdapter'; import type { MatrixSelection } from './selection'; -import { applySelection } from './selection'; +import { applyPlan, applySelection } from './selection'; import { usesRenderTargets } from './traits'; import { isScrolling } from './world'; @@ -923,6 +924,33 @@ export interface MatrixOutcome { * `onCellResult` (optional) fires after every cell so the caller can persist it * immediately; the returned {@link MatrixOutcome} is the same set aggregated. */ +/** The cell selection one matrix invocation would measure, resolved without touching a browser. */ +export interface MatrixCellSelection { + readonly backends: readonly Backend[]; + readonly plan?: RunPlan; + readonly filter?: Partial; + readonly selection?: MatrixSelection; + readonly timedFramesOverride?: number; +} + +/** + * Resolve the cells a run would measure: the capability-gated matrix narrowed by + * the suite plan, then by the free filter, then by the multi-value selection. + * + * Exported so `--dry-run` can report the real planned workload - cell count, + * frame budgets, arms - from the same code path the run itself uses. A dry run + * derived from a second, parallel enumeration would be a description of a run + * nobody performs. + */ +export const resolveMatrixCells = (options: MatrixCellSelection): CellSpec[] => { + const allCells = buildMatrix([...ADAPTER_CAPABILITIES, ...requestedCalibrationArms(options.selection)], options.backends); + const planned = options.plan ? applyPlan(allCells, options.plan) : allCells; + const filtered = options.filter ? applyFilter(planned, options.filter) : planned; + const selected = options.selection ? applySelection(filtered, options.selection) : filtered; + + return options.timedFramesOverride === undefined ? selected : selected.map(cell => ({ ...cell, timedFrames: options.timedFramesOverride! })); +}; + export const runMatrix = async (options: { backends: readonly Backend[]; /** @@ -942,6 +970,13 @@ export const runMatrix = async (options: { * is detected from its own version string and needs no declaration. */ platform?: PlatformDeclaration; + /** + * Resolved suite plan restricting the matrix to the loads that plan selects, + * applied BEFORE `filter` and `selection` so a free filter narrows within the + * plan. Omitted, the run covers each archetype's own full ladder, which is + * what an unqualified `bench` invocation has always meant. + */ + plan?: RunPlan; filter?: Partial; /** Multi-value selection applied after `filter`; see {@link MatrixSelection}. */ selection?: MatrixSelection; @@ -969,10 +1004,7 @@ export const runMatrix = async (options: { }): Promise => { const engineVersion = readEngineVersion(); const libraries = readLibraryProvenance(RENDERING_LIBRARY_ARMS); - const allCells = buildMatrix([...ADAPTER_CAPABILITIES, ...requestedCalibrationArms(options.selection)], options.backends); - const filtered = options.filter ? applyFilter(allCells, options.filter) : allCells; - const selected = options.selection ? applySelection(filtered, options.selection) : filtered; - const cells = options.timedFramesOverride === undefined ? selected : selected.map(cell => ({ ...cell, timedFrames: options.timedFramesOverride! })); + const cells = resolveMatrixCells(options); if (cells.length === 0) { throw new Error('The baseline matrix is empty: no adapter supports the requested backends/filter.'); diff --git a/packages/exojs-bench/src/rendering/selection.ts b/packages/exojs-bench/src/rendering/selection.ts index dbaa09167b20a53e872b87332770e9b7ae333335..75dda969c251976c0a66e5d53938b776602cb7d1 100644 GIT binary patch delta 964 zcmZva!AcxK5QZ;diHq0V67ov|?#eoQ*BDVy$RR4`D1z;D%}!%)_t4$rI>@qlgB4`T!KrUY$0Kk1%agPgS9hlsq@m}>hx?uaVC06mC2 z=w>Cp?HR$DN0_nC=)t-~=kErhsz|s9w5?xhr-H3(Atkr&CXAzk9GRTbniyD;kXo5& zE^bqjR=30nNuM>5rA)4CNpQ89_=J!gK-+3cCUk5QGochR?t9#)hji-P8Xux9-CSs~ z{MOsrj7!;jI{xx^@AV?79yedQ%^kqd44*~y!SkujWCT-B+0fPX$H8#F-v+T`|wn%ZZuHc7w { +const resolveDomain = (raw: string | undefined): DomainSelector => { if (raw === undefined) { return 'rendering'; } - if ((DOMAINS as readonly string[]).includes(raw)) { - return raw as Domain; + if (raw === 'all' || (DOMAINS as readonly string[]).includes(raw)) { + return raw as DomainSelector; + } + + throw new Error(`--domain must be one of [${DOMAINS.join(', ')}, all] (got '${raw}').`); +}; + +/** + * Output directory for one domain of a run. + * + * `--domain=all` gives each domain its own SUBDIRECTORY of the requested output + * rather than letting the second domain's `results.json` overwrite the first's. + * A single-domain run keeps writing exactly where it always did. + */ +const outDirFor = (args: Map, domain: Domain, selector: DomainSelector): string => { + const requested = args.get('out'); + + if (selector !== 'all') { + return resolve(requested ?? (domain === 'rendering' ? DEFAULT_OUT_DIR : DEFAULT_PHYSICS_OUT_DIR)); } - throw new Error(`--domain must be one of [${DOMAINS.join(', ')}] (got '${raw}').`); + return resolve(requested ?? DEFAULT_ALL_OUT_DIR, domain); +}; + +/** Resolve the suite plan for one domain from the shared `--suite` / `--extreme` flags. */ +const resolvePlanFor = (args: Map, domain: Domain, exploratory: boolean): RunPlan => + resolveSuitePlan({ + suite: parseSuite(args.get('suite')), + domain, + extreme: args.has('extreme'), + exploratory, + ladders: laddersFor(domain), + }); + +/** + * Every archetype a domain implements, mapped to its own load ladder. + * + * This is what a catalog scenario is checked against, and what `full` falls back + * to for the ExoJS-internal probes the published catalog deliberately omits. + */ +const laddersFor = (domain: Domain): ReadonlyMap => + new Map( + domain === 'rendering' + ? ARCHETYPES.map(archetype => [archetype.id as string, archetype.nodeCounts]) + : PHYSICS_ARCHETYPES.map(archetype => [archetype.id as string, archetype.bodyCounts]), + ); + +/** + * Print the resolved plan without measuring anything. + * + * States the planned workload - scenarios, loads, cells and sampling budget - + * and the capability gaps the catalog still has. Deliberately prints no wall + * clock estimate: how long a cell takes is a property of the machine, and a + * number invented here would be quoted as if it had been measured. + */ +const printDryRun = (plan: RunPlan, backends: readonly Backend[]): void => { + const scenarios = new Set(plan.workloads.map(workload => workload.scenarioId)); + + console.log( + `\n=== Plan: ${plan.meta.planId} suite=${plan.meta.suite} rev=${String(plan.meta.planRevision)} hash=${plan.meta.planHash} domain=${plan.meta.domain} ===`, + ); + console.log( + ` ${String(scenarios.size)} scenarios, ${String(plan.workloads.length)} scenario/load pairs${plan.meta.extreme ? ' (extreme loads included)' : ''}`, + ); + + if (plan.meta.domain === 'rendering') { + const cells = resolveMatrixCells({ backends, plan }); + const frames = cells.reduce((total, cell) => total + cell.timedFrames, 0); + const arms = new Set(cells.map(cell => `${cell.engine} ${cell.config}`)); + + console.log(` ${String(cells.length)} cells over ${String(arms.size)} arms and backends [${backends.join(', ')}]`); + console.log( + ` ${String(frames)} timed frames planned, plus ${String(cells.reduce((total, cell) => total + cell.warmupFrames, 0))} discarded warmup frames`, + ); + + for (const backend of backends) { + console.log(` ${backend}: ${String(cells.filter(cell => cell.backend === backend).length)} cells`); + } + } else { + const armCount = PHYSICS_LIBRARY_ARMS.length + 1; + + console.log(` ${String(plan.workloads.length * armCount)} cells at most, over ${String(armCount)} arms (exojs plus ${PHYSICS_LIBRARY_ARMS.join(', ')})`); + console.log(' Arm availability is decided by the measuring browser, so the built matrix may be smaller; a missing arm is recorded, never dropped.'); + } + + for (const workload of plan.workloads) { + console.log( + ` ${workload.scenarioId.padEnd(22)} ${workload.loadId.padStart(5)} = ${String(workload.value).padStart(9)} ${workload.unit}${workload.primary ? ' [headline]' : ''}${workload.extreme ? ' [extreme]' : ''}`, + ); + } + + if (plan.missingScenarios.length > 0) { + console.warn(`\n CAPABILITY GAP — catalog scenarios with no archetype behind them: ${plan.missingScenarios.join(', ')}`); + } }; /** @@ -186,13 +287,13 @@ const runProfileMode = async ( }; /** Run the rendering benchmark domain end-to-end and write its report artifacts. */ -const runRenderingDomain = async (args: Map): Promise => { +const runRenderingDomain = async (args: Map, selector: DomainSelector): Promise => { const backendArg = args.get('backend'); const archetypeArg = args.get('archetype'); const nodesArg = args.get('nodes'); const framesArg = args.get('frames'); const engineArg = args.get('engine'); - const outDir = resolve(args.get('out') ?? DEFAULT_OUT_DIR); + const outDir = outDirFor(args, 'rendering', selector); // `--browser` selects the engine the run is measured in and is stamped into // every provenance block, so a WebKit number can never be read as a Chromium @@ -268,7 +369,18 @@ const runRenderingDomain = async (args: Map): Promise => { return; } + // A plan resolved WHOLE is a publishable contract however few cells it holds; + // only a free filter makes the run exploratory. `--backend` is a free filter + // in that sense too: it publishes one backend's block under a plan that names + // both. const isSubset = backendArg !== undefined || hasSelection || timedFramesOverride !== undefined; + const plan = resolvePlanFor(args, 'rendering', isSubset); + + if (args.has('dry-run')) { + printDryRun(plan, backends); + + return; + } if (isSubset) { console.warn('SUBSET RUN — not a reportable comparison (see the same-session rule).'); @@ -288,6 +400,7 @@ const runRenderingDomain = async (args: Map): Promise => { const data = await runMatrix({ backends, browser, + plan, ...(platform !== undefined && { platform }), ...(hasSelection && { selection }), ...(timedFramesOverride !== undefined && { timedFramesOverride }), @@ -349,14 +462,12 @@ const runRenderingDomain = async (args: Map): Promise => { * analogue), `--frames` overrides the timed-step count for a fast spot-check * (never a reportable run). */ -const runPhysicsDomain = async (args: Map): Promise => { - const { runPhysicsMatrix, writePhysicsReport } = await import('./physics'); - +const runPhysicsDomain = async (args: Map, selector: DomainSelector): Promise => { const archetypeArg = args.get('archetype'); const bodiesArg = args.get('bodies'); const framesArg = args.get('frames'); const engineArg = args.get('engine'); - const outDir = resolve(args.get('out') ?? DEFAULT_PHYSICS_OUT_DIR); + const outDir = outDirFor(args, 'physics', selector); const browser = parseRenderingBrowser(args.get('browser')); const platform = resolvePlatform(args); @@ -404,6 +515,15 @@ const runPhysicsDomain = async (args: Map): Promise => { } const isSubset = archetypeArg !== undefined || bodiesArg !== undefined || engineArg !== undefined || timedStepsOverride !== undefined; + const plan = resolvePlanFor(args, 'physics', isSubset); + + if (args.has('dry-run')) { + printDryRun(plan, []); + + return; + } + + const { runPhysicsMatrix, writePhysicsReport } = await import('./physics'); if (isSubset) { console.warn('SUBSET RUN — not a reportable comparison (see the same-run rule).'); @@ -421,6 +541,7 @@ const runPhysicsDomain = async (args: Map): Promise => { const data = await runPhysicsMatrix({ browser, + plan, ...(platform !== undefined && { platform }), ...(isSubset && { filter }), ...(timedStepsOverride !== undefined && { timedStepsOverride }), @@ -463,13 +584,15 @@ const main = async (): Promise => { const args = parseArgs(process.argv.slice(2)); const domain = resolveDomain(args.get('domain')); - switch (domain) { - case 'rendering': - await runRenderingDomain(args); - break; - case 'physics': - await runPhysicsDomain(args); - break; + // Serial on purpose: the two domains contend for the same CPU (and the + // rendering one for the GPU), so running them concurrently would measure the + // contention rather than either domain. + if (domain === 'rendering' || domain === 'all') { + await runRenderingDomain(args, domain); + } + + if (domain === 'physics' || domain === 'all') { + await runPhysicsDomain(args, domain); } }; diff --git a/packages/exojs-bench/src/suite/catalog.ts b/packages/exojs-bench/src/suite/catalog.ts new file mode 100644 index 000000000..6aa9e5243 --- /dev/null +++ b/packages/exojs-bench/src/suite/catalog.ts @@ -0,0 +1,428 @@ +/** + * Published workload catalog: which loads of which scenario belong to a + * comparison the project publishes, and which belong to the development matrix. + * + * The domain matrices (`buildMatrix`, `buildPhysicsMatrix`) stay the source of + * truth for what a scenario IS - its scene shape, budgets and capability gates. + * This module only states which of their rungs a given suite selects, plus the + * unit a load is quoted in, which is what lets a reader tell a million world + * tiles apart from a million visible particles. + */ + +/** Test plans a run can be resolved against. */ +export type SuiteKind = 'reference' | 'full'; + +/** + * Unit a scenario's load is quoted in. + * + * The number alone is ambiguous across the catalog - 100 000 means scene nodes + * in one scenario and world tiles in another, and those are not the same claim - + * so every load carries the unit it is counted in and the published page quotes + * it beside the figure. + */ +export type LoadUnit = 'sprites' | 'nodes' | 'labels' | 'tiles' | 'layers' | 'particles' | 'widgets' | 'rects' | 'bodies' | 'viewport'; + +/** One selectable load of one scenario. */ +export interface LoadSpec { + /** Stable identifier, unique within its scenario, used in cell and card keys. */ + readonly loadId: string; + /** + * Scalar handed to the domain matrix for this load - node count for rendering + * cells, body count for physics cells. For a scenario whose load is not a + * count of scene nodes (tiles, layers, particles, widgets, viewport rows) the + * domain archetype derives its scene parameters from this same scalar, so one + * number keeps identifying one cell. + */ + readonly value: number; + /** + * Display label overriding the formatted `value` + {@link ScenarioLoads.unit} + * pair. Only for loads the pair cannot state - a render resolution reads as + * `1280 x 720`, not as `720 viewport`. + */ + readonly label?: string; + /** Whether the `reference` plan selects this load. `full` selects every load. */ + readonly reference: boolean; + /** + * Whether this load is the scenario's headline - the one a card shows before + * the reader picks another. Exactly one load per scenario sets it. + */ + readonly primary?: boolean; + /** + * Whether this load runs only under `--extreme`. An extreme load is never part + * of `reference`: it exists to find where a scenario stops being viable, and + * that is a development question rather than a published comparison. + */ + readonly extreme?: boolean; +} + +/** Every selectable load of one scenario, in ascending order. */ +export interface ScenarioLoads { + /** Archetype id in the scenario's own domain. */ + readonly scenarioId: string; + /** Unit the loads are counted in; see {@link LoadUnit}. */ + readonly unit: LoadUnit; + /** Loads, smallest first. */ + readonly loads: readonly LoadSpec[]; +} + +/** Build a load list from a compact `[value, flags]` description. */ +const loads = (entries: ReadonlyArray): readonly LoadSpec[] => + entries.map(([value, flags, label]) => ({ + loadId: loadIdFor(value), + value, + ...(label !== undefined && { label }), + reference: flags.includes('r'), + ...(flags.includes('*') && { primary: true }), + ...(flags.includes('x') && { extreme: true }), + })); + +/** + * Canonical id for a load value: `1000` is `1k`, `100000` is `100k`, `1000000` + * is `1m`, anything else is its own digits. Ids appear in cell keys, so they are + * derived rather than hand-written - a hand-written id can disagree with the + * value it names, and the two would then identify different cells under one key. + */ +export const loadIdFor = (value: number): string => { + if (value >= 1_000_000 && value % 1_000_000 === 0) return `${String(value / 1_000_000)}m`; + if (value >= 1_000 && value % 1_000 === 0) return `${String(value / 1_000)}k`; + + return String(value); +}; + +/** + * Rendering scenarios and their loads. + * + * `reference` keeps one load for most scenarios and a short ladder for the few + * whose scaling is itself the finding, which is what makes a published run + * finish without dropping a comparison. `full` keeps every rung the development + * matrix has always had, so no historical cell stops being reachable. + * + * ExoJS-internal probes (`overdraw`, `split-screen`, the material and mesh rows, + * `instanced-batch`) are absent on purpose: a competitor arm renders some other + * scene on those, so they are development rows and never a published comparison. + * They keep running under `full` through the domain ladder. + */ +export const RENDERING_SCENARIOS: readonly ScenarioLoads[] = [ + { + scenarioId: 'static-heavy', + unit: 'sprites', + loads: loads([ + [1_000, 'r'], + [5_000, ''], + [10_000, 'r*'], + [25_000, ''], + [100_000, 'r'], + [1_000_000, 'x'], + ]), + }, + { + scenarioId: 'dynamic-heavy', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, ''], + [10_000, 'r*'], + [25_000, ''], + [100_000, ''], + ]), + }, + { + scenarioId: 'dynamic-all', + unit: 'sprites', + loads: loads([ + [1_000, 'r'], + [10_000, 'r*'], + [100_000, 'r'], + ]), + }, + { + scenarioId: 'deep-hierarchy', + unit: 'nodes', + loads: loads([ + [1_000, ''], + [5_000, ''], + [10_000, 'r*'], + [25_000, ''], + [100_000, ''], + ]), + }, + { + scenarioId: 'lifecycle-churn', + unit: 'nodes', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'batch-breaking', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'batch-breaking-atlased', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'mixed-blend', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'text-static', + unit: 'labels', + loads: loads([ + [200, ''], + [1_000, 'r*'], + [5_000, 'r'], + ]), + }, + { + scenarioId: 'text-dynamic', + unit: 'labels', + loads: loads([ + [200, ''], + [1_000, 'r*'], + [5_000, 'r'], + ]), + }, + { + scenarioId: 'filter-chain-1', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'filter-chain-2', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'filter-chain-4', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'composite', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'mask-clip', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'mask-clip-animated', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'scrolling-world', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, ''], + [10_000, 'r*'], + [25_000, ''], + [100_000, ''], + ]), + }, + { + scenarioId: 'fill-layers', + unit: 'layers', + loads: loads([ + [8, ''], + [32, 'r*'], + [128, ''], + ]), + }, + { + scenarioId: 'tilemap-scroll', + unit: 'tiles', + loads: loads([ + [10_000, 'r'], + [100_000, 'r*'], + [1_000_000, 'x'], + ]), + }, + { + scenarioId: 'tilemap-edit', + unit: 'tiles', + loads: loads([ + [10_000, ''], + [100_000, 'r*'], + ]), + }, + { + scenarioId: 'particles-draw', + unit: 'particles', + loads: loads([ + [1_000, 'r'], + [10_000, 'r*'], + [100_000, 'r'], + [1_000_000, 'x'], + ]), + }, + { + scenarioId: 'particles-lifecycle', + unit: 'particles', + loads: loads([ + [1_000, ''], + [10_000, 'r*'], + [100_000, ''], + ]), + }, + { + scenarioId: 'ui-layout-update', + unit: 'widgets', + loads: loads([ + [100, ''], + [1_000, 'r*'], + [5_000, ''], + ]), + }, + { + scenarioId: 'interaction-picking', + unit: 'rects', + loads: loads([ + [1_000, ''], + [10_000, 'r*'], + [100_000, ''], + ]), + }, + { + scenarioId: 'fx-blur', + unit: 'viewport', + loads: loads([ + [360, '', '640 x 360'], + [720, 'r*', '1280 x 720'], + [1_080, '', '1920 x 1080'], + ]), + }, +]; + +/** + * Physics scenarios and their loads. + * + * Each ladder is the domain's own, unchanged; `reference` selects the middle + * rung, which is the one that straddles the 60 fps frame (see the ladder notes + * in `physics/archetypes.ts`). Nothing here re-derives a scene: a rung that + * moved would be a different world under the same name. + */ +export const PHYSICS_SCENARIOS: readonly ScenarioLoads[] = [ + { + scenarioId: 'box-stack', + unit: 'bodies', + loads: loads([ + [3_000, ''], + [5_500, 'r*'], + [10_000, ''], + ]), + }, + { + scenarioId: 'many-dynamic', + unit: 'bodies', + loads: loads([ + [800, ''], + [1_500, 'r*'], + [2_200, ''], + ]), + }, + { + scenarioId: 'mixed-static-dynamic', + unit: 'bodies', + loads: loads([ + [900, ''], + [1_700, 'r*'], + [3_200, ''], + ]), + }, + { + scenarioId: 'raycast', + unit: 'bodies', + loads: loads([ + [900, ''], + [1_700, 'r*'], + [3_200, ''], + ]), + }, + { + scenarioId: 'body-churn', + unit: 'bodies', + loads: loads([ + [800, ''], + [1_500, 'r*'], + [2_400, ''], + ]), + }, + { + scenarioId: 'joints', + unit: 'bodies', + loads: loads([ + [4_500, ''], + [9_000, 'r*'], + [15_000, ''], + ]), + }, + { + scenarioId: 'settling-pile', + unit: 'bodies', + loads: loads([ + [1_500, ''], + [3_000, 'r*'], + [5_800, ''], + ]), + }, +]; + +/** Scenario catalog for one benchmark domain. */ +export const scenariosFor = (domain: 'rendering' | 'physics'): readonly ScenarioLoads[] => (domain === 'rendering' ? RENDERING_SCENARIOS : PHYSICS_SCENARIOS); + +/** The scenario's headline load, i.e. the one a published card shows first. */ +export const primaryLoadOf = (scenario: ScenarioLoads): LoadSpec => { + const primary = scenario.loads.find(load => load.primary === true); + + if (primary === undefined) { + throw new Error(`Scenario '${scenario.scenarioId}' names no primary load; exactly one load must carry it.`); + } + + return primary; +}; diff --git a/packages/exojs-bench/src/suite/plan.ts b/packages/exojs-bench/src/suite/plan.ts new file mode 100644 index 000000000..8e7da9cdb --- /dev/null +++ b/packages/exojs-bench/src/suite/plan.ts @@ -0,0 +1,216 @@ +import { createHash } from 'node:crypto'; + +import type { LoadUnit, ScenarioLoads, SuiteKind } from './catalog'; +import { loadIdFor, scenariosFor } from './catalog'; + +/** Benchmark domains a plan can be resolved for. */ +export type PlanDomain = 'rendering' | 'physics'; + +/** One scenario load the plan selected, with everything a cell key needs. */ +export interface WorkloadSpec { + /** Archetype id in its own domain. */ + readonly scenarioId: string; + /** Load identifier within the scenario. */ + readonly loadId: string; + /** Scalar the domain matrix builds the cell from - node count or body count. */ + readonly value: number; + /** Unit the load is counted in; carried so a figure is never quoted without it. */ + readonly unit: LoadUnit; + /** Display label for loads the value/unit pair cannot state, e.g. a resolution. */ + readonly label?: string; + /** Whether this is the scenario's headline load. */ + readonly primary: boolean; + /** Whether this load was admitted by `--extreme` rather than by the suite itself. */ + readonly extreme: boolean; +} + +/** Identity of one resolved plan, stamped into every artifact the run writes. */ +export interface RunPlanMeta { + /** Plan the run was resolved against. */ + readonly suite: SuiteKind; + /** Stable name of the plan family. */ + readonly planId: 'exojs-comparison'; + /** + * Revision of the catalog's published contract. Bumped when a reference load, + * a scenario's membership or a unit changes, so two runs that resolved + * different contracts can never be pooled under one identity. + */ + readonly planRevision: number; + /** Digest over the resolved workloads; see {@link computePlanHash}. */ + readonly planHash: string; + /** Domain this plan covers. */ + readonly domain: PlanDomain; + /** Whether extreme loads were admitted. */ + readonly extreme: boolean; + /** + * Whether the run narrowed the plan with free filters (`--archetype`, + * `--engine`, `--nodes`, `--frames`). + * + * An exploratory run is never a published comparison: its cells do not match + * the plan the comparison builder expects, so pooling it with a full run would + * mix two different questions. A plan resolved WHOLE is publishable regardless + * of how few cells it has - a smaller published contract is still a contract. + */ + readonly exploratory: boolean; +} + +/** A resolved plan: its identity and the workloads it selected. */ +export interface RunPlan { + readonly meta: RunPlanMeta; + readonly workloads: readonly WorkloadSpec[]; + /** + * Catalog scenarios with no archetype behind them yet, i.e. named capability + * gaps. Reported rather than dropped: a comparison that quietly lost a + * scenario reads exactly like one that never promised it. + */ + readonly missingScenarios: readonly string[]; +} + +/** Current revision of the published catalog contract; see {@link RunPlanMeta.planRevision}. */ +export const PLAN_REVISION = 1; + +/** + * Digest over a plan's semantic content only - suite, domain, and each selected + * workload's scenario, load and unit. + * + * Deliberately excludes timestamps, output paths and host data: the hash answers + * "did these two runs measure the same contract", and a path difference is not a + * contract difference. A changed load value or unit is. + */ +export const computePlanHash = (suite: SuiteKind, domain: PlanDomain, workloads: readonly WorkloadSpec[]): string => { + const body = workloads.map(workload => `${workload.scenarioId}/${workload.loadId}/${String(workload.value)}/${workload.unit}`).join('\n'); + + return createHash('sha256') + .update(`exojs-bench-plan/v1\n${suite}\n${domain}\n${String(PLAN_REVISION)}\n${body}`) + .digest('hex') + .slice(0, 16); +}; + +/** + * Canonical key of one measured cell. + * + * Carries the arm, its configuration, the backend, the scenario revision and the + * load, because each of those changes what was measured. Two results may only be + * pooled when their keys match; anything coarser pools a WebGPU cell with a + * WebGL2 one, or this revision of a scene with the previous one. + */ +export const cellKey = (parts: { + readonly domain: PlanDomain; + readonly engine: string; + readonly config: string; + readonly backend: string; + readonly scenarioId: string; + readonly loadId: string; +}): string => `${parts.domain}|${parts.engine}|${parts.config}|${parts.backend}|${parts.scenarioId}|${parts.loadId}`; + +/** Whether a scenario's load belongs to the given suite, once `--extreme` is accounted for. */ +const admits = (suite: SuiteKind, extreme: boolean, load: ScenarioLoads['loads'][number]): boolean => { + if (load.extreme === true) { + return suite === 'full' && extreme; + } + + return suite === 'full' || load.reference; +}; + +/** + * Resolve a suite into the workloads it selects. + * + * `ladders` maps every archetype id the domain implements to its own load + * ladder. Two things follow from taking the whole domain rather than only the + * catalog: + * + * - A catalog scenario with no archetype behind it is reported in + * {@link RunPlan.missingScenarios} rather than planned, so a capability gap is + * visible in the dry run instead of surfacing as a column of failed cells. + * - `full` keeps every archetype the domain has, catalog or not, on its own + * ladder. The development matrix is what `full` means, and the ExoJS-internal + * probes deliberately absent from the published catalog must not disappear + * with it. + */ +export const resolveSuitePlan = (options: { + readonly suite: SuiteKind; + readonly domain: PlanDomain; + readonly extreme?: boolean; + readonly exploratory?: boolean; + readonly ladders: ReadonlyMap; +}): RunPlan => { + const extreme = options.extreme ?? false; + + if (extreme && options.suite !== 'full') { + throw new Error('--extreme is only valid with --suite=full: an extreme load is a development probe and is never part of a published reference comparison.'); + } + + const workloads: WorkloadSpec[] = []; + const missingScenarios: string[] = []; + const catalogued = new Set(); + + for (const scenario of scenariosFor(options.domain)) { + catalogued.add(scenario.scenarioId); + + if (!options.ladders.has(scenario.scenarioId)) { + missingScenarios.push(scenario.scenarioId); + continue; + } + + for (const load of scenario.loads) { + if (!admits(options.suite, extreme, load)) { + continue; + } + + workloads.push({ + scenarioId: scenario.scenarioId, + loadId: load.loadId, + value: load.value, + unit: scenario.unit, + ...(load.label !== undefined && { label: load.label }), + primary: load.primary === true, + extreme: load.extreme === true, + }); + } + } + + if (options.suite === 'full') { + const unit: LoadUnit = options.domain === 'rendering' ? 'nodes' : 'bodies'; + + for (const [scenarioId, ladder] of options.ladders) { + if (catalogued.has(scenarioId)) { + continue; + } + + for (const value of ladder) { + workloads.push({ scenarioId, loadId: loadIdFor(value), value, unit, primary: false, extreme: false }); + } + } + } + + return { + meta: { + suite: options.suite, + planId: 'exojs-comparison', + planRevision: PLAN_REVISION, + planHash: computePlanHash(options.suite, options.domain, workloads), + domain: options.domain, + extreme, + exploratory: options.exploratory ?? false, + }, + workloads, + missingScenarios, + }; +}; + +/** Load values the plan selected for one scenario, ascending. */ +export const loadValuesFor = (plan: RunPlan, scenarioId: string): readonly number[] => + plan.workloads.filter(workload => workload.scenarioId === scenarioId).map(workload => workload.value); + +/** Parse and validate the `--suite` selector. */ +export const parseSuite = (raw: string | undefined): SuiteKind => { + if (raw === undefined || raw === 'full') { + return 'full'; + } + + if (raw === 'reference') { + return 'reference'; + } + + throw new Error(`--suite must be one of [reference, full] (got '${raw}').`); +}; diff --git a/packages/exojs-bench/test/suite-plan.test.ts b/packages/exojs-bench/test/suite-plan.test.ts new file mode 100644 index 000000000..0847e7150 --- /dev/null +++ b/packages/exojs-bench/test/suite-plan.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; + +import { PHYSICS_ARCHETYPES } from '../src/physics/archetypes'; +import { ARCHETYPES } from '../src/rendering/archetypes'; +import type { CellSpec } from '../src/rendering/EngineAdapter'; +import { applyPlan } from '../src/rendering/selection'; +import type { ScenarioLoads } from '../src/suite/catalog'; +import { PHYSICS_SCENARIOS, primaryLoadOf, RENDERING_SCENARIOS } from '../src/suite/catalog'; +import type { PlanDomain } from '../src/suite/plan'; +import { cellKey, parseSuite, resolveSuitePlan } from '../src/suite/plan'; + +const RENDERING_LADDERS = new Map(ARCHETYPES.map(archetype => [archetype.id as string, archetype.nodeCounts])); +const PHYSICS_LADDERS = new Map(PHYSICS_ARCHETYPES.map(archetype => [archetype.id as string, archetype.bodyCounts])); + +const planFor = (suite: 'reference' | 'full', domain: PlanDomain, extreme = false): ReturnType => + resolveSuitePlan({ suite, domain, extreme, ladders: domain === 'rendering' ? RENDERING_LADDERS : PHYSICS_LADDERS }); + +const scenarioIds = (plan: ReturnType): Set => new Set(plan.workloads.map(workload => workload.scenarioId)); + +/** + * ExoJS-internal probes: archetypes the published catalog deliberately omits + * because a competitor arm renders some other scene on them. They must survive + * `full` and must never reach `reference`. + */ +const INTERNAL_PROBES = [ + 'split-screen', + 'instanced-batch', + 'mixed-material', + 'mixed-material-atlased', + 'mixed-sprite-mesh-static', + 'mixed-sprite-mesh-array', + 'overdraw', +]; + +describe('published workload catalog', () => { + const catalogs: readonly (readonly [string, readonly ScenarioLoads[]])[] = [ + ['rendering', RENDERING_SCENARIOS], + ['physics', PHYSICS_SCENARIOS], + ]; + + for (const [domain, scenarios] of catalogs) { + it(`${domain}: every scenario names exactly one headline load, and it is part of the reference plan`, () => { + const offenders = scenarios.filter(scenario => scenario.loads.filter(load => load.primary === true).length !== 1 || !primaryLoadOf(scenario).reference); + + expect(offenders.map(scenario => scenario.scenarioId)).toStrictEqual([]); + }); + + it(`${domain}: load ids and values are unique and ascending within a scenario`, () => { + const offenders = scenarios.filter(scenario => { + const values = scenario.loads.map(load => load.value); + const ascending = values.every((value, index) => index === 0 || value > values[index - 1]!); + + return !ascending || new Set(scenario.loads.map(load => load.loadId)).size !== scenario.loads.length; + }); + + expect(offenders.map(scenario => scenario.scenarioId)).toStrictEqual([]); + }); + + it(`${domain}: no extreme load is part of the reference plan`, () => { + for (const scenario of scenarios) { + for (const load of scenario.loads.filter(entry => entry.extreme === true)) { + expect(load.reference).toBe(false); + } + } + }); + } + + it('physics loads are ladder rungs, because a physics seed folds the body count in', () => { + const offRungs = PHYSICS_SCENARIOS.flatMap(scenario => + scenario.loads + .filter(load => PHYSICS_LADDERS.get(scenario.scenarioId)?.includes(load.value) !== true) + .map(load => `${scenario.scenarioId}/${load.loadId}`), + ); + + expect(offRungs).toStrictEqual([]); + }); +}); + +describe('suite resolution', () => { + it('reference selects the headline load of every catalogued scenario and no internal probe', () => { + const plan = planFor('reference', 'rendering'); + const selected = scenarioIds(plan); + + for (const probe of INTERNAL_PROBES) { + expect(selected).not.toContain(probe); + } + + for (const scenario of RENDERING_SCENARIOS) { + if (!RENDERING_LADDERS.has(scenario.scenarioId)) { + continue; + } + + const headline = primaryLoadOf(scenario); + + expect(plan.workloads.some(workload => workload.scenarioId === scenario.scenarioId && workload.loadId === headline.loadId && workload.primary)).toBe( + true, + ); + } + }); + + it('reference carries no extreme load and no million-scale cell', () => { + const plan = planFor('reference', 'rendering'); + + expect(plan.workloads.some(workload => workload.extreme)).toBe(false); + expect(plan.workloads.some(workload => workload.value >= 1_000_000)).toBe(false); + }); + + it('full keeps every archetype the domain implements, including the internal probes', () => { + const selected = scenarioIds(planFor('full', 'rendering')); + + expect(ARCHETYPES.map(archetype => archetype.id).filter(id => !selected.has(id))).toStrictEqual([]); + }); + + it('full is a superset of reference, load by load', () => { + const reference = planFor('reference', 'rendering'); + const full = new Set(planFor('full', 'rendering').workloads.map(workload => `${workload.scenarioId}/${workload.loadId}`)); + + for (const workload of reference.workloads) { + expect(full).toContain(`${workload.scenarioId}/${workload.loadId}`); + } + }); + + it('full keeps every rung of every existing development ladder', () => { + const full = new Set(planFor('full', 'rendering').workloads.map(workload => `${workload.scenarioId}/${String(workload.value)}`)); + + for (const archetype of ARCHETYPES) { + for (const nodeCount of archetype.nodeCounts) { + expect(full).toContain(`${archetype.id}/${String(nodeCount)}`); + } + } + }); + + it('extreme loads are admitted only by full plus the flag', () => { + expect(planFor('full', 'rendering').workloads.some(workload => workload.extreme)).toBe(false); + expect(planFor('full', 'rendering', true).workloads.some(workload => workload.extreme)).toBe(true); + expect(() => planFor('reference', 'rendering', true)).toThrow(/only valid with --suite=full/); + }); + + it('names a catalogued scenario with no archetype behind it instead of dropping it', () => { + const plan = resolveSuitePlan({ suite: 'reference', domain: 'rendering', ladders: new Map([['static-heavy', [10_000]]]) }); + + expect(plan.missingScenarios).toContain('tilemap-scroll'); + expect(scenarioIds(plan)).toStrictEqual(new Set(['static-heavy'])); + }); + + it('parseSuite defaults to full so an unqualified run keeps meaning the development matrix', () => { + expect(parseSuite(undefined)).toBe('full'); + expect(parseSuite('reference')).toBe('reference'); + expect(() => parseSuite('quick')).toThrow(/--suite must be one of/); + }); +}); + +describe('plan identity', () => { + it('is stable for the same contract and differs across suites and domains', () => { + expect(planFor('reference', 'rendering').meta.planHash).toBe(planFor('reference', 'rendering').meta.planHash); + expect(planFor('reference', 'rendering').meta.planHash).not.toBe(planFor('full', 'rendering').meta.planHash); + expect(planFor('reference', 'rendering').meta.planHash).not.toBe(planFor('reference', 'physics').meta.planHash); + }); + + it('changes when a planned load moves and not when an unmeasured detail does', () => { + // An uncatalogued scenario, whose loads `full` takes from the ladder: the + // one case where moving a rung moves the plan itself. A catalogued + // scenario's loads come from the catalog, so its hash is deliberately + // insensitive to the development ladder underneath it. + const base = resolveSuitePlan({ suite: 'full', domain: 'rendering', ladders: new Map([['split-screen', [1_000]]]) }); + const same = resolveSuitePlan({ suite: 'full', domain: 'rendering', exploratory: true, ladders: new Map([['split-screen', [1_000]]]) }); + const moved = resolveSuitePlan({ suite: 'full', domain: 'rendering', ladders: new Map([['split-screen', [1_200]]]) }); + + expect(same.meta.planHash).toBe(base.meta.planHash); + expect(moved.meta.planHash).not.toBe(base.meta.planHash); + }); + + it('cell keys separate backend, arm configuration and load', () => { + const base = { domain: 'rendering' as const, engine: 'exojs', config: 'current', backend: 'webgl2', scenarioId: 'static-heavy', loadId: '10k' }; + + expect(cellKey(base)).toBe(cellKey({ ...base })); + expect(cellKey(base)).not.toBe(cellKey({ ...base, backend: 'webgpu' })); + expect(cellKey(base)).not.toBe(cellKey({ ...base, config: 'retained' })); + expect(cellKey(base)).not.toBe(cellKey({ ...base, loadId: '100k' })); + }); +}); + +describe('applyPlan', () => { + const cell = (archetype: string, nodeCount: number): CellSpec => + ({ engine: 'exojs', config: 'current', backend: 'webgl2', archetype, nodeCount, timedFrames: 1, warmupFrames: 1 }) as CellSpec; + + it('emits the plan loads for a named scenario, including rungs the ladder lacks', () => { + const plan = resolveSuitePlan({ suite: 'reference', domain: 'rendering', ladders: RENDERING_LADDERS }); + const kept = applyPlan([cell('static-heavy', 1_000), cell('static-heavy', 25_000), cell('split-screen', 1_000)], plan); + + expect(kept.map(entry => entry.nodeCount).sort((a, b) => a - b)).toStrictEqual([1_000, 10_000, 100_000]); + expect(kept.every(entry => entry.archetype === 'static-heavy')).toBe(true); + }); + + it('rebudgets frames per emitted load rather than inheriting the source cell', () => { + const plan = resolveSuitePlan({ suite: 'reference', domain: 'rendering', ladders: RENDERING_LADDERS }); + const kept = applyPlan([cell('static-heavy', 1_000)], plan); + const largest = kept.find(entry => entry.nodeCount === 100_000); + + expect(largest?.timedFrames).toBe(30); + expect(largest?.warmupFrames).toBe(40); + }); +}); From d06ad64f5c74ea74e60748202d8add137235ac62 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 05:09:53 +0200 Subject: [PATCH 09/15] feat(bench): qualify every comparison against the clock that timed it The clock probe landed without a producer: nothing measured a rendering page's grid, nothing attached one to a cell, and the check that reads it sat in the site with no caller. Every rendering comparison therefore published as "timer not recorded", which reads like a finding and is merely a missing wire. The harness now probes the grid in the page, once per session, and every cell carries the grid of the session that produced it. That attribution matters here: a rendering run opens one browser session per arm, so a backend-wide figure read from whichever page happened to be open would qualify cells it never timed. The comparison builder checks each arm's duration against its own session's grid and the pooling stage merges the per-run results, so a limitation one run established cannot be lifted by a better-resolved repetition. Physics is checked against the batch the clock actually bracketed rather than the per-step quotient the report divides out, which would mark a well-resolved cell unresolved purely for being batched. The rendering block also stops collapsing onto a single table-wide node count. A published page offers the reader a load to pick, and one count discarded every other measurement before the profile was written. Each row is now one archetype at one load, stating the load and the unit it is counted in, so a hundred thousand world tiles can never read as a hundred thousand sprites. What the single count protected - that no load is chosen to suit an outcome - is protected by the plan instead, which fixes the loads before the run starts. Schema version 7 carries all of it, and the result verifier requires it of a version 7 document rather than trusting the stage that writes it. Version 6 profiles still read exactly as they did. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- .../exojs-bench/src/comparison/aggregate.ts | 33 +- packages/exojs-bench/src/comparison/build.ts | 292 +++++++++++------- packages/exojs-bench/src/profile/document.ts | 1 + packages/exojs-bench/src/profile/schema.ts | 24 +- .../src/rendering/EngineAdapter.ts | 11 + packages/exojs-bench/src/rendering/driver.ts | 40 ++- .../exojs-bench/src/rendering/page/harness.ts | 23 ++ packages/exojs-bench/src/shared/timerCheck.ts | 66 ++++ packages/exojs-bench/test/comparison.test.ts | 114 +++++-- packages/exojs-bench/test/profile.test.ts | 1 + .../exojs-bench/test/report-merge.test.ts | 9 + .../test/selection-and-hitching.test.ts | 8 + .../exojs-bench/test/structural-gate.test.ts | 8 + packages/exojs-bench/test/timer-check.test.ts | 80 +++++ packages/exojs-bench/test/timing-gate.test.ts | 8 + scripts/verify-bench-results.ts | 59 ++++ site/src/lib/bench-profiles.ts | 58 +--- test/site/bench-timer-check.test.ts | 72 +---- 18 files changed, 635 insertions(+), 272 deletions(-) create mode 100644 packages/exojs-bench/src/shared/timerCheck.ts create mode 100644 packages/exojs-bench/test/timer-check.test.ts diff --git a/packages/exojs-bench/src/comparison/aggregate.ts b/packages/exojs-bench/src/comparison/aggregate.ts index a5ebf7ba1..f5a961b73 100644 --- a/packages/exojs-bench/src/comparison/aggregate.ts +++ b/packages/exojs-bench/src/comparison/aggregate.ts @@ -5,6 +5,7 @@ import type { Provenance } from '../rendering/driver'; import type { ReportData } from '../rendering/report'; import { exceedsFrameBudget } from '../shared/frameBudget'; import type { LibraryProvenance, PlatformVersionStamp, PrereleaseStamp } from '../shared/provenance'; +import { mergeTimerChecks } from '../shared/timerCheck'; import { median } from '../shared/timing'; import type { BackendComparison, ComparisonCell, ComparisonRow, ComparisonSection, ExcludedRow } from './build'; import { buildPhysicsComparison, buildRenderingComparison } from './build'; @@ -289,6 +290,11 @@ const aggregateCell = (perRun: readonly ComparisonCell[], runCount: number): Agg competitorOverFrameBudget: exceedsFrameBudget(competitorMs), verdict: stable ? pooled : UNSTABLE_VERDICT, mechanism: first.mechanism, + // Merged from the per-run checks rather than recomputed from the pooled + // medians: a limitation any run established stands for the pooled figure, + // and pooling the durations first would let a well-resolved repetition carry + // a limited one past the threshold. + timer: mergeTimerChecks(perRun.map(cell => cell.timer)), aggregate: { runs: perRun.length, reference: references.length > 0 ? spreadOf(references) : NO_SPREAD, @@ -318,13 +324,11 @@ const groupBy = (values: readonly T[], key: (value: T) => string): Map { const first = perRun[0]!; @@ -333,7 +337,7 @@ const aggregateRow = (perRun: readonly ComparisonRow[], runCount: number, domain if (counts.length > 1) { throw new IncomparableRunsError( domain, - `${domain} runs measured '${first.archetype}' at different counts (${counts.map(String).join(', ')}), so their medians describe different scenes. Re-measure: a row's count only moves when an arm failed to produce a valid cell.`, + `${domain} runs measured '${first.archetype}' at different counts under one load id (${counts.map(String).join(', ')}), so their medians describe different scenes.`, ); } @@ -342,11 +346,20 @@ const aggregateRow = (perRun: readonly ComparisonRow[], runCount: number, domain cell => cell.competitor, ); - return { archetype: first.archetype, category: first.category, count: first.count, cells: [...cells.values()].map(group => aggregateCell(group, runCount)) }; + return { + archetype: first.archetype, + category: first.category, + count: first.count, + loadId: first.loadId, + unit: first.unit, + primary: first.primary, + ...(first.label !== undefined && { label: first.label }), + cells: [...cells.values()].map(group => aggregateCell(group, runCount)), + }; }; const aggregateRows = (perRun: ReadonlyArray, runCount: number, domain: string): readonly AggregatedRow[] => - [...groupBy(perRun.flat(), row => row.archetype).values()].map(group => aggregateRow(group, runCount, domain)); + [...groupBy(perRun.flat(), row => `${row.archetype}|${row.loadId}`).values()].map(group => aggregateRow(group, runCount, domain)); const aggregateSection = (perRun: readonly ComparisonSection[], runCount: number, domain: string): AggregatedSection => ({ title: perRun[0]!.title, @@ -478,7 +491,7 @@ export const aggregatePhysicsRuns = (runs: readonly PhysicsReportData[]): Aggreg runs: runs.map(run => run.provenance), libraries: runs[0]!.libraries, section: aggregateSection( - runs.map(run => buildPhysicsComparison(run.results)), + runs.map(run => buildPhysicsComparison(run.results, run.provenance.clock)), runs.length, 'physics', ), diff --git a/packages/exojs-bench/src/comparison/build.ts b/packages/exojs-bench/src/comparison/build.ts index fab1819bd..573aed353 100644 --- a/packages/exojs-bench/src/comparison/build.ts +++ b/packages/exojs-bench/src/comparison/build.ts @@ -2,7 +2,12 @@ import { PHYSICS_ARCHETYPES } from '../physics/archetypes'; import type { PhysicsCellResult } from '../physics/PhysicsAdapter'; import { ARCHETYPES } from '../rendering/archetypes'; import type { ArchetypeCategory, Backend, CellResult, StructuralCounters } from '../rendering/EngineAdapter'; +import type { ClockReport } from '../shared/clock'; import { exceedsFrameBudget } from '../shared/frameBudget'; +import type { TimerCheck } from '../shared/timerCheck'; +import { mergeTimerChecks, timerCheckOfRun } from '../shared/timerCheck'; +import type { LoadUnit } from '../suite/catalog'; +import { loadIdFor, scenariosFor } from '../suite/catalog'; import { physicsMechanism, renderingMechanism } from './mechanism'; import type { Verdict } from './verdict'; import { compareMedians } from './verdict'; @@ -17,12 +22,17 @@ import { compareMedians } from './verdict'; * * - No aggregation across archetypes, anywhere. Categories are section headings, * never rows, because any mean over a category hides its worst cell. - * - A row's node or body count is chosen from the archetype LADDERS before any - * timing is read (see {@link chooseRowCount}), so it can never be picked to - * suit the outcome. A rendering block goes further and puts every row on one - * count (see {@link chooseHeadlineCount}); the physics block cannot, and - * states each row's count on the row instead. + * - A row is one archetype at one LOAD, and every load the run measured becomes + * its own row. Nothing picks a load after the fact: the plan fixed which loads + * would be measured before the run started, and a row states the load it + * belongs to. * - A row with no evidenced mechanism does not enter the table. + * + * The rendering block used to collapse onto a single table-wide node count. It + * cannot any more: the published page offers the reader a load to pick, and a + * single count discards every other measurement before the profile is even + * written. The property that count protected - that no load is chosen to suit an + * outcome - is kept by the plan, which fixes the loads in advance. */ /** The reference arm every comparison is drawn against: ExoJS on its default path. */ @@ -101,23 +111,40 @@ export interface ComparisonCell { readonly verdict: Verdict; /** Evidenced mechanism, or `null` when the counters carry none. */ readonly mechanism: string | null; + /** + * What the clock this run read the two durations on established about them. + * + * Evaluated here, against the grid of the very session that produced each + * cell, because that is the only place both are known together. The pooling + * stage merges the runs' results; it never re-runs the check against pooled + * medians, which would let a well-resolved repetition carry a limited one. + */ + readonly timer: TimerCheck; } -/** One published row: an archetype at the count it was measured at, across every competitor. */ +/** + * One published row: an archetype at one load, across every competitor. + * + * An archetype measured at several loads produces several rows. A reader + * compares the arms WITHIN a row, which is like for like by construction, and + * never two rows against each other: two loads are two different scenes, as are + * two archetypes. + */ export interface ComparisonRow { - /** Archetype id - the row's identity. */ + /** Archetype id. Together with {@link ComparisonRow.loadId} it identifies the row. */ readonly archetype: string; /** The category section this row sits under. */ readonly category: string; - /** - * Node or body count this row was measured at. - * - * In a rendering block every row carries the block's single headline count. In - * the physics block the rows carry their OWN counts, because the physics - * archetypes have per-archetype ladders; two physics rows are therefore never - * comparable with each other, only the arms within one row are. - */ + /** Node or body count this row was measured at. */ readonly count: number; + /** Catalog load id for {@link ComparisonRow.count}, e.g. `10k`. */ + readonly loadId: string; + /** Unit the count is quoted in, so a figure is never published without one. */ + readonly unit: LoadUnit; + /** Whether this is the scenario's headline load - the one a card shows first. */ + readonly primary: boolean; + /** Display label for a load the count/unit pair cannot state, e.g. a resolution. */ + readonly label?: string; /** One entry per competitor arm, in a stable order. */ readonly cells: readonly ComparisonCell[]; } @@ -160,12 +187,43 @@ export interface BackendComparison { /** Key identifying one arm's cell within a backend. */ const cellKey = (engine: string, config: string, archetype: string, count: number): string => `${engine}|${config}|${archetype}|${count}`; +/** + * The catalog identity of one load: its id, the unit it is counted in, whether + * it is the scenario's headline, and a label where the pair cannot state it. + * + * A scenario the catalog does not carry - an ExoJS-internal probe measured under + * `full` - still gets a well-formed identity, so every row in the model can be + * addressed the same way. It is quoted in scene nodes, which is what those + * probes count. + */ +const loadIdentity = (scenarioId: string, count: number): { loadId: string; unit: LoadUnit; primary: boolean; label?: string } => { + const scenario = + scenariosFor('rendering').find(entry => entry.scenarioId === scenarioId) ?? scenariosFor('physics').find(entry => entry.scenarioId === scenarioId); + const load = scenario?.loads.find(entry => entry.value === count); + + return { + loadId: loadIdFor(count), + unit: scenario?.unit ?? 'nodes', + primary: load?.primary === true, + ...(load?.label !== undefined && { label: load.label }), + }; +}; + /** Identity used to preserve the old CPU-only Phaser block while new WebGL2 profiles migrate. */ const armKeyOf = (result: { readonly spec: { readonly engine: string; readonly config: string } }): string => `${result.spec.engine}|${result.spec.config}`; /** Whether a result can be compared at all: it measured, and it measured something. */ const isComparable = (result: { status: string; note?: string }): boolean => result.status === 'ok'; +/** + * The duration the clock actually bracketed for one physics cell: the median + * sample, which covers `stepsPerSample` steps. + * + * The harness batches steps precisely so a sample clears the clock's grid, so + * the batch is the reading and the per-step quotient is a derived figure. + */ +const batchMsOf = (result: PhysicsCellResult): number => result.stepMsMedian * result.stepsPerSample; + /** * The count one row is published at: the largest rung of its own LADDER at which * every arm produced a valid cell. @@ -256,75 +314,83 @@ const buildBackend = (backend: Backend, results: readonly CellResult[]): Backend : 'ExoJS-internal structural probe: a competitor arm renders a different scene here, so a wall-clock comparison would not be like for like', })); + /** Loads the reference arm actually measured for one archetype, ascending. */ + const measuredLoads = (archetype: string): readonly number[] => + [ + ...new Set( + onBackend + .filter(result => result.spec.engine === REFERENCE_ENGINE && result.spec.config === REFERENCE_CONFIG && result.spec.archetype === archetype) + .map(result => result.spec.nodeCount), + ), + ].sort((a, b) => a - b); + const sections: ComparisonSection[] = []; for (const category of CATEGORY_ORDER) { const rows: ComparisonRow[] = []; for (const archetype of comparable.filter(candidate => candidate.category === category)) { - // The headline count is one number for the whole table; an archetype whose - // ladder does not contain it is reported in the full sweep instead of being - // given a count of its own. - if (headlineCount === null || !archetype.nodeCounts.includes(headlineCount)) { - excluded.push({ - archetype: archetype.id, - reason: - headlineCount === null - ? 'no single node count qualified for the headline table on this backend' - : `its ladder does not contain the headline node count (${headlineCount}); see the full sweep`, - }); - - continue; - } - - const reference = byKey.get(cellKey(REFERENCE_ENGINE, REFERENCE_CONFIG, archetype.id, headlineCount)); - const cells: ComparisonCell[] = []; - - for (const competitor of competitors) { - const competitorCell = onBackend.find( - result => result.spec.engine === competitor && result.spec.archetype === archetype.id && result.spec.nodeCount === headlineCount, - ); + const loads = measuredLoads(archetype.id); + let published = 0; + + for (const count of loads) { + const reference = byKey.get(cellKey(REFERENCE_ENGINE, REFERENCE_CONFIG, archetype.id, count)); + const cells: ComparisonCell[] = []; + + for (const competitor of competitors) { + const competitorCell = onBackend.find( + result => result.spec.engine === competitor && result.spec.archetype === archetype.id && result.spec.nodeCount === count, + ); + + if (reference === undefined || competitorCell === undefined || !isComparable(reference) || !isComparable(competitorCell)) { + continue; + } + + const counters = (result: CellResult): StructuralCounters | null => (result.structural.drawCalls > 0 ? result.structural : null); + const mechanism = renderingMechanism(counters(reference), counters(competitorCell)); + + cells.push({ + competitor, + referenceMs: reference.cpuMsMedian, + referenceP95Ms: reference.cpuMsP95, + referenceGpuMs: reference.frameMsMedian, + referenceOverFrameBudget: exceedsFrameBudget(reference.cpuMsMedian), + competitorMs: competitorCell.cpuMsMedian, + competitorP95Ms: competitorCell.cpuMsP95, + competitorGpuMs: competitorCell.frameMsMedian, + competitorOverFrameBudget: exceedsFrameBudget(competitorCell.cpuMsMedian), + verdict: compareMedians(reference.cpuMsMedian, competitorCell.cpuMsMedian), + mechanism, + // The frame is the bracket the clock read on this backend, so the + // per-frame medians are the durations the check applies to. Each arm + // is checked against the grid of the session that measured it: the + // arms run in separate browser sessions, which need not share one. + timer: mergeTimerChecks([ + timerCheckOfRun([reference.cpuMsMedian], reference.clock?.resolutionMs ?? null), + timerCheckOfRun([competitorCell.cpuMsMedian], competitorCell.clock?.resolutionMs ?? null), + ]), + }); + } - if (reference === undefined || competitorCell === undefined || !isComparable(reference) || !isComparable(competitorCell)) { + // The mechanism rule: a row where NO competitor comparison could be + // evidenced does not enter the table. + if (cells.every(cell => cell.mechanism === null)) { continue; } - const counters = (result: CellResult): StructuralCounters | null => (result.structural.drawCalls > 0 ? result.structural : null); - const mechanism = renderingMechanism(counters(reference), counters(competitorCell)); - - cells.push({ - competitor, - referenceMs: reference.cpuMsMedian, - referenceP95Ms: reference.cpuMsP95, - referenceGpuMs: reference.frameMsMedian, - referenceOverFrameBudget: exceedsFrameBudget(reference.cpuMsMedian), - competitorMs: competitorCell.cpuMsMedian, - competitorP95Ms: competitorCell.cpuMsP95, - competitorGpuMs: competitorCell.frameMsMedian, - competitorOverFrameBudget: exceedsFrameBudget(competitorCell.cpuMsMedian), - verdict: compareMedians(reference.cpuMsMedian, competitorCell.cpuMsMedian), - mechanism, - }); + rows.push({ archetype: archetype.id, category: CATEGORY_TITLES[category], count, ...loadIdentity(archetype.id, count), cells }); + published += 1; } - // The mechanism rule: a row where NO competitor comparison could be - // evidenced does not enter the table. - if (cells.length === 0) { - excluded.push({ archetype: archetype.id, reason: 'no arm pair produced a comparable cell at the headline node count' }); - - continue; - } - - if (cells.every(cell => cell.mechanism === null)) { + if (published === 0) { excluded.push({ archetype: archetype.id, - reason: 'no structural mechanism could be evidenced for any arm pair (the arm reported no counters), so the row would be a number without a cause', + reason: + loads.length === 0 + ? 'not measured in this run' + : 'no arm pair produced a comparable cell with an evidenced structural mechanism at any measured load, so every row would be a number without a cause', }); - - continue; } - - rows.push({ archetype: archetype.id, category: CATEGORY_TITLES[category], count: headlineCount, cells }); } if (rows.length > 0) { @@ -368,11 +434,21 @@ const buildBackend = (backend: Backend, results: readonly CellResult[]): Backend competitorOverFrameBudget: exceedsFrameBudget(competitorCell.cpuMsMedian), verdict: compareMedians(reference.cpuMsMedian, competitorCell.cpuMsMedian), mechanism: null, + timer: mergeTimerChecks([ + timerCheckOfRun([reference.cpuMsMedian], reference.clock?.resolutionMs ?? null), + timerCheckOfRun([competitorCell.cpuMsMedian], competitorCell.clock?.resolutionMs ?? null), + ]), }); } if (cells.length > 0) { - webgl1.push({ archetype: archetype.id, category: CATEGORY_TITLES[archetype.category], count: headlineCount, cells }); + webgl1.push({ + archetype: archetype.id, + category: CATEGORY_TITLES[archetype.category], + count: headlineCount, + ...loadIdentity(archetype.id, headlineCount), + cells, + }); } } } @@ -406,7 +482,7 @@ export const buildRenderingComparison = (results: readonly CellResult[]): readon * suit an outcome, and that is still prevented: a row's count comes from its * ladder, and the timings only decide whether the largest rung survives. */ -export const buildPhysicsComparison = (results: readonly PhysicsCellResult[]): ComparisonSection => { +export const buildPhysicsComparison = (results: readonly PhysicsCellResult[], clock: ClockReport | null = null): ComparisonSection => { const competitors = [...new Set(results.map(result => result.spec.engine))].filter(engine => engine !== 'exojs-physics').sort(); // As in the rendering block: an archetype the run did not measure is absent // rather than empty, or every subset run would produce an empty table. @@ -415,49 +491,49 @@ export const buildPhysicsComparison = (results: readonly PhysicsCellResult[]): C const rows: ComparisonRow[] = []; for (const archetype of comparable) { - const count = chooseRowCount(archetype.bodyCounts, candidate => { - const cells = results.filter(result => result.spec.archetype === archetype.id && result.spec.bodyCount === candidate); - - // A count with NO cell for this archetype is not a valid candidate. - // Testing only "every cell is ok" would accept it, since an empty set - // satisfies that vacuously - which is how a run at one body count ended up - // choosing another and publishing nothing. - return cells.some(result => result.spec.engine === 'exojs-physics') && cells.every(result => isComparable(result)); - }); - - if (count === null) { - continue; - } + const loads = [ + ...new Set( + results.filter(result => result.spec.engine === 'exojs-physics' && result.spec.archetype === archetype.id).map(result => result.spec.bodyCount), + ), + ].sort((a, b) => a - b); + + for (const count of loads) { + const reference = results.find( + result => result.spec.engine === 'exojs-physics' && result.spec.archetype === archetype.id && result.spec.bodyCount === count, + ); + const cells: ComparisonCell[] = []; - const reference = results.find( - result => result.spec.engine === 'exojs-physics' && result.spec.archetype === archetype.id && result.spec.bodyCount === count, - ); - const cells: ComparisonCell[] = []; + for (const competitor of competitors) { + const competitorCell = results.find( + result => result.spec.engine === competitor && result.spec.archetype === archetype.id && result.spec.bodyCount === count, + ); - for (const competitor of competitors) { - const competitorCell = results.find( - result => result.spec.engine === competitor && result.spec.archetype === archetype.id && result.spec.bodyCount === count, - ); + if (reference === undefined || competitorCell === undefined || !isComparable(reference) || !isComparable(competitorCell)) { + continue; + } - if (reference === undefined || competitorCell === undefined || !isComparable(reference) || !isComparable(competitorCell)) { - continue; + cells.push({ + competitor, + referenceMs: reference.stepMsMedian, + referenceP95Ms: reference.stepMsP95, + referenceOverFrameBudget: exceedsFrameBudget(reference.stepMsMedian), + competitorMs: competitorCell.stepMsMedian, + competitorP95Ms: competitorCell.stepMsP95, + competitorOverFrameBudget: exceedsFrameBudget(competitorCell.stepMsMedian), + verdict: compareMedians(reference.stepMsMedian, competitorCell.stepMsMedian), + mechanism: physicsMechanism(reference.structural, competitorCell.structural), + // The clock bracketed a BATCH of steps, not one step: the published + // ms/step is that bracket divided by the batch size. Checking the + // quotient would compare a derived number against a grid it was never + // read on, and would mark a well-resolved cell as unresolved purely + // because the harness batched it. + timer: timerCheckOfRun([batchMsOf(reference), batchMsOf(competitorCell)], clock?.resolutionMs ?? null), + }); } - cells.push({ - competitor, - referenceMs: reference.stepMsMedian, - referenceP95Ms: reference.stepMsP95, - referenceOverFrameBudget: exceedsFrameBudget(reference.stepMsMedian), - competitorMs: competitorCell.stepMsMedian, - competitorP95Ms: competitorCell.stepMsP95, - competitorOverFrameBudget: exceedsFrameBudget(competitorCell.stepMsMedian), - verdict: compareMedians(reference.stepMsMedian, competitorCell.stepMsMedian), - mechanism: physicsMechanism(reference.structural, competitorCell.structural), - }); - } - - if (cells.some(cell => cell.mechanism !== null)) { - rows.push({ archetype: archetype.id, category: 'Physics', count, cells }); + if (cells.some(cell => cell.mechanism !== null)) { + rows.push({ archetype: archetype.id, category: 'Physics', count, ...loadIdentity(archetype.id, count), cells }); + } } } diff --git a/packages/exojs-bench/src/profile/document.ts b/packages/exojs-bench/src/profile/document.ts index eb2817903..48c2a32b3 100644 --- a/packages/exojs-bench/src/profile/document.ts +++ b/packages/exojs-bench/src/profile/document.ts @@ -41,6 +41,7 @@ const toRenderingStamp = (provenance: Provenance): RenderingStamp => ({ os: provenance.os, platformVersion: { ...provenance.platformVersion }, prerelease: { ...provenance.prerelease }, + clock: provenance.clock === null ? null : { ...provenance.clock }, flags: [...provenance.flags], headless: provenance.headless, software: provenance.software, diff --git a/packages/exojs-bench/src/profile/schema.ts b/packages/exojs-bench/src/profile/schema.ts index 662ce03a1..811cd3fd2 100644 --- a/packages/exojs-bench/src/profile/schema.ts +++ b/packages/exojs-bench/src/profile/schema.ts @@ -1,6 +1,7 @@ import type { AggregatedBackendComparison, AggregatedSection } from '../comparison/pooled'; import type { PhysicsClockReport } from '../physics/page/contract'; import type { Backend } from '../rendering/EngineAdapter'; +import type { ClockReport } from '../shared/clock'; import type { PlatformVersionStamp, PrereleaseStamp, RenderingBrowser } from '../shared/provenance'; /** @@ -54,9 +55,16 @@ export const BENCH_PROFILE_SCHEMA_VERSION = 7; * have since moved, and because the per-cell seed folds the body count in, a * moved rung is a different scene rather than the same one measured again. * - * Version 6 is still read. Version 7 only adds the GPU frame time beside each - * arm's CPU time, so every figure a version 6 document publishes still means - * what it meant; such a cell reports no GPU time rather than a wrong one. + * Version 6 is still read, and every figure it publishes still means what it + * meant. Version 7 adds four things beside those figures rather than changing + * any of them: the GPU frame time next to each arm's CPU time; the load each row + * was measured at, with the unit it is counted in, so an archetype measured at + * several loads publishes a row per load instead of one row at a single + * table-wide count; what the measuring page's clock resolved to, recorded per + * rendering stamp; and, per published comparison, what the timer check made of + * the two durations behind it. A version 6 document carries none of those, so a + * reader offers one load per row and states that the clock was not recorded, + * rather than inferring either. */ export const SUPPORTED_BENCH_PROFILE_SCHEMA_VERSIONS: readonly number[] = [6, BENCH_PROFILE_SCHEMA_VERSION]; @@ -202,6 +210,16 @@ export interface RenderingStamp { readonly software: boolean; /** Resolved WebGPU sprite-batch texture-slot tier; absent for a backend that negotiates none. */ readonly slotTier?: number; + /** + * What the clock of this backend's first measuring page resolved to, or `null` + * where no session opened. + * + * Provenance for the reader: a run opens one browser session per arm, so this + * describes the conditions rather than qualifying any one comparison. What + * qualifies a comparison is the per-cell check the published model carries, + * which was taken against the grid of the session that actually timed it. + */ + readonly clock: ClockReport | null; /** Engine version under test. */ readonly engineVersion: string; /** ISO-8601 timestamp of the run. */ diff --git a/packages/exojs-bench/src/rendering/EngineAdapter.ts b/packages/exojs-bench/src/rendering/EngineAdapter.ts index 6fbf67281..89ed5b696 100644 --- a/packages/exojs-bench/src/rendering/EngineAdapter.ts +++ b/packages/exojs-bench/src/rendering/EngineAdapter.ts @@ -1,3 +1,4 @@ +import type { ClockReport } from '../shared/clock'; import type { BaseCellResult } from '../shared/result'; /** Rendering backend under test. */ @@ -356,6 +357,16 @@ export interface CellResult extends BaseCellResult { readonly queueMsP95: number | null; /** Structural draw-call counters gathered while measuring this cell. */ readonly structural: StructuralCounters; + /** + * What the clock of the page this cell was measured in resolved to, or `null` + * where no page produced the cell. + * + * Recorded per cell rather than per backend because a rendering run opens one + * browser session per arm: a grid read from whichever page happened to be open + * would qualify cells it never timed. The comparison builder checks each + * measured duration against the grid it was actually read on. + */ + readonly clock: ClockReport | null; } /** Neutral contract an engine arm implements so the harness can drive it identically across arms. */ diff --git a/packages/exojs-bench/src/rendering/driver.ts b/packages/exojs-bench/src/rendering/driver.ts index a40408de7..f33ffb31b 100644 --- a/packages/exojs-bench/src/rendering/driver.ts +++ b/packages/exojs-bench/src/rendering/driver.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import type { Browser } from 'playwright'; import { chromium, webkit } from 'playwright'; +import type { ClockReport } from '../shared/clock'; import type { BaseProvenance, LibraryProvenance, PlatformDeclaration, PlatformVersionStamp, PrereleaseStamp, RenderingBrowser } from '../shared/provenance'; import { classifyPrerelease, @@ -74,6 +75,15 @@ export interface Provenance extends BaseProvenance { readonly headless: boolean; /** True when the adapter is a software rasterizer - timings are then untrusted. */ readonly software: boolean; + /** + * The clock grid the run's FIRST session observed, or `null` where no session + * opened. + * + * Provenance only. A run opens one session per arm, so this describes the + * conditions rather than qualifying any particular measurement; each cell + * carries the grid of the session that actually timed it. + */ + readonly clock: ClockReport | null; /** * Resolved WebGPU sprite-batch texture-slot tier for this run's adapter (8 / * 16 / 32), or `undefined` for the WebGL2 backend (whose batcher uses a fixed @@ -395,8 +405,15 @@ export const readWebGpuAdapter = async (page: import('playwright').Page, browser return { adapter, usable: true, note: '', slotTier }; }; -/** A cell that could not be measured: zeroed timings/structure, `unavailable` status, and an explanatory note. */ -const unavailableCell = (spec: CellSpec, note: string): CellResult => ({ +/** + * A cell that could not be measured: zeroed timings/structure, `unavailable` + * status, and an explanatory note. + * + * `clock` carries the session's grid where a session existed, so a cell that + * failed inside a live page is still attributed to the page it failed in. It is + * `null` only where no page produced the cell at all. + */ +const unavailableCell = (spec: CellSpec, note: string, clock: ClockReport | null = null): CellResult => ({ spec, cpuMsMedian: 0, cpuMsP95: 0, @@ -405,6 +422,7 @@ const unavailableCell = (spec: CellSpec, note: string): CellResult => ({ queueMsMedian: null, queueMsP95: null, structural: { drawCalls: 0, textureBinds: 0, bufferUploads: 0 }, + clock, status: 'unavailable', note, }); @@ -554,6 +572,10 @@ const runBackend = async (options: { // read once and reused across every arm's session (same GPU, same flags). let renderer: string | null = null; let webgpuIdentity: WebGpuIdentity | null = null; + // The first session's grid, kept for the backend's provenance line. It + // describes the run's conditions and is NOT what qualifies a cell: each cell + // carries the grid of the session that produced it. + let clock: ClockReport | null = null; // Read off the first session and reused: every session in a run launches the // same build, and a run with no session at all reports the absence rather than // an invented version. @@ -577,12 +599,19 @@ const runBackend = async (options: { await page.goto(baseUrl, { waitUntil: 'load' }); await page.waitForFunction(() => typeof globalThis.__runBaselineCell === 'function'); + // Probed in THIS page, before its cells run: the grid belongs to the + // browsing context, and this run opens one session per arm, so a value + // read from another session would qualify cells it never timed. + const sessionClock = await page.evaluate(() => globalThis.__probeClock!()); + + clock ??= sessionClock; + if (backend === 'webgpu') { webgpuIdentity ??= await readWebGpuAdapter(page, browserName); if (!webgpuIdentity.usable) { for (const cell of remaining) { - collect(unavailableCell(cell, webgpuIdentity.note)); + collect(unavailableCell(cell, webgpuIdentity.note, sessionClock)); } remaining = []; @@ -598,6 +627,7 @@ const runBackend = async (options: { unavailableCell( cell, `cell wedged the browser (no result after ${CELL_TIMEOUT_MS}ms — a mid-frame GPU-driver stall the in-page guards cannot interrupt); isolated as unavailable, browser relaunched for the arm's remaining cells`, + sessionClock, ), ); remaining = remaining.slice(1); @@ -626,6 +656,7 @@ const runBackend = async (options: { const platform = { browser: browserName, browserVersion, + clock, os: readOsRelease(), platformVersion: readPlatformVersion(options.platform), prerelease: classifyPrerelease({ browserVersion, declared: declaredPrereleaseOf(options.platform) }), @@ -882,6 +913,9 @@ export const profileCell = async (options: { prerelease: classifyPrerelease({ browserVersion, declared: declaredPrereleaseOf(options.platform) }), flags, headless: true, + // A CPU profile reports attributed self time, never a wall-clock + // figure anyone compares, so no grid is probed for it. + clock: null, engineVersion, timestamp: new Date().toISOString(), software: isSoftwareRenderer(adapter), diff --git a/packages/exojs-bench/src/rendering/page/harness.ts b/packages/exojs-bench/src/rendering/page/harness.ts index 1a7a4600e..0b1e92221 100644 --- a/packages/exojs-bench/src/rendering/page/harness.ts +++ b/packages/exojs-bench/src/rendering/page/harness.ts @@ -3,6 +3,8 @@ // engine module instances) but never runs during a matrix cell. import './timerProbe'; +import type { ClockReport } from '../../shared/clock'; +import { probeClock } from '../../shared/clock'; import { mutationSignature, selectMutationIndices } from '../../shared/mutation'; import { createCpuTimer, median, percentile, shouldAbort } from '../../shared/timing'; import { createExoJsAdapter } from '../adapters/exojs'; @@ -136,6 +138,18 @@ const HARD_FRAME_BUDGET_MS = FRAME_BUDGET_MS * 10; * remainder means the harness has a bug (a fractional draw call is nonsense), * so the raw totals are surfaced instead and flagged via the returned note. */ +/** + * This page's clock grid, probed once and reused by every cell the session + * measures. + * + * Probing per cell would cost a five-figure loop before each measurement and + * answer the same question every time: the coarsening is a property of the + * browsing context, which does not change between two cells of one session. + */ +let probedClock: ClockReport | null = null; + +const pageClock = (): ClockReport => (probedClock ??= probeClock()); + const perFrameStructural = (totals: StructuralCounters, frames: number): { structural: StructuralCounters; note: string | null } => { const draws = totals.drawCalls / frames; const binds = totals.textureBinds / frames; @@ -436,6 +450,7 @@ export const runCell = async (adapter: EngineAdapter, spec: CellSpec, canvas: HT queueMsMedian, queueMsP95, structural, + clock: pageClock(), status: exceeded ? 'exceeded' : 'ok', ...(note !== null && { note }), }; @@ -583,12 +598,20 @@ const profileDispose = (): void => { declare global { var __runBaselineCell: ((cell: CellSpec) => Promise) | undefined; + /** + * Reports what THIS page's clock resolves to. Exposed from the page rather + * than evaluated as a driver-side function so the probe runs in the same + * browsing context as the cells it qualifies: the coarsening depends on the + * context, and one session's grid says nothing about another's. + */ + var __probeClock: (() => ClockReport) | undefined; var __profileSetup: ((cell: CellSpec, warmupFrames: number) => Promise) | undefined; var __profileFrames: ((count: number) => number) | undefined; var __profileDispose: (() => void) | undefined; } globalThis.__runBaselineCell = runBaselineCell; +globalThis.__probeClock = probeClock; globalThis.__profileSetup = profileSetup; globalThis.__profileFrames = profileFrames; globalThis.__profileDispose = profileDispose; diff --git a/packages/exojs-bench/src/shared/timerCheck.ts b/packages/exojs-bench/src/shared/timerCheck.ts new file mode 100644 index 000000000..f8b5f437a --- /dev/null +++ b/packages/exojs-bench/src/shared/timerCheck.ts @@ -0,0 +1,66 @@ +/** + * Whether the clock a measurement was read on was fine enough for a comparison + * to be drawn from it. + * + * A duration only a few steps above the grid it was read on carries a + * quantisation error of the same order as the difference a comparison would + * claim, so the check runs before any factor is published and travels with the + * cell into the profile. It is evaluated per RUN, against that run's own clock, + * and only then merged: pooling the durations first lets a well-resolved + * repetition carry a limited one past the threshold, which is the reading this + * check exists to prevent. + */ + +/** + * How many of the clock's observed steps a duration has to span before a + * comparison built from it publishes a factor. + * + * A guard, chosen to be safely clear of the one- and two-step readings that a + * coarse clock produces, and applied to every library, browser and profile + * alike. It is not a standard and not a precision claim: clearing it means this + * one check did not trip, never that the comparison is accurate or + * statistically established. Every other check a cell passes still applies. + */ +export const MIN_RESOLVED_STEPS = 10; + +/** What the timer check established about a comparison. */ +export type TimerCheck = 'resolved' | 'limited' | 'unknown'; + +/** + * Whether both durations of one run stand far enough above the step that run's + * clock was observed to deliver. + * + * Each duration is checked on its own - the question is how large a reading is + * against the grid it was read on, not how far the two arms are apart. A run + * with no recorded step yields `unknown`, which is the absence of the check and + * never a pass. + * + * The durations must be the ones the clock actually BRACKETED. Where a harness + * times a batch of steps and divides, the batch duration is the reading and the + * per-step quotient is not: checking the quotient would compare a derived + * number against a grid it was never read on. + */ +export const timerCheckOfRun = (durations: ReadonlyArray, resolutionMs: number | null): TimerCheck => { + if (resolutionMs === null || !Number.isFinite(resolutionMs) || resolutionMs <= 0) return 'unknown'; + + const floor = resolutionMs * MIN_RESOLVED_STEPS; + const measured = durations.filter((ms): ms is number => ms !== null && Number.isFinite(ms)); + + return measured.some(ms => ms < floor) ? 'limited' : 'resolved'; +}; + +/** + * One verdict for a comparison from the verdicts of the runs behind it. + * + * A limitation any run established stands for the pooled figure, and a run + * whose clock was never recorded cannot lift it: missing information does not + * cancel an established one. Only a comparison whose every run cleared the + * check is reported as resolved. + */ +export const mergeTimerChecks = (checks: readonly TimerCheck[]): TimerCheck => { + if (checks.length === 0) return 'unknown'; + if (checks.includes('limited')) return 'limited'; + if (checks.includes('unknown')) return 'unknown'; + + return 'resolved'; +}; diff --git a/packages/exojs-bench/test/comparison.test.ts b/packages/exojs-bench/test/comparison.test.ts index 74cfba887..9a8ce76c1 100644 --- a/packages/exojs-bench/test/comparison.test.ts +++ b/packages/exojs-bench/test/comparison.test.ts @@ -10,6 +10,14 @@ import type { PhysicsReportData } from '../src/physics/report'; import type { Provenance } from '../src/rendering/driver'; import type { ArchetypeId, Backend, CellResult } from '../src/rendering/EngineAdapter'; import type { ReportData } from '../src/rendering/report'; +import type { ClockReport } from '../src/shared/clock'; +import type { TimerCheck } from '../src/shared/timerCheck'; + +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; /** A measured rendering cell, with everything the comparison does not read left at a neutral value. */ const cell = (options: { @@ -40,6 +48,7 @@ const cell = (options: { queueMsMedian: null, queueMsP95: null, structural: { drawCalls: options.drawCalls ?? 1, textureBinds: options.textureBinds ?? 1, bufferUploads: options.bufferUploads ?? 1 }, + clock: FINE_CLOCK, status: options.status ?? 'ok', }); @@ -66,6 +75,7 @@ const stamp = (engineVersion = '0.17.0', overrides: Partial = {}): P browser: 'chromium', browserVersion: '151.0.7922.34', os: 'win32 10.0.26200', + clock: FINE_CLOCK, platformVersion: { major: 11, source: 'detected', evidence: "os.release() reported '10.0.26200'" }, prerelease: { value: false, source: 'assumed-stable', evidence: 'no marker, none declared' }, flags: ['--force-device-scale-factor=1'], @@ -271,25 +281,55 @@ describe('buildRenderingComparison', () => { expect(blocks.map(block => block.backend)).toEqual(['webgl2']); }); - test('picks one count for the whole table and applies it to every row', () => { + test('publishes a row per measured load rather than collapsing onto one count', () => { const [block] = buildRenderingComparison(fixture()); - const counts = new Set(block!.sections.flatMap(section => section.rows.map(row => row.count))); + const rows = block!.sections.flatMap(section => section.rows); - expect(block!.headlineCount).toBe(5_000); - expect([...counts]).toEqual([5_000]); + expect(rows.filter(row => row.archetype === 'static-heavy').map(row => row.count)).toEqual([1_000, 5_000]); + expect(rows.filter(row => row.archetype === 'static-heavy').map(row => row.loadId)).toEqual(['1k', '5k']); + }); + + test('states the unit each load is counted in and marks the catalog headline', () => { + const [block] = buildRenderingComparison(fixture()); + const rows = block!.sections.flatMap(section => section.rows); + + expect(rows.find(row => row.archetype === 'static-heavy')!.unit).toBe('sprites'); + expect(rows.find(row => row.archetype === 'text-static')!.unit).toBe('labels'); + expect(rows.find(row => row.archetype === 'text-static' && row.count === 1_000)!.primary).toBe(true); + expect(rows.find(row => row.archetype === 'text-static' && row.count === 5_000)!.primary).toBe(false); }); test('computes each verdict from the two medians', () => { const [block] = buildRenderingComparison(fixture()); const rows = block!.sections.flatMap(section => section.rows); - const scaling = rows.find(row => row.archetype === 'static-heavy')!; - const text = rows.find(row => row.archetype === 'text-static')!; + const scaling = rows.find(row => row.archetype === 'static-heavy' && row.count === 5_000)!; + const text = rows.find(row => row.archetype === 'text-static' && row.count === 5_000)!; expect(scaling.cells[0]!.verdict.side).toBe('exojs'); expect(scaling.cells[0]!.verdict.structural).toBe(true); expect(text.cells[0]!.verdict.side).toBe('neither'); }); + test('qualifies each cell against the grid its own session read it on', () => { + const coarse: ClockReport = { resolutionMs: 0.5, crossOriginIsolated: false }; + const [block] = buildRenderingComparison([ + cell({ engine: 'exojs', archetype: 'static-heavy', nodeCount: 5_000, cpuMsMedian: 1 }), + { ...cell({ engine: 'pixi', config: 'default', archetype: 'static-heavy', nodeCount: 5_000, cpuMsMedian: 40, drawCalls: 50 }), clock: coarse }, + ]); + const row = block!.sections.flatMap(section => section.rows).find(entry => entry.archetype === 'static-heavy')!; + + // 40 ms clears a 0.5 ms grid, but the ExoJS arm's 1 ms does not stand ten + // steps above it, so the pair publishes no factor. + expect(row.cells[0]!.timer).toBe('resolved'); + + const [limited] = buildRenderingComparison([ + { ...cell({ engine: 'exojs', archetype: 'static-heavy', nodeCount: 5_000, cpuMsMedian: 1 }), clock: coarse }, + cell({ engine: 'pixi', config: 'default', archetype: 'static-heavy', nodeCount: 5_000, cpuMsMedian: 40, drawCalls: 50 }), + ]); + + expect(limited!.sections.flatMap(section => section.rows)[0]!.cells[0]!.timer).toBe('limited'); + }); + test('files rows under category sections and never emits a category row', () => { const [block] = buildRenderingComparison(fixture()); @@ -361,35 +401,48 @@ describe('buildPhysicsComparison', () => { physicsCell({ engine: 'matter-js', archetype, bodyCount, stepMsMedian: competitorMs, contactCount: 50 }), ]); - test('publishes a row per archetype at the top of that archetype own ladder', () => { + test('publishes a row per measured rung of the archetype own ladder', () => { const section = buildPhysicsComparison(sweep('box-stack', 1, 3)); - expect(section.rows).toHaveLength(1); - expect(section.rows[0]!.count).toBe(ladderOf('box-stack').at(-1)); - expect(section.rows[0]!.cells[0]!.verdict.side).toBe('exojs'); + expect(section.rows.map(row => row.count)).toEqual([...ladderOf('box-stack')]); + expect(section.rows.every(row => row.cells[0]!.verdict.side === 'exojs')).toBe(true); }); - test('gives each archetype the count from its OWN ladder, never one archetype the count of another', () => { + test('gives each archetype the counts from its OWN ladder, never one archetype the counts of another', () => { const section = buildPhysicsComparison([...sweep('box-stack', 1, 3), ...sweep('joints', 1, 3)]); - const counts = new Map(section.rows.map(row => [row.archetype, row.count])); + const counts = (archetype: string): number[] => section.rows.filter(row => row.archetype === archetype).map(row => row.count); - expect(counts.get('box-stack')).toBe(ladderOf('box-stack').at(-1)); - expect(counts.get('joints')).toBe(ladderOf('joints').at(-1)); + expect(counts('box-stack')).toEqual([...ladderOf('box-stack')]); + expect(counts('joints')).toEqual([...ladderOf('joints')]); // The two ladders share no rung, so a single table-wide count would have had // to publish one of these rows at the other's size, or publish neither. - expect(counts.get('box-stack')).not.toBe(counts.get('joints')); + expect(counts('box-stack').some(count => counts('joints').includes(count))).toBe(false); }); - test('lowers one archetype to its next rung without moving any other archetype', () => { + test('drops only the rung an arm failed at, leaving every other rung of every archetype', () => { const ladder = ladderOf('box-stack'); const results = [...sweep('box-stack', 1, 3), ...sweep('joints', 1, 3)].map(result => result.spec.archetype === 'box-stack' && result.spec.bodyCount === ladder.at(-1) ? { ...result, status: 'exceeded' as const } : result, ); const section = buildPhysicsComparison(results); - const counts = new Map(section.rows.map(row => [row.archetype, row.count])); + const counts = (archetype: string): number[] => section.rows.filter(row => row.archetype === archetype).map(row => row.count); + + expect(counts('box-stack')).toEqual(ladder.slice(0, -1)); + expect(counts('joints')).toEqual([...ladderOf('joints')]); + }); + + test('checks the timer against the batch the clock bracketed, not the per-step quotient', () => { + const coarse: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: false }; + // 0.002 ms per step is two steps of the grid and would read as limited on its + // own; the harness timed 20 of them at once, so the bracket was 0.040 ms. + const batched = sweep('box-stack', 0.002, 0.002).map(result => ({ ...result, stepsPerSample: 20 })); + + expect(buildPhysicsComparison(batched, coarse).rows[0]!.cells[0]!.timer).toBe('resolved'); + expect(buildPhysicsComparison(sweep('box-stack', 0.002, 0.002), coarse).rows[0]!.cells[0]!.timer).toBe('limited'); + }); - expect(counts.get('box-stack')).toBe(ladder.at(-2)); - expect(counts.get('joints')).toBe(ladderOf('joints').at(-1)); + test('reports an unrecorded clock as unknown rather than as a pass', () => { + expect(buildPhysicsComparison(sweep('box-stack', 1, 3)).rows[0]!.cells[0]!.timer).toBe('unknown'); }); test('publishes each arm p95 beside its median, and computes the verdict from the medians alone', () => { @@ -572,20 +625,25 @@ describe('aggregatePhysicsRuns', () => { expect(() => aggregatePhysicsRuns([run(2), older])).toThrow(/different machine/); }); - test('pools each archetype at the count its own ladder produced, and publishes its p95', () => { - const pooled = aggregatePhysicsRuns([run(3), run(3), run(3)]).section.rows[0]!; + test('pools every rung the ladder produced, keeping each load apart, and publishes its p95', () => { + const section = aggregatePhysicsRuns([run(3), run(3), run(3)]).section; - expect(pooled.count).toBe(boxStackLadder.at(-1)); - expect(pooled.cells[0]!.referenceP95Ms).toBe(1.2); - expect(pooled.cells[0]!.competitorP95Ms).toBeCloseTo(3.6, 10); + expect(section.rows.map(row => row.count)).toEqual([...boxStackLadder]); + expect(section.rows[0]!.cells[0]!.referenceP95Ms).toBe(1.2); + expect(section.rows[0]!.cells[0]!.competitorP95Ms).toBeCloseTo(3.6, 10); }); - test('rejects runs whose rows landed on different counts, which would pool medians of different scenes', () => { + test('drops a rung one run could not measure instead of pooling it with the rungs that stayed', () => { const lowered = physicsRun( run(3).results.map(result => (result.spec.bodyCount === boxStackLadder.at(-1) ? { ...result, status: 'exceeded' as const } : result)), ); + const section = aggregatePhysicsRuns([run(3), run(3), lowered]).section; + const top = section.rows.find(row => row.count === boxStackLadder.at(-1))!; - expect(() => aggregatePhysicsRuns([run(3), run(3), lowered])).toThrow(/at different counts/); + // The rung is still published - two runs measured it - but it can no longer + // claim a verdict, because one run placed the pair on no rung at all. + expect(top.cells[0]!.aggregate.runs).toBe(2); + expect(top.cells[0]!.aggregate.stable).toBe(false); }); test('recomputes the frame-budget mark from the pooled median, not from any one run', () => { @@ -680,10 +738,12 @@ describe('renderComparison', () => { ), ), }); - const row = document.split('\n').find(line => line.startsWith('| `box-stack`'))!; + const rows = document.split('\n').filter(line => line.startsWith('| `box-stack`')); + const row = rows.at(-1)!; expect(document).toContain('own body-count ladder'); expect(document).toContain('rows are not comparable with one another'); + expect(rows).toHaveLength(ladder.length); expect(row).toContain(`| ${String(ladder.at(-1))} |`); expect(row).toContain('20.000 ms'); expect(row).toContain('over the 16.7 ms frame'); diff --git a/packages/exojs-bench/test/profile.test.ts b/packages/exojs-bench/test/profile.test.ts index c45b05553..8416a5c94 100644 --- a/packages/exojs-bench/test/profile.test.ts +++ b/packages/exojs-bench/test/profile.test.ts @@ -26,6 +26,7 @@ const renderingStamp = (adapter: string, backend: RenderingStamp['backend'] = 'w os: '', platformVersion: WINDOWS_11, prerelease: { value: false, source: 'assumed-stable', evidence: 'no marker, none declared' }, + clock: { resolutionMs: 0.001, crossOriginIsolated: true }, flags: [], headless: true, software: false, diff --git a/packages/exojs-bench/test/report-merge.test.ts b/packages/exojs-bench/test/report-merge.test.ts index 4a9c0cdf0..aed039669 100644 --- a/packages/exojs-bench/test/report-merge.test.ts +++ b/packages/exojs-bench/test/report-merge.test.ts @@ -10,8 +10,15 @@ import { type PhysicsReportData, writePhysicsReport } from '../src/physics/repor import type { Provenance } from '../src/rendering/driver'; import type { ArchetypeId, Backend, CellResult } from '../src/rendering/EngineAdapter'; import { type ReportData, writeReport } from '../src/rendering/report'; +import type { ClockReport } from '../src/shared/clock'; import { mergeCellResults, mergeLibraries } from '../src/shared/report'; +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; + const cell = (options: { archetype: ArchetypeId; nodeCount: number; @@ -36,6 +43,7 @@ const cell = (options: { queueMsMedian: null, queueMsP95: null, structural: { drawCalls: 1, textureBinds: 1, bufferUploads: 1 }, + clock: FINE_CLOCK, status: options.status ?? 'ok', }); @@ -45,6 +53,7 @@ const stamp = (backend: Backend, timestamp: string): Provenance => ({ browser: 'chromium', browserVersion: '151.0.7922.34', os: 'win32 10.0.26200', + clock: FINE_CLOCK, platformVersion: { major: 11, source: 'detected', evidence: "os.release() reported '10.0.26200'" }, prerelease: { value: false, source: 'assumed-stable', evidence: 'no marker, none declared' }, flags: [], diff --git a/packages/exojs-bench/test/selection-and-hitching.test.ts b/packages/exojs-bench/test/selection-and-hitching.test.ts index ea76dfd71..59735cda8 100644 --- a/packages/exojs-bench/test/selection-and-hitching.test.ts +++ b/packages/exojs-bench/test/selection-and-hitching.test.ts @@ -1,6 +1,13 @@ import type { CellResult, CellSpec } from '../src/rendering/EngineAdapter'; import { isHitching } from '../src/rendering/report'; import { applySelection } from '../src/rendering/selection'; +import type { ClockReport } from '../src/shared/clock'; + +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; const cell = (overrides: Partial = {}): CellSpec => ({ engine: 'exojs', @@ -22,6 +29,7 @@ const result = (cpuMsMedian: number, cpuMsP95: number): CellResult => ({ queueMsMedian: null, queueMsP95: null, structural: { drawCalls: 0, textureBinds: 0, bufferUploads: 0 }, + clock: FINE_CLOCK, status: 'ok', }); diff --git a/packages/exojs-bench/test/structural-gate.test.ts b/packages/exojs-bench/test/structural-gate.test.ts index 3e0873579..0a2ff1158 100644 --- a/packages/exojs-bench/test/structural-gate.test.ts +++ b/packages/exojs-bench/test/structural-gate.test.ts @@ -13,6 +13,13 @@ import { recordBaseline, UNGUARDED_ARCHETYPES, } from '../src/rendering/structuralGate'; +import type { ClockReport } from '../src/shared/clock'; + +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; /** A measured cell with the counters the gate reads. */ const cell = (options: { @@ -40,6 +47,7 @@ const cell = (options: { queueMsMedian: null, queueMsP95: null, structural: { drawCalls: options.drawCalls, textureBinds: options.textureBinds ?? 0, bufferUploads: options.bufferUploads ?? 0 }, + clock: FINE_CLOCK, status: options.status ?? 'ok', ...(options.note !== undefined && { note: options.note }), }); diff --git a/packages/exojs-bench/test/timer-check.test.ts b/packages/exojs-bench/test/timer-check.test.ts new file mode 100644 index 000000000..4af313dbc --- /dev/null +++ b/packages/exojs-bench/test/timer-check.test.ts @@ -0,0 +1,80 @@ +/** + * The published-comparison guard against a clock that did not separate the two + * arms. + * + * A duration only a step or two above the grid `performance.now()` delivers on + * carries no ratio worth printing: the same scene measured again lands on the + * neighbouring step, and the factor built from it moves by a whole multiple. The + * check therefore refuses the comparison rather than the measurement - both + * times stay readable, and only the figure, the bar and the winner go. + * + * Clearing the check is not a precision claim. It establishes that this one + * guard did not trip; every other check a cell passes still applies. + */ + +import { describe, expect, it } from 'vitest'; + +import type { TimerCheck } from '../src/shared/timerCheck'; +import { mergeTimerChecks, timerCheckOfRun } from '../src/shared/timerCheck'; + +/** The step the WebKit build behind the published macOS profiles was observed to deliver. */ +const COARSE_STEP = 0.02; + +describe('timerCheckOfRun', () => { + it('refuses a run whose two durations sit on the same single step', () => { + expect(timerCheckOfRun([COARSE_STEP, COARSE_STEP], COARSE_STEP)).toBe('limited'); + }); + + it('refuses one step against two, which is a rounding artefact rather than a doubling', () => { + expect(timerCheckOfRun([COARSE_STEP, COARSE_STEP * 2], COARSE_STEP)).toBe('limited'); + }); + + it('refuses a run where only one arm sits near the step, however far the other is above it', () => { + expect(timerCheckOfRun([COARSE_STEP, 13.38], COARSE_STEP)).toBe('limited'); + expect(timerCheckOfRun([13.38, COARSE_STEP], COARSE_STEP)).toBe('limited'); + }); + + it('reports an unrecorded step as unknown rather than as a pass', () => { + expect(timerCheckOfRun([0.5, 1.5], null)).toBe('unknown'); + }); + + it('treats a zero or negative step as unknown, never as a clock of unlimited precision', () => { + expect(timerCheckOfRun([0.001, 0.002], 0)).toBe('unknown'); + expect(timerCheckOfRun([0.001, 0.002], -1)).toBe('unknown'); + }); + + it('passes a run whose durations both stand clear of its own step', () => { + expect(timerCheckOfRun([0.5, 1.5], COARSE_STEP)).toBe('resolved'); + }); +}); + +describe('mergeTimerChecks', () => { + /** + * Pooling the durations first would hide this: the pooled median is 0.300 ms + * and the coarsest step 0.020 ms, which clears the threshold, while the first + * run stood nine steps above its own clock and did not. + */ + it('refuses a comparison whose first run was limited even though the pooled figure would clear the coarsest step', () => { + const perRun = [timerCheckOfRun([0.18, 5], 0.02), timerCheckOfRun([0.3, 5], 0.005), timerCheckOfRun([0.3, 5], 0.005)]; + + expect(perRun).toStrictEqual(['limited', 'resolved', 'resolved']); + expect(mergeTimerChecks(perRun)).toBe('limited'); + }); + + it('does not let a run with no recorded step lift a limitation another run established', () => { + expect(mergeTimerChecks(['limited', 'unknown'])).toBe('limited'); + expect(mergeTimerChecks(['unknown', 'limited', 'resolved'])).toBe('limited'); + }); + + it('reports a comparison with any unrecorded run as unknown rather than resolved', () => { + expect(mergeTimerChecks(['resolved', 'unknown', 'resolved'])).toBe('unknown'); + }); + + it('reports resolved only where every run cleared the check', () => { + expect(mergeTimerChecks(['resolved', 'resolved', 'resolved'])).toBe('resolved'); + }); + + it('reports no runs at all as unknown', () => { + expect(mergeTimerChecks([])).toBe('unknown'); + }); +}); diff --git a/packages/exojs-bench/test/timing-gate.test.ts b/packages/exojs-bench/test/timing-gate.test.ts index ec0f63fb8..1b9635430 100644 --- a/packages/exojs-bench/test/timing-gate.test.ts +++ b/packages/exojs-bench/test/timing-gate.test.ts @@ -12,6 +12,13 @@ import { TIMING_THRESHOLD, timingCellId, } from '../src/rendering/timingGate'; +import type { ClockReport } from '../src/shared/clock'; + +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; /** A measured cell with the timings the gate reads. */ const cell = (options: { archetype: ArchetypeId; cpuMsMedian: number; cpuMsP95?: number; status?: 'ok' | 'exceeded' }): CellResult => ({ @@ -31,6 +38,7 @@ const cell = (options: { archetype: ArchetypeId; cpuMsMedian: number; cpuMsP95?: queueMsMedian: null, queueMsP95: null, structural: { drawCalls: 1, textureBinds: 0, bufferUploads: 0 }, + clock: FINE_CLOCK, status: options.status ?? 'ok', }); diff --git a/scripts/verify-bench-results.ts b/scripts/verify-bench-results.ts index 15e184540..d619cf166 100644 --- a/scripts/verify-bench-results.ts +++ b/scripts/verify-bench-results.ts @@ -78,6 +78,9 @@ const missingFields = (subject: unknown, fields: readonly string[], where: strin // `flags` is deliberately absent: a browser that takes no launch arguments // records an empty set, and demanding content there would push a run into // claiming flags it never passed. +/** First schema version whose rendering stamps record the clock their session read on. */ +const RENDERING_CLOCK_SINCE = 7; + const RENDERING_STAMP_FIELDS = [ 'backend', 'adapter', @@ -188,6 +191,48 @@ const HOST_FIELDS = ['cpu', 'cpuCount', 'os', 'platformVersion', 'arch'] as cons const PROFILE_FIELDS = ['slug', 'gpu', 'os', 'browser', 'platform', 'engineVersion', 'measuredAt', 'runs'] as const; const PLATFORM_FIELDS = ['name', 'version', 'versionSource', 'prerelease'] as const; +/** Fields every published row of a version 7 document carries; see {@link ROW_FIELDS_SINCE}. */ +const ROW_FIELDS = ['archetype', 'category', 'count', 'loadId', 'unit', 'primary', 'cells'] as const; + +/** Fields every published cell of a version 7 document carries. */ +const CELL_FIELDS = ['competitor', 'referenceMs', 'competitorMs', 'verdict', 'timer'] as const; + +/** + * First schema version whose rows state their load and whose cells state what + * the clock established about them. + * + * Checked rather than assumed: the fields are produced by the aggregation stage, + * and a stage that silently stopped emitting one would publish a document whose + * loads the page cannot tell apart and whose comparisons carry no timer + * qualification - which reads exactly like a document that was never meant to. + */ +const ROW_FIELDS_SINCE = 7; + +/** Every row and cell of one published section carries the fields its schema version promises. */ +const checkModelRows = (section: unknown, where: string, problems: string[]): void => { + const rows = isRecord(section) ? section['rows'] : undefined; + + if (!Array.isArray(rows)) { + problems.push(`${where}.rows is missing`); + + return; + } + + for (const [index, row] of rows.entries()) { + problems.push(...missingFields(row, [...ROW_FIELDS], `${where}.rows[${String(index)}]`)); + + const cells = isRecord(row) ? row['cells'] : undefined; + + if (!Array.isArray(cells)) { + continue; + } + + for (const [cellIndex, cell] of cells.entries()) { + problems.push(...missingFields(cell, [...CELL_FIELDS], `${where}.rows[${String(index)}].cells[${String(cellIndex)}]`)); + } + } +}; + /** * The operating system the file name claims. * @@ -343,6 +388,10 @@ const checkProfile = (path: string): string[] => { problems.push(...missingFields(stamp, [...RENDERING_STAMP_FIELDS], where)); checkRenderingStampShape(stamp, where, problems); + if (version >= RENDERING_CLOCK_SINCE && isRecord(stamp) && !('clock' in stamp)) { + problems.push(`${where}.clock is missing, so nothing states what the page's clock resolved to`); + } + if (isRecord(stamp) && typeof stamp['engineVersion'] === 'string') { engineVersions.add(stamp['engineVersion']); } @@ -351,6 +400,14 @@ const checkProfile = (path: string): string[] => { if (!Array.isArray(rendering['backends']) || rendering['backends'].length === 0) { problems.push('rendering.backends is missing or empty'); + } else if (version >= ROW_FIELDS_SINCE) { + for (const [index, block] of rendering['backends'].entries()) { + const sections = isRecord(block) ? block['sections'] : undefined; + + for (const [sectionIndex, section] of (Array.isArray(sections) ? sections : []).entries()) { + checkModelRows(section, `rendering.backends[${String(index)}].sections[${String(sectionIndex)}]`, problems); + } + } } armVersions.push(...checkLibraries(rendering, 'rendering', problems)); @@ -377,6 +434,8 @@ const checkProfile = (path: string): string[] => { if (!isRecord(physics['section'])) { problems.push('physics.section is missing'); + } else if (version >= ROW_FIELDS_SINCE) { + checkModelRows(physics['section'], 'physics.section', problems); } armVersions.push(...checkLibraries(physics, 'physics', problems)); diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts index 0f76c2024..df6f4c695 100644 --- a/site/src/lib/bench-profiles.ts +++ b/site/src/lib/bench-profiles.ts @@ -683,58 +683,16 @@ export const OUTCOME_LABELS: Readonly> = { }; /** - * How many of the clock's observed steps a duration has to span before a - * comparison built from it publishes a factor. - * - * A guard, chosen to be safely clear of the one- and two-step readings that a - * coarse clock produces, and applied to every library, browser and profile - * alike. It is not a standard and not a precision claim: clearing it means this - * one check did not trip, never that the comparison is accurate or - * statistically established. Every other check a cell passes still applies. - */ -const MIN_RESOLVED_STEPS = 10; - -/** What the timer check established about a comparison. */ -export type TimerCheck = 'resolved' | 'limited' | 'unknown'; - -/** - * Whether both durations of one run stand far enough above the step that run's - * clock was observed to deliver. - * - * Each duration is checked on its own - the question is how large a reading is - * against the grid it was read on, not how far the two arms are apart. A run - * with no recorded step yields `unknown`, which is the absence of the check and - * never a pass. - * - * This evaluates ONE run. A pooled comparison must merge the runs' results with - * {@link mergeTimerChecks} rather than run this against pooled medians: pooling - * the durations first lets a well-resolved repetition carry a limited one past - * the threshold, which is the reading the per-run check exists to prevent. - */ -export const timerCheckOfRun = (durations: readonly (number | null)[], resolutionMs: number | null): TimerCheck => { - if (resolutionMs === null || !Number.isFinite(resolutionMs) || resolutionMs <= 0) return 'unknown'; - - const floor = resolutionMs * MIN_RESOLVED_STEPS; - const measured = durations.filter((ms): ms is number => ms !== null && Number.isFinite(ms)); - - return measured.some(ms => ms < floor) ? 'limited' : 'resolved'; -}; - -/** - * One verdict for a comparison from the verdicts of the runs behind it. + * What the timer check established about a comparison. * - * A limitation any run established stands for the pooled figure, and a run - * whose clock was never recorded cannot lift it: missing information does not - * cancel an established one. Only a comparison whose every run cleared the - * check is reported as resolved. + * The check itself runs in the harness, per run and against the grid of the + * session that produced each cell, and its merged result travels in the profile. + * Recomputing it here is not possible and would not be right: the per-run + * durations and their per-session clocks are not in the published document, and + * pooled medians checked against a pooled grid is exactly the reading the + * per-run check exists to prevent. */ -export const mergeTimerChecks = (checks: readonly TimerCheck[]): TimerCheck => { - if (checks.length === 0) return 'unknown'; - if (checks.includes('limited')) return 'limited'; - if (checks.includes('unknown')) return 'unknown'; - - return 'resolved'; -}; +export type TimerCheck = 'resolved' | 'limited' | 'unknown'; /** * Which outcome a cell publishes. diff --git a/test/site/bench-timer-check.test.ts b/test/site/bench-timer-check.test.ts index 774bdacac..334133ef9 100644 --- a/test/site/bench-timer-check.test.ts +++ b/test/site/bench-timer-check.test.ts @@ -14,15 +14,7 @@ import { describe, expect, it } from 'vitest'; -import { - type CellOutcome, - mergeTimerChecks, - outcomeOf, - type ProfileCell, - SUMMARY_OF, - type TimerCheck, - timerCheckOfRun, -} from '../../site/src/lib/bench-profiles'; +import { type CellOutcome, outcomeOf, type ProfileCell, SUMMARY_OF, type TimerCheck } from '../../site/src/lib/bench-profiles'; /** A stable comparison carrying a timer verdict; every other field is the uninteresting default. */ const cellOf = (timer: TimerCheck | undefined, stable = true): ProfileCell => ({ @@ -45,68 +37,6 @@ const cellOf = (timer: TimerCheck | undefined, stable = true): ProfileCell => ({ }, }); -/** The step the WebKit build behind the published macOS profiles was observed to deliver. */ -const COARSE_STEP = 0.02; - -describe('timerCheckOfRun', () => { - it('refuses a run whose two durations sit on the same single step', () => { - expect(timerCheckOfRun([COARSE_STEP, COARSE_STEP], COARSE_STEP)).toBe('limited'); - }); - - it('refuses one step against two, which is a rounding artefact rather than a doubling', () => { - expect(timerCheckOfRun([COARSE_STEP, COARSE_STEP * 2], COARSE_STEP)).toBe('limited'); - }); - - it('refuses a run where only one arm sits near the step, however far the other is above it', () => { - expect(timerCheckOfRun([COARSE_STEP, 13.38], COARSE_STEP)).toBe('limited'); - expect(timerCheckOfRun([13.38, COARSE_STEP], COARSE_STEP)).toBe('limited'); - }); - - it('reports an unrecorded step as unknown rather than as a pass', () => { - expect(timerCheckOfRun([0.5, 1.5], null)).toBe('unknown'); - }); - - it('treats a zero or negative step as unknown, never as a clock of unlimited precision', () => { - expect(timerCheckOfRun([0.001, 0.002], 0)).toBe('unknown'); - expect(timerCheckOfRun([0.001, 0.002], -1)).toBe('unknown'); - }); - - it('passes a run whose durations both stand clear of its own step', () => { - expect(timerCheckOfRun([0.5, 1.5], COARSE_STEP)).toBe('resolved'); - }); -}); - -describe('mergeTimerChecks', () => { - /** - * Pooling the durations first would hide this: the pooled median is 0.300 ms - * and the coarsest step 0.020 ms, which clears the threshold, while the first - * run stood nine steps above its own clock and did not. - */ - it('refuses a comparison whose first run was limited even though the pooled figure would clear the coarsest step', () => { - const perRun = [timerCheckOfRun([0.18, 5], 0.02), timerCheckOfRun([0.3, 5], 0.005), timerCheckOfRun([0.3, 5], 0.005)]; - - expect(perRun).toStrictEqual(['limited', 'resolved', 'resolved']); - expect(mergeTimerChecks(perRun)).toBe('limited'); - }); - - it('does not let a run with no recorded step lift a limitation another run established', () => { - expect(mergeTimerChecks(['limited', 'unknown'])).toBe('limited'); - expect(mergeTimerChecks(['unknown', 'limited', 'resolved'])).toBe('limited'); - }); - - it('reports a comparison with any unrecorded run as unknown rather than resolved', () => { - expect(mergeTimerChecks(['resolved', 'unknown', 'resolved'])).toBe('unknown'); - }); - - it('reports resolved only where every run cleared the check', () => { - expect(mergeTimerChecks(['resolved', 'resolved', 'resolved'])).toBe('resolved'); - }); - - it('reports no runs at all as unknown', () => { - expect(mergeTimerChecks([])).toBe('unknown'); - }); -}); - describe('outcomeOf', () => { it('reports a timer-limited pair as such, ahead of any verdict the ladder reached', () => { expect(outcomeOf(cellOf('limited'))).toBe('timer-limited'); From e8999e8541183d7c018db4eccf532bac7f2002b2 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 05:18:21 +0200 Subject: [PATCH 10/15] feat(bench): add the fully-moving sprite scene and the layered fill scene Two scenes the matrix never had, both built from the sprite path every arm already implements. `dynamic-all` is `dynamic-heavy` with every leaf moving instead of 7.5 % of them. The existing row is the shape a real scene has - a few actors over a mostly still background - so the delta between the two is what the still 92.5 % costs once it stops being still. It is a new archetype rather than a raised mutation fraction on the old one, because both questions are worth publishing and changing the old one would silently invalidate every number measured under its name. `fill-layers` is a stack of translucent full-screen layers, the workload a parallax background plus a weather pass plus a few tint overlays adds up to. It shares its geometry with `overdraw`, which sweeps thousands of viewport-sized quads to find the fill ceiling - not something anything ships. This one sweeps 8 to 128 and fixes a low per-layer alpha, so every layer has to be composited rather than skipped by an occlusion policy. The geometry the two share used to be an archetype-id test repeated in each of the four arms, which is how an archetype added with the same shape under another name would have been laid out four different ways. It is a trait predicate now, like the text and mask questions beside it. The structural gate leaves `fill-layers` unguarded for the reason it already leaves `overdraw` unguarded: the fill is enormous under a software rasterizer and its draw structure is one call `static-heavy` guards already. Claude-Session: https://claude.ai/code/session_01PXSHGYhLZX3zbLbPFKQVmg --- .../exojs-bench/baselines/structural.json | 22 ++++++- .../src/rendering/EngineAdapter.ts | 20 +++++++ .../src/rendering/adapters/excalibur.ts | 11 +++- .../src/rendering/adapters/exojs.ts | 22 ++++++- .../src/rendering/adapters/phaser.ts | 11 +++- .../src/rendering/adapters/pixi.ts | 22 ++++++- .../exojs-bench/src/rendering/archetypes.ts | 60 +++++++++++++++++++ .../src/rendering/structuralGate.ts | 2 + packages/exojs-bench/src/rendering/traits.ts | 13 ++++ packages/exojs-bench/test/archetypes.test.ts | 30 ++++++++++ 10 files changed, 204 insertions(+), 9 deletions(-) diff --git a/packages/exojs-bench/baselines/structural.json b/packages/exojs-bench/baselines/structural.json index 4638e7791..74823dec7 100644 --- a/packages/exojs-bench/baselines/structural.json +++ b/packages/exojs-bench/baselines/structural.json @@ -1,6 +1,6 @@ { "recorded": { - "at": "2026-09-08T17:20:19.719Z", + "at": "2026-09-11T03:16:41.165Z", "engineVersion": "0.17.0", "adapter": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)" }, @@ -35,6 +35,16 @@ "textureBinds": 0, "bufferUploads": 1 }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "dynamic-all", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, { "engine": "exojs", "config": "current", @@ -235,6 +245,16 @@ "textureBinds": 0, "bufferUploads": 19 }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "dynamic-all", + "nodeCount": 200, + "drawCalls": 4, + "textureBinds": 0, + "bufferUploads": 7 + }, { "engine": "exojs", "config": "retained", diff --git a/packages/exojs-bench/src/rendering/EngineAdapter.ts b/packages/exojs-bench/src/rendering/EngineAdapter.ts index 89ed5b696..7c157b0cf 100644 --- a/packages/exojs-bench/src/rendering/EngineAdapter.ts +++ b/packages/exojs-bench/src/rendering/EngineAdapter.ts @@ -8,8 +8,10 @@ export type Backend = 'webgl2' | 'webgpu'; export type ArchetypeId = | 'static-heavy' | 'dynamic-heavy' + | 'dynamic-all' | 'deep-hierarchy' | 'overdraw' + | 'fill-layers' | 'batch-breaking' | 'batch-breaking-atlased' | 'split-screen' @@ -238,6 +240,24 @@ export interface ArchetypeSpec { * to them. */ readonly churn?: boolean; + /** + * When `true`, every leaf is stretched to the whole viewport and stacked at + * the origin, so the scene's cost is fill rather than node count. + * + * Read through {@link '../rendering/traits'.hasFullViewportLeaves} rather than + * by testing the archetype id in each arm, so every arm lays the scene out the + * same way. + */ + readonly fullViewportLeaves?: boolean; + /** + * Alpha every leaf carries, or `undefined` for opaque leaves. + * + * Meaningful together with {@link fullViewportLeaves}: a stack of + * viewport-sized quads is a blend workload only while each of them is + * translucent. Opaque, the cost depends on whatever occlusion policy each arm + * happens to have, which is a different comparison. + */ + readonly leafAlpha?: number; /** * Number of chained post-process filters applied to the scene root, or * `undefined` for the unfiltered scene every other archetype builds. diff --git a/packages/exojs-bench/src/rendering/adapters/excalibur.ts b/packages/exojs-bench/src/rendering/adapters/excalibur.ts index 3b4564013..c733b322d 100644 --- a/packages/exojs-bench/src/rendering/adapters/excalibur.ts +++ b/packages/exojs-bench/src/rendering/adapters/excalibur.ts @@ -3,7 +3,7 @@ import * as ex from 'excalibur'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; import { createDigitAtlasCanvas, createDistinctTextureCanvas, DIGIT_ALPHABET, DIGIT_CELL_HEIGHT, DIGIT_CELL_WIDTH, TEXT_FONT_SIZE } from '../sceneAssets'; -import { isChurning, isTextArchetype, isTextUpdating, textForLeaf, usesRenderTargets } from '../traits'; +import { hasFullViewportLeaves, isChurning, isTextArchetype, isTextUpdating, leafAlpha, textForLeaf, usesRenderTargets } from '../traits'; import { GRID_MARGIN, gridLayout, gridPosition, isScrolling, SPRITE_SIZE, VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from '../world'; /** @@ -192,7 +192,8 @@ export const createExcaliburAdapter = (): EngineAdapter => { // the position `world.ts` computes, so a change to the layout cannot move // one arm's scene without moving every arm's. const layout = gridLayout(nodeCount, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, GRID_MARGIN); - const overdraw = spec.id === 'overdraw'; + const overdraw = hasFullViewportLeaves(spec); + const alpha = leafAlpha(spec); // Shared, canonical mutation selection - the SAME helper every arm routes // through, so all arms select the byte-for-byte identical index set and the @@ -251,6 +252,12 @@ export const createExcaliburAdapter = (): EngineAdapter => { sprite.destSize = { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }; } + // A fixed leaf alpha is what makes a stack of full-viewport quads a + // blend workload: every layer has to be composited rather than skipped. + if (alpha < 1) { + sprite.opacity = alpha; + } + actor.graphics.use(sprite); return { actor, text: null }; diff --git a/packages/exojs-bench/src/rendering/adapters/exojs.ts b/packages/exojs-bench/src/rendering/adapters/exojs.ts index e6a02521f..a02ca44ef 100644 --- a/packages/exojs-bench/src/rendering/adapters/exojs.ts +++ b/packages/exojs-bench/src/rendering/adapters/exojs.ts @@ -29,7 +29,18 @@ import type { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; import { createDistinctTextureCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; -import { compositeBlurRadius, filterChainDepth, hasMaskMotion, isChurning, isTextArchetype, isTextUpdating, maskDepth, textForLeaf } from '../traits'; +import { + compositeBlurRadius, + filterChainDepth, + hasFullViewportLeaves, + hasMaskMotion, + isChurning, + isTextArchetype, + isTextUpdating, + leafAlpha, + maskDepth, + textForLeaf, +} from '../traits'; import { BLOOM_DOWNSCALE, cameraCenterAt, @@ -570,7 +581,8 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E // the size of the viewport, i.e. the pre-existing layout unchanged. const world = worldExtent(spec, VIEWPORT_WIDTH, VIEWPORT_HEIGHT); const layout = gridLayout(nodeCount, world.width, world.height, GRID_MARGIN); - const overdraw = spec.id === 'overdraw'; + const overdraw = hasFullViewportLeaves(spec); + const alpha = leafAlpha(spec); // Canonical, shared mutation selection: draw one RNG value per leaf in // index order and select when below `mutationFraction`. Using the shared @@ -670,6 +682,12 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E leaf.height = VIEWPORT_HEIGHT; } + // A fixed leaf alpha is what makes a stack of full-viewport quads a + // blend workload: every layer has to be composited rather than skipped. + if (alpha < 1) { + leaf.tint.a = alpha; + } + const { x, y } = leafPosition(i); leaf.setPosition(x, y); diff --git a/packages/exojs-bench/src/rendering/adapters/phaser.ts b/packages/exojs-bench/src/rendering/adapters/phaser.ts index c9ac89a2e..707fd242f 100644 --- a/packages/exojs-bench/src/rendering/adapters/phaser.ts +++ b/packages/exojs-bench/src/rendering/adapters/phaser.ts @@ -3,7 +3,7 @@ import * as Phaser from 'phaser'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; import { createDigitAtlasCanvas, createDistinctTextureCanvas, DIGIT_ALPHABET, DIGIT_CELL_HEIGHT, DIGIT_CELL_WIDTH, TEXT_FONT_SIZE } from '../sceneAssets'; -import { isChurning, isTextArchetype, isTextUpdating, textForLeaf, usesRenderTargets } from '../traits'; +import { hasFullViewportLeaves, isChurning, isTextArchetype, isTextUpdating, leafAlpha, textForLeaf, usesRenderTargets } from '../traits'; import { GRID_MARGIN, gridLayout, gridPosition, isScrolling, VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from '../world'; /** @@ -227,7 +227,8 @@ export const createPhaserAdapter = (): EngineAdapter => { // the position `world.ts` computes, so a change to the layout cannot move // one arm's scene without moving every arm's. const layout = gridLayout(nodeCount, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, GRID_MARGIN); - const overdraw = spec.id === 'overdraw'; + const overdraw = hasFullViewportLeaves(spec); + const alpha = leafAlpha(spec); // Shared, canonical mutation selection - the SAME helper every arm routes // through, so all arms select the byte-for-byte identical index set and the @@ -283,6 +284,12 @@ export const createPhaserAdapter = (): EngineAdapter => { sprite.setDisplaySize(VIEWPORT_WIDTH, VIEWPORT_HEIGHT); } + // A fixed leaf alpha is what makes a stack of full-viewport quads a + // blend workload: every layer has to be composited rather than skipped. + if (alpha < 1) { + sprite.setAlpha(alpha); + } + sprite.setPosition(x, y); return sprite; diff --git a/packages/exojs-bench/src/rendering/adapters/pixi.ts b/packages/exojs-bench/src/rendering/adapters/pixi.ts index ff74f78a4..405caffad 100644 --- a/packages/exojs-bench/src/rendering/adapters/pixi.ts +++ b/packages/exojs-bench/src/rendering/adapters/pixi.ts @@ -17,7 +17,18 @@ import { import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; import { createDistinctTextureCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; -import { compositeBlurRadius, filterChainDepth, hasMaskMotion, isChurning, isTextArchetype, isTextUpdating, maskDepth, textForLeaf } from '../traits'; +import { + compositeBlurRadius, + filterChainDepth, + hasFullViewportLeaves, + hasMaskMotion, + isChurning, + isTextArchetype, + isTextUpdating, + leafAlpha, + maskDepth, + textForLeaf, +} from '../traits'; import { BLOOM_DOWNSCALE, cameraCenterAt, @@ -304,7 +315,8 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine // the shared mutation selection below. const world = worldExtent(spec, VIEWPORT_WIDTH, VIEWPORT_HEIGHT); const layout = gridLayout(nodeCount, world.width, world.height, GRID_MARGIN); - const overdraw = spec.id === 'overdraw'; + const overdraw = hasFullViewportLeaves(spec); + const alpha = leafAlpha(spec); // Shared, canonical mutation selection - the SAME helper the ExoJS arm // routes through, so both arms select the byte-for-byte identical index set @@ -369,6 +381,12 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine sprite.height = VIEWPORT_HEIGHT; } + // A fixed leaf alpha is what makes a stack of full-viewport quads a + // blend workload: every layer has to be composited rather than skipped. + if (alpha < 1) { + sprite.alpha = alpha; + } + const { x, y } = leafPosition(i); sprite.position.set(x, y); diff --git a/packages/exojs-bench/src/rendering/archetypes.ts b/packages/exojs-bench/src/rendering/archetypes.ts index 664fd7861..054f47862 100644 --- a/packages/exojs-bench/src/rendering/archetypes.ts +++ b/packages/exojs-bench/src/rendering/archetypes.ts @@ -19,6 +19,22 @@ const GPU_BOUND_COUNTS = [1_000, 5_000, 25_000] as const; */ const TEXT_COUNTS = [200, 1_000, 5_000] as const; +/** + * Node counts for `dynamic-all`. Three steps spanning 100x, sharing 1k and 100k + * with the sprite ladder so the row can be read against `static-heavy` and + * `dynamic-heavy` at both ends of the sweep. The intermediate rungs the sprite + * ladder carries would only refine a slope this archetype states plainly. + */ +const DYNAMIC_ALL_COUNTS = [1_000, 10_000, 100_000] as const; + +/** + * Layer counts for `fill-layers`. The load is full-screen layers, not scene + * nodes, so the ladder is three steps of a few dozen rather than thousands: 8 is + * an ordinary parallax stack, 32 a heavy one, and 128 past anything that ships. + * At 1280x720 the top step already resolves the viewport 128 times over. + */ +const FILL_LAYER_COUNTS = [8, 32, 128] as const; + /** * Characters per text leaf across both text archetypes. Twelve is the length of * an ordinary label (a score, a name, a damage number) - long enough that layout @@ -73,6 +89,25 @@ export const ARCHETYPES: readonly ArchetypeSpec[] = [ mutationFraction: 0.075, cullingEnabled: false, }, + // EVERY leaf moves, every frame. `dynamic-heavy` builds the identical scene + // and moves 7.5 % of it, which is the shape a real scene has - a few actors + // over a mostly still background - so the delta between the two rows is what + // the remaining 92.5 % costs once it stops being still. + // + // It is a separate archetype rather than a raised `mutationFraction` on + // `dynamic-heavy` because the two answer different questions and both are + // worth publishing; changing the existing one would also silently invalidate + // every number measured under its name. + { + id: 'dynamic-all', + category: 'node-scaling', + crossArm: true, + nodeCounts: DYNAMIC_ALL_COUNTS, + nestingDepth: 4, + textureCount: 1, + mutationFraction: 1, + cullingEnabled: false, + }, { id: 'deep-hierarchy', category: 'node-scaling', @@ -99,6 +134,31 @@ export const ARCHETYPES: readonly ArchetypeSpec[] = [ textureCount: 1, mutationFraction: 0, cullingEnabled: false, + fullViewportLeaves: true, + }, + // The same geometry as `overdraw` at a workload a real scene reaches: a + // handful of translucent full-screen layers rather than thousands of them. + // `overdraw` sweeps 1k to 25k viewport-sized quads, which is a fill-rate + // ceiling probe and not something anything ships; this sweeps 8 to 128, the + // range a parallax background, a weather pass and a few tint overlays add up + // to. + // + // `leafAlpha: 0.05` is what makes it a blend workload: every layer has to be + // composited, and none of them can be skipped by an occlusion policy the way + // an opaque top layer could. The load is the LAYER COUNT rather than a node + // count, which is why it carries its own short ladder instead of the sprite + // one. + { + id: 'fill-layers', + category: 'fill-and-state', + crossArm: true, + nodeCounts: FILL_LAYER_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: false, + fullViewportLeaves: true, + leafAlpha: 0.05, }, // 40 textures: must exceed EVERY sprite-batcher slot ceiling any granted // backend/tier reaches, or the archetype silently stops breaking batches on diff --git a/packages/exojs-bench/src/rendering/structuralGate.ts b/packages/exojs-bench/src/rendering/structuralGate.ts index 766d5c9f1..134a456fd 100644 --- a/packages/exojs-bench/src/rendering/structuralGate.ts +++ b/packages/exojs-bench/src/rendering/structuralGate.ts @@ -36,6 +36,8 @@ export const UNGUARDED_ARCHETYPES: Readonly> 'text-dynamic': 'the cell aborts on a software rasterizer as too slow, so its counters cover an unpredictable number of frames', overdraw: 'its fill cost dominates the gate wall clock in software (stacked full-viewport quads are hundreds of millions of shaded pixels per frame) while its draw structure is a single call that static-heavy already guards', + 'fill-layers': + 'the same stacked full-viewport geometry as overdraw, and unguarded for the same reason: enormous fill in software, no draw structure static-heavy does not already guard', }; /** Identity of one guarded cell. */ diff --git a/packages/exojs-bench/src/rendering/traits.ts b/packages/exojs-bench/src/rendering/traits.ts index d1ebd1638..b1a67ad3f 100644 --- a/packages/exojs-bench/src/rendering/traits.ts +++ b/packages/exojs-bench/src/rendering/traits.ts @@ -14,6 +14,19 @@ import type { ArchetypeSpec } from './EngineAdapter'; /** Whether the archetype's leaves are text nodes rather than sprites. */ export const isTextArchetype = (spec: ArchetypeSpec): boolean => (spec.textGlyphsPerNode ?? 0) > 0; +/** + * Whether every leaf is stretched to the whole viewport and stacked at the + * origin, which makes the scene fill-bound rather than node-bound. + * + * A predicate rather than an archetype-id check in each adapter: the arms used + * to test the id separately, and an archetype added with the same geometry under + * a different name would have been laid out four different ways. + */ +export const hasFullViewportLeaves = (spec: ArchetypeSpec): boolean => spec.fullViewportLeaves === true; + +/** Per-leaf alpha the archetype fixes, or `1` where it leaves the leaves opaque. */ +export const leafAlpha = (spec: ArchetypeSpec): number => spec.leafAlpha ?? 1; + /** Whether the per-frame mutation re-sets each selected text leaf's string. */ export const isTextUpdating = (spec: ArchetypeSpec): boolean => isTextArchetype(spec) && spec.textUpdate === true; diff --git a/packages/exojs-bench/test/archetypes.test.ts b/packages/exojs-bench/test/archetypes.test.ts index 3289d7680..10b010d41 100644 --- a/packages/exojs-bench/test/archetypes.test.ts +++ b/packages/exojs-bench/test/archetypes.test.ts @@ -1,6 +1,7 @@ import { createExoJsAdapter } from '../src/rendering/adapters/exojs'; import { ARCHETYPES, buildMatrix, createRng, timedFramesFor, warmupFramesFor } from '../src/rendering/archetypes'; import type { Backend, EngineAdapter } from '../src/rendering/EngineAdapter'; +import { hasFullViewportLeaves, leafAlpha } from '../src/rendering/traits'; describe('createRng', () => { test('is deterministic for a given seed', () => { @@ -71,6 +72,35 @@ describe('ARCHETYPES', () => { expect(byId['dynamic-heavy']!.mutationFraction).toBeGreaterThan(0); }); + test('dynamic-all moves every leaf while dynamic-heavy keeps moving only a few', () => { + const byId = Object.fromEntries(ARCHETYPES.map(a => [a.id, a])); + const all = byId['dynamic-all']!; + const heavy = byId['dynamic-heavy']!; + + expect(all.mutationFraction).toBe(1); + // The delta between the two rows is only the mutated fraction, so every + // other field has to stay identical. + expect(heavy.mutationFraction).toBe(0.075); + expect(all.nestingDepth).toBe(heavy.nestingDepth); + expect(all.textureCount).toBe(heavy.textureCount); + expect(all.cullingEnabled).toBe(heavy.cullingEnabled); + }); + + test('fill-layers stacks translucent viewport-sized layers on a ladder of its own', () => { + const byId = Object.fromEntries(ARCHETYPES.map(a => [a.id, a])); + const layers = byId['fill-layers']!; + + expect(hasFullViewportLeaves(layers)).toBe(true); + expect(leafAlpha(layers)).toBeLessThan(1); + // The load is full-screen layers, not scene nodes, so the sprite ladder + // would describe a workload nothing ships. + expect(layers.nodeCounts).toEqual([8, 32, 128]); + // `overdraw` shares the geometry and is deliberately opaque: it probes the + // fill ceiling, where this one probes an ordinary layered scene. + expect(hasFullViewportLeaves(byId['overdraw']!)).toBe(true); + expect(leafAlpha(byId['overdraw']!)).toBe(1); + }); + test('separates retained-recordable mesh switches from array-mesh repacking', () => { const byId = Object.fromEntries(ARCHETYPES.map(a => [a.id, a])); const staticMesh = byId['mixed-sprite-mesh-static']!; From dd1eb03836c4f6231df9dace99587c7bcf271c3a Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 06:22:06 +0200 Subject: [PATCH 11/15] feat(bench): compare the three engines on real tilemaps The one scene shape practically every 2D game has and that no sprite archetype describes: a world far larger than the viewport, drawn through a dedicated tile path rather than one node per tile. `scrolling-world` is not this test - it lays independent sprite nodes over a few viewports and its per-node cost is the finding, while here the world is a hundred thousand tiles, the visible window never changes size, and what is compared is each arm's tile path: an instanced chunk renderer, an imperatively painted quad buffer, a shader over a data texture. Two scenes. `tilemap-scroll` scrolls a fully populated map past a fixed window, so its ladder says how large a map an arm can hold rather than how much of it it draws. `tilemap-edit` replaces visible tile ids every frame, which is where the three paths differ most sharply, because each has to get the change to the GPU a different way. The editing scene holds its camera still, and that is a determinism requirement rather than a simplification. The harness may cut an arm's warmup short on wall clock, so two arms can reach the timed window having run a different number of frames; with a moving camera the edited cells move too, and the arms then draw worlds differing by whatever the extra frames edited. Holding the camera makes each frame set the same cells from that frame alone, and keeps the delta against the scrolling scene the edit cost by itself. Excalibur sits both scenes out. Its `TileMap` is a grid of tiles each holding its own graphics list, drawn through the ordinary graphics path - there is no dedicated tile submission path of the kind the other three arms are being compared on, and building one would mean writing the arm's missing feature rather than adapting to it. The driver can now capture each measured cell's final frame, which is what found two real defects in this work: the Pixi arm scrolled at double speed (the tile pipe combines the rendered root's transform with its own, so the scrolled container is nested now), and repainting one `Tilemap` instance twice renders nothing at all (an edited chunk gets a fresh instance). A cell that draws the wrong scene still produces a number, and that number looks like a result. --- .../exojs-bench/baselines/structural.json | 42 +- packages/exojs-bench/competitors/package.json | 1 + .../exojs-bench/competitors/pnpm-lock.yaml | 12 + packages/exojs-bench/package.json | 2 + packages/exojs-bench/src/comparison/build.ts | 2 + .../src/rendering/EngineAdapter.ts | 17 +- .../src/rendering/adapters/excalibur.ts | 11 +- .../src/rendering/adapters/exojs.ts | 133 ++- .../src/rendering/adapters/phaser.ts | 108 +- .../src/rendering/adapters/pixi.ts | 243 +++- .../exojs-bench/src/rendering/archetypes.ts | 58 + packages/exojs-bench/src/rendering/driver.ts | 44 +- .../exojs-bench/src/rendering/page/harness.ts | 42 +- .../exojs-bench/src/rendering/sceneAssets.ts | 36 + packages/exojs-bench/src/rendering/tilemap.ts | 187 +++ packages/exojs-bench/src/run.ts | 9 +- .../exojs-bench/test/scrolling-world.test.ts | 10 +- packages/exojs-bench/test/suite-plan.test.ts | 16 +- pnpm-lock.yaml | 1028 +++++++++++------ 19 files changed, 1625 insertions(+), 376 deletions(-) create mode 100644 packages/exojs-bench/src/rendering/tilemap.ts diff --git a/packages/exojs-bench/baselines/structural.json b/packages/exojs-bench/baselines/structural.json index 74823dec7..42eb9ad88 100644 --- a/packages/exojs-bench/baselines/structural.json +++ b/packages/exojs-bench/baselines/structural.json @@ -1,6 +1,6 @@ { "recorded": { - "at": "2026-09-11T03:16:41.165Z", + "at": "2026-09-11T04:21:15.092Z", "engineVersion": "0.17.0", "adapter": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)" }, @@ -215,6 +215,26 @@ "textureBinds": 0, "bufferUploads": 0 }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "tilemap-edit", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 0 + }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "tilemap-scroll", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 0 + }, { "engine": "exojs", "config": "retained", @@ -424,6 +444,26 @@ "drawCalls": 4, "textureBinds": 0, "bufferUploads": 12 + }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "tilemap-edit", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 0 + }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "tilemap-scroll", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 0 } ] } diff --git a/packages/exojs-bench/competitors/package.json b/packages/exojs-bench/competitors/package.json index f4e71b832..1d88bb78a 100644 --- a/packages/exojs-bench/competitors/package.json +++ b/packages/exojs-bench/competitors/package.json @@ -5,6 +5,7 @@ "description": "Pinned exact-version competitor libraries for @codexo/exojs-bench (Pixi, Phaser, Excalibur, matter-js, planck, nape-js, rapier2d-compat arms). Deliberately NOT a member of the repository workspace: a plain root `pnpm install` never resolves or downloads anything here, so a normal contributor pays zero weight for competitor libraries. Only `pnpm --filter @codexo/exojs-bench bench:setup` installs this folder - as its own workspace root (see pnpm-workspace.yaml beside this file), which applies the same minimumReleaseAge supply-chain quarantine as the repository workspace - and links the results into ../node_modules so the adapters' plain `import 'pixi.js'` (etc.) resolve unmodified.", "dependencies": { "pixi.js": "8.19.0", + "@pixi/tilemap": "5.0.2", "phaser": "4.2.1", "excalibur": "0.32.0", "matter-js": "0.20.0", diff --git a/packages/exojs-bench/competitors/pnpm-lock.yaml b/packages/exojs-bench/competitors/pnpm-lock.yaml index 039c1c61b..39a5e96da 100644 --- a/packages/exojs-bench/competitors/pnpm-lock.yaml +++ b/packages/exojs-bench/competitors/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@newkrok/nape-js': specifier: 3.42.0 version: 3.42.0 + '@pixi/tilemap': + specifier: 5.0.2 + version: 5.0.2(pixi.js@8.19.0) '@types/matter-js': specifier: 0.20.2 version: 0.20.2 @@ -45,6 +48,11 @@ packages: '@pixi/colord@2.9.6': resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + '@pixi/tilemap@5.0.2': + resolution: {integrity: sha512-J+K1eU7uB58WPVD33d7usXrhKA03IGq3IfPvXBFr1027Bb9ScxrGhH0NomR9VpVVUPiHkK7FcTGuOtJMVGAv6A==} + peerDependencies: + pixi.js: '>=8.5.0' + '@types/earcut@3.0.0': resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} @@ -111,6 +119,10 @@ snapshots: '@pixi/colord@2.9.6': {} + '@pixi/tilemap@5.0.2(pixi.js@8.19.0)': + dependencies: + pixi.js: 8.19.0 + '@types/earcut@3.0.0': {} '@types/matter-js@0.20.2': {} diff --git a/packages/exojs-bench/package.json b/packages/exojs-bench/package.json index a062fd8aa..2ff036e36 100644 --- a/packages/exojs-bench/package.json +++ b/packages/exojs-bench/package.json @@ -21,7 +21,9 @@ "devDependencies": { "@codexo/exojs": "workspace:*", "@codexo/exojs-config": "workspace:*", + "@codexo/exojs-particles": "workspace:*", "@codexo/exojs-physics": "workspace:*", + "@codexo/exojs-tilemap": "workspace:*", "@types/node": "^26.4.0", "@webgpu/types": "^0.1.69", "playwright": "^1.62.1", diff --git a/packages/exojs-bench/src/comparison/build.ts b/packages/exojs-bench/src/comparison/build.ts index 573aed353..de885a6d5 100644 --- a/packages/exojs-bench/src/comparison/build.ts +++ b/packages/exojs-bench/src/comparison/build.ts @@ -52,6 +52,7 @@ const CATEGORY_ORDER: readonly ArchetypeCategory[] = [ 'text', 'render-targets', 'camera-and-world', + 'tilemaps', 'submission', ]; @@ -63,6 +64,7 @@ const CATEGORY_TITLES: Readonly> = { text: 'Text', 'render-targets': 'Render targets', 'camera-and-world': 'Camera and world', + tilemaps: 'Tilemaps', submission: 'Submission paths', }; diff --git a/packages/exojs-bench/src/rendering/EngineAdapter.ts b/packages/exojs-bench/src/rendering/EngineAdapter.ts index 7c157b0cf..2ef5bc67c 100644 --- a/packages/exojs-bench/src/rendering/EngineAdapter.ts +++ b/packages/exojs-bench/src/rendering/EngineAdapter.ts @@ -12,6 +12,8 @@ export type ArchetypeId = | 'deep-hierarchy' | 'overdraw' | 'fill-layers' + | 'tilemap-scroll' + | 'tilemap-edit' | 'batch-breaking' | 'batch-breaking-atlased' | 'split-screen' @@ -38,7 +40,8 @@ export type ArchetypeId = * several archetypes into one number, and any average over them hides the worst * cell. Nothing in the report aggregates across archetypes. */ -export type ArchetypeCategory = 'node-scaling' | 'fill-and-state' | 'material-variety' | 'text' | 'render-targets' | 'camera-and-world' | 'submission'; +export type ArchetypeCategory = + 'node-scaling' | 'fill-and-state' | 'material-variety' | 'text' | 'render-targets' | 'camera-and-world' | 'submission' | 'tilemaps'; /** Structural definition of a scene archetype, independent of any engine or backend. */ export interface ArchetypeSpec { @@ -258,6 +261,18 @@ export interface ArchetypeSpec { * happens to have, which is a different comparison. */ readonly leafAlpha?: number; + /** + * Renders a tilemap instead of a sprite scene, and whether the scene also + * edits tiles. + * + * `'scroll'` scrolls a fully-populated map past a fixed viewport; `'edit'` does + * the same and additionally replaces a fixed number of VISIBLE tile ids every + * frame, so the arm has to submit the change before it draws. The node count is + * the map's total tile count, not its visible one - a large world with a small + * window is the point, and `tilemap.ts` maps the count onto the map's + * dimensions. + */ + readonly tilemap?: 'scroll' | 'edit'; /** * Number of chained post-process filters applied to the scene root, or * `undefined` for the unfiltered scene every other archetype builds. diff --git a/packages/exojs-bench/src/rendering/adapters/excalibur.ts b/packages/exojs-bench/src/rendering/adapters/excalibur.ts index c733b322d..fb0e08f0f 100644 --- a/packages/exojs-bench/src/rendering/adapters/excalibur.ts +++ b/packages/exojs-bench/src/rendering/adapters/excalibur.ts @@ -3,6 +3,7 @@ import * as ex from 'excalibur'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; import { createDigitAtlasCanvas, createDistinctTextureCanvas, DIGIT_ALPHABET, DIGIT_CELL_HEIGHT, DIGIT_CELL_WIDTH, TEXT_FONT_SIZE } from '../sceneAssets'; +import { isTilemap } from '../tilemap'; import { hasFullViewportLeaves, isChurning, isTextArchetype, isTextUpdating, leafAlpha, textForLeaf, usesRenderTargets } from '../traits'; import { GRID_MARGIN, gridLayout, gridPosition, isScrolling, SPRITE_SIZE, VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from '../world'; @@ -127,7 +128,15 @@ export const createExcaliburAdapter = (): EngineAdapter => { // equivalent API at all: its `PostProcessor` chain is a full-SCREEN pass // rather than a filtered subtree, and it ships no mask source, so those // cells could only be approximated - which the fairness rule forbids. - return !isScrolling(spec) && !usesRenderTargets(spec); + // + // The tilemap archetypes are sat out for a capability reason rather than a + // policy one. Excalibur 0.32's `TileMap` is a grid of `Tile`s each holding + // its own graphics list, drawn through the ordinary graphics path - there + // is no dedicated tile submission path of the kind the other three arms + // are being compared on, so its cell would answer a different question + // than the row it sat in. Building one here would mean writing the arm's + // missing feature rather than adapting to it. + return !isScrolling(spec) && !usesRenderTargets(spec) && !isTilemap(spec); }, async init(canvas: HTMLCanvasElement, target: Backend): Promise { diff --git a/packages/exojs-bench/src/rendering/adapters/exojs.ts b/packages/exojs-bench/src/rendering/adapters/exojs.ts index a02ca44ef..2a4846087 100644 --- a/packages/exojs-bench/src/rendering/adapters/exojs.ts +++ b/packages/exojs-bench/src/rendering/adapters/exojs.ts @@ -1,3 +1,5 @@ +import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileSet } from '@codexo/exojs-tilemap'; + import { Application } from '#core/Application'; import { Color } from '#core/Color'; import { Matrix } from '#math/Matrix'; @@ -22,13 +24,26 @@ import { Sprite } from '#rendering/sprite/Sprite'; import { Text } from '#rendering/text/Text'; import { RenderTexture } from '#rendering/texture/RenderTexture'; import { Texture } from '#rendering/texture/Texture'; +import { TextureRegion } from '#rendering/texture/TextureRegion'; import { BlendModes } from '#rendering/types'; import { View } from '#rendering/View'; import type { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; -import { createDistinctTextureCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; +import { createDistinctTextureCanvas, createTileAtlasCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; +import type { TilemapExtent } from '../tilemap'; +import { + isTilemap, + isTilemapEditing, + TILE_SIZE, + TILE_VARIANTS, + tileIdAt, + tilemapCameraAt, + tilemapCameraFrameFor, + tilemapEditsAt, + tilemapExtent, +} from '../tilemap'; import { compositeBlurRadius, filterChainDepth, @@ -467,6 +482,87 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E views = []; }; + /** The tile layer the tilemap scenes paint into, or `null` for every other archetype. */ + let tileLayer: TileLayer | null = null; + + /** The tileset the tile layer draws from, kept so teardown releases its texture. */ + let tileTexture: Texture | null = null; + + /** The tilemap scene's root node, and the map extent its camera is bounded by. */ + let tilemapNode: TileMapNode | null = null; + let tilemapSpec: ArchetypeSpec | null = null; + let tilemapMapExtent: TilemapExtent = { width: 0, height: 0 }; + + /** + * Build the tilemap scene: one fully-populated layer over a single-page + * tileset, rendered through the package's own chunk renderer. + * + * Every tile is written at build time, outside the timed window - the scenes + * compare drawing and editing a populated map, not populating one. The camera + * is the View's, as it is for `scrolling-world`: the engine has a real camera, + * and its rect is what the chunk culling and the retained products are keyed + * on. + */ + const buildTilemapScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const extent = tilemapExtent(nodeCount); + const texture = new Texture(createTileAtlasCanvas()); + const tileset = new TileSet({ + name: 'tiles', + texture: new TextureRegion(texture, { x: 0, y: 0, width: TILE_SIZE * TILE_VARIANTS, height: TILE_SIZE }), + tileWidth: TILE_SIZE, + tileHeight: TILE_SIZE, + tileCount: TILE_VARIANTS, + }); + const layer = new TileLayer({ + id: 1, + name: 'ground', + width: extent.width, + height: extent.height, + tileWidth: TILE_SIZE, + tileHeight: TILE_SIZE, + tilesets: [tileset], + }); + + for (let y = 0; y < extent.height; y += 1) { + for (let x = 0; x < extent.width; x += 1) { + layer.setTileAt(x, y, { tileset, localTileId: tileIdAt(x, y), transform: TILE_TRANSFORM_IDENTITY }); + } + } + + const map = new TileMap({ + name: 'benchmark', + width: extent.width, + height: extent.height, + tileWidth: TILE_SIZE, + tileHeight: TILE_SIZE, + tilesets: [tileset], + layers: [layer], + }); + + root = new Container(); + tilemapNode = new TileMapNode(map); + root.addChild(tilemapNode); + + tileLayer = layer; + tileTexture = texture; + tilemapSpec = spec; + tilemapMapExtent = extent; + + const start = tilemapCameraAt(0, extent); + + app!.rendering.view.setCenter(start.x + VIEWPORT_WIDTH / 2, start.y + VIEWPORT_HEIGHT / 2); + }; + + /** Drop the tilemap scene so a rebuild (or teardown) leaks no GPU resources. */ + const releaseTilemap = (): void => { + tilemapNode?.destroy(); + tilemapNode = null; + tileTexture?.destroy(); + tileTexture = null; + tileLayer = null; + tilemapSpec = null; + }; + return { engine: 'exojs', config, @@ -491,6 +587,11 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E backend: { type: backend }, clearColor: Color.black, hello: false, + // Registered for every cell, not only the tilemap ones: the extension + // contributes renderer bindings rather than scene work, and an engine + // configured differently per archetype would make two archetypes' + // numbers describe two engines. + extensions: [tilemapExtension], }); // Boot the full production init path (awaits the backend's async @@ -517,9 +618,18 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E releaseBatchScene(); releaseComposite(); + releaseTilemap(); sharedMeshGeometry?.destroy(); sharedMeshGeometry = null; + // The tilemap scenes leave the sprite path behind: the leaves are tiles in + // a packed layer rather than nodes, so nothing below applies to them. + if (isTilemap(spec)) { + buildTilemapScene(spec, nodeCount); + + return; + } + // `instanced-batch` leaves the scene graph behind entirely: nodeCount // instances are laid out on the same grid every other archetype uses, but // submitted as ceil(nodeCount / batchSize) explicit drawBatch calls over one @@ -785,6 +895,26 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E }, mutate(frame: number): void { + // Tilemap scenes: move the window, then submit this frame's tile changes. + // Both belong in the bracket - scrolling and editing ARE the per-frame work + // these scenes do, and an edit an arm defers past the draw would not be an + // edit the frame paid for. + if (tilemapSpec !== null && app !== null && tileLayer !== null) { + const camera = tilemapCameraAt(tilemapCameraFrameFor(tilemapSpec, frame), tilemapMapExtent); + + app.rendering.view.setCenter(camera.x + VIEWPORT_WIDTH / 2, camera.y + VIEWPORT_HEIGHT / 2); + + if (isTilemapEditing(tilemapSpec)) { + const tileset = tileLayer.tilesets[0]!; + + for (const edit of tilemapEditsAt(frame, tilemapMapExtent)) { + tileLayer.setTileAt(edit.x, edit.y, { tileset, localTileId: edit.tileId, transform: TILE_TRANSFORM_IDENTITY }); + } + } + + return; + } + // Camera step for a scrolling archetype. Both this and the wobble below // run inside the harness's CPU bracket, which is correct: moving the // camera IS the per-frame work such a scene does. @@ -917,6 +1047,7 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E teardown(): void { releaseBatchScene(); releaseComposite(); + releaseTilemap(); if (root !== null) { root.destroy(); diff --git a/packages/exojs-bench/src/rendering/adapters/phaser.ts b/packages/exojs-bench/src/rendering/adapters/phaser.ts index 707fd242f..330901d15 100644 --- a/packages/exojs-bench/src/rendering/adapters/phaser.ts +++ b/packages/exojs-bench/src/rendering/adapters/phaser.ts @@ -2,7 +2,17 @@ import * as Phaser from 'phaser'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; -import { createDigitAtlasCanvas, createDistinctTextureCanvas, DIGIT_ALPHABET, DIGIT_CELL_HEIGHT, DIGIT_CELL_WIDTH, TEXT_FONT_SIZE } from '../sceneAssets'; +import { + createDigitAtlasCanvas, + createDistinctTextureCanvas, + createTileAtlasCanvas, + DIGIT_ALPHABET, + DIGIT_CELL_HEIGHT, + DIGIT_CELL_WIDTH, + TEXT_FONT_SIZE, +} from '../sceneAssets'; +import type { TilemapExtent } from '../tilemap'; +import { isTilemap, isTilemapEditing, TILE_SIZE, tileIdAt, tilemapCameraAt, tilemapCameraFrameFor, tilemapEditsAt, tilemapExtent } from '../tilemap'; import { hasFullViewportLeaves, isChurning, isTextArchetype, isTextUpdating, leafAlpha, textForLeaf, usesRenderTargets } from '../traits'; import { GRID_MARGIN, gridLayout, gridPosition, isScrolling, VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from '../world'; @@ -112,6 +122,67 @@ export const createPhaserAdapter = (): EngineAdapter => { /** Characters per text leaf of the built archetype; `0` when it has no text. */ let textGlyphs = 0; + /** The tilemap scene's layer and map, or `null` for every other archetype. */ + let tileLayer: Phaser.Tilemaps.TilemapGPULayer | null = null; + let tileMap: Phaser.Tilemaps.Tilemap | null = null; + let tilemapSpec: ArchetypeSpec | null = null; + let tileExtent: TilemapExtent = { width: 0, height: 0 }; + + /** + * Build the tilemap scene on Phaser's GPU tile layer. + * + * The GPU layer is the arm's fastest path for exactly this shape of work - one + * tileset, one orthographic grid, no per-tile game objects - and it is what a + * Phaser project would use here, so it is what the comparison measures. It + * renders the whole layer as a single quad over a data texture, which is why + * an edit has to be followed by regenerating that texture rather than being + * picked up on its own. + */ + const buildTilemapScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const extent = tilemapExtent(nodeCount); + const key = `${SCENE_KEY}-tiles`; + + if (game!.textures.exists(key)) { + game!.textures.remove(key); + } + + game!.textures.addCanvas(key, createTileAtlasCanvas()); + + const data: number[][] = []; + + for (let y = 0; y < extent.height; y += 1) { + const row = new Array(extent.width); + + for (let x = 0; x < extent.width; x += 1) { + row[x] = tileIdAt(x, y); + } + + data.push(row); + } + + const map = scene!.make.tilemap({ data, tileWidth: TILE_SIZE, tileHeight: TILE_SIZE }); + const tileset = map.addTilesetImage('tiles', key, TILE_SIZE, TILE_SIZE, 0, 0)!; + const layer = map.createLayer(0, tileset, 0, 0, true) as Phaser.Tilemaps.TilemapGPULayer; + + tileMap = map; + tileLayer = layer; + tilemapSpec = spec; + tileExtent = extent; + + const camera = tilemapCameraAt(0, extent); + + scene!.cameras.main.setScroll(camera.x, camera.y); + }; + + /** Drop the tilemap scene so a rebuild (or teardown) leaks nothing. */ + const releaseTilemap = (): void => { + tileLayer?.destroy(); + tileMap?.destroy(); + tileLayer = null; + tileMap = null; + tilemapSpec = null; + }; + return { engine: 'phaser', config: 'webgl2', @@ -188,6 +259,16 @@ export const createPhaserAdapter = (): EngineAdapter => { throw new Error('buildScene was called before init.'); } + releaseTilemap(); + + // The tilemap scenes leave the sprite path behind: the leaves are tiles in + // a layer rather than game objects, so nothing below applies to them. + if (isTilemap(spec)) { + buildTilemapScene(spec, nodeCount); + + return; + } + const textures = game.textures; textureKeys = []; @@ -323,6 +404,27 @@ export const createPhaserAdapter = (): EngineAdapter => { }, mutate(frame: number): void { + // Tilemap scenes: scroll the camera, then submit this frame's tile changes. + // The GPU layer reads its tiles from a data texture that does not follow a + // tile write on its own, so regenerating that texture is what actually + // submits the edit - and it belongs in the bracket for the same reason the + // other arms' repacking does. + if (tilemapSpec !== null && tileLayer !== null && tileMap !== null && scene !== null) { + const camera = tilemapCameraAt(tilemapCameraFrameFor(tilemapSpec, frame), tileExtent); + + scene.cameras.main.setScroll(camera.x, camera.y); + + if (isTilemapEditing(tilemapSpec)) { + for (const edit of tilemapEditsAt(frame, tileExtent)) { + tileMap.putTileAt(edit.tileId, edit.x, edit.y, false, tileLayer as unknown as Phaser.Tilemaps.TilemapLayer); + } + + tileLayer.generateLayerDataTexture(); + } + + return; + } + // Structural churn: destroy each selected leaf and build its replacement in // the same place. Phaser's `destroy` removes the object from its parent // container itself, so nothing detaches it first. @@ -360,7 +462,7 @@ export const createPhaserAdapter = (): EngineAdapter => { }, renderFrame(): void { - if (game === null || root === null) { + if (game === null || (root === null && tileLayer === null)) { throw new Error('renderFrame was called before buildScene.'); } @@ -376,6 +478,8 @@ export const createPhaserAdapter = (): EngineAdapter => { }, teardown(): void { + releaseTilemap(); + if (game !== null) { // `destroy` only FLAGS pending destruction (normally consumed by the next // game step); since the loop is stopped, drive one explicit `step` - which diff --git a/packages/exojs-bench/src/rendering/adapters/pixi.ts b/packages/exojs-bench/src/rendering/adapters/pixi.ts index 405caffad..38a7f4be3 100644 --- a/packages/exojs-bench/src/rendering/adapters/pixi.ts +++ b/packages/exojs-bench/src/rendering/adapters/pixi.ts @@ -1,3 +1,4 @@ +import { settings as tilemapSettings, Tilemap } from '@pixi/tilemap'; import { Application, BitmapText, @@ -7,16 +8,30 @@ import { Culler, type Filter, Graphics, + Rectangle, RendererType, RenderTexture, Sprite, Texture, + type TextureSource, type WebGPURenderer, } from 'pixi.js'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; -import { createDistinctTextureCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; +import { createDistinctTextureCanvas, createTileAtlasCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; +import type { TilemapExtent } from '../tilemap'; +import { + isTilemap, + isTilemapEditing, + TILE_SIZE, + TILE_VARIANTS, + tileIdAt, + tilemapCameraAt, + tilemapCameraFrameFor, + tilemapEditsAt, + tilemapExtent, +} from '../tilemap'; import { compositeBlurRadius, filterChainDepth, @@ -217,6 +232,187 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine bloom = null; }; + /** + * Tiles per side of one painted chunk. + * + * Pixi's `Tilemap` is an imperatively painted quad buffer with no culling of + * its own, so a single map-wide instance would submit every world tile every + * frame. Chunking it is the documented way to draw a large map, and 32 matches + * the chunk size the ExoJS tile renderer uses by default, so neither arm draws + * a visible window cut into a different number of pieces. + */ + const TILE_CHUNK = 32; + + /** One painted chunk of the tilemap scene. */ + interface TileChunk { + tilemap: Tilemap; + readonly originX: number; + readonly originY: number; + readonly width: number; + readonly height: number; + } + + let tileChunks: TileChunk[] = []; + let tileTextures: Texture[] = []; + let tileWorld: Container | null = null; + /** The rendered root of the tilemap scene; it stays at identity, see {@link buildTilemapScene}. */ + let tileRoot: Container | null = null; + let tilemapSpec: ArchetypeSpec | null = null; + let tileExtent: TilemapExtent = { width: 0, height: 0 }; + /** Tile ids as this arm currently holds them, so an edited chunk can be repainted from one source. */ + let tileIds: Uint8Array = new Uint8Array(0); + /** The tile atlas every chunk draws from, kept so a rebuilt chunk gets the identical tileset. */ + let tileAtlas: TextureSource | null = null; + + /** Paint one chunk from {@link tileIds} into its (empty) tilemap. */ + const paintChunk = (chunk: TileChunk): void => { + for (let y = 0; y < chunk.height; y += 1) { + for (let x = 0; x < chunk.width; x += 1) { + const worldX = chunk.originX + x; + const worldY = chunk.originY + y; + + chunk.tilemap.tile(tileTextures[tileIds[worldY * tileExtent.width + worldX]!]!, x * TILE_SIZE, y * TILE_SIZE); + } + } + }; + + /** + * Rebuild one chunk after its tile data changed. + * + * A fresh `Tilemap` rather than `clear()` on the existing one: measured, the + * second `clear()` + repaint of one instance renders nothing at all, so a + * scene that edits the same chunk on consecutive frames goes blank. Replacing + * the instance is also the honest shape of this API's edit cost - it offers no + * per-tile update, so a changed tile means rebuilding the chunk's quad buffer + * either way. + */ + const repaintChunk = (index: number): void => { + const chunk = tileChunks[index]!; + const replacement = new Tilemap(tileAtlas!); + + replacement.position.set(chunk.originX * TILE_SIZE, chunk.originY * TILE_SIZE); + replacement.visible = chunk.tilemap.visible; + + tileWorld?.addChild(replacement); + chunk.tilemap.destroy(); + chunk.tilemap = replacement; + + paintChunk(chunk); + }; + + /** + * Build the tilemap scene: one painted `Tilemap` per chunk, all parented and + * shown or hidden per frame by the visible window. + * + * Painting happens once, at build time and outside the timed window, as it does + * on every other arm. What the timed window sees is the scroll - and, in the + * editing scene, the repaint an edited chunk needs, which is the real cost of + * this API: `Tilemap` exposes no per-tile update, so a changed tile means + * rebuilding the quad buffer of the chunk that holds it. + */ + const buildTilemapScene = (spec: ArchetypeSpec, nodeCount: number): void => { + // Lifts the 16-bit index ceiling, which would otherwise cap a single painted + // buffer at about 16k tiles. The chunking above keeps every buffer far below + // that, so this only removes a limit from the comparison rather than + // changing how the arm draws. + tilemapSettings.use32bitIndex = true; + + const extent = tilemapExtent(nodeCount); + const atlas = Texture.from(createTileAtlasCanvas()); + + tileAtlas = atlas.source; + + tileTextures = Array.from( + { length: TILE_VARIANTS }, + (_, index) => new Texture({ source: atlas.source, frame: new Rectangle(index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE) }), + ); + tileIds = new Uint8Array(extent.width * extent.height); + + for (let y = 0; y < extent.height; y += 1) { + for (let x = 0; x < extent.width; x += 1) { + tileIds[y * extent.width + x] = tileIdAt(x, y); + } + } + + // The scrolled container is a CHILD of the rendered root rather than the + // root itself. Measured: with the chunks parented directly to the rendered + // root, moving that root scrolled this scene at twice the requested rate - + // the tile pipe combines the root's transform with the one it applies + // itself. Ordinary sprite scenes do not (`scrolling-world` moves its + // rendered root and tracks the ExoJS arm exactly), so this is specific to + // the tile path. Keeping the root at identity avoids it either way. + const outerRoot = new Container(); + const world = new Container(); + const chunks: TileChunk[] = []; + + outerRoot.addChild(world); + + for (let originY = 0; originY < extent.height; originY += TILE_CHUNK) { + for (let originX = 0; originX < extent.width; originX += TILE_CHUNK) { + const tilemap = new Tilemap(atlas.source); + + tilemap.position.set(originX * TILE_SIZE, originY * TILE_SIZE); + + const chunk: TileChunk = { + tilemap, + originX, + originY, + width: Math.min(TILE_CHUNK, extent.width - originX), + height: Math.min(TILE_CHUNK, extent.height - originY), + }; + + chunks.push(chunk); + world.addChild(tilemap); + } + } + + tileExtent = extent; + tileChunks = chunks; + + for (const chunk of chunks) { + paintChunk(chunk); + } + + tileWorld = world; + tileRoot = outerRoot; + tilemapSpec = spec; + + const camera = tilemapCameraAt(0, extent); + + world.position.set(-camera.x, -camera.y); + showVisibleChunks(camera.x, camera.y); + }; + + /** + * Show the chunks the window overlaps and hide the rest. + * + * Visibility rather than reparenting: a hidden container costs Pixi a flag test, + * while adding and removing children every frame would measure the scene + * graph's bookkeeping instead of the tile path. + */ + const showVisibleChunks = (cameraX: number, cameraY: number): void => { + const left = cameraX / TILE_SIZE; + const top = cameraY / TILE_SIZE; + const right = left + VIEWPORT_WIDTH / TILE_SIZE; + const bottom = top + VIEWPORT_HEIGHT / TILE_SIZE; + + for (const chunk of tileChunks) { + chunk.tilemap.visible = chunk.originX < right && chunk.originX + chunk.width > left && chunk.originY < bottom && chunk.originY + chunk.height > top; + } + }; + + /** Drop the tilemap scene so a rebuild (or teardown) leaks no GPU resources. */ + const releaseTilemap = (): void => { + tileRoot?.destroy({ children: true }); + tileRoot = null; + tileWorld = null; + tileChunks = []; + tileTextures = []; + tileIds = new Uint8Array(0); + tileAtlas = null; + tilemapSpec = null; + }; + return { engine: 'pixi', config, @@ -228,7 +424,12 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine coversArchetype(spec: ArchetypeSpec): boolean { // The stock arm runs everywhere; the culled variant only where culling can // actually remove something. - return config === 'default' || spec.cullingEnabled; + // + // The tilemap scenes are the exception among the culling-enabled ones: the + // tile path decides chunk visibility itself, and `Culler.shared.cull` acts + // on `.cullable` scene nodes it never sees. The variant would therefore + // measure the stock arm a second time under another name. + return config === 'default' || (spec.cullingEnabled && !isTilemap(spec)); }, async init(canvas: HTMLCanvasElement, target: Backend): Promise { @@ -277,12 +478,23 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine throw new Error('buildScene was called before init.'); } + releaseTilemap(); + textures = []; for (let t = 0; t < spec.textureCount; t++) { textures.push(createDistinctTexture(t, spec.textureCount)); } + // The tilemap scenes leave the sprite path behind: the leaves are tiles in + // a painted quad buffer rather than nodes, so nothing below applies. + if (isTilemap(spec)) { + buildTilemapScene(spec, nodeCount); + root = tileRoot; + + return; + } + // Nested-container spine of depth `nestingDepth`, exactly as the ExoJS arm // builds it. Pixi has no separate retained/immediate tier here, so this one // arm is the whole Pixi comparison; the spine still exercises deep transform @@ -489,6 +701,32 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine }, mutate(frame: number): void { + // Tilemap scenes: move the window, reveal the chunks it now overlaps, and + // submit this frame's tile changes. An edit means repainting the chunk that + // holds it - `Tilemap` has no per-tile update - and that repaint is the + // work this arm genuinely does, so it belongs inside the bracket. + if (tilemapSpec !== null && tileWorld !== null) { + const camera = tilemapCameraAt(tilemapCameraFrameFor(tilemapSpec, frame), tileExtent); + + tileWorld.position.set(-camera.x, -camera.y); + showVisibleChunks(camera.x, camera.y); + + if (isTilemapEditing(tilemapSpec)) { + const dirty = new Set(); + + for (const edit of tilemapEditsAt(frame, tileExtent)) { + tileIds[edit.y * tileExtent.width + edit.x] = edit.tileId; + dirty.add(Math.floor(edit.y / TILE_CHUNK) * Math.ceil(tileExtent.width / TILE_CHUNK) + Math.floor(edit.x / TILE_CHUNK)); + } + + for (const index of dirty) { + repaintChunk(index); + } + } + + return; + } + if (scrollingSpec !== null && root !== null) { const centre = cameraCenterAt(scrollingSpec, frame, VIEWPORT_WIDTH, VIEWPORT_HEIGHT); @@ -593,6 +831,7 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine teardown(): void { releaseBloom(); + releaseTilemap(); if (root !== null) { root.destroy({ children: true }); diff --git a/packages/exojs-bench/src/rendering/archetypes.ts b/packages/exojs-bench/src/rendering/archetypes.ts index 054f47862..c4887d83e 100644 --- a/packages/exojs-bench/src/rendering/archetypes.ts +++ b/packages/exojs-bench/src/rendering/archetypes.ts @@ -35,6 +35,22 @@ const DYNAMIC_ALL_COUNTS = [1_000, 10_000, 100_000] as const; */ const FILL_LAYER_COUNTS = [8, 32, 128] as const; +/** + * World tile totals for the tilemap scenes. The visible window is the same at + * every rung - 1280x720 over 32 px tiles is about 41x23 tiles - so the ladder + * sweeps how large a map an arm can hold rather than how much of it it draws. + * `tilemap.ts` maps each count onto the map's dimensions. + */ +const TILEMAP_COUNTS = [10_000, 100_000, 1_000_000] as const; + +/** + * World tile totals for the editing scene. It stops below the million the + * scrolling scene reaches: an edit is submitted per frame, and at a million tiles + * an arm that has to repack or re-upload a whole map's worth of data would be + * measured on its allocator rather than on its tile path. + */ +const TILEMAP_EDIT_COUNTS = [10_000, 100_000] as const; + /** * Characters per text leaf across both text archetypes. Twelve is the length of * an ordinary label (a score, a name, a damage number) - long enough that layout @@ -535,6 +551,48 @@ export const ARCHETYPES: readonly ArchetypeSpec[] = [ // `mask-clip` with every rect moving each frame: the delta against the row // above is what an effect change alone costs the retained products around // it, which is the shape of every scrolling clip. + // TILEMAPS. The one scene shape practically every 2D game has and that no + // sprite archetype describes: a world far larger than the viewport, drawn + // through a dedicated tile path rather than one node per tile. + // + // `scrolling-world` is NOT this test. It lays independent sprite nodes over a + // world a few viewports across, and its per-node cost is the finding; here the + // world is a hundred thousand tiles, the visible window never changes size, and + // what is being compared is each arm's tile path - an instanced chunk renderer, + // an imperatively painted quad buffer, a shader over a data texture. + // + // `nodeCount` is the WORLD tile total, so the ladder says how large a map an + // arm can hold, not how much of it is on screen. The visible tile count is + // fixed by the viewport at every rung, which is exactly what makes the two + // questions separable. See `tilemap.ts` for the shared map, camera and edits. + { + id: 'tilemap-scroll', + category: 'tilemaps', + crossArm: true, + nodeCounts: TILEMAP_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: true, + tilemap: 'scroll', + }, + // The same map and the same camera, with visible tile ids replaced every frame. + // The delta against the row above is what submitting a tile change costs - + // which is where the three tile paths differ most sharply, because a shader + // reading a data texture has to re-upload it, a chunked quad buffer has to + // repack the affected chunk, and an instanced renderer has to invalidate the + // chunk's cached geometry. + { + id: 'tilemap-edit', + category: 'tilemaps', + crossArm: true, + nodeCounts: TILEMAP_EDIT_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: true, + tilemap: 'edit', + }, { id: 'mask-clip-animated', category: 'render-targets', diff --git a/packages/exojs-bench/src/rendering/driver.ts b/packages/exojs-bench/src/rendering/driver.ts index f33ffb31b..f7348f5c6 100644 --- a/packages/exojs-bench/src/rendering/driver.ts +++ b/packages/exojs-bench/src/rendering/driver.ts @@ -22,7 +22,7 @@ import type { ArchetypeSpec, Backend, CellResult, CellSpec, EngineAdapter } from import type { MatrixSelection } from './selection'; import { applyPlan, applySelection } from './selection'; import { usesRenderTargets } from './traits'; -import { isScrolling } from './world'; +import { isScrolling, VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from './world'; // Re-exported so the rendering barrel and the CLI keep importing the selection // surface from `driver` unchanged. It lives in its own module because the tests @@ -453,9 +453,9 @@ export type CellResultSink = (result: CellResult) => void; * discipline is untouched - means a failing cell costs only itself: it becomes * an `unavailable` datapoint carrying the error, and the run continues. */ -const runCellInPage = async (page: import('playwright').Page, spec: CellSpec): Promise => { +const runCellInPage = async (page: import('playwright').Page, spec: CellSpec, hold: boolean): Promise => { try { - return await page.evaluate(cell => globalThis.__runBaselineCell!(cell), spec); + return await page.evaluate(args => globalThis.__runBaselineCell!(args.cell, args.hold), { cell: spec, hold }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -487,13 +487,13 @@ const CELL_WEDGED = Symbol('cell-wedged'); * closes; its rejection is swallowed so it never surfaces as an unhandled * rejection (`runCellInPage` already never rejects on a normal cell error). */ -const runCellOrWedge = async (page: import('playwright').Page, spec: CellSpec): Promise => { +const runCellOrWedge = async (page: import('playwright').Page, spec: CellSpec, hold = false): Promise => { let timer: ReturnType | undefined; const timeout = new Promise(resolvePromise => { timer = setTimeout(() => resolvePromise(CELL_WEDGED), CELL_TIMEOUT_MS); }); - const run = runCellInPage(page, spec).then(result => { + const run = runCellInPage(page, spec, hold).then(result => { if (timer !== undefined) { clearTimeout(timer); } @@ -527,6 +527,9 @@ const runCellOrWedge = async (page: import('playwright').Page, spec: CellSpec): * For WebGPU the adapter identity is read once; a null or software adapter emits * every cell as `unavailable` rather than measuring a software rasterizer. */ +/** File stem of one cell's captured frame - the cell's identity, safe for a file name. */ +const captureName = (cell: CellSpec): string => `${cell.backend}-${cell.archetype}-${String(cell.nodeCount)}-${cell.engine}-${cell.config}`; + const runBackend = async (options: { baseUrl: string; backend: Backend; @@ -538,8 +541,10 @@ const runBackend = async (options: { platform?: PlatformDeclaration; /** Replaces the per-backend launch flags; see {@link runMatrix}. */ launchFlags?: readonly string[]; + /** Directory to capture each measured cell's last frame into; see {@link runMatrix}. */ + captureDir?: string; }): Promise<{ provenance: Provenance; results: CellResult[] }> => { - const { baseUrl, backend, browser: browserName, cells, engineVersion, onCellResult } = options; + const { baseUrl, backend, browser: browserName, cells, engineVersion, onCellResult, captureDir } = options; const flags = resolveLaunchFlags(browserName, backend, options.launchFlags); // Group cells by arm (engine|config) in first-seen order so each arm runs in its @@ -620,7 +625,7 @@ const runBackend = async (options: { while (remaining.length > 0) { const cell = remaining[0]!; - const outcome = await runCellOrWedge(page, cell); + const outcome = await runCellOrWedge(page, cell, captureDir !== undefined); if (outcome === CELL_WEDGED) { collect( @@ -637,6 +642,20 @@ const runBackend = async (options: { collect(outcome); + // Frame capture: the canvas as the measured cell left it. The cell was + // asked to HOLD its scene for exactly this, because some arms blank the + // canvas when they release their context and a capture taken after + // teardown would compare teardown policies rather than scenes. + // `page.screenshot` rather than a canvas readback: a WebGPU canvas + // never yields its contents to `drawImage`. + if (captureDir !== undefined && outcome.status === 'ok') { + await page.screenshot({ + path: resolve(captureDir, `${captureName(cell)}.png`), + clip: { x: 0, y: 0, width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }, + }); + await page.evaluate(() => globalThis.__disposeHeldCell!()); + } + if (backend === 'webgl2' && renderer === null && outcome.status === 'ok') { renderer = await readRendererInPage(page); } @@ -1035,6 +1054,16 @@ export const runMatrix = async (options: { * GPU number. */ launchFlags?: readonly string[]; + /** + * Directory each measured cell's final frame is captured into, as + * `----.png`. + * + * For checking that two arms asked to render one scene actually rendered it - + * a cell that draws nothing measures a frame nobody would ship, and its number + * looks like a win. Off unless asked for: a capture costs a full readback per + * cell, which has no place in a reportable run. + */ + captureDir?: string; }): Promise => { const engineVersion = readEngineVersion(); const libraries = readLibraryProvenance(RENDERING_LIBRARY_ARMS); @@ -1073,6 +1102,7 @@ export const runMatrix = async (options: { onCellResult, ...(options.platform !== undefined && { platform: options.platform }), ...(options.launchFlags !== undefined && { launchFlags: options.launchFlags }), + ...(options.captureDir !== undefined && { captureDir: options.captureDir }), }); provenance.push(outcome.provenance); diff --git a/packages/exojs-bench/src/rendering/page/harness.ts b/packages/exojs-bench/src/rendering/page/harness.ts index 0b1e92221..ff2ef7fbe 100644 --- a/packages/exojs-bench/src/rendering/page/harness.ts +++ b/packages/exojs-bench/src/rendering/page/harness.ts @@ -456,10 +456,30 @@ export const runCell = async (adapter: EngineAdapter, spec: CellSpec, canvas: HT }; } finally { probe.detach(); - adapter.teardown(); + + // A held cell keeps its scene on the canvas so the driver can capture the + // frame it just measured. Tearing down first leaves an arm-dependent canvas + // - some engines keep the last frame, others release the context and blank + // it - so a capture taken afterwards would compare teardown policies rather + // than scenes. + if (heldAdapter === null) { + adapter.teardown(); + } } }; +/** + * The arm whose scene is being kept alive for a capture, or `null` when no cell + * is held. Disposed by {@link disposeHeldCell} before the next cell runs. + */ +let heldAdapter: EngineAdapter | null = null; + +/** Tear down the scene {@link runCell} was asked to hold. Safe to call when nothing is held. */ +const disposeHeldCell = (): void => { + heldAdapter?.teardown(); + heldAdapter = null; +}; + /** Registry key uniquely identifying an engine arm by its engine + config labels. */ const adapterKey = (engine: string, config: string): string => `${engine}${config}`; @@ -527,10 +547,19 @@ const resolveAdapter = async (engine: string, config: string): Promise => { +const runBaselineCell = async (cell: CellSpec, hold = false): Promise => { + // Whatever the previous cell was asked to hold is released here rather than + // after the capture, so a capture failure can never leave an arm's scene alive + // underneath the next cell's measurement. + disposeHeldCell(); + const canvas = freshStageCanvas(); const adapter = await resolveAdapter(cell.engine, cell.config); + if (hold) { + heldAdapter = adapter; + } + return runCell(adapter, cell, canvas); }; @@ -597,7 +626,13 @@ const profileDispose = (): void => { }; declare global { - var __runBaselineCell: ((cell: CellSpec) => Promise) | undefined; + /** + * Measures one cell. `hold` keeps the scene on the canvas afterwards, for the + * driver's frame capture; the next call releases it. + */ + var __runBaselineCell: ((cell: CellSpec, hold?: boolean) => Promise) | undefined; + /** Releases a held cell's scene without measuring another. */ + var __disposeHeldCell: (() => void) | undefined; /** * Reports what THIS page's clock resolves to. Exposed from the page rather * than evaluated as a driver-side function so the probe runs in the same @@ -612,6 +647,7 @@ declare global { globalThis.__runBaselineCell = runBaselineCell; globalThis.__probeClock = probeClock; +globalThis.__disposeHeldCell = disposeHeldCell; globalThis.__profileSetup = profileSetup; globalThis.__profileFrames = profileFrames; globalThis.__profileDispose = profileDispose; diff --git a/packages/exojs-bench/src/rendering/sceneAssets.ts b/packages/exojs-bench/src/rendering/sceneAssets.ts index 4f3acfa43..f9f437a3e 100644 --- a/packages/exojs-bench/src/rendering/sceneAssets.ts +++ b/packages/exojs-bench/src/rendering/sceneAssets.ts @@ -1,3 +1,4 @@ +import { TILE_SIZE, TILE_VARIANTS } from './tilemap'; import { SPRITE_SIZE } from './world'; /** @@ -49,6 +50,41 @@ export const createDistinctTextureCanvas = (index: number, total: number): HTMLC return canvas; }; +/** + * The tile atlas: one row of {@link TILE_VARIANTS} cells of {@link TILE_SIZE} + * pixels, each a flat distinct colour with a one-pixel darker border. + * + * One row and one page, so every arm resolves its tiles out of a single texture + * and none of them pays a texture-slot or page-switch cost the others avoid. The + * border is what makes a captured frame readable as a tile grid rather than as a + * colour field, which is how a scroll or an edit is checked by eye; nothing + * measures it. + */ +export const createTileAtlasCanvas = (): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + + canvas.width = TILE_SIZE * TILE_VARIANTS; + canvas.height = TILE_SIZE; + + const context = canvas.getContext('2d'); + + if (context === null) { + throw new Error('A 2D context is required to generate the benchmark tile atlas.'); + } + + for (let index = 0; index < TILE_VARIANTS; index += 1) { + const hue = Math.round((index / TILE_VARIANTS) * 360); + + context.fillStyle = `hsl(${hue}, 65%, 52%)`; + context.fillRect(index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE); + context.fillStyle = `hsl(${hue}, 65%, 32%)`; + context.fillRect(index * TILE_SIZE, 0, TILE_SIZE, 1); + context.fillRect(index * TILE_SIZE, 0, 1, TILE_SIZE); + } + + return canvas; +}; + /** * The digit glyph sheet: one row of {@link DIGIT_ALPHABET}.length cells, white on * transparent. diff --git a/packages/exojs-bench/src/rendering/tilemap.ts b/packages/exojs-bench/src/rendering/tilemap.ts new file mode 100644 index 000000000..55a947e42 --- /dev/null +++ b/packages/exojs-bench/src/rendering/tilemap.ts @@ -0,0 +1,187 @@ +import { createRng } from '../shared/rng'; +import type { ArchetypeSpec } from './EngineAdapter'; +import { VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from './world'; + +/** + * The tilemap scenes' shared definition: map size, tile content, camera path and + * per-frame edits, derived once so every arm paints the identical world. + * + * Each arm expresses a tilemap through its own API - an instanced chunk renderer, + * an imperatively painted quad buffer, a shader over a data texture - and those + * are the implementations under comparison. What must not differ is the map they + * are given, which is why nothing here is an arm's business to decide. + */ + +/** Tile edge length in pixels. Square, and the same in every arm. */ +export const TILE_SIZE = 32; + +/** + * Distinct tiles in the tileset. + * + * Enough that the map is not one repeated tile - which a renderer could collapse + * - and few enough to sit in a single atlas page on every arm, so no arm pays a + * texture-slot cost the others avoid. The comparison is about tile throughput, + * not about batching across tilesets. + */ +export const TILE_VARIANTS = 16; + +/** Camera travel per frame in pixels, along the map diagonal. */ +export const TILEMAP_CAMERA_SPEED = 8; + +/** Unique visible tiles whose id is replaced each frame by the editing scene. */ +export const TILEMAP_EDITS_PER_FRAME = 64; + +/** Seed the tile content is drawn from; fixed, so every arm and every run paints one map. */ +export const TILEMAP_SEED = 0xc0_ff_ee; + +/** Map dimensions in tiles. */ +export interface TilemapExtent { + readonly width: number; + readonly height: number; +} + +/** + * Map dimensions for a tile total. + * + * Tabulated rather than derived from a square root: the published loads are + * 100x100, 400x250 and 1000x1000, and the middle one is deliberately not square + * - a map wider than it is tall is the ordinary shape of a side-scrolling level, + * and a square-only ladder would never exercise a chunk grid's minor axis + * differently from its major one. + */ +export const tilemapExtent = (tileCount: number): TilemapExtent => { + switch (tileCount) { + case 10_000: + return { width: 100, height: 100 }; + case 100_000: + return { width: 400, height: 250 }; + case 1_000_000: + return { width: 1_000, height: 1_000 }; + default: { + // An off-ladder count (a spot check, a smoke run) still gets a real map: + // the widest rectangle whose area does not exceed the count, in the same + // 16:10 proportion as the published middle rung. + const height = Math.max(1, Math.round(Math.sqrt(tileCount / 1.6))); + + return { width: Math.max(1, Math.floor(tileCount / height)), height }; + } + } +}; + +/** + * Tile id at map coordinate `(x, y)`, in `0..TILE_VARIANTS - 1`. + * + * Derived from the coordinate rather than from a stream, so an arm can fill its + * map in whatever order its storage wants and still paint the identical world. + * The hash is the one the shared RNG uses, seeded per tile. + */ +export const tileIdAt = (x: number, y: number): number => { + const seed = (TILEMAP_SEED ^ (x * 0x9e_37_79_b1) ^ (y * 0x85_eb_ca_6b)) >>> 0; + + return Math.floor(createRng(seed)() * TILE_VARIANTS) % TILE_VARIANTS; +}; + +/** Top-left corner of the visible window, in pixels. */ +export interface TilemapCamera { + readonly x: number; + readonly y: number; +} + +/** + * Camera position at `frame`: a diagonal that reflects off the map edges. + * + * Reflection rather than a wrap, because a wrap teleports the window across the + * map and hands every arm a full chunk turnover on that one frame, which is a + * spike in the middle of a steady-state measurement rather than a property of + * the scroll. + */ +export const tilemapCameraAt = (frame: number, extent: TilemapExtent): TilemapCamera => ({ + x: reflect(frame * TILEMAP_CAMERA_SPEED, Math.max(0, extent.width * TILE_SIZE - VIEWPORT_WIDTH)), + y: reflect(frame * TILEMAP_CAMERA_SPEED, Math.max(0, extent.height * TILE_SIZE - VIEWPORT_HEIGHT)), +}); + +/** Reflect `value` into `0..span`, so the camera turns around at the map edge instead of jumping. */ +const reflect = (value: number, span: number): number => { + if (span <= 0) { + return 0; + } + + const cycle = value % (span * 2); + + return cycle <= span ? cycle : span * 2 - cycle; +}; + +/** One tile the editing scene replaces. */ +export interface TilemapEdit { + readonly x: number; + readonly y: number; + readonly tileId: number; +} + +/** + * The tiles `tilemap-edit` replaces on `frame`: {@link TILEMAP_EDITS_PER_FRAME} + * distinct coordinates inside the visible window, each given a tile id different + * from the one it held. + * + * Inside the window on purpose. An edit outside it is a data change an arm may + * legitimately defer until the chunk is next drawn, so a scene editing off-screen + * tiles would measure how long each arm defers rather than what an edit costs. + */ +export const tilemapEditsAt = (frame: number, extent: TilemapExtent): readonly TilemapEdit[] => { + const camera = tilemapCameraAt(EDIT_CAMERA_FRAME, extent); + const originX = Math.floor(camera.x / TILE_SIZE); + const originY = Math.floor(camera.y / TILE_SIZE); + const columns = Math.min(extent.width, Math.ceil(VIEWPORT_WIDTH / TILE_SIZE)); + const rows = Math.min(extent.height, Math.ceil(VIEWPORT_HEIGHT / TILE_SIZE)); + const edits: TilemapEdit[] = []; + const visited = new Set(); + + // A stride coprime with the window's tile count walks distinct cells without + // rejection sampling, so the set is exactly the requested size on every frame + // rather than "usually" it. + const cells = columns * rows; + const stride = 61; + + for (let index = 0; index < TILEMAP_EDITS_PER_FRAME && visited.size < cells; index += 1) { + const cell = (index * stride) % cells; + + if (visited.has(cell)) { + continue; + } + + visited.add(cell); + + const x = Math.min(extent.width - 1, originX + (cell % columns)); + const y = Math.min(extent.height - 1, originY + Math.floor(cell / columns)); + + edits.push({ x, y, tileId: (tileIdAt(x, y) + 1 + (frame % (TILE_VARIANTS - 1))) % TILE_VARIANTS }); + } + + return edits; +}; + +/** Whether the archetype renders a tilemap rather than a sprite scene. */ +export const isTilemap = (spec: ArchetypeSpec): boolean => spec.tilemap !== undefined; + +/** + * The frame the editing scene's camera is fixed at. + * + * The editing scene does not scroll, and that is a determinism requirement + * rather than a simplification. The harness may cut an arm's warmup short on + * wall clock, so two arms can reach the timed window having run a different + * number of frames; with a moving camera the edited cells move too, and the two + * arms would then be drawing worlds that differ by whatever the extra warmup + * frames edited - a difference that looks like a rendering bug and is not. + * + * Holding the camera makes each frame set the SAME cells to a value derived from + * that frame alone, so the visible world depends only on the last frame drawn. + * The cost of scrolling is what `tilemap-scroll` measures; keeping it out of + * here also makes the delta between the two scenes the edit cost alone. + */ +export const EDIT_CAMERA_FRAME = 0; + +/** The frame whose camera position an archetype draws at. */ +export const tilemapCameraFrameFor = (spec: ArchetypeSpec, frame: number): number => (isTilemapEditing(spec) ? EDIT_CAMERA_FRAME : frame); + +/** Whether the archetype replaces visible tile ids every frame. */ +export const isTilemapEditing = (spec: ArchetypeSpec): boolean => spec.tilemap === 'edit'; diff --git a/packages/exojs-bench/src/run.ts b/packages/exojs-bench/src/run.ts index b651cae74..03ed52f39 100644 --- a/packages/exojs-bench/src/run.ts +++ b/packages/exojs-bench/src/run.ts @@ -373,7 +373,7 @@ const runRenderingDomain = async (args: Map, selector: DomainSel // only a free filter makes the run exploratory. `--backend` is a free filter // in that sense too: it publishes one backend's block under a plan that names // both. - const isSubset = backendArg !== undefined || hasSelection || timedFramesOverride !== undefined; + const isSubset = backendArg !== undefined || hasSelection || timedFramesOverride !== undefined || args.has('capture'); const plan = resolvePlanFor(args, 'rendering', isSubset); if (args.has('dry-run')) { @@ -397,10 +397,17 @@ const runRenderingDomain = async (args: Map, selector: DomainSel // probe was the observed one - can never discard the cells already measured. const checkpoint = createCheckpointWriter(outDir); + // `--capture=`: write each measured cell's final frame there, for + // checking by eye that two arms asked for one scene rendered it. A capture + // costs a readback per cell, so it belongs to a spot check and never to a + // reportable run - which is why it also marks the run a subset. + const captureDir = args.get('capture'); + const data = await runMatrix({ backends, browser, plan, + ...(captureDir !== undefined && { captureDir: resolve(captureDir) }), ...(platform !== undefined && { platform }), ...(hasSelection && { selection }), ...(timedFramesOverride !== undefined && { timedFramesOverride }), diff --git a/packages/exojs-bench/test/scrolling-world.test.ts b/packages/exojs-bench/test/scrolling-world.test.ts index 5243aaf4b..47e436786 100644 --- a/packages/exojs-bench/test/scrolling-world.test.ts +++ b/packages/exojs-bench/test/scrolling-world.test.ts @@ -1,5 +1,6 @@ import { ARCHETYPES, buildMatrix } from '../src/rendering/archetypes'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../src/rendering/EngineAdapter'; +import { isTilemap } from '../src/rendering/tilemap'; import { cameraCenterAt, GRID_MARGIN, isScrolling, SPRITE_SIZE, VIEWPORT_HEIGHT, VIEWPORT_WIDTH, visibleLeafCount, worldExtent } from '../src/rendering/world'; const scrollingWorld = ARCHETYPES.find(archetype => archetype.id === 'scrolling-world')!; @@ -8,9 +9,12 @@ const scrollingWorld = ARCHETYPES.find(archetype => archetype.id === 'scrolling- const SAMPLE_FRAMES = [0, 1, 7, 60, 120, 199, 331, 512, 900, 1_337]; describe('scrolling-world archetype', () => { - test('is the only archetype with off-screen content and a moving camera', () => { + test('is the only SPRITE archetype with off-screen content and a moving camera', () => { const scrolling = ARCHETYPES.filter(isScrolling); - const culling = ARCHETYPES.filter(archetype => archetype.cullingEnabled); + // The tilemap scenes also hold more world than they show, but they express + // their camera through the tile path rather than through `cameraSpeed`, so + // only their culling flag overlaps with this one. + const culling = ARCHETYPES.filter(archetype => archetype.cullingEnabled && !isTilemap(archetype)); expect(scrolling.map(archetype => archetype.id)).toEqual(['scrolling-world']); expect(culling.map(archetype => archetype.id)).toEqual(['scrolling-world']); @@ -135,7 +139,7 @@ describe('arm coverage', () => { }); test('the culled Pixi variant is measured only where culling can remove something', () => { - const cells = buildMatrix([fakeAdapter('pixi', 'culled', spec => spec.cullingEnabled)], ['webgl2']); + const cells = buildMatrix([fakeAdapter('pixi', 'culled', spec => spec.cullingEnabled && !isTilemap(spec))], ['webgl2']); expect(new Set(cells.map(cell => cell.archetype))).toEqual(new Set(['scrolling-world'])); expect(cells.map(cell => cell.nodeCount)).toEqual([...scrollingWorld.nodeCounts]); diff --git a/packages/exojs-bench/test/suite-plan.test.ts b/packages/exojs-bench/test/suite-plan.test.ts index 0847e7150..f7688dc4f 100644 --- a/packages/exojs-bench/test/suite-plan.test.ts +++ b/packages/exojs-bench/test/suite-plan.test.ts @@ -120,14 +120,18 @@ describe('suite resolution', () => { } }); - it('full keeps every rung of every existing development ladder', () => { + it('full keeps every rung of every existing development ladder, extreme loads aside', () => { const full = new Set(planFor('full', 'rendering').workloads.map(workload => `${workload.scenarioId}/${String(workload.value)}`)); + const extreme = new Set( + planFor('full', 'rendering', true) + .workloads.filter(workload => workload.extreme) + .map(workload => `${workload.scenarioId}/${String(workload.value)}`), + ); + const missing = ARCHETYPES.flatMap(archetype => + archetype.nodeCounts.map(nodeCount => `${archetype.id}/${String(nodeCount)}`).filter(key => !full.has(key) && !extreme.has(key)), + ); - for (const archetype of ARCHETYPES) { - for (const nodeCount of archetype.nodeCounts) { - expect(full).toContain(`${archetype.id}/${String(nodeCount)}`); - } - } + expect(missing).toStrictEqual([]); }); it('extreme loads are admitted only by full plus the flag', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28876d831..8bb88ee73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,10 +52,10 @@ importers: version: 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) '@vitest/browser-webdriverio': specifier: ^4.1.10 - version: 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0) + version: 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0(supports-color@7.2.0)) '@vitest/coverage-istanbul': specifier: ^4.1.7 - version: 4.1.11(vitest@4.1.11) + version: 4.1.11(supports-color@7.2.0)(vitest@4.1.11) '@webgpu/types': specifier: ^0.1.69 version: 0.1.72 @@ -64,22 +64,22 @@ importers: version: 0.28.2 eslint: specifier: ~10.9.1 - version: 10.9.1(jiti@2.7.0) + version: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.9.1(jiti@2.7.0)) + version: 10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-security: specifier: ^4.0.0 version: 4.0.1 eslint-plugin-simple-import-sort: specifier: ^14.0.0 - version: 14.0.0(eslint@10.9.1(jiti@2.7.0)) + version: 14.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-unicorn: specifier: ^74.0.0 - version: 74.0.0(eslint@10.9.1(jiti@2.7.0)) + version: 74.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) globals: specifier: ^17.11.0 version: 17.11.0 @@ -112,7 +112,7 @@ importers: version: 4.63.1 rollup-plugin-esbuild: specifier: ^6.2.1 - version: 6.2.1(esbuild@0.28.2)(rollup@4.63.1) + version: 6.2.1(esbuild@0.28.2)(rollup@4.63.1)(supports-color@7.2.0) size-limit: specifier: ^13.0.3 version: 13.0.3 @@ -127,7 +127,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ~8.68.0 - version: 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vitest: specifier: ^4.1.7 version: 4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(@vitest/browser-webdriverio@4.1.11)(@vitest/coverage-istanbul@4.1.11)(jsdom@29.1.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -154,7 +154,7 @@ importers: version: 26.4.1 eslint: specifier: ~10.9.1 - version: 10.9.1(jiti@2.7.0) + version: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: specifier: ~6.0.3 version: 6.0.3 @@ -185,9 +185,15 @@ importers: '@codexo/exojs-config': specifier: workspace:* version: link:../exojs-config + '@codexo/exojs-particles': + specifier: workspace:* + version: link:../exojs-particles '@codexo/exojs-physics': specifier: workspace:* version: link:../exojs-physics + '@codexo/exojs-tilemap': + specifier: workspace:* + version: link:../exojs-tilemap '@types/node': specifier: ^26.4.0 version: 26.4.1 @@ -246,28 +252,28 @@ importers: version: link:../exojs-build '@eslint-react/eslint-plugin': specifier: ^5.9.0 - version: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + version: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.9.1(jiti@2.7.0)) + version: 10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@vitest/eslint-plugin': specifier: ^1.6.20 - version: 1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.11) + version: 1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)(vitest@4.1.11) eslint-plugin-react-hooks: specifier: ^7.1.1 - version: 7.1.1(eslint@10.9.1(jiti@2.7.0)) + version: 7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-security: specifier: ^4.0.0 version: 4.0.1 eslint-plugin-simple-import-sort: specifier: ^14.0.0 - version: 14.0.0(eslint@10.9.1(jiti@2.7.0)) + version: 14.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-unicorn: specifier: ^74.0.0 - version: 74.0.0(eslint@10.9.1(jiti@2.7.0)) + version: 74.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) globals: specifier: ^17.11.0 version: 17.11.0 @@ -276,7 +282,7 @@ importers: version: 1.2.7 typescript-eslint: specifier: ~8.68.0 - version: 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) packages/exojs-ldtk: devDependencies: @@ -352,10 +358,10 @@ importers: version: 19.2.18 eslint: specifier: ~10.9.1 - version: 10.9.1(jiti@2.7.0) + version: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.9.1(jiti@2.7.0)) + version: 10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) packages/exojs-tiled: devDependencies: @@ -403,10 +409,10 @@ importers: dependencies: '@astrojs/mdx': specifier: ^7.0.5 - version: 7.0.8(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 7.0.8(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(supports-color@7.2.0) '@astrojs/react': specifier: ^6.0.2 - version: 6.0.5(@types/node@26.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) + version: 6.0.5(@types/node@26.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.7)(supports-color@7.2.0)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) '@codexo/exojs': specifier: workspace:* version: link:.. @@ -440,7 +446,7 @@ importers: version: 0.9.10(prettier@3.9.6)(typescript@6.0.3) '@codecov/astro-plugin': specifier: ^2.0.1 - version: 2.0.1(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 2.0.1(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) '@codexo/exojs-config': specifier: workspace:* version: link:../packages/exojs-config @@ -458,10 +464,10 @@ importers: version: 7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) eslint: specifier: ~10.9.1 - version: 10.9.1(jiti@2.7.0) + version: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.9.1(jiti@2.7.0)) + version: 10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) pagefind: specifier: ^1.5.2 version: 1.5.2 @@ -896,156 +902,312 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -2903,6 +3065,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -4766,10 +4933,6 @@ packages: resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} - engines: {node: '>=20.18.1'} - undici@7.29.0: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} @@ -4915,6 +5078,46 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@7.3.6: resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5498,7 +5701,7 @@ snapshots: transitivePeerDependencies: - typescript - '@astrojs/markdown-remark@7.2.4': + '@astrojs/markdown-remark@7.2.4(supports-color@7.2.0)': dependencies: '@astrojs/internal-helpers': 0.10.4 '@astrojs/prism': 4.0.2 @@ -5508,8 +5711,8 @@ snapshots: mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 - remark-gfm: 4.0.1 - remark-parse: 11.0.0 + remark-gfm: 4.0.1(supports-color@7.2.0) + remark-parse: 11.0.0(supports-color@7.2.0) remark-rehype: 11.1.2 remark-smartypants: 3.0.3 unified: 11.0.5 @@ -5527,11 +5730,11 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@7.0.8(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': + '@astrojs/mdx@7.0.8(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(supports-color@7.2.0)': dependencies: '@astrojs/internal-helpers': 0.10.4 - '@astrojs/markdown-remark': 7.2.4 - '@mdx-js/mdx': 3.1.1 + '@astrojs/markdown-remark': 7.2.4(supports-color@7.2.0) + '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) acorn: 8.18.0 astro: 7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) es-module-lexer: 2.3.2 @@ -5539,7 +5742,7 @@ snapshots: hast-util-to-html: 9.0.5 piccolore: 0.1.3 rehype-raw: 7.0.0 - remark-gfm: 4.0.1 + remark-gfm: 4.0.1(supports-color@7.2.0) remark-smartypants: 3.0.3 source-map: 0.7.6 unist-util-visit: 5.1.0 @@ -5551,12 +5754,12 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@6.0.5(@types/node@26.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)': + '@astrojs/react@6.0.5(@types/node@26.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.7)(supports-color@7.2.0)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)': dependencies: '@astrojs/internal-helpers': 0.11.0 '@types/react': 19.2.18 '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@vitejs/plugin-react': 5.2.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitejs/plugin-react': 5.2.0(supports-color@7.2.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) devalue: 5.9.2 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -5596,20 +5799,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@7.2.0) '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5634,19 +5837,19 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@7.2.0) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -5671,14 +5874,14 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -5689,7 +5892,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8': + '@babel/traverse@7.29.8(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -5697,7 +5900,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -5764,10 +5967,10 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@codecov/astro-plugin@2.0.1(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': + '@codecov/astro-plugin@2.0.1(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 - '@codecov/vite-plugin': 2.0.1(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) + '@codecov/vite-plugin': 2.0.1(vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) astro: 7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) unplugin: 1.16.1 transitivePeerDependencies: @@ -5788,11 +5991,11 @@ snapshots: rollup: 4.63.1 unplugin: 1.16.1 - '@codecov/vite-plugin@2.0.1(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': + '@codecov/vite-plugin@2.0.1(vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 unplugin: 1.16.1 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) + vite: 6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) '@csstools/color-helpers@6.0.2': {} @@ -5862,182 +6065,260 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.28.2': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.28.2': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.28.2': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.28.2': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.28.2': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.28.2': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.28.2': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.28.2': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.28.2': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.28.2': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.28.2': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.28.2': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.28.2': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.28.2': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.28.2': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.28.2': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.28.2': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.28.2': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.28.2': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.28.2': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.28.2': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.28.2': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.28.2': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.28.2': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint-react/ast@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/ast@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) string-ts: 2.3.1 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/core@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/core@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/eslint-plugin@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/eslint-plugin@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) - eslint-plugin-react-dom: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-jsx: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-naming-convention: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-rsc: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-web-api: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-x: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-react-dom: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-jsx: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-naming-convention: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-rsc: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-web-api: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-x: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/eslint@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/eslint@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/jsx@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/jsx@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/shared@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/shared@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 zod: 4.4.3 transitivePeerDependencies: - supports-color - '@eslint-react/var@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/var@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 transitivePeerDependencies: - supports-color @@ -6055,9 +6336,9 @@ snapshots: mdn-data: 2.29.0 source-map-js: 1.2.1 - '@eslint/js@10.0.1(eslint@10.9.1(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))': optionalDependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) '@eslint/object-schema@3.0.5': {} @@ -6216,7 +6497,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -6238,9 +6519,9 @@ snapshots: '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - '@mdx-js/mdx@3.1.1': + '@mdx-js/mdx@3.1.1(supports-color@7.2.0)': dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -6252,14 +6533,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.1 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6 + hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.18.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0 - remark-mdx: 3.1.1 - remark-parse: 11.0.0 + rehype-recma: 1.0.0(supports-color@7.2.0) + remark-mdx: 3.1.1(supports-color@7.2.0) + remark-parse: 11.0.0(supports-color@7.2.0) remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -6443,12 +6724,12 @@ snapshots: dependencies: spacetrim: 0.11.59 - '@puppeteer/browsers@2.13.2': + '@puppeteer/browsers@2.13.2(supports-color@7.2.0)': dependencies: - debug: 4.4.3 - extract-zip: 2.0.1 + debug: 4.4.3(supports-color@7.2.0) + extract-zip: 2.0.1(supports-color@7.2.0) progress: 2.0.3 - proxy-agent: 6.5.0 + proxy-agent: 6.5.0(supports-color@7.2.0) semver: 7.8.5 tar-fs: 3.1.3 yargs: 17.7.3 @@ -6837,15 +7118,15 @@ snapshots: '@types/node': 26.4.1 optional: true - '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.68.0 - '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.68.0 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -6853,41 +7134,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.68.0 '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.68.0 - debug: 4.4.3 - eslint: 10.9.1(jiti@2.7.0) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.63.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.63.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.67.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.67.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) '@typescript-eslint/types': 8.67.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.68.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.68.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) '@typescript-eslint/types': 8.68.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -6919,25 +7200,25 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -6949,13 +7230,13 @@ snapshots: '@typescript-eslint/types@8.68.0': {} - '@typescript-eslint/typescript-estree@8.63.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.63.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.63.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 '@typescript-eslint/visitor-keys': 8.63.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -6964,13 +7245,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.67.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.67.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.67.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.67.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) '@typescript-eslint/types': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -6979,13 +7260,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.68.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.68.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.68.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) '@typescript-eslint/types': 8.68.0 '@typescript-eslint/visitor-keys': 8.68.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -6994,35 +7275,35 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.63.0 '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.68.0 '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7044,11 +7325,11 @@ snapshots: '@ungap/structured-clone@1.4.0': {} - '@vitejs/plugin-react@5.2.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(supports-color@7.2.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 @@ -7069,11 +7350,11 @@ snapshots: - utf-8-validate - vite - '@vitest/browser-webdriverio@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0)': + '@vitest/browser-webdriverio@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0(supports-color@7.2.0))': dependencies: '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) vitest: 4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(@vitest/browser-webdriverio@4.1.11)(@vitest/coverage-istanbul@4.1.11)(jsdom@29.1.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) - webdriverio: 9.30.0 + webdriverio: 9.30.0(supports-color@7.2.0) transitivePeerDependencies: - bufferutil - msw @@ -7097,9 +7378,9 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-istanbul@4.1.11(vitest@4.1.11)': + '@vitest/coverage-istanbul@4.1.11(supports-color@7.2.0)(vitest@4.1.11)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@istanbuljs/schema': 0.1.6 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -7113,13 +7394,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.11)': + '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)(vitest@4.1.11)': dependencies: '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) typescript: 6.0.3 vitest: 4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(@vitest/browser-webdriverio@4.1.11)(@vitest/coverage-istanbul@4.1.11)(jsdom@29.1.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) transitivePeerDependencies: @@ -7216,11 +7497,11 @@ snapshots: '@vscode/l10n@0.0.18': {} - '@wdio/config@9.30.0': + '@wdio/config@9.30.0(supports-color@7.2.0)': dependencies: '@wdio/logger': 9.29.1 '@wdio/types': 9.29.1 - '@wdio/utils': 9.30.0 + '@wdio/utils': 9.30.0(supports-color@7.2.0) deepmerge-ts: 7.1.6 glob: 10.5.0 import-meta-resolve: 4.2.0 @@ -7249,22 +7530,22 @@ snapshots: dependencies: '@types/node': 20.19.43 - '@wdio/utils@9.30.0': + '@wdio/utils@9.30.0(supports-color@7.2.0)': dependencies: - '@puppeteer/browsers': 2.13.2 + '@puppeteer/browsers': 2.13.2(supports-color@7.2.0) '@wdio/logger': 9.29.1 '@wdio/types': 9.29.1 decamelize: 6.0.1 deepmerge-ts: 7.1.6 - edgedriver: 6.3.0 - geckodriver: 6.1.1 + edgedriver: 6.3.0(supports-color@7.2.0) + geckodriver: 6.1.1(supports-color@7.2.0) get-port: 7.2.0 import-meta-resolve: 4.2.0 locate-app: 2.5.0 mitt: 3.0.1 safaridriver: 1.0.1 split2: 4.2.0 - wait-port: 1.1.0 + wait-port: 1.1.0(supports-color@7.2.0) transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -7739,9 +8020,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decamelize@6.0.1: {} @@ -7814,15 +8097,15 @@ snapshots: '@types/which': 2.0.2 which: 2.0.2 - edgedriver@6.3.0: + edgedriver@6.3.0(supports-color@7.2.0): dependencies: '@wdio/logger': 9.29.1 '@zip.js/zip.js': 2.10.0 decamelize: 6.0.1 edge-paths: 3.0.5 fast-xml-parser: 5.11.1 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) which: 6.0.1 transitivePeerDependencies: - supports-color @@ -7877,6 +8160,35 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -7920,108 +8232,108 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) - eslint-plugin-react-dom@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-dom@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) compare-versions: 6.1.1 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(jiti@2.7.0)): + eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/parser': 7.29.7 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react-jsx@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-jsx@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-naming-convention@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-naming-convention@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-rsc@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-rsc@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-web-api@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-web-api@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) birecord: 0.1.2 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-x@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-x@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.63.0 - '@typescript-eslint/type-utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) compare-versions: 6.1.1 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) string-ts: 2.3.1 ts-api-utils: 2.5.0(typescript@6.0.3) ts-pattern: 5.9.0 @@ -8033,13 +8345,13 @@ snapshots: dependencies: safe-regex: 2.1.1 - eslint-plugin-simple-import-sort@14.0.0(eslint@10.9.1(jiti@2.7.0)): + eslint-plugin-simple-import-sort@14.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) - eslint-plugin-unicorn@74.0.0(eslint@10.9.1(jiti@2.7.0)): + eslint-plugin-unicorn@74.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@eslint/css-tree': 4.0.5 browserslist: 4.28.8 change-case: 5.4.4 @@ -8047,7 +8359,7 @@ snapshots: core-js-compat: 3.50.0 detect-indent: 7.0.2 entities: 8.0.0 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) find-up-simple: 1.0.1 globals: 17.11.0 indent-string: 5.0.0 @@ -8061,11 +8373,11 @@ snapshots: strip-indent: 4.1.1 yaml: 2.9.0 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) eslint-scope@9.1.2: dependencies: @@ -8078,11 +8390,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.9.1(jiti@2.7.0): + eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@7.2.0) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 @@ -8092,7 +8404,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -8186,9 +8498,9 @@ snapshots: extend@3.0.2: {} - extract-zip@2.0.1: + extract-zip@2.0.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -8240,6 +8552,10 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -8285,13 +8601,13 @@ snapshots: function-timeout@1.0.2: {} - geckodriver@6.1.1: + geckodriver@6.1.1(supports-color@7.2.0): dependencies: '@wdio/logger': 9.29.1 '@zip.js/zip.js': 2.10.0 decamelize: 6.0.1 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) modern-tar: 0.7.7 transitivePeerDependencies: - supports-color @@ -8316,11 +8632,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.5: + get-uri@6.0.5(supports-color@7.2.0): dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -8413,7 +8729,7 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-estree@3.1.3: + hast-util-to-estree@3.1.3(supports-color@7.2.0): dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -8423,9 +8739,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -8448,7 +8764,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6: + hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 @@ -8457,9 +8773,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -8526,17 +8842,17 @@ snapshots: http-cache-semantics@4.2.0: {} - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -8663,12 +8979,12 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.1 + lru-cache: 11.5.2 parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.28.0 + undici: 7.29.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -8874,14 +9190,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -8899,67 +9215,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: + mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0: + mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 @@ -8967,7 +9283,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -8976,23 +9292,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0: + mdast-util-mdx@3.0.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.3 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1: + mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -9280,10 +9596,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@7.2.0): dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -9424,16 +9740,16 @@ snapshots: p-timeout@7.0.1: {} - pac-proxy-agent@7.2.0: + pac-proxy-agent@7.2.0(supports-color@7.2.0): dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + get-uri: 6.0.5(supports-color@7.2.0) + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 + socks-proxy-agent: 8.0.5(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -9574,16 +9890,16 @@ snapshots: property-information@7.2.0: {} - proxy-agent@6.5.0: + proxy-agent@6.5.0(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 + pac-proxy-agent: 7.2.0(supports-color@7.2.0) proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 + socks-proxy-agent: 8.0.5(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -9692,11 +10008,11 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0: + rehype-recma@1.0.0(supports-color@7.2.0): dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 - hast-util-to-estree: 3.1.3 + hast-util-to-estree: 3.1.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -9706,28 +10022,28 @@ snapshots: hast-util-to-html: 9.0.5 unified: 11.0.5 - remark-gfm@4.0.1: + remark-gfm@4.0.1(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 + mdast-util-gfm: 3.1.0(supports-color@7.2.0) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@7.2.0) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1: + remark-mdx@3.1.1(supports-color@7.2.0): dependencies: - mdast-util-mdx: 3.0.0 + mdast-util-mdx: 3.0.0(supports-color@7.2.0) micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -9832,9 +10148,9 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.7 '@rolldown/binding-win32-x64-msvc': 1.2.7 - rollup-plugin-esbuild@6.2.1(esbuild@0.28.2)(rollup@4.63.1): + rollup-plugin-esbuild@6.2.1(esbuild@0.28.2)(rollup@4.63.1)(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) es-module-lexer: 1.7.0 esbuild: 0.28.2 get-tsconfig: 4.14.0 @@ -10011,10 +10327,10 @@ snapshots: smol-toml@1.8.0: {} - socks-proxy-agent@8.0.5: + socks-proxy-agent@8.0.5(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) socks: 2.8.10 transitivePeerDependencies: - supports-color @@ -10175,7 +10491,7 @@ snapshots: terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -10201,8 +10517,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyrainbow@3.1.1: {} @@ -10269,13 +10585,13 @@ snapshots: dependencies: semver: 7.8.5 - typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -10300,8 +10616,6 @@ snapshots: undici@6.28.0: {} - undici@7.28.0: {} - undici@7.29.0: {} undici@8.10.1: {} @@ -10422,6 +10736,24 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.4.1 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.33.0 + sass: 1.101.7 + terser: 5.48.0 + tsx: 4.23.13 + yaml: 2.9.0 + vite@7.3.6(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0): dependencies: esbuild: 0.28.2 @@ -10486,8 +10818,8 @@ snapshots: optionalDependencies: '@types/node': 26.4.1 '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) - '@vitest/browser-webdriverio': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0) - '@vitest/coverage-istanbul': 4.1.11(vitest@4.1.11) + '@vitest/browser-webdriverio': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0(supports-color@7.2.0)) + '@vitest/coverage-istanbul': 4.1.11(supports-color@7.2.0)(vitest@4.1.11) jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -10602,11 +10934,11 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wait-port@1.1.0: + wait-port@1.1.0(supports-color@7.2.0): dependencies: chalk: 4.1.2 commander: 9.5.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -10614,17 +10946,17 @@ snapshots: web-worker@1.5.0: {} - webdriver@9.30.0: + webdriver@9.30.0(supports-color@7.2.0): dependencies: '@types/node': 20.19.43 '@types/ws': 8.18.1 - '@wdio/config': 9.30.0 + '@wdio/config': 9.30.0(supports-color@7.2.0) '@wdio/logger': 9.29.1 '@wdio/protocols': 9.30.0 '@wdio/types': 9.29.1 - '@wdio/utils': 9.30.0 + '@wdio/utils': 9.30.0(supports-color@7.2.0) deepmerge-ts: 7.1.6 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@7.2.0) undici: 6.28.0 ws: 8.21.3 transitivePeerDependencies: @@ -10635,16 +10967,16 @@ snapshots: - supports-color - utf-8-validate - webdriverio@9.30.0: + webdriverio@9.30.0(supports-color@7.2.0): dependencies: '@types/node': 20.19.43 '@types/sinonjs__fake-timers': 8.1.5 - '@wdio/config': 9.30.0 + '@wdio/config': 9.30.0(supports-color@7.2.0) '@wdio/logger': 9.29.1 '@wdio/protocols': 9.30.0 '@wdio/repl': 9.16.2 '@wdio/types': 9.29.1 - '@wdio/utils': 9.30.0 + '@wdio/utils': 9.30.0(supports-color@7.2.0) archiver: 7.0.1 aria-query: 5.3.2 cheerio: 1.2.0 @@ -10661,7 +10993,7 @@ snapshots: rgb2hex: 0.2.5 serialize-error: 12.0.0 urlpattern-polyfill: 10.1.0 - webdriver: 9.30.0 + webdriver: 9.30.0(supports-color@7.2.0) transitivePeerDependencies: - bare-abort-controller - bare-buffer From 1aba3f11795bfe3604c9ccc37050fe15fcd748cc Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 06:47:10 +0200 Subject: [PATCH 12/15] feat(bench): compare the three engines on particles, drawing and simulating Two scenes on one draw path, kept apart because they answer different questions and a figure from one would be read as the other. `particles-draw` submits a fixed set of small translucent quads and simulates nothing, so it measures the submission path alone: the ExoJS particle renderer, Pixi's `ParticleContainer` with every property declared static, a Phaser emitter whose simulation is never stepped. A million quads there is a million quads drawn, not a million interactive sprites. `particles-lifecycle` puts the shared effect on top of that same path - a two second life, a linear drift, a linear fade and a respawn at the end - with the pool held at the node count. ExoJS and Phaser each run their own emitter, which is what is under comparison. Pixi has no emitter at all, so its arm pairs `ParticleContainer` with exactly the shared update rule written out in the harness; that code sits inside the measured bracket and is named for what it is rather than passed off as a Pixi feature. Verified by capture rather than by reading the adapters: all three arms light the identical 161233 pixels in the draw scene, and land within half a percent of each other in the lifecycle scene, where their particles are not on the same coordinates by design. The harness was resolving the official extension packages to their BUILT output while resolving the engine beside them to source, so an extension arm measured a different tree from the rest of the matrix - and the two copies of a class failed every `instanceof` across the boundary, which is how a `ParticleSystem` silently kept its default capacity of 4096 instead of the ten thousand its cell asked for. The dev server now maps the extension entries and the engine's subpath exports to source, and resolves `#*` per importer so each package's own map still reaches its own sources. --- .../exojs-bench/baselines/structural.json | 46 +++++- packages/exojs-bench/src/comparison/build.ts | 2 + .../src/rendering/EngineAdapter.ts | 15 +- .../src/rendering/adapters/exojs.ts | 151 +++++++++++++++++- .../src/rendering/adapters/phaser.ts | 115 ++++++++++++- .../src/rendering/adapters/pixi.ts | 129 ++++++++++++++- .../exojs-bench/src/rendering/archetypes.ts | 50 ++++++ .../exojs-bench/src/rendering/particles.ts | 101 ++++++++++++ .../exojs-bench/src/rendering/sceneAssets.ts | 27 ++++ packages/exojs-bench/src/shared/viteServer.ts | 57 ++++++- 10 files changed, 683 insertions(+), 10 deletions(-) create mode 100644 packages/exojs-bench/src/rendering/particles.ts diff --git a/packages/exojs-bench/baselines/structural.json b/packages/exojs-bench/baselines/structural.json index 42eb9ad88..16e252515 100644 --- a/packages/exojs-bench/baselines/structural.json +++ b/packages/exojs-bench/baselines/structural.json @@ -1,6 +1,6 @@ { "recorded": { - "at": "2026-09-11T04:21:15.092Z", + "at": "2026-09-11T04:46:48.402Z", "engineVersion": "0.17.0", "adapter": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)" }, @@ -175,6 +175,26 @@ "textureBinds": 0, "bufferUploads": 0 }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "particles-draw", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "particles-lifecycle", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, { "engine": "exojs", "config": "current", @@ -223,7 +243,7 @@ "nodeCount": 200, "drawCalls": 1, "textureBinds": 0, - "bufferUploads": 0 + "bufferUploads": 1 }, { "engine": "exojs", @@ -405,6 +425,26 @@ "textureBinds": 0, "bufferUploads": 0 }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "particles-draw", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "particles-lifecycle", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, { "engine": "exojs", "config": "retained", @@ -453,7 +493,7 @@ "nodeCount": 200, "drawCalls": 1, "textureBinds": 0, - "bufferUploads": 0 + "bufferUploads": 1 }, { "engine": "exojs", diff --git a/packages/exojs-bench/src/comparison/build.ts b/packages/exojs-bench/src/comparison/build.ts index de885a6d5..4219ffe39 100644 --- a/packages/exojs-bench/src/comparison/build.ts +++ b/packages/exojs-bench/src/comparison/build.ts @@ -53,6 +53,7 @@ const CATEGORY_ORDER: readonly ArchetypeCategory[] = [ 'render-targets', 'camera-and-world', 'tilemaps', + 'particles', 'submission', ]; @@ -65,6 +66,7 @@ const CATEGORY_TITLES: Readonly> = { 'render-targets': 'Render targets', 'camera-and-world': 'Camera and world', tilemaps: 'Tilemaps', + particles: 'Particles', submission: 'Submission paths', }; diff --git a/packages/exojs-bench/src/rendering/EngineAdapter.ts b/packages/exojs-bench/src/rendering/EngineAdapter.ts index 2ef5bc67c..2b4ec2a63 100644 --- a/packages/exojs-bench/src/rendering/EngineAdapter.ts +++ b/packages/exojs-bench/src/rendering/EngineAdapter.ts @@ -14,6 +14,8 @@ export type ArchetypeId = | 'fill-layers' | 'tilemap-scroll' | 'tilemap-edit' + | 'particles-draw' + | 'particles-lifecycle' | 'batch-breaking' | 'batch-breaking-atlased' | 'split-screen' @@ -41,7 +43,7 @@ export type ArchetypeId = * cell. Nothing in the report aggregates across archetypes. */ export type ArchetypeCategory = - 'node-scaling' | 'fill-and-state' | 'material-variety' | 'text' | 'render-targets' | 'camera-and-world' | 'submission' | 'tilemaps'; + 'node-scaling' | 'fill-and-state' | 'material-variety' | 'text' | 'render-targets' | 'camera-and-world' | 'submission' | 'tilemaps' | 'particles'; /** Structural definition of a scene archetype, independent of any engine or backend. */ export interface ArchetypeSpec { @@ -273,6 +275,17 @@ export interface ArchetypeSpec { * dimensions. */ readonly tilemap?: 'scroll' | 'edit'; + /** + * Renders particles instead of a sprite scene, and whether the scene also + * simulates them. + * + * `'draw'` submits a fixed set of quads through the arm's particle draw path + * and advances nothing; `'lifecycle'` runs a steady effect - ageing, movement, + * fading, respawning - on top of that same path. The two answer different + * questions and a figure from one says nothing about the other, which is why + * they are separate archetypes. See `particles.ts` for the shared scene. + */ + readonly particles?: 'draw' | 'lifecycle'; /** * Number of chained post-process filters applied to the scene root, or * `undefined` for the unfiltered scene every other archetype builds. diff --git a/packages/exojs-bench/src/rendering/adapters/exojs.ts b/packages/exojs-bench/src/rendering/adapters/exojs.ts index 2a4846087..db14eaacd 100644 --- a/packages/exojs-bench/src/rendering/adapters/exojs.ts +++ b/packages/exojs-bench/src/rendering/adapters/exojs.ts @@ -1,7 +1,9 @@ +import { AlphaFadeOverLifetime, Curve, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileSet } from '@codexo/exojs-tilemap'; import { Application } from '#core/Application'; import { Color } from '#core/Color'; +import type { Seconds } from '#core/units'; import { Matrix } from '#math/Matrix'; import { Rectangle } from '#math/Rectangle'; import { CallbackRenderPass } from '#rendering/CallbackRenderPass'; @@ -31,7 +33,17 @@ import type { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; -import { createDistinctTextureCanvas, createTileAtlasCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; +import { + isParticleLifecycle, + isParticles, + PARTICLE_ALPHA, + PARTICLE_LIFETIME, + PARTICLE_PREROLL_STEPS, + PARTICLE_STEP, + PARTICLE_TINT, + particleSeedAt, +} from '../particles'; +import { createDistinctTextureCanvas, createParticleCanvas, createTileAtlasCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; import type { TilemapExtent } from '../tilemap'; import { isTilemap, @@ -563,6 +575,118 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E tilemapSpec = null; }; + /** The particle system the particle scenes draw, or `null` for every other archetype. */ + let particleSystem: ParticleSystem | null = null; + let particleTexture: Texture | null = null; + let particleSpec: ArchetypeSpec | null = null; + + /** + * Fill the system up to its capacity, giving each particle the shared scene's + * deterministic state. + * + * `emit()` returns `null` once the pool is full, which is what holds the live + * count at the node count: the scene tops the pool up every frame rather than + * spawning at a rate and hoping the two balance out. + */ + const fillParticles = (count: number, initial: boolean): void => { + for (let filled = 0; filled < count; filled += 1) { + const particle = particleSystem!.emit(); + + if (particle === null) { + return; + } + + // The cursor walks the whole seed set rather than restarting at zero, so a + // respawn lands on the next unused layout instead of piling every + // replacement onto the same handful of positions - which is what turned + // the pool into a few dense clusters while the arms beside it stayed + // evenly spread. + const index = particleCursor % count; + + particleCursor += 1; + + const seed = particleSeedAt(index, count); + + particle.position.set(seed.x, seed.y); + particle.velocity.set(seed.velocityX, seed.velocityY); + // The sprite is already PARTICLE_SIZE square, and `scale` is a factor: + // setting it to the size would draw a quad four times too large. + particle.scale.set(1, 1); + particle.color = PARTICLE_TINT; + if (particleLifetime === null) { + // The draw-only scene never advances, so its particles must not expire: + // a finite life would shrink the pool over a long cell for no reason the + // scene is measuring. + particle.lifetime = Number.MAX_SAFE_INTEGER; + } else { + // On the first fill each particle gets what is LEFT of one lifetime, so + // the pool starts evenly aged and its respawns land on different frames + // instead of arriving as one burst. + particle.lifetime = initial ? Math.max(PARTICLE_STEP, particleLifetime - seed.age) : particleLifetime; + } + } + }; + + /** Next seed index a spawn takes; see {@link fillParticles}. */ + let particleCursor = 0; + + /** Seconds a particle lives, or `null` in the draw-only scene, which never ages one. */ + let particleLifetime: number | null = null; + + /** + * Build the particle scene: a system at the node count's capacity, filled + * once, and - for the lifecycle scene - advanced through one full lifetime so + * the timed window sees a steady pool rather than a settling one. + */ + const buildParticleScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const texture = new Texture(createParticleCanvas()); + const system = new ParticleSystem(texture, { capacity: nodeCount }); + + particleSystem = system; + particleTexture = texture; + particleSpec = spec; + particleLifetime = isParticleLifecycle(spec) ? PARTICLE_LIFETIME : null; + particleCursor = 0; + + system.setBlendMode(BlendModes.Normal); + + if (particleLifetime !== null) { + // Linear fade over the life, the shared scene's one update rule. Every arm + // applies the same one, so no arm is paying for an effect the others skip. + // From the scene's alpha to zero. The module's default curve starts at 1, + // which would make this arm's particles twice as bright as every other + // arm's for the whole of their lives. + system.addUpdateModule( + new AlphaFadeOverLifetime( + new Curve([ + { t: 0, v: PARTICLE_ALPHA }, + { t: 1, v: 0 }, + ]), + ), + ); + } + + root = new Container(); + root.addChild(system); + + fillParticles(nodeCount, true); + + for (let step = 0; step < (particleLifetime === null ? 0 : PARTICLE_PREROLL_STEPS); step += 1) { + system.update(PARTICLE_STEP as Seconds); + fillParticles(nodeCount, false); + } + }; + + /** Drop the particle scene so a rebuild (or teardown) leaks no GPU resources. */ + const releaseParticles = (): void => { + particleSystem?.destroy(); + particleSystem = null; + particleTexture?.destroy(); + particleTexture = null; + particleSpec = null; + particleLifetime = null; + }; + return { engine: 'exojs', config, @@ -591,7 +715,7 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E // contributes renderer bindings rather than scene work, and an engine // configured differently per archetype would make two archetypes' // numbers describe two engines. - extensions: [tilemapExtension], + extensions: [tilemapExtension, particlesExtension], }); // Boot the full production init path (awaits the backend's async @@ -619,6 +743,7 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E releaseBatchScene(); releaseComposite(); releaseTilemap(); + releaseParticles(); sharedMeshGeometry?.destroy(); sharedMeshGeometry = null; @@ -630,6 +755,14 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E return; } + // The particle scenes leave the sprite path behind too: their leaves live + // in the system's own storage rather than in the scene graph. + if (isParticles(spec)) { + buildParticleScene(spec, nodeCount); + + return; + } + // `instanced-batch` leaves the scene graph behind entirely: nodeCount // instances are laid out on the same grid every other archetype uses, but // submitted as ceil(nodeCount / batchSize) explicit drawBatch calls over one @@ -895,6 +1028,19 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E }, mutate(frame: number): void { + // Particle scenes: the lifecycle one advances the simulation and tops the + // pool back up, both inside the bracket, because ageing, moving, fading and + // respawning ARE the per-frame work it measures. The draw-only scene + // advances nothing - it submits the same quads every frame by design. + if (particleSpec !== null && particleSystem !== null) { + if (particleLifetime !== null) { + particleSystem.update(PARTICLE_STEP as Seconds); + fillParticles(particleSystem.capacity, false); + } + + return; + } + // Tilemap scenes: move the window, then submit this frame's tile changes. // Both belong in the bracket - scrolling and editing ARE the per-frame work // these scenes do, and an edit an arm defers past the draw would not be an @@ -1048,6 +1194,7 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E releaseBatchScene(); releaseComposite(); releaseTilemap(); + releaseParticles(); if (root !== null) { root.destroy(); diff --git a/packages/exojs-bench/src/rendering/adapters/phaser.ts b/packages/exojs-bench/src/rendering/adapters/phaser.ts index 330901d15..5a054ea94 100644 --- a/packages/exojs-bench/src/rendering/adapters/phaser.ts +++ b/packages/exojs-bench/src/rendering/adapters/phaser.ts @@ -2,9 +2,11 @@ import * as Phaser from 'phaser'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; +import { isParticleLifecycle, isParticles, PARTICLE_ALPHA, PARTICLE_LIFETIME, PARTICLE_STEP, particleSeedAt } from '../particles'; import { createDigitAtlasCanvas, createDistinctTextureCanvas, + createParticleCanvas, createTileAtlasCanvas, DIGIT_ALPHABET, DIGIT_CELL_HEIGHT, @@ -183,6 +185,97 @@ export const createPhaserAdapter = (): EngineAdapter => { tilemapSpec = null; }; + /** The particle scene's emitter, or `null` for every other archetype. */ + let particleEmitter: Phaser.GameObjects.Particles.ParticleEmitter | null = null; + let particleSpec: ArchetypeSpec | null = null; + /** Milliseconds of emitter time already simulated, so `preUpdate` gets a monotonic clock. */ + let particleClockMs = 0; + + /** + * Build the particle scene on Phaser's own emitter. + * + * The draw-only scene emits the whole pool at once and then never steps the + * emitter: the harness drives rendering alone, so an unstepped emitter holds + * its particles exactly where they were put - which is the resting simulation + * this scene asks every arm for. Their positions are overwritten from the + * shared layout, so this arm draws the identical picture rather than its own + * random spread. + * + * The lifecycle scene keeps Phaser's emitter doing the simulating, configured + * to the shared contract: a two second life, a steady rate that holds the pool + * at the node count, a linear drift and a linear fade. Its particles do not + * land on the same coordinates as the other arms' - the emitter draws its own + * randoms - and they are not supposed to: the comparison is of equal work, not + * of identical pixels, and forcing the positions would replace the emitter + * under test with harness code. + */ + const buildParticleScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const key = `${SCENE_KEY}-particle`; + + if (game!.textures.exists(key)) { + game!.textures.remove(key); + } + + game!.textures.addCanvas(key, createParticleCanvas()); + + particleClockMs = 0; + + if (isParticleLifecycle(spec)) { + const emitter = scene!.add.particles(0, 0, key, { + lifespan: PARTICLE_LIFETIME * 1000, + speed: { min: 20, max: 60 }, + angle: { min: 0, max: 360 }, + alpha: { start: PARTICLE_ALPHA, end: 0 }, + // One emission per frame of the share of the pool that expires in it, so + // the live count holds at the node count instead of oscillating. + frequency: 0, + quantity: Math.max(1, Math.round(nodeCount / (PARTICLE_LIFETIME / PARTICLE_STEP))), + maxAliveParticles: nodeCount, + // Spread over the viewport through the emitter's own per-particle x/y + // ranges, so the pool fills the frame the way every other arm's does. + x: { min: 0, max: VIEWPORT_WIDTH }, + y: { min: 0, max: VIEWPORT_HEIGHT }, + }); + + // One full lifetime before the timed window, like every other arm's + // preroll: the pool reaches its steady state outside the measurement. + emitter.fastForward(PARTICLE_LIFETIME * 1000, PARTICLE_STEP * 1000); + particleClockMs = PARTICLE_LIFETIME * 1000; + particleEmitter = emitter; + } else { + const emitter = scene!.add.particles(0, 0, key, { + lifespan: Number.MAX_SAFE_INTEGER, + speed: 0, + alpha: PARTICLE_ALPHA, + emitting: false, + maxAliveParticles: nodeCount, + }); + + emitter.explode(nodeCount); + + let index = 0; + + emitter.forEachAlive(particle => { + const seed = particleSeedAt(index, nodeCount); + + index += 1; + particle.x = seed.x; + particle.y = seed.y; + }, null); + + particleEmitter = emitter; + } + + particleSpec = spec; + }; + + /** Drop the particle scene so a rebuild (or teardown) leaks nothing. */ + const releaseParticles = (): void => { + particleEmitter?.destroy(); + particleEmitter = null; + particleSpec = null; + }; + return { engine: 'phaser', config: 'webgl2', @@ -260,6 +353,13 @@ export const createPhaserAdapter = (): EngineAdapter => { } releaseTilemap(); + releaseParticles(); + + if (isParticles(spec)) { + buildParticleScene(spec, nodeCount); + + return; + } // The tilemap scenes leave the sprite path behind: the leaves are tiles in // a layer rather than game objects, so nothing below applies to them. @@ -404,6 +504,18 @@ export const createPhaserAdapter = (): EngineAdapter => { }, mutate(frame: number): void { + // Particle scenes: the lifecycle one steps Phaser's emitter, which is the + // simulation under comparison; the draw-only one steps nothing, so its + // particles stay where the build put them. + if (particleSpec !== null && particleEmitter !== null) { + if (isParticleLifecycle(particleSpec)) { + particleClockMs += PARTICLE_STEP * 1000; + particleEmitter.preUpdate(particleClockMs, PARTICLE_STEP * 1000); + } + + return; + } + // Tilemap scenes: scroll the camera, then submit this frame's tile changes. // The GPU layer reads its tiles from a data texture that does not follow a // tile write on its own, so regenerating that texture is what actually @@ -462,7 +574,7 @@ export const createPhaserAdapter = (): EngineAdapter => { }, renderFrame(): void { - if (game === null || (root === null && tileLayer === null)) { + if (game === null || (root === null && tileLayer === null && particleEmitter === null)) { throw new Error('renderFrame was called before buildScene.'); } @@ -479,6 +591,7 @@ export const createPhaserAdapter = (): EngineAdapter => { teardown(): void { releaseTilemap(); + releaseParticles(); if (game !== null) { // `destroy` only FLAGS pending destruction (normally consumed by the next diff --git a/packages/exojs-bench/src/rendering/adapters/pixi.ts b/packages/exojs-bench/src/rendering/adapters/pixi.ts index 38a7f4be3..44e463c76 100644 --- a/packages/exojs-bench/src/rendering/adapters/pixi.ts +++ b/packages/exojs-bench/src/rendering/adapters/pixi.ts @@ -8,6 +8,8 @@ import { Culler, type Filter, Graphics, + Particle, + ParticleContainer, Rectangle, RendererType, RenderTexture, @@ -19,7 +21,8 @@ import { import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; -import { createDistinctTextureCanvas, createTileAtlasCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; +import { isParticleLifecycle, isParticles, PARTICLE_ALPHA, PARTICLE_LIFETIME, PARTICLE_PREROLL_STEPS, PARTICLE_STEP, particleSeedAt } from '../particles'; +import { createDistinctTextureCanvas, createParticleCanvas, createTileAtlasCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; import type { TilemapExtent } from '../tilemap'; import { isTilemap, @@ -413,6 +416,111 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine tilemapSpec = null; }; + /** The particle scene's container, or `null` for every other archetype. */ + let particleContainer: ParticleContainer | null = null; + let particleList: Particle[] = []; + let particleTexture: Texture | null = null; + let particleSpec: ArchetypeSpec | null = null; + /** Per-particle simulation state the lifecycle scene advances; empty in the draw-only scene. */ + let particleVelocityX: Float32Array = new Float32Array(0); + let particleVelocityY: Float32Array = new Float32Array(0); + let particleAge: Float32Array = new Float32Array(0); + + /** + * Advance the lifecycle scene by one fixed step: age, move, fade, and respawn + * whatever reached the end of its life. + * + * This is HARNESS code, not a Pixi feature, and the comparison says so. + * `ParticleContainer` is a draw path - it has no emitter, no ageing and no + * respawn - so the lifecycle scene pairs it with exactly the shared update + * rule every other arm runs, written out here. It sits inside the measured + * bracket, because it is work this arm genuinely performs. + */ + const advanceParticles = (): void => { + const count = particleList.length; + + for (let index = 0; index < count; index += 1) { + const particle = particleList[index]!; + let age = particleAge[index]! + PARTICLE_STEP; + + if (age >= PARTICLE_LIFETIME) { + const seed = particleSeedAt(index, count); + + age = 0; + particle.x = seed.x; + particle.y = seed.y; + particleVelocityX[index] = seed.velocityX; + particleVelocityY[index] = seed.velocityY; + } else { + particle.x += particleVelocityX[index]! * PARTICLE_STEP; + particle.y += particleVelocityY[index]! * PARTICLE_STEP; + } + + particleAge[index] = age; + particle.alpha = PARTICLE_ALPHA * (1 - age / PARTICLE_LIFETIME); + } + }; + + /** + * Build the particle scene: one `ParticleContainer` holding the node count's + * worth of particles. + * + * The draw-only scene declares every property static, which is the fastest + * shape this API offers and the one a project drawing a fixed set of quads + * would use. The lifecycle scene declares position and colour dynamic, + * because it changes both every frame - declaring them static there would + * measure a scene whose motion never reaches the GPU. + */ + const buildParticleScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const texture = Texture.from(createParticleCanvas()); + const lifecycle = isParticleLifecycle(spec); + const container = new ParticleContainer({ + dynamicProperties: { position: lifecycle, rotation: false, vertex: false, uvs: false, color: lifecycle }, + }); + + particleList = new Array(nodeCount); + particleVelocityX = new Float32Array(lifecycle ? nodeCount : 0); + particleVelocityY = new Float32Array(lifecycle ? nodeCount : 0); + particleAge = new Float32Array(lifecycle ? nodeCount : 0); + + for (let index = 0; index < nodeCount; index += 1) { + const seed = particleSeedAt(index, nodeCount); + // Centred, because the ExoJS particle storage positions a particle by its + // centre; left at Pixi's top-left default the two arms would draw the same + // scene half a particle apart. + const particle = new Particle({ texture, x: seed.x, y: seed.y, alpha: PARTICLE_ALPHA, anchorX: 0.5, anchorY: 0.5 }); + + if (lifecycle) { + particleVelocityX[index] = seed.velocityX; + particleVelocityY[index] = seed.velocityY; + // Evenly aged at the start, so respawns land on different frames + // instead of arriving as one burst. + particleAge[index] = seed.age; + } + + particleList[index] = particle; + container.addParticle(particle); + } + + particleContainer = container; + particleTexture = texture; + particleSpec = spec; + + for (let step = 0; step < (lifecycle ? PARTICLE_PREROLL_STEPS : 0); step += 1) { + advanceParticles(); + } + }; + + /** Drop the particle scene so a rebuild (or teardown) leaks no GPU resources. */ + const releaseParticles = (): void => { + particleContainer?.destroy({ children: true }); + particleContainer = null; + particleList = []; + particleTexture?.destroy(true); + particleTexture = null; + particleSpec = null; + }; + return { engine: 'pixi', config, @@ -479,6 +587,7 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine } releaseTilemap(); + releaseParticles(); textures = []; @@ -495,6 +604,13 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine return; } + if (isParticles(spec)) { + buildParticleScene(spec, nodeCount); + root = particleContainer; + + return; + } + // Nested-container spine of depth `nestingDepth`, exactly as the ExoJS arm // builds it. Pixi has no separate retained/immediate tier here, so this one // arm is the whole Pixi comparison; the spine still exercises deep transform @@ -701,6 +817,16 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine }, mutate(frame: number): void { + // Particle scenes: the lifecycle one advances the shared update rule; the + // draw-only one submits the same quads every frame by design. + if (particleSpec !== null) { + if (isParticleLifecycle(particleSpec)) { + advanceParticles(); + } + + return; + } + // Tilemap scenes: move the window, reveal the chunks it now overlaps, and // submit this frame's tile changes. An edit means repainting the chunk that // holds it - `Tilemap` has no per-tile update - and that repaint is the @@ -832,6 +958,7 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine teardown(): void { releaseBloom(); releaseTilemap(); + releaseParticles(); if (root !== null) { root.destroy({ children: true }); diff --git a/packages/exojs-bench/src/rendering/archetypes.ts b/packages/exojs-bench/src/rendering/archetypes.ts index c4887d83e..a2579955c 100644 --- a/packages/exojs-bench/src/rendering/archetypes.ts +++ b/packages/exojs-bench/src/rendering/archetypes.ts @@ -51,6 +51,21 @@ const TILEMAP_COUNTS = [10_000, 100_000, 1_000_000] as const; */ const TILEMAP_EDIT_COUNTS = [10_000, 100_000] as const; +/** + * Particle counts for the draw-only scene. Four steps spanning 1000x: the top + * one is a million quads submitted in a frame, which is where a particle draw + * path either holds up or does not. + */ +const PARTICLE_DRAW_COUNTS = [1_000, 10_000, 100_000, 1_000_000] as const; + +/** + * Particle counts for the lifecycle scene. It stops below the million the + * draw-only scene reaches: a million simulated particles measures each arm's + * update loop rather than the effect, and no effect anything ships keeps that + * many alive at once. + */ +const PARTICLE_LIFECYCLE_COUNTS = [1_000, 10_000, 100_000] as const; + /** * Characters per text leaf across both text archetypes. Twelve is the length of * an ordinary label (a score, a name, a damage number) - long enough that layout @@ -593,6 +608,41 @@ export const ARCHETYPES: readonly ArchetypeSpec[] = [ cullingEnabled: true, tilemap: 'edit', }, + // PARTICLES. Two scenes on one draw path, kept apart because they answer + // different questions and a figure from one would be read as the other. + // + // `particles-draw` submits a fixed set of small translucent quads and + // simulates nothing, so it measures the submission path alone: an arm's + // particle renderer, Pixi's `ParticleContainer`, a Phaser emitter whose + // simulation is not stepped. A million quads here is a million quads drawn, + // NOT a million interactive sprites, and nothing in the published figure may + // suggest otherwise. + { + id: 'particles-draw', + category: 'particles', + crossArm: true, + nodeCounts: PARTICLE_DRAW_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: false, + particles: 'draw', + }, + // The same draw path with a steady effect on top: every particle ages, moves, + // fades and respawns at the end of its life, with the pool held at the node + // count. The delta against the row above is what the simulation costs, which + // is the half a bare container never pays. + { + id: 'particles-lifecycle', + category: 'particles', + crossArm: true, + nodeCounts: PARTICLE_LIFECYCLE_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: false, + particles: 'lifecycle', + }, { id: 'mask-clip-animated', category: 'render-targets', diff --git a/packages/exojs-bench/src/rendering/particles.ts b/packages/exojs-bench/src/rendering/particles.ts new file mode 100644 index 000000000..9faec3174 --- /dev/null +++ b/packages/exojs-bench/src/rendering/particles.ts @@ -0,0 +1,101 @@ +import { createRng } from '../shared/rng'; +import type { ArchetypeSpec } from './EngineAdapter'; +import { VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from './world'; + +/** + * The particle scenes' shared definition: how large a particle is, where it + * starts, how it moves and how long it lives. + * + * The two scenes ask different questions and must not be confused. `particles-draw` + * submits a fixed set of quads through each arm's particle draw path and + * simulates nothing; `particles-lifecycle` runs a steady effect - ageing, + * movement, fading and respawning - on top of that same draw path. A figure + * from one says nothing about the other, which is why they are separate + * archetypes rather than one with a flag. + */ + +/** Edge length of a particle quad, in pixels. Small, so the scenes are submission-bound rather than fill-bound. */ +export const PARTICLE_SIZE = 4; + +/** Alpha every particle carries, so the scenes exercise a blended draw rather than an opaque one. */ +export const PARTICLE_ALPHA = 0.5; + +/** + * Packed `0xAABBGGRR` tint every particle carries: white at + * {@link PARTICLE_ALPHA}. + * + * Stated as the packed word because that is the form the storage takes, and + * deriving it in each arm is how two arms end up a rounding step apart on the + * alpha channel. + */ +export const PARTICLE_TINT = ((Math.round(PARTICLE_ALPHA * 255) << 24) | 0x00_ff_ff_ff) >>> 0; + +/** Seconds a particle lives in the lifecycle scene. */ +export const PARTICLE_LIFETIME = 2; + +/** Fixed simulation step, in seconds. The lifecycle scene advances by exactly this much per frame on every arm. */ +export const PARTICLE_STEP = 1 / 60; + +/** + * Simulation steps the lifecycle scene runs before it is measured. + * + * Exactly one lifetime, so the pool reaches the steady state it is supposed to + * be measured in: every particle has been through a respawn, and the respawns + * are spread evenly across the frames instead of arriving as one burst. Run at + * build time, outside the timed window, like every other arm's scene + * construction. + */ +export const PARTICLE_PREROLL_STEPS = 120; + +/** Seed the particle layout is drawn from. */ +const PARTICLE_SEED = 0xc0_ff_ee; + +/** One particle's initial state, derived from its index alone. */ +export interface ParticleSeedState { + readonly x: number; + readonly y: number; + readonly velocityX: number; + readonly velocityY: number; + /** Seconds this particle has already lived at preroll time, spread across one lifetime. */ + readonly age: number; +} + +/** + * Deterministic sample stream for particle `index`. + * + * Seeded per particle from the shared RNG rather than hashed inline, so an arm + * can fill its storage in whatever order it wants and still produce the + * identical scene - and so neighbouring indices decorrelate, which a + * hand-rolled integer mix does not reliably do: an earlier one here laid + * consecutive particles along visible lines. + */ +const sampler = (index: number): (() => number) => createRng((PARTICLE_SEED + Math.imul(index, 0x9e_37_79_b1)) >>> 0); + +/** + * The initial state of particle `index` of `total`. + * + * Positions fill the viewport and velocities are a modest drift, so the + * lifecycle scene stays inside the frame for its whole life and no arm spends + * its time on particles nothing can see. Ages are spread evenly over one + * lifetime, which is what makes respawns land on different frames rather than + * all at once. + */ +export const particleSeedAt = (index: number, total: number): ParticleSeedState => { + const random = sampler(index); + const angle = random() * Math.PI * 2; + const speed = 20 + random() * 40; + + return { + x: random() * (VIEWPORT_WIDTH - PARTICLE_SIZE), + y: random() * (VIEWPORT_HEIGHT - PARTICLE_SIZE), + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed, + age: total > 0 ? (index / total) * PARTICLE_LIFETIME : 0, + }; +}; + +/** Whether the archetype renders particles rather than a sprite scene. */ +export const isParticles = (spec: ArchetypeSpec): boolean => spec.particles !== undefined; + +/** Whether the archetype simulates its particles rather than only drawing them. */ +export const isParticleLifecycle = (spec: ArchetypeSpec): boolean => spec.particles === 'lifecycle'; diff --git a/packages/exojs-bench/src/rendering/sceneAssets.ts b/packages/exojs-bench/src/rendering/sceneAssets.ts index f9f437a3e..5c62d7316 100644 --- a/packages/exojs-bench/src/rendering/sceneAssets.ts +++ b/packages/exojs-bench/src/rendering/sceneAssets.ts @@ -1,3 +1,4 @@ +import { PARTICLE_SIZE } from './particles'; import { TILE_SIZE, TILE_VARIANTS } from './tilemap'; import { SPRITE_SIZE } from './world'; @@ -50,6 +51,32 @@ export const createDistinctTextureCanvas = (index: number, total: number): HTMLC return canvas; }; +/** + * The particle sprite: one flat white {@link PARTICLE_SIZE} square. + * + * White and opaque, so the per-particle tint and alpha every arm applies are + * what decide the drawn colour - a coloured or pre-faded source would let one + * arm's tint handling show up as a brightness difference rather than as the + * cost it is. + */ +export const createParticleCanvas = (): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + + canvas.width = PARTICLE_SIZE; + canvas.height = PARTICLE_SIZE; + + const context = canvas.getContext('2d'); + + if (context === null) { + throw new Error('A 2D context is required to generate the benchmark particle sprite.'); + } + + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, PARTICLE_SIZE, PARTICLE_SIZE); + + return canvas; +}; + /** * The tile atlas: one row of {@link TILE_VARIANTS} cells of {@link TILE_SIZE} * pixels, each a flat distinct colour with a one-pixel darker border. diff --git a/packages/exojs-bench/src/shared/viteServer.ts b/packages/exojs-bench/src/shared/viteServer.ts index 62551007d..633dd1a55 100644 --- a/packages/exojs-bench/src/shared/viteServer.ts +++ b/packages/exojs-bench/src/shared/viteServer.ts @@ -21,6 +21,59 @@ const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); /** The engine's TypeScript source root every harness page benchmarks (`/src`). */ const ENGINE_SRC = resolve(REPO_ROOT, 'src'); +/** + * Engine and official-extension package entries, mapped to their SOURCE. + * + * The engine's `@codexo/exojs-source` condition only redirects a package's own + * `#*` imports; the package ENTRY still resolves through `exports`, which points + * at `dist`. Without these aliases an extension arm would load the built engine + * while the adapter beside it loads the source, so one cell would measure a + * different tree from the rest of the matrix - and the two copies of a class + * fail every `instanceof` across the boundary, which is how a `ParticleSystem` + * silently kept its default capacity instead of the one the cell asked for. + */ +const SOURCE_PACKAGE_ALIASES: ReadonlyArray<{ find: string; replacement: string }> = [ + { find: '@codexo/exojs-particles', replacement: resolve(REPO_ROOT, 'packages/exojs-particles/src/index.ts') }, + { find: '@codexo/exojs-tilemap', replacement: resolve(REPO_ROOT, 'packages/exojs-tilemap/src/index.ts') }, + { find: '@codexo/exojs/renderer-sdk', replacement: resolve(ENGINE_SRC, 'renderer-sdk.ts') }, + { find: '@codexo/exojs/extensions', replacement: resolve(ENGINE_SRC, 'extensions/index.ts') }, + { find: '@codexo/exojs', replacement: resolve(ENGINE_SRC, 'index.ts') }, +]; + +/** + * Source roots whose `#*` specifiers belong to the package they sit in rather + * than to the engine - the official extensions the harness measures through. + * The benchmark package itself is deliberately absent: its adapters use `#*` to + * reach engine modules. + */ +const EXTENSION_SOURCE_ROOTS: readonly string[] = [resolve(REPO_ROOT, 'packages/exojs-particles'), resolve(REPO_ROOT, 'packages/exojs-tilemap')]; + +/** + * Resolve `#...` specifiers to the ENGINE source - but only for importers + * outside the extension packages. + * + * Each extension package has its own `#*` map pointing at its own `src`, and a + * blanket alias would send `#gpu/ParticleGpuState` into the engine tree, where + * no such module exists. Declining here leaves those specifiers to Vite's normal + * `imports` resolution, which the source conditions already steer to the + * package's own sources. + */ +const engineHashImports = () => ({ + name: 'exojs-bench-engine-hash-imports', + enforce: 'pre', + resolveId(source: string, importer: string | undefined) { + if (!source.startsWith('#') || source.endsWith('.vert') || source.endsWith('.frag')) { + return null; + } + + if (importer !== undefined && EXTENSION_SOURCE_ROOTS.some(root => resolve(importer).startsWith(root))) { + return null; + } + + return `${resolve(ENGINE_SRC, source.slice(1))}.ts`; + }, +}); + /** WebGl2Shader extensions the engine imports as text. */ const SHADER_EXTENSIONS = ['.vert', '.frag', '.glsl', '.wgsl'] as const; @@ -232,7 +285,7 @@ export const startViteServer = async (options: StartViteServerOptions): Promise< // condition below, so the engine graph is measured exactly as it ships. // `.vert`/`.frag` specifiers carry their extension and are handled by // `realShaderPlugin`'s transform. - resolve: { alias: [{ find: /^#(.*)$/, replacement: `${ENGINE_SRC}/$1` }], conditions: srcConditions }, + resolve: { alias: [...SOURCE_PACKAGE_ALIASES, { find: /^#(.*)\.(vert|frag)$/, replacement: `${ENGINE_SRC}/$1.$2` }], conditions: srcConditions }, ssr: { resolve: { conditions: srcConditions } }, // `noDiscovery` keeps the automatic dep scanner OFF - it runs esbuild over // the whole import graph, which would choke on the engine's `.vert`/`.frag` @@ -248,7 +301,7 @@ export const startViteServer = async (options: StartViteServerOptions): Promise< // pre-bundled. optimizeDeps: { noDiscovery: true, include: resolvableCompetitors(libraryArms) }, define: { __DEV__: String(ENGINE_DEV_BUILD), __VERSION__: JSON.stringify(version), __REVISION__: JSON.stringify('baseline'), ...extraDefine }, - plugins: [realShaderPlugin, devGlobalsPlugin(version), ...extraPlugins], + plugins: [engineHashImports(), realShaderPlugin, devGlobalsPlugin(version), ...extraPlugins], }); await server.listen(); From d2a1e1aae2dd3e09ae5adf14f5e702dc1263c35f Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 06:56:38 +0200 Subject: [PATCH 13/15] feat(site): lead the benchmarks with result cards, on one page Rendering and physics were two pages fronted by a scoreboard and a wall of tables. A reader arriving with "how does ExoJS do on the work I am about to do" had to pick a domain, learn a table and read a tally before anything answered them. One page now, and it opens on result cards: the scenario in plain words, the load it was measured at, and one horizontal bar per library with its time on it. Bars are linear from zero within a card, so a bar's length is the time it shows and two bars can be read directly against each other - no log scale and no ratio axis, both of which have to be learned before they can be read and both of which make a small difference look large. A card compares the arms within one load and never two loads or two cards, because those are different scenes. Which scenarios open each section is fixed in the source before any run happens, and a profile that carries only some of them is topped up in its own order rather than by result, so the top of the page cannot become a selection of whatever ExoJS won. Losses keep the same treatment as wins: the clipping card leads with ExoJS's bar being the long one. Where a scenario was measured at several loads the card offers them as buttons, each showing figures that were actually measured - nothing is interpolated between rungs. The full tables keep every row, spread, p95 and omission one disclosure below, and the methodology stays at the foot where a reader reaches for it once a row surprises them. The old physics URL keeps working: it has been linked to, so it redirects into the physics section rather than 404ing, and it is a redirect rather than a second copy because two pages carrying one profile would eventually disagree about it. --- site/src/components/BenchResultCard.astro | 230 ++++++++++++++ .../src/components/pages/BenchmarksPage.astro | 281 ++++++++++++++---- site/src/lib/bench-cards.ts | 208 +++++++++++++ site/src/lib/bench-profiles.ts | 98 +++++- site/src/pages/de/benchmarks/index.astro | 2 +- .../pages/de/benchmarks/physics/index.astro | 22 +- site/src/pages/en/benchmarks/index.astro | 2 +- .../pages/en/benchmarks/physics/index.astro | 22 +- 8 files changed, 790 insertions(+), 75 deletions(-) create mode 100644 site/src/components/BenchResultCard.astro create mode 100644 site/src/lib/bench-cards.ts diff --git a/site/src/components/BenchResultCard.astro b/site/src/components/BenchResultCard.astro new file mode 100644 index 000000000..110a704b1 --- /dev/null +++ b/site/src/components/BenchResultCard.astro @@ -0,0 +1,230 @@ +--- +/** + * One scenario's result card: what the scene is, how heavy it was, and how long + * each library took. + * + * Times are drawn on a LINEAR scale from zero within the card, so a bar's length + * is the time it shows and two bars can be read against each other directly. + * There is no log scale and no ratio axis: both require the reader to learn the + * axis before they can read the result, and both make a small difference look + * like a large one. + * + * A card compares the arms WITHIN one load. It never compares two loads or two + * cards against each other - those are different scenes, and the harness makes + * no claim about them. + */ + +import { archetypeDescription, archetypeTitle, formatMs, OUTCOME_LABELS } from '../lib/bench-profiles'; +import type { BenchCard } from '../lib/bench-cards'; +import { openingLoad } from '../lib/bench-cards'; + +interface Props { + card: BenchCard; + /** Anchor prefix, so a rendering and a physics card of one name stay distinct. */ + domain: 'rendering' | 'physics'; +} + +const { card, domain } = Astro.props; +const opening = openingLoad(card); +const description = archetypeDescription(card.id); + +/** + * Bar width for one figure, as a percentage of the card's slowest arm. + * + * A measured figure always gets at least a sliver, so a very fast arm stays + * visible - but the sliver is deliberately narrow enough that nobody reads it as + * a quantity. An absent figure gets no bar at all rather than a zero-length one: + * zero would say the arm took no time, which is the opposite of what happened. + */ +const widthOf = (ms: number | null, maxMs: number): number | null => (ms === null || !Number.isFinite(ms) || maxMs <= 0 ? null : Math.max(1.5, (ms / maxMs) * 100)); +--- + +
+
+

{archetypeTitle(card.id)}

+ {description !== undefined &&

{description}

} +
+ + {card.loads.length > 1 && ( +
+ {card.loads.map(load => ( + + ))} +
+ )} + + {card.loads.map(load => ( + + ))} +
+ + + + diff --git a/site/src/components/pages/BenchmarksPage.astro b/site/src/components/pages/BenchmarksPage.astro index 6ad252592..db57d0e84 100644 --- a/site/src/components/pages/BenchmarksPage.astro +++ b/site/src/components/pages/BenchmarksPage.astro @@ -1,66 +1,44 @@ --- /** - * BenchmarksPage - the published cross-library measurements. + * BenchmarksPage - the published cross-library measurements, rendering and + * physics on one page. * * The page is generated at build time from the machine profiles under * `packages/exojs-bench/results/` and from nothing else, so it cannot drift from - * the harness: a re-measurement rewrites the scoreboard, the tables and the + * the harness: a re-measurement rewrites the cards, the tables and the * provenance together. Losses are published on the same terms as wins, nothing * is aggregated into a score or an overall winner, and the rows the harness left * out of a comparison are listed with their reasons. * - * The numbers come first and the practices behind them last. Methodology, - * reproduction and fairness are what a reader consults once a row surprises - * them, so they sit under the tables rather than in front of them - and they - * stay on the page when the directory holds no profile at all, which is the - * state a fresh clone and a release branch before its reference run are in. + * Results lead and the practices behind them follow. Which scenarios open each + * section is fixed in `bench-cards.ts` before any run happens, so the top of the + * page cannot become a selection of whatever ExoJS won; the rest stay one + * disclosure away in the same section. */ import BenchProfileReport from '../BenchProfileReport.astro'; +import BenchResultCard from '../BenchResultCard.astro'; import BenchLocalProbe from '../BenchLocalProbe.astro'; import DocsLayout from '../../layouts/DocsLayout.astro'; import EnglishFallbackNotice from '../EnglishFallbackNotice.astro'; import { appInfo } from '../../lib/app-info'; +import { PHYSICS_HEADLINE_SCENARIOS, physicsCards, RENDERING_HEADLINE_SCENARIOS, renderingCards, selectCards } from '../../lib/bench-cards'; import { - type BenchDomain, + BACKEND_LABELS, coversDomain, formatDay, FRAME_BUDGET_MS, furtherProfiles, olderThanReference, - profileScope, + type ProfileBackendName, referenceProfile, } from '../../lib/bench-profiles'; interface Props { locale: 'en' | 'de'; - domain: BenchDomain; } -const { locale, domain } = Astro.props; -const scope = referenceProfile === undefined ? '' : profileScope(referenceProfile, domain); - -/** - * The two views, as ordinary links. - * - * They are styled as tabs but they are navigation, not a tab widget: two static - * pages need no panel, no selection state and no arrow-key model, and a reader - * can link to either one. Rendering is the page the section's own URL serves, - * so there is no third URL that only forwards to it. - */ -const base = import.meta.env.BASE_URL; -const views: readonly { id: BenchDomain; label: string; href: string }[] = [ - { id: 'rendering', label: 'Rendering', href: `${base}${locale}/benchmarks/` }, - { id: 'physics', label: 'Physics', href: `${base}${locale}/benchmarks/physics/` }, -]; - -const covered = referenceProfile !== undefined && coversDomain(referenceProfile, domain); - -/** Only the further machines that measured this domain: an empty disclosure is a promise the profile cannot keep. */ -const furtherMachines = furtherProfiles.filter(document => coversDomain(document, domain)); - -/** What this view measures, stated once beside the numbers rather than in the methodology alone. */ -const metric = domain === 'rendering' ? 'CPU milliseconds per frame' : 'CPU milliseconds per fixed step'; +const { locale } = Astro.props; /** * Runs the reproduction below pools. A published profile states the number it @@ -72,35 +50,33 @@ const REPRODUCTION_RUNS = 3; const pooledRuns = referenceProfile?.profile.runs ?? REPRODUCTION_RUNS; const machine = referenceProfile?.profile; +/** Cards per rendering backend, so the section switches without a second request. */ +const backends: readonly ProfileBackendName[] = ['webgl2', 'webgpu']; +const rendering = backends + .map(backend => ({ + backend, + ...selectCards(referenceProfile === undefined ? [] : renderingCards(referenceProfile, backend), RENDERING_HEADLINE_SCENARIOS, 6), + })) + .filter(entry => entry.headline.length > 0); + +const physics = selectCards(referenceProfile === undefined ? [] : physicsCards(referenceProfile), PHYSICS_HEADLINE_SCENARIOS, 4); + +/** Only the further machines that measured something, so no disclosure opens onto nothing. */ +const furtherMachines = furtherProfiles.filter(document => coversDomain(document, 'rendering') || coversDomain(document, 'physics')); --- - +
{locale === 'de' && }

Benchmarks

- {metric}, lower is better. Every published value is the median of {pooledRuns} independent runs. + How long one frame of rendering, and one fixed step of physics, costs on the CPU. Lower is better, and every published value is the median + of {pooledRuns} independent runs.

- - {machine !== undefined && (

@@ -109,7 +85,7 @@ const machine = referenceProfile?.profile; ExoJS {machine.engineVersion} · measured {formatDay(machine.measuredAt)} · {machine.runs} pooled runs - {scope !== '' && {scope}} + Details and methodology

)} @@ -118,24 +94,109 @@ const machine = referenceProfile?.profile; No measurements are published yet. The reference measurement is {REPRODUCTION_RUNS} separate runs on one machine after a release is tagged, pooled into a single profile file in the repository; until then there is nothing here to read. The practices below already apply.

- ) : covered ? ( - ) : ( -

- The leading profile - {referenceProfile.profile.gpu} / {referenceProfile.profile.os} / {referenceProfile.profile.browser} - carries no{' '} - {domain} measurement, so there is nothing to show here for that machine. Another machine below may have measured it; the page does not - quietly swap the profile out, because two machines' numbers do not belong in one reading. -

+ <> +
+
+

Rendering

+ {rendering.length > 1 && ( +
+ {rendering.map((entry, index) => ( + + ))} +
+ )} +
+ + {rendering.length === 0 ? ( +

This machine published no rendering measurement.

+ ) : ( + rendering.map((entry, index) => ( + + )) + )} +
+ +
+
+

Physics

+
+ + {physics.headline.length === 0 ? ( +

This machine published no physics measurement.

+ ) : ( + <> +
+ {physics.headline.map(card => ( + + ))} +
+ + {physics.rest.length > 0 && ( +
+ All physics tests ({physics.rest.length} more) +
+ {physics.rest.map(card => ( + + ))} +
+
+ )} + + )} +
+ +
+

Full data

+

+ Every measured row with its spread, its p95, the mechanism behind each difference and the rows the harness left out - the same + numbers the cards above are drawn from, in full. +

+ + {coversDomain(referenceProfile, 'rendering') && ( +
+ Rendering: every row, spread and omission + +
+ )} + + {coversDomain(referenceProfile, 'physics') && ( +
+ Physics: every row, spread and omission + +
+ )} +
+ )} - {domain === 'rendering' && } + {furtherMachines.length > 0 && (

Further machines

- Contributed profiles that measured {domain} on other hardware, newest engine version first: do the ratios hold on another GPU, driver - and JavaScript engine? + Contributed profiles measured on other hardware, newest engine version first: do the ratios hold on another GPU, driver and JavaScript + engine?

{furtherMachines.map(document => { const newer = olderThanReference(document); @@ -156,7 +217,10 @@ const machine = referenceProfile?.profile; reference profile with that in mind.

)} - + {coversDomain(document, 'rendering') && ( + + )} + {coversDomain(document, 'physics') && }
); })} @@ -337,6 +401,27 @@ pnpm bench:compare -- \
+ + + + diff --git a/site/src/lib/bench-cards.ts b/site/src/lib/bench-cards.ts new file mode 100644 index 000000000..2b030f756 --- /dev/null +++ b/site/src/lib/bench-cards.ts @@ -0,0 +1,208 @@ +/** + * The card model the benchmarks page is built from. + * + * A card is one scenario: the loads it was measured at, and for each load the + * time every arm took. The page leads with these rather than with a table + * because the question a reader arrives with is "how does ExoJS do on the work I + * am about to do", and a table answers that only after they have learned how to + * read it. + * + * Nothing here computes a timing or a verdict. The loads, their order and which + * one opens a card are all decided by the harness before a run starts, and this + * module only groups what the profile already carries - so no card can be + * assembled to suit the numbers inside it. + */ + +import type { BenchProfileDocument, ProfileBackendName, ProfileCell, ProfileRow, ProfileSection } from './bench-profiles'; +import { armLabel, formatLoad, outcomeOf } from './bench-profiles'; + +/** One arm's time on one load of one scenario. */ +export interface CardArm { + /** Arm id, e.g. `pixi`. */ + readonly id: string; + /** Human label, e.g. `PixiJS`. */ + readonly label: string; + /** Milliseconds, or `null` where the arm produced no comparable figure. */ + readonly ms: number | null; + /** 95th percentile of the same window, or `null`. */ + readonly p95Ms: number | null; + /** True when this arm is ExoJS itself rather than a competitor. */ + readonly reference: boolean; + /** True when the figure is past a whole 60 fps frame. */ + readonly overFrameBudget: boolean; + /** What the comparison this arm belongs to could establish; see `outcomeOf`. */ + readonly outcome: ReturnType; +} + +/** One selectable load of one scenario. */ +export interface CardLoad { + /** Stable id within the scenario, used as the control's value. */ + readonly id: string; + /** How the load reads beside the figures, e.g. `10,000 sprites`. */ + readonly label: string; + /** Whether this is the load the card opens on. */ + readonly primary: boolean; + /** ExoJS first, then the competitors in the profile's own order. */ + readonly arms: readonly CardArm[]; + /** Largest measured figure on this load, for scaling the bars. */ + readonly maxMs: number; +} + +/** One scenario's card. */ +export interface BenchCard { + /** Archetype id - the card's identity and its anchor. */ + readonly id: string; + /** The section the scenario is filed under. */ + readonly category: string; + /** Loads, in the order the harness measured them. */ + readonly loads: readonly CardLoad[]; +} + +/** + * Scenarios the rendering section opens with, in this order. + * + * Chosen for the spread of work they represent - sprites moving, sprites still, + * text changing, a large map scrolling, an effect running, clipping - and fixed + * here rather than derived from the results, so the opening of the page cannot + * become a selection of whatever ExoJS happened to win. + */ +export const RENDERING_HEADLINE_SCENARIOS: readonly string[] = [ + 'dynamic-all', + 'static-heavy', + 'text-dynamic', + 'tilemap-scroll', + 'particles-lifecycle', + 'mask-clip', +]; + +/** Scenarios the physics section opens with, chosen on the same terms. */ +export const PHYSICS_HEADLINE_SCENARIOS: readonly string[] = ['many-dynamic', 'box-stack', 'raycast', 'joints']; + +/** + * Fallback order for a profile that carries none of the headline scenarios - + * an older measurement taken before they existed. + * + * A page that simply showed nothing there would report the absence of the + * scenarios as an absence of results, so the section opens on whatever the + * profile does carry, in its own order. + */ +const headlineOrFirst = (cards: readonly BenchCard[], preferred: readonly string[], count: number): readonly BenchCard[] => { + const chosen = preferred.map(id => cards.find(card => card.id === id)).filter((card): card is BenchCard => card !== undefined); + const taken = new Set(chosen.map(card => card.id)); + + // Topped up in the profile's own order, never by result: an older profile + // carries only some of the preferred scenarios, and a section that showed + // three cards because three names happened to match would report the age of + // the measurement as a shortage of tests. + for (const card of cards) { + if (chosen.length >= count) { + break; + } + + if (!taken.has(card.id)) { + chosen.push(card); + taken.add(card.id); + } + } + + return chosen; +}; + +/** ExoJS's own figure, which every cell of a row repeats because every pair shares it. */ +const referenceArm = (cells: readonly ProfileCell[]): CardArm | null => { + const first = cells[0]; + + if (first === undefined) { + return null; + } + + return { + id: 'exojs', + label: armLabel('exojs'), + ms: first.referenceMs, + p95Ms: first.referenceP95Ms, + reference: true, + overFrameBudget: first.referenceOverFrameBudget, + outcome: outcomeOf(first), + }; +}; + +const competitorArm = (cell: ProfileCell): CardArm => ({ + id: cell.competitor, + label: armLabel(cell.competitor), + ms: cell.competitorMs, + p95Ms: cell.competitorP95Ms, + reference: false, + overFrameBudget: cell.competitorOverFrameBudget, + outcome: outcomeOf(cell), +}); + +/** One row becomes one selectable load. */ +const loadOf = (row: ProfileRow): CardLoad | null => { + const reference = referenceArm(row.cells); + + if (reference === null) { + return null; + } + + const arms = [reference, ...row.cells.map(competitorArm)]; + const measured = arms.map(arm => arm.ms).filter((ms): ms is number => ms !== null && Number.isFinite(ms)); + + return { + id: row.loadId ?? String(row.count), + label: formatLoad(row), + primary: row.primary ?? false, + arms, + maxMs: measured.length > 0 ? Math.max(...measured) : 0, + }; +}; + +/** Group a domain's sections into one card per scenario. */ +const cardsOf = (sections: readonly ProfileSection[]): readonly BenchCard[] => { + const byScenario = new Map(); + + for (const section of sections) { + for (const row of section.rows) { + const load = loadOf(row); + + if (load === null) { + continue; + } + + const card = byScenario.get(row.archetype) ?? { category: section.title, loads: [] }; + + card.loads.push(load); + byScenario.set(row.archetype, card); + } + } + + return [...byScenario.entries()].map(([id, card]) => ({ id, category: card.category, loads: card.loads })); +}; + +/** The rendering cards of one profile on one backend, or an empty list where it measured none. */ +export const renderingCards = (document: BenchProfileDocument, backend: ProfileBackendName): readonly BenchCard[] => { + const block = document.rendering?.backends.find(entry => entry.backend === backend); + + return block === undefined ? [] : cardsOf(block.sections); +}; + +/** The physics cards of one profile. Physics has no backend axis. */ +export const physicsCards = (document: BenchProfileDocument): readonly BenchCard[] => + document.physics === undefined ? [] : cardsOf([document.physics.section]); + +/** The cards a section opens with, and the ones kept behind its "show all" control. */ +export interface CardSelection { + readonly headline: readonly BenchCard[]; + readonly rest: readonly BenchCard[]; +} + +/** Split a domain's cards into the fixed opening set and the remainder. */ +export const selectCards = (cards: readonly BenchCard[], preferred: readonly string[], count: number): CardSelection => { + const headline = headlineOrFirst(cards, preferred, count); + const shown = new Set(headline.map(card => card.id)); + + return { headline, rest: cards.filter(card => !shown.has(card.id)) }; +}; + +/** The load a card opens on: its headline, or the first one it carries. */ +export const openingLoad = (card: BenchCard): CardLoad | undefined => card.loads.find(load => load.primary) ?? card.loads[0]; diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts index df6f4c695..64d4de2f6 100644 --- a/site/src/lib/bench-profiles.ts +++ b/site/src/lib/bench-profiles.ts @@ -108,20 +108,51 @@ export interface ProfileCell { } /** - * One published row: an archetype at the count it was measured at. + * One published row: an archetype at one load. * - * A rendering row carries its block's single node count. A physics row carries - * its own body count, because the physics archetypes have per-archetype ladders - - * so two physics rows are never comparable with each other, only the arms within - * one row are. + * An archetype measured at several loads publishes a row per load. A reader + * compares the arms WITHIN a row, which is like for like by construction, and + * never two rows against each other: two loads are two different scenes, as are + * two archetypes. */ export interface ProfileRow { readonly archetype: string; readonly category: string; readonly count: number; + /** + * Catalog load id, e.g. `10k`. Absent in profiles written before rows carried + * their load, where an archetype appears exactly once. + */ + readonly loadId?: string; + /** Unit {@link count} is quoted in, so a figure is never shown without one. */ + readonly unit?: LoadUnit; + /** Whether this is the scenario's headline load - the one a card opens on. */ + readonly primary?: boolean; + /** Display label for a load the count/unit pair cannot state, e.g. a resolution. */ + readonly label?: string; readonly cells: readonly ProfileCell[]; } +/** + * Unit a load is counted in. + * + * Carried per row rather than assumed per domain: the same figure means scene + * nodes in one scenario and world tiles in another, and those are not the same + * claim. + */ +export type LoadUnit = 'sprites' | 'nodes' | 'labels' | 'tiles' | 'layers' | 'particles' | 'widgets' | 'rects' | 'bodies' | 'viewport'; + +/** How a load reads beside its figure: `10,000 sprites`, or the row's own label. */ +export const formatLoad = (row: ProfileRow): string => { + if (row.label !== undefined) { + return row.label; + } + + const figure = row.count.toLocaleString('en-US'); + + return row.unit === undefined ? figure : `${figure} ${row.unit}`; +}; + /** A category section of a published table. */ export interface ProfileSection { readonly title: string; @@ -384,6 +415,8 @@ export const BACKEND_LABELS: Readonly> = { we * harness never silently renames it here. */ const ARM_LABELS: Readonly> = { + exojs: 'ExoJS', + 'exojs-physics': 'ExoJS', pixi: 'PixiJS', excalibur: 'Excalibur', phaser: 'Phaser', @@ -435,8 +468,63 @@ const ARCHETYPE_DESCRIPTIONS: Readonly> = { 'body-churn': 'Bodies rebuilt every step; stresses broad-phase repair and lifecycle work.', joints: 'Constraint chains; stresses impulse propagation through joints.', 'settling-pile': 'A dissipating pile; exposes steady-state settling and sleeping behavior.', + 'dynamic-all': 'Every sprite moving every frame; stresses transform and upload work at full mutation.', + 'fill-layers': 'Stacked translucent full-screen layers; stresses blended fill.', + 'tilemap-scroll': 'A large tile map scrolling past a fixed window; stresses the tile draw path.', + 'tilemap-edit': 'Tile ids replaced every frame; stresses getting a tile change to the GPU.', + 'particles-draw': 'A fixed set of quads submitted through the particle path; no simulation.', + 'particles-lifecycle': 'A steady particle effect: ageing, movement, fading and respawning.', }; +/** + * A scenario's title, as a card names it. + * + * Plain words rather than the archetype id: the id is the contract with the + * harness and belongs in the details, while the card has to be readable by + * someone who has never run the benchmark. + */ +const ARCHETYPE_TITLES: Readonly> = { + 'static-heavy': 'Static scene', + 'dynamic-heavy': 'Scene with few changes', + 'dynamic-all': 'Fully moving sprites', + 'deep-hierarchy': 'Deep scene hierarchy', + overdraw: 'Overdraw ceiling', + 'fill-layers': 'Transparent screen layers', + 'batch-breaking': 'Many texture changes', + 'batch-breaking-atlased': 'Same scene, atlased', + 'split-screen': 'Split screen', + 'mixed-blend': 'Mixed blend modes', + 'mixed-material': 'Custom materials', + 'mixed-material-atlased': 'Custom materials, atlased', + 'instanced-batch': 'Instanced submission', + 'mixed-sprite-mesh-array': 'Sprites and array meshes', + 'mixed-sprite-mesh-static': 'Sprites and static meshes', + 'scrolling-world': 'Camera over a sprite world', + 'text-static': 'Static labels', + 'text-dynamic': 'Changing labels', + 'lifecycle-churn': 'Creating and destroying objects', + 'filter-chain-1': 'One filter pass', + 'filter-chain-2': 'Two filter passes', + 'filter-chain-4': 'Four filter passes', + 'mask-clip': 'Clipping', + 'mask-clip-animated': 'Moving clip', + composite: 'Composited effect', + 'tilemap-scroll': 'Large tile map', + 'tilemap-edit': 'Editing tiles', + 'particles-draw': 'Drawing particles', + 'particles-lifecycle': 'Particle effect', + 'box-stack': 'Box stack', + 'many-dynamic': 'Many active bodies', + 'mixed-static-dynamic': 'Static level, falling bodies', + raycast: 'Ray queries', + 'body-churn': 'Bodies created and destroyed', + joints: 'Joint chains', + 'settling-pile': 'Settling pile', +}; + +/** The readable title for a scenario, falling back to its id where none is written. */ +export const archetypeTitle = (archetype: string): string => ARCHETYPE_TITLES[archetype] ?? archetype; + /** The one-line workload description for an archetype, or `undefined` where none is written. */ export const archetypeDescription = (archetype: string): string | undefined => ARCHETYPE_DESCRIPTIONS[archetype]; diff --git a/site/src/pages/de/benchmarks/index.astro b/site/src/pages/de/benchmarks/index.astro index 6e4251f0a..cdc06c4ed 100644 --- a/site/src/pages/de/benchmarks/index.astro +++ b/site/src/pages/de/benchmarks/index.astro @@ -2,4 +2,4 @@ import BenchmarksPage from '../../../components/pages/BenchmarksPage.astro'; --- - + diff --git a/site/src/pages/de/benchmarks/physics/index.astro b/site/src/pages/de/benchmarks/physics/index.astro index a0f0994b4..93486d4ee 100644 --- a/site/src/pages/de/benchmarks/physics/index.astro +++ b/site/src/pages/de/benchmarks/physics/index.astro @@ -1,5 +1,23 @@ --- -import BenchmarksPage from '../../../../components/pages/BenchmarksPage.astro'; +/** + * Compatibility route for the physics view's old URL. + * + * Rendering and physics share one page now. The URL stays reachable rather than + * 404ing - it has been linked to - and sends the reader to the physics section + * of that page. A redirect rather than a second copy of the page: two pages + * carrying one profile would eventually disagree about it. + */ +const target = `${import.meta.env.BASE_URL}de/benchmarks/#physics`; --- - + + + + + + ExoJS physics benchmarks + + +

The physics benchmarks are part of the benchmarks page.

+ + diff --git a/site/src/pages/en/benchmarks/index.astro b/site/src/pages/en/benchmarks/index.astro index c6b87fd9d..09ef67f34 100644 --- a/site/src/pages/en/benchmarks/index.astro +++ b/site/src/pages/en/benchmarks/index.astro @@ -2,4 +2,4 @@ import BenchmarksPage from '../../../components/pages/BenchmarksPage.astro'; --- - + diff --git a/site/src/pages/en/benchmarks/physics/index.astro b/site/src/pages/en/benchmarks/physics/index.astro index cac7e3252..90539984c 100644 --- a/site/src/pages/en/benchmarks/physics/index.astro +++ b/site/src/pages/en/benchmarks/physics/index.astro @@ -1,5 +1,23 @@ --- -import BenchmarksPage from '../../../../components/pages/BenchmarksPage.astro'; +/** + * Compatibility route for the physics view's old URL. + * + * Rendering and physics share one page now. The URL stays reachable rather than + * 404ing - it has been linked to - and sends the reader to the physics section + * of that page. A redirect rather than a second copy of the page: two pages + * carrying one profile would eventually disagree about it. + */ +const target = `${import.meta.env.BASE_URL}en/benchmarks/#physics`; --- - + + + + + + ExoJS physics benchmarks + + +

The physics benchmarks are part of the benchmarks page.

+ + From bbda59280ee8621025825a77456ed8fc6b315f67 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 16:35:56 +0200 Subject: [PATCH 14/15] fix(bench): resolve the extension arms from source in the type-check and the unit lane The rendering adapters import @codexo/exojs-particles and @codexo/exojs-tilemap, whose package entries point at dist. Neither the bench type-check nor the jsdom unit lane builds the packages, so both resolved nothing while a tree with a built dist lying around resolved fine. Mapping the entries to source is not enough on its own for particles: it resolves its own #* imports through a package imports map whose default arm is dist/esm/*.d.ts, so the type-check read stale declarations instead of the sources beside them. @codexo/exojs-particles-source in customConditions is what steers that map to src. The unit lane needed the same distinction one level down. Its blanket #* alias sent #distributions/Curve, imported from inside exojs-particles, into the engine tree. A resolver that declines for importers inside an extension package leaves those specifiers to Vite's own imports resolution, which srcConditions already steers to source; everything else it delegates back through resolution rather than assuming an extension, so shader specifiers keep working. Claude-Session: https://claude.ai/code/session_01UWQw3PuiCFjTVJJBY4AHQG --- packages/exojs-bench/tsconfig.json | 6 ++-- packages/exojs-config/vitest/index.js | 9 +++-- vitest.config.ts | 48 +++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/exojs-bench/tsconfig.json b/packages/exojs-bench/tsconfig.json index 629cae92c..8988018cf 100644 --- a/packages/exojs-bench/tsconfig.json +++ b/packages/exojs-bench/tsconfig.json @@ -1,14 +1,16 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "_comment": "The rendering domain benchmarks the engine through its internal `#*` subpath imports (resolved in the browser page by the driver's Vite alias, never in the Node CLI); the `#* -> ../../src/*` path reproduces the root package.json wildcard for the type-checker. The physics domain instead imports the `@codexo/exojs-physics` package (which itself imports the `@codexo/exojs` barrel), and neither exposes a `@codexo/exojs-source` export condition, so those specifiers are mapped to source here — mirroring the runtime aliasing in scripts/glsl-loader.ts and vitest.config.ts's aliasConfig.", + "_comment": "The rendering domain benchmarks the engine through its internal `#*` subpath imports (resolved in the browser page by the driver's Vite alias, never in the Node CLI); the `#* -> ../../src/*` path reproduces the root package.json wildcard for the type-checker. The physics domain instead imports the `@codexo/exojs-physics` package (which itself imports the `@codexo/exojs` barrel), and the rendering extension arms import `@codexo/exojs-particles` and `@codexo/exojs-tilemap`; none of those expose a `@codexo/exojs-source` export condition, and their package entries point at `dist`, which the type-check runs without, so their entries are mapped to source here; `@codexo/exojs-particles-source` is in `customConditions` for the same reason one level down, since `@codexo/exojs-particles` resolves its own `#*` imports through a package `imports` map that otherwise lands on stale `dist` declarations — mirroring the runtime aliasing in scripts/glsl-loader.ts and vitest.config.ts's aliasConfig.", "extends": "@codexo/exojs-config/typescript/test.json", "compilerOptions": { "noEmit": true, - "customConditions": ["@codexo/exojs-source"], + "customConditions": ["@codexo/exojs-source", "@codexo/exojs-particles-source"], "types": ["vitest/globals", "node", "@webgpu/types"], "paths": { "#*": ["../../src/*"], + "@codexo/exojs-particles": ["../exojs-particles/src/index.ts"], "@codexo/exojs-physics": ["../exojs-physics/src/index.ts"], + "@codexo/exojs-tilemap": ["../exojs-tilemap/src/index.ts"], "@codexo/exojs": ["../../src/index.ts"], "@codexo/exojs/extensions": ["../../src/extensions/index.ts"], "@codexo/exojs/renderer-sdk": ["../../src/renderer-sdk.ts"], diff --git a/packages/exojs-config/vitest/index.js b/packages/exojs-config/vitest/index.js index f187fb2e6..f6b803e38 100644 --- a/packages/exojs-config/vitest/index.js +++ b/packages/exojs-config/vitest/index.js @@ -41,14 +41,17 @@ export const workerTransformPlugin = createWorkerPlugin(); * the function; it does not otherwise change how V8 collects. Note this is a * top-level test option in Vitest 4 - under `poolOptions.forks` it is silently * ignored. - * @param {{ name: string, include: string[], exclude?: string[], setupFiles?: string[], alias?: NonNullable['alias'] }} opts + * `plugins` are prepended to the shared shader/worklet/worker transforms, so a + * project that needs its own resolution step keeps the transforms it would + * otherwise have to reproduce. + * @param {{ name: string, include: string[], exclude?: string[], setupFiles?: string[], alias?: NonNullable['alias'], plugins?: import('vitest/config').ViteUserConfig['plugins'] }} opts */ export function createJsdomTestProject(opts) { - const { name, include, exclude, setupFiles = ['./test/setup-env.vitest.ts'], alias } = opts; + const { name, include, exclude, setupFiles = ['./test/setup-env.vitest.ts'], alias, plugins = [] } = opts; return { resolve: { alias, conditions: srcConditions }, ssr: { resolve: { conditions: srcConditions } }, - plugins: [createShaderPlugin(), workletTransformPlugin, workerTransformPlugin], + plugins: [...plugins, createShaderPlugin(), workletTransformPlugin, workerTransformPlugin], define: { __DEV__: JSON.stringify(true), __VERSION__: JSON.stringify('0.0.0'), __REVISION__: JSON.stringify('test') }, test: { name, diff --git a/vitest.config.ts b/vitest.config.ts index 75e901295..9ace32496 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,11 @@ +import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createShaderPlugin } from '@codexo/exojs-build'; import { createJsdomTestProject, srcConditions, workerTransformPlugin, workletTransformPlugin } from '@codexo/exojs-config/vitest'; import { playwright } from '@vitest/browser-playwright'; import { webdriverio } from '@vitest/browser-webdriverio'; -import { defineConfig } from 'vitest/config'; +import { defineConfig, type Plugin } from 'vitest/config'; import { emitAllocationRecord, startHeapSampling, stopHeapSampling } from './test/perf/webgpu/heapSamplingCommands'; import { resetParityEvidence, writeParityEvidence } from './test/rendering/parity/evidenceSink'; @@ -24,6 +25,10 @@ const aliasConfig = [ // exports a `@codexo/exojs-source` condition, so alias to source for in-repo tests. // @codexo/exojs-physics is aliased too so the example physics↔tilemap bridge // recipe (examples/shared/physics-tilemap.ts) can be unit-tested in-repo. + // @codexo/exojs-particles and @codexo/exojs-tilemap are aliased for the same + // reason: the benchmark's extension arms import them, and this lane runs + // without building the packages, so their `dist` entries resolve to nothing. + { find: '@codexo/exojs-particles', replacement: fileURLToPath(new URL('./packages/exojs-particles/src/index.ts', import.meta.url)) }, { find: '@codexo/exojs-tilemap', replacement: fileURLToPath(new URL('./packages/exojs-tilemap/src/index.ts', import.meta.url)) }, { find: '@codexo/exojs-tiled', replacement: fileURLToPath(new URL('./packages/exojs-tiled/src/index.ts', import.meta.url)) }, { find: '@codexo/exojs-aseprite', replacement: fileURLToPath(new URL('./packages/exojs-aseprite/src/index.ts', import.meta.url)) }, @@ -38,6 +43,44 @@ const aliasConfig = [ { find: 'create-exo-app', replacement: fileURLToPath(new URL('./packages/create-exo-app/src/scaffold.ts', import.meta.url)) }, ] as const; +/** + * Source roots whose `#*` specifiers address the package they sit in rather than + * the engine. The benchmark package is deliberately absent: its adapters use + * `#*` to reach engine modules. + */ +const EXTENSION_SOURCE_ROOTS = [ + fileURLToPath(new URL('./packages/exojs-particles', import.meta.url)), + fileURLToPath(new URL('./packages/exojs-tilemap', import.meta.url)), +] as const; + +/** + * Resolves the benchmark adapters' `#*` specifiers to the engine source. + * + * A blanket alias cannot do this: each extension package carries its own `#*` + * map pointing at its own `src`, so `#distributions/Curve` imported from inside + * `@codexo/exojs-particles` would be sent into the engine tree, where no such + * module exists. Declining for those importers leaves the specifier to Vite's + * normal `imports` resolution, which `srcConditions` already steers to the + * package's own sources. + */ +const benchEngineHashImports = (): Plugin => { + const engineSrc = fileURLToPath(new URL('./src', import.meta.url)); + + return { + name: 'exojs-bench-engine-hash-imports', + enforce: 'pre', + async resolveId(source: string, importer: string | undefined, options) { + if (!source.startsWith('#')) return null; + if (importer !== undefined && EXTENSION_SOURCE_ROOTS.some(root => resolve(importer).startsWith(root))) return null; + + // Re-enter resolution rather than returning the path: the engine's `#*` + // specifiers carry no extension, and shader imports need `.frag`/`.wgsl` + // rather than the `.ts` a hand-built path would have to assume. + return this.resolve(`${engineSrc}/${source.slice(1)}`, importer, options); + }, + }; +}; + // Loads every shader source (`.vert`/`.frag`/`.wgsl`) as its REAL text, exactly // as the production build does. Tests read what ships: the renderer performance // harness reflects attribute names out of the actual GLSL, the parity specs @@ -281,7 +324,8 @@ export default defineConfig({ // PR - run it on demand via `pnpm --filter @codexo/exojs-bench test`. createJsdomTestProject({ name: 'exojs-bench', - alias: [...aliasConfig, { find: /^#(.*)$/, replacement: `${fileURLToPath(new URL('./src', import.meta.url))}/$1` }], + alias: aliasConfig, + plugins: [benchEngineHashImports()], include: ['packages/exojs-bench/test/**/*.test.ts'], }), From d257e8c94b95fc62f0cd4101dd448817383e06d8 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 16:35:58 +0200 Subject: [PATCH 15/15] test: cap the jsdom lane's worker pool outside CI Vitest sizes its fork pool at one worker per core, so the unit lane saturated a 16-core workstation for the three minutes it runs - on every push, since the pre-push hook runs it. Half the cores costs about 6% wall time here (209s to 197s) because the lane is not purely CPU-bound. CI keeps the default: its runners have few cores and nothing else to serve. EXOJS_TEST_MAX_WORKERS overrides both. Claude-Session: https://claude.ai/code/session_01UWQw3PuiCFjTVJJBY4AHQG --- vitest.config.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 9ace32496..453154236 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -140,8 +140,21 @@ const parityCommands = { writeParityEvidence, resetParityEvidence }; // reachable only over CDP, which lives on the node side. See the command module. const allocationCommands = { startHeapSampling, stopHeapSampling, emitAllocationRecord }; +/** + * Worker cap for the jsdom lane. + * + * Vitest otherwise sizes its fork pool at one worker per core, which saturates a + * developer machine for the several minutes the unit lane runs - and the pre-push + * hook runs it on every push. Half the cores keeps the machine usable and costs + * little wall time, since the lane is not purely CPU-bound. CI keeps the default: + * its runners have few cores and nothing else to serve. `EXOJS_TEST_MAX_WORKERS` + * overrides both (a plain count or a `"50%"`-style share). + */ +const maxWorkers = process.env['EXOJS_TEST_MAX_WORKERS'] ?? (process.env['CI'] ? undefined : '50%'); + export default defineConfig({ test: { + ...(maxWorkers === undefined ? {} : { maxWorkers }), coverage: { provider: 'istanbul', reporter: ['lcov', 'clover', 'text-summary'],