diff --git a/packages/exojs-bench/docs/harness.md b/packages/exojs-bench/docs/harness.md
index 1e45c4f03..3fee35c0c 100644
--- a/packages/exojs-bench/docs/harness.md
+++ b/packages/exojs-bench/docs/harness.md
@@ -256,6 +256,7 @@ Rules the generator enforces rather than merely intends:
- Every row names the mechanism behind its difference, drawn from the structural counters. A row whose mechanism cannot be evidenced is not published - it is listed under Omissions with the reason, so a dropped row stays auditable.
- One column per competitor, no "best competitor" composite. Phaser occupies its own WebGL1 block, CPU time only, explicitly carrying no mechanism.
- Cells where ExoJS loses are published exactly like the cells where it wins.
+- A scenario whose arms turn out not to do the same work is repaired and re-measured. It is never published with a "not comparable" label in place of its comparison, and never hidden: a label would keep a known-unfair row on the page, and hiding it would let the choice of what to show depend on the outcome.
### Machine profiles
diff --git a/site/src/components/BenchComparisonTable.astro b/site/src/components/BenchComparisonTable.astro
index c12a6528c..071d2b158 100644
--- a/site/src/components/BenchComparisonTable.astro
+++ b/site/src/components/BenchComparisonTable.astro
@@ -53,7 +53,6 @@ import {
pooledFactor,
ratioBand,
rungSide,
- withheldScenario,
type ProfileCell,
type ProfileSpread,
} from '../lib/bench-profiles';
@@ -219,15 +218,8 @@ const winnerOf = (cell: ProfileCell): string => {
{row.load}
)}
- {/*
- * A row whose arms are known not to do the same work
- * publishes their times and no comparison: the factor,
- * the bar and the winner all go, the same way they do
- * for a pair the clock could not separate.
- */}
{row.entries.map((entry, column) => {
const cell = entry.cell;
- const withheld = withheldScenario(row.archetype) !== undefined;
if (cell === null) {
const reason =
@@ -248,7 +240,7 @@ const winnerOf = (cell: ProfileCell): string => {
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 = withheld || outcome === 'timer-limited' || outcome === 'timer-unknown';
+ 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;
@@ -279,16 +271,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. */}
{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. */}
-
- {withheld
- ? 'not comparable'
- : unresolved
- ? OUTCOME_STATUS[outcome]
- : mixed
- ? OUTCOME_STATUS.unstable
- : level
- ? ''
- : winnerOf(cell)}
+
+ {unresolved ? OUTCOME_STATUS[outcome] : mixed ? OUTCOME_STATUS.unstable : level ? '' : winnerOf(cell)}
@@ -308,7 +292,6 @@ const winnerOf = (cell: ProfileCell): string => {
{/* The row already names its archetype; the label repeats it only where a reader reaches the summary without that context. */}
Measurement detail
- {withheldScenario(row.archetype) !== undefined &&
{/*
- * The load's own note about itself: why its comparison is
- * withheld, or what state every arm on it shares. Nothing to say
- * leaves no row, so the paragraph is only rendered where there
- * is something to read - an always-present element with nothing
- * inside it is invisible on screen but still there for `:empty`
- * and similar checks to miss.
+ * The load's own note about itself: what state every arm on it
+ * shares. Nothing to say leaves no row, so the paragraph is only
+ * rendered where there is something to read - an always-present
+ * element with nothing inside it is invisible on screen but still
+ * there for `:empty` and similar checks to miss.
*/}
- {(load.withheld !== undefined || statesOf(load).length > 0) && (
+ {statesOf(load).length > 0 && (
{arm.label}
- {/*
- * A withheld row draws no track at all. Its bars would
- * be durations like any other, but side by side they
- * are read as the comparison the row is published
- * without, and an empty track under every row reads as
- * a chart that failed to draw.
- */}
- {load.withheld === undefined ? (
-
- {widthOf(arm.ms, load.maxMs) !== null && }
-
- ) : (
-
- )}
+
+ {widthOf(arm.ms, load.maxMs) !== null && }
+
{arm.ms === null ? (
{OUTCOME_STATUS[arm.outcome]}
@@ -328,14 +309,6 @@ const statesOf = (load: BenchCard['loads'][number]): readonly CellOutcome[] => [
margin-left: auto;
}
- /* Forced onto its own line under the status badge: the reason reads as a caption to it, not a second fact beside it. */
- .reason {
- flex-basis: 100%;
- font-size: 0.74rem;
- line-height: 1.45;
- text-wrap: pretty;
- }
-
/* No frame, no ground: the card is already the box, and the global `.panel` rule is what used to draw a second one here. */
.bench-load-panel {
border: 0;
diff --git a/site/src/components/pages/BenchmarksPage.astro b/site/src/components/pages/BenchmarksPage.astro
index 8628ece28..2efd4bfef 100644
--- a/site/src/components/pages/BenchmarksPage.astro
+++ b/site/src/components/pages/BenchmarksPage.astro
@@ -11,8 +11,7 @@
* `bench-cards.ts` before any run happens, so the top of the page cannot become
* a selection of whatever ExoJS won.
*
- * The page carries cards and nothing else. A withheld comparison states its own
- * reason on the card that withholds it, and the full per-row report - every
+ * The page carries cards and nothing else. The full per-row report - every
* measured load, its spread and the rows the harness left out - is its own
* page, linked once rather than repeated on every card that would otherwise
* say the same thing.
diff --git a/site/src/content/guide/debugging/renderer-sdk-contract.mdx b/site/src/content/guide/debugging/renderer-sdk-contract.mdx
index 04f8e848d..6e9d9bc58 100644
--- a/site/src/content/guide/debugging/renderer-sdk-contract.mdx
+++ b/site/src/content/guide/debugging/renderer-sdk-contract.mdx
@@ -84,6 +84,8 @@ Recorded batches reference a `RetainedGroupBundle` you own. One field carries th
Set `nodeCount` on a batch whenever one render node expands into several GPU instances — a tile chunk, a nine-slice, a repeating sprite, a text run. `instanceCount` is the replay draw's instance argument, not a node count, and without `nodeCount` the recorded tier reports instances where the live and entry-replay tiers report nodes. A node is booked once, against whichever batch was open when its first instance was written.
+For WebGL2, return `false` from `replayRetainedBatch` when a defensive check prevents drawing. The backend then leaves `batches` and `drawCalls` unchanged. Returning `true` or nothing keeps descriptor-based accounting, so existing SDK replayers remain compatible. WebGPU replayers increment these two counters after issuing a draw. Both backends count `submittedNodes` before replay, including submissions that cannot draw.
+
## Seam 4 — the pass coordinator
Target, view, clear, the scissor stack and the stencil-clip stack belong to the backend's `passCoordinator`. Reach it through the backend when you need a pass of your own:
diff --git a/site/src/lib/bench-cards.ts b/site/src/lib/bench-cards.ts
index 6e4653968..32145f919 100644
--- a/site/src/lib/bench-cards.ts
+++ b/site/src/lib/bench-cards.ts
@@ -19,7 +19,7 @@
*/
import type { BenchProfileDocument, ProfileBackendName, ProfileCell, ProfileRow, ProfileSection } from './bench-profiles';
-import { armLabel, formatLoad, isQuantitative, isWasmReferenceArm, orderArms, OUTCOME_ORDER, outcomeOf, publishedMs, withheldScenario } from './bench-profiles';
+import { armLabel, formatLoad, isQuantitative, isWasmReferenceArm, orderArms, OUTCOME_ORDER, outcomeOf, publishedMs } from './bench-profiles';
/** One arm's time on one load of one scenario. */
export interface CardArm {
@@ -61,15 +61,6 @@ export interface CardLoad {
readonly arms: readonly CardArm[];
/** Largest plottable figure on this load, for scaling the bars. */
readonly maxMs: number;
- /**
- * Why this load publishes no cross-arm comparison, or `undefined` where it
- * publishes one; see `withheldScenario`.
- *
- * A withheld load keeps every arm's time and loses every bar, factor and
- * winner: the arms ran the same scene and are not doing the same work in it,
- * which a bar length would assert they were.
- */
- readonly withheld: string | undefined;
}
/** One scenario's card. */
@@ -201,16 +192,8 @@ const loadOf = (row: ProfileRow): CardLoad | null => {
return null;
}
- const withheld = withheldScenario(row.archetype);
const cells = orderArms(row.cells, cell => cell.competitor);
- // A withheld row loses its quantitative treatment wholesale rather than per
- // arm: the doubt is about the comparison, so no arm in it may keep a bar.
- const canonical = [reference, ...cells.map(competitorArm)].map(arm => (withheld === undefined ? arm : { ...arm, quantitative: false }));
- // Sorted even where the comparison is withheld. The order is the reader's way
- // through the rows and the same one on every card, and leaving a withheld
- // load in canonical order does not stop anyone ranking four printed numbers,
- // it only makes them do it by eye. That these times are not a ranking is said
- // where it belongs, on the load's own marker.
+ const canonical = [reference, ...cells.map(competitorArm)];
const arms = fastestFirst(canonical);
// Every published figure sets the scale, because the bars are durations: an
// arm whose PAIR the clock could not separate still took the time it reports,
@@ -225,7 +208,6 @@ const loadOf = (row: ProfileRow): CardLoad | null => {
primary: row.primary ?? false,
arms,
maxMs: plotted.length > 0 ? Math.max(...plotted) : 0,
- withheld,
};
};
diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts
index d99089f23..b3b4274ae 100644
--- a/site/src/lib/bench-profiles.ts
+++ b/site/src/lib/bench-profiles.ts
@@ -660,25 +660,6 @@ const ARCHETYPE_TITLES: Readonly> = {
'settling-pile': 'Settling pile',
};
-/**
- * Scenarios whose CROSS-ARM comparison is withheld, and why.
- *
- * Not a scenario that is hidden, and not one that is dropped from the page: the
- * arms all ran it, and each arm's own times are published. What is withheld is
- * the comparison between them - no factor, no winner, no bar read as a
- * performance claim - because the arms are known not to be doing the same work.
- *
- * Withholding is a statement about the measurement and never about the result.
- * A row is listed here only for a documented reason that applies whichever way
- * the figures came out, and a row is never listed because ExoJS trails on it;
- * removing a losing card and quietly keeping a winning one is exactly what a
- * fixed headline set exists to prevent.
- */
-const WITHHELD_SCENARIOS: Readonly> = {};
-
-/** Why a scenario publishes no cross-arm comparison, or `undefined` where it publishes one. */
-export const withheldScenario = (archetype: string): string | undefined => WITHHELD_SCENARIOS[archetype];
-
/** 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;
diff --git a/src/rendering/coreRendererBindings.ts b/src/rendering/coreRendererBindings.ts
index e79a08e00..adebc80ae 100644
--- a/src/rendering/coreRendererBindings.ts
+++ b/src/rendering/coreRendererBindings.ts
@@ -9,13 +9,11 @@ import { BitmapText } from '#rendering/text/BitmapText';
import { Text } from '#rendering/text/Text';
import { Video } from '#rendering/video/Video';
import { WebGl2MeshRenderer } from '#rendering/webgl2/WebGl2MeshRenderer';
-import { WebGl2NineSliceSpriteRenderer } from '#rendering/webgl2/WebGl2NineSliceSpriteRenderer';
-import { WebGl2RepeatingSpriteRenderer } from '#rendering/webgl2/WebGl2RepeatingSpriteRenderer';
+import { WebGl2ScalableSpriteRenderer } from '#rendering/webgl2/WebGl2ScalableSpriteRenderer';
import { WebGl2SpriteRenderer } from '#rendering/webgl2/WebGl2SpriteRenderer';
import { WebGl2TextRenderer } from '#rendering/webgl2/WebGl2TextRenderer';
import { WebGpuMeshRenderer } from '#rendering/webgpu/WebGpuMeshRenderer';
-import { WebGpuNineSliceSpriteRenderer } from '#rendering/webgpu/WebGpuNineSliceSpriteRenderer';
-import { WebGpuRepeatingSpriteRenderer } from '#rendering/webgpu/WebGpuRepeatingSpriteRenderer';
+import { WebGpuScalableSpriteRenderer } from '#rendering/webgpu/WebGpuScalableSpriteRenderer';
import { WebGpuSpriteRenderer } from '#rendering/webgpu/WebGpuSpriteRenderer';
import { WebGpuTextRenderer } from '#rendering/webgpu/WebGpuTextRenderer';
import { WebGpuVideoRenderer } from '#rendering/webgpu/WebGpuVideoRenderer';
@@ -48,13 +46,9 @@ export const buildCoreRendererBindings = (options: RenderingApplicationOptions):
[RenderBackendType.WebGl2]: () => new WebGl2TextRenderer(),
[RenderBackendType.WebGpu]: () => new WebGpuTextRenderer(),
};
- const nineSliceRenderers: BackendRendererMap = {
- [RenderBackendType.WebGl2]: () => new WebGl2NineSliceSpriteRenderer(spriteRendererBatchSize),
- [RenderBackendType.WebGpu]: () => new WebGpuNineSliceSpriteRenderer(),
- };
- const repeatingSpriteRenderers: BackendRendererMap = {
- [RenderBackendType.WebGl2]: () => new WebGl2RepeatingSpriteRenderer(spriteRendererBatchSize),
- [RenderBackendType.WebGpu]: () => new WebGpuRepeatingSpriteRenderer(),
+ const scalableSpriteRenderers: BackendRendererMap = {
+ [RenderBackendType.WebGl2]: () => new WebGl2ScalableSpriteRenderer(spriteRendererBatchSize),
+ [RenderBackendType.WebGpu]: () => new WebGpuScalableSpriteRenderer(),
};
const videoRenderers: BackendRendererMap