diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 967e80f03..c4bbedcc8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -162,6 +162,15 @@ jobs:
--repo "${GITHUB_REPOSITORY}" \
--out ".release/release-notes.md"
+ # A second copy under a version-less name. GitHub serves the assets of the
+ # newest release at /releases/latest/download/, which is a
+ # permanent direct-download URL only for a name that does not move with
+ # the version - so the site can link the archive itself instead of the
+ # release page. The versioned name stays: it is what a downloaded file
+ # should be called once it is on disk.
+ - name: Add the version-less Full ZIP alias
+ run: cp ".release/exojs-${TARGET_TAG}-full.zip" .release/exojs-full.zip
+
- name: Create GitHub release with Full ZIP
uses: softprops/action-gh-release@v3
with:
@@ -170,5 +179,6 @@ jobs:
files: |
.release/exojs-${{ env.TARGET_TAG }}-full.zip
.release/exojs-${{ env.TARGET_TAG }}-full.zip.sha256
+ .release/exojs-full.zip
.release/artifacts/checksums.sha256
.release/artifacts/release-manifest.json
diff --git a/.husky/pre-push b/.husky/pre-push
index 6de739015..5afbf4d5c 100644
--- a/.husky/pre-push
+++ b/.husky/pre-push
@@ -1,7 +1,15 @@
#!/usr/bin/env sh
# Pre-push verification.
#
-# Branch pushes: `verify:quick` — the static CI-parity gates: typecheck (core
+# Branch pushes: first `scripts/ci/push-scope.ts`, which answers how much of
+# the below the pushed range needs. A range that changes no
+# tracked file verifies nothing; one carrying published
+# benchmark data alone runs only `verify:bench-results`, the
+# single gate that opens those files. Anything else - including
+# a range whose scope cannot be determined - takes the full
+# path:
+#
+# `verify:quick` — the static CI-parity gates: typecheck (core
# + guides + examples + extension packages), lint:all,
# format:check, docs:api:check and the site gates. Then the test
# lanes the pushed range actually requires, chosen from
@@ -53,20 +61,6 @@ while read local_ref local_sha remote_ref remote_sha; do
esac
done
-# verify:quick includes `typecheck:site` and `full-bundle:exports:check`, which
-# read the PUBLISHED entry points (package.json `exports` → dist/) rather than
-# src/. A dist that is missing fails them with a wall of ts(2307) "Cannot find
-# module 'exojs'"; one that merely lags the sources - the state every local
-# edit leaves behind - fails the freshness gate inside the site group, after
-# the typecheck and lint groups have already run. Either way the push is lost,
-# the build runs by hand, and everything runs again. So the build runs here
-# instead, exactly when the source stamp says it is due and never otherwise: a
-# clean tree pays one hash walk.
-if [ "$is_branch_push" = "1" ]; then
- echo "[pre-push] checking dist against the sources"
- node scripts/check-dist-fresh.ts --rebuild || exit 1
-fi
-
if [ "$is_tag_push" = "1" ]; then
echo "[pre-push] tag push detected - checking the tagged commit is releasable"
git fetch --quiet origin main || exit 1
@@ -76,12 +70,9 @@ if [ "$is_tag_push" = "1" ]; then
fi
if [ "$is_branch_push" = "1" ]; then
- echo "[pre-push] running verify:quick (static CI-parity gates)"
- npm run verify:quick || exit 1
-
# Range for lane selection. A branch with no remote tracking ref yet has no
# base to diff against, so fall back to the merge base with origin/HEAD
- # (the remote's default branch, whichever one that is) — and to running
+ # (the remote's default branch, whichever one that is) - and to running
# every lane if even that is unavailable, because a push whose scope cannot
# be determined must not be validated partially.
lane_base="$push_base_sha"
@@ -89,11 +80,42 @@ if [ "$is_branch_push" = "1" ]; then
lane_base=$(git merge-base "$push_head_sha" origin/HEAD 2>/dev/null || true)
fi
- if [ -n "$lane_base" ]; then
- echo "[pre-push] running the test lanes this push requires"
- npm run lanes -- --run --tests-only --base "$lane_base" || exit 1
- else
- echo "[pre-push] no base commit to scope lanes against — running every lane"
- npm run lanes -- --run --tests-only --all || exit 1
- fi
+ # How much of the above the range actually needs. `full` for anything
+ # `push-scope.ts` does not positively recognise, including an undeterminable
+ # range, so a narrower answer is always a deliberate one.
+ scope=$(node scripts/ci/push-scope.ts "$lane_base" "$push_head_sha")
+
+ case "$scope" in
+ none)
+ echo "[pre-push] the pushed range changes no tracked file - nothing to verify"
+ ;;
+ data)
+ echo "[pre-push] published benchmark data only - running its own gate"
+ npm run verify:bench-results || exit 1
+ ;;
+ *)
+ # verify:quick includes `typecheck:site` and `full-bundle:exports:check`, which
+ # read the PUBLISHED entry points (package.json `exports` → dist/) rather than
+ # src/. A dist that is missing fails them with a wall of ts(2307) "Cannot find
+ # module 'exojs'"; one that merely lags the sources - the state every local
+ # edit leaves behind - fails the freshness gate inside the site group, after
+ # the typecheck and lint groups have already run. Either way the push is lost,
+ # the build runs by hand, and everything runs again. So the build runs here
+ # instead, exactly when the source stamp says it is due and never otherwise: a
+ # clean tree pays one hash walk.
+ echo "[pre-push] checking dist against the sources"
+ node scripts/check-dist-fresh.ts --rebuild || exit 1
+
+ echo "[pre-push] running verify:quick (static CI-parity gates)"
+ npm run verify:quick || exit 1
+
+ if [ -n "$lane_base" ]; then
+ echo "[pre-push] running the test lanes this push requires"
+ npm run lanes -- --run --tests-only --base "$lane_base" || exit 1
+ else
+ echo "[pre-push] no base commit to scope lanes against - running every lane"
+ npm run lanes -- --run --tests-only --all || exit 1
+ fi
+ ;;
+ esac
fi
diff --git a/AGENTS.md b/AGENTS.md
index 8b34975c6..ab4eda344 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,6 +18,8 @@ Use `CONTRIBUTING.md` when a task touches repository conventions such as imports
package boundaries, distribution, build constants, or public API conventions.
Read package-local documentation when working inside that package.
+Prose is written in long lines: pull request descriptions, guides, `docs/`, READMEs, commit bodies. Break a line at the end of a paragraph or where the break carries meaning, never at a column width. Commits are their Conventional Commits subject (with `!` for a breaking change); the detail goes into the pull request description, which the changelog links to and does not copy.
+
`.workspace/` is private working context, not repository authority. Plans,
research, reviews, and temporary design artifacts belong there by default.
Agents may use relevant files there as context, but they are not part of the
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9f9e5b00..a6bef6c92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,3026 +11,403 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
### Changed
- **BREAKING: Retain filtered and clipped scopes, coalesce WebGPU row patches, add hitArea and move culling to RenderNode.** ([#681](https://github.com/Exoridus/ExoJS/pull/681))
- Two per-frame costs that scaled with the scene rather than with the
- change.
-
- **A filter or a clip was a retention floor.** A barrier-bearing node
- collected through the effect path and never reached the plan builder's
- group branch, so it never got the automatic persistent render
- representation. Everything below a filter chain or a rect clip was
- walked out of the scene graph, transform-derived and material-resolved
- on every single frame, however static it was. The barrier's content now
- climbs the same ladder a render root does, while the effect itself stays
- live in the barrier entry and the effect executor: a changed filter
- chain, clip rect or target size still takes effect on the frame it
- changes.
-
- A 5 000-sprite scene under a colour-matrix chain drops from 3.8 ms to
- 0.25 ms per frame on WebGL2 and from 2.7 ms to 0.35 ms on WebGPU, with
- the per-frame instance re-upload gone (7 -> 1). Three nested rectangle
- masks over the same scene drop from 3.9 ms to 2.2 ms (WebGL2) and 3.1 ms
- to 1.9 ms (WebGPU). Draw calls are unchanged everywhere.
-
- **A moving node cost a GPU upload of its own on WebGPU.** The retained
- group bundle wrote each patched transform row with its own
- `queue.writeBuffer`, so the per-frame upload count followed the number
- of moving nodes: 375 moved sprites were 375 calls, against one on
- WebGL2, whose row store is a texture whose dirty rect is unioned and
- committed once. The bundle now mirrors its rows, marks the blocks a
- patch touches and uploads them at the end of the patch pass: tight runs
- while the moves cluster, one span once they scatter. Uploads per frame
- on `dynamic-heavy` at 5 000 nodes: 369 -> 1.
-
- Text nodes get the same treatment as sprites: a moved text node no
- longer costs a `queue.writeBuffer` of its own, since the retained
- node-data store is now block-mirrored and uploaded per dirty region
- through the shared `DirtyRowTracker`. A WGSL filter pass writes its
- resolution and user uniform blocks only when they actually change, which
- makes the per-frame upload count on `filter-chain-1/2/4` flat in chain
- depth (11 -> 3 at depth four) instead of growing by two per link.
-
- The benchmark's structural baseline is re-recorded in the same change:
- the immediate arm's filter-chain and mask-clip cells now submit what the
- retained arm already did.
-
- Measured but not changed here: `lifecycle-churn` is not object
- construction cost (1.2 us per sprite create/add/destroy) but a full
- immediate collect after every structural change, which needs an
- incremental structural delta in the retained source; `mask-clip` keeps
- ~1.9 ms of entry replay behind nested clips because a recorded fragment
- cannot carry a barrier, which needs a splice-contract change;
- `scrolling-world` already renders in one draw call (the earlier "120
- draws" read raw totals over 120 frames).
-
- **Also in this change: `hitArea`, and `cullable`/`cullArea` move to
- `RenderNode`.** A bare `SceneNode` is structural and never rendered, and
- the cull test is evaluated over render items, so the two culling
- properties now live on `RenderNode`; semantics unchanged (read live,
- caller-owned rectangle, default `cullable = true`). `RenderNode.hitArea`
- accepts a `Rectangle`, `Circle`, `Ellipse` or `Polygon` in the node's
- own space (default `null`): when set, `contains()` maps the world point
- through the inverse world transform and tests the shape instead of the
- bounds, so a round button, a hex cell or a province outline picks
- exactly, rotated or not. Picking only; bounds, culling and rendering
- ignore it. Widgets inherit it.
-
- **Breaking changes**
- - `cullable` / `cullArea` no longer exist on a bare `SceneNode`; every
- renderable class keeps them through `RenderNode`.
-
- ***
-
- **BREAKING: Multi-stop gradients, caps and slant variants, decorations, line clamping, case mapping and tab stops.** ([#682](https://github.com/Exoridus/ExoJS/pull/682))
- A sweep across `TextStyle` and `LayoutOptions` that closes the gap
- between what the text stack can lay out and what display typography
- actually needs, plus two DX fixes.
-
- **Multi-stop gradients with an angle.** `gradient: { stops, angle }`
- replaces `gradientColors` + `gradientAxis`. Up to eight stops, offsets
- clamped and sorted on the way in, and an angle in degrees following the
- CSS `linear-gradient` convention (0 = to top, 90 = to right, default
- 180). The ramp spans the ink box corner to corner, so the first and last
- stops land on the box edges at every angle. Evaluated per fragment from
- the node's own packed style row, still a `'tint'` change that never
- touches the atlas. The stop type is reused from the existing
- `GradientStop`.
-
- **Caps and slant variants.** `fontStyle` gains `'oblique'`;
- `fontVariant: 'small-caps'` is new. Both go into the CSS `font`
- shorthand the rasterizer hands to Canvas 2D, and both are part of a
- glyph atlas's identity, so a small-cap `a` never shares a cache entry
- with an ordinary one.
-
- **Underline and strikethrough.** `underline`, `strikethrough`,
- `decorationColor`, `decorationThickness`, `decorationOffset`. Rules are
- quads the layout emits per line, so they follow alignment, wrapping and
- letter spacing. Their position comes from the font's own ascent, descent
- and x-height rather than from a fraction of the font size. A rule takes
- the fill, gradient included, unless `decorationColor` overrides it.
- `BitmapText` renders no rules: an offline atlas has no opaque block to
- sample.
-
- **Line clamping.** `maxLines` caps the laid-out line count after
- wrapping and clips on its own; pair it with `overflow: 'ellipsis'` for a
- marker. `ellipsis` configures that marker (`'...'`, `''`, anything).
- Under a cap the marker also reaches a line that no wrap could shorten,
- such as `maxLines: 1` on a single unbreakable word.
-
- **Case mapping.** `textTransform: 'none' | 'uppercase' | 'lowercase' |
-'capitalize'`, applied at layout time per grapheme cluster and under the
- layout locale. The node's `text` is untouched, and a cluster that
- changes length under the mapping still traces back to the character it
- came from, so a caret lands where the reader clicked.
-
- **Tab stops.** `tabSize` (default 8, the CSS `tab-size` initial value)
- advances a preserved tab to the next stop from the line origin, so
- tab-separated values line up as columns. Only reachable under
- `whiteSpace: 'pre'`; the collapsing modes turn a tab into one space
- before layout, as CSS does.
-
- **DX.** `AbstractText.update()` is gone; `syncDirty()` is the name.
- Stale references to a distance-field package the engine never depended
- on are replaced with a description of the in-house rasterizer. Glyph
- caches are keyed by one named `FontVariantKey` instead of four
- positional strings.
-
- New guide sections (case, variants, decorations, line clamping, tab
- stops) and the example `text-fonts/typographic-styling`. Verified on
- both backends in the browser, including new pixel-probing tests that
- prove a rule reaches the frame and that `decorationColor` reaches only
- the rule.
-
- **Breaking changes**
- - `TextStyleOptions.gradientColors` / `gradientAxis` -> `gradient`; the
- `GradientAxis` type is gone. Serialized text styles carry `gradient`
- instead of the two old keys.
- - `AbstractText.update()` is removed. `syncDirty()` is the name; the
- renderer and every extent read already resolve a pending pass on their
- own, so most callers need nothing.
- - `GlyphAtlasPool.getAtlas` / `getMetrics` / `getShapedMetrics` /
- `clearVariant`, and the `GlyphAtlas` / `GlyphMetrics` /
- `ShapedTextMetrics` constructors, take one named `FontVariantKey` (`{
-family, fontStyle?, fontWeight?, fontVariant? }`, each optional field
- defaulting to `'normal'`) instead of positional strings. `clearVariant`
- takes the narrower `FontTypefaceKey`. `ShapedTextSourceOptions` nests
- its font fields under `font`.
- - `TextPageQuads` gains `decorations`; the packed per-vertex node index
- narrows from 24 to 23 bits to make room for the decoration flag.
-
- ***
-
-- **`ShaderSource.glsl.vertex` is optional.** A sprite material and a shader
- filter never compiled the author's vertex stage (the sprite vertex program is
- engine-owned, the filter draws a fullscreen quad), yet the source required a
- non-empty string, so callers passed a dummy. `glsl: { fragment }` is now
- enough for both; `ShaderSource.glsl.vertex` reads `null` in that case, and a
- mesh or particle material without a vertex stage fails with a named error.
-
-- **The extension seams that survived the `@internal` strip lost their
- underscore prefix.** A member an official package (or any extension author)
- has to call is API, and now reads like it: `SceneNode._setLocalBounds` is
- `setLocalBounds`, `RenderNode._collect` is `collect`, `Material._onDispose` is
- `onDispose`, `AudioBus._getInputNode`/`_getOutputNode` are
- `getInputNode`/`getOutputNode`, and `Playable._createVoice` is `createVoice`
- (with `Sound`, `AudioStream` and `AudioGenerator` following). On the renderer
- SDK, `WebGl2Backend._stageViewportUniform` is `stageViewportUniform`,
- `_pushTransform` and `_recordRetainedBatch` are `pushTransform` and
- `recordRetainedBatch` on both backends, `WebGpuBackend._transformStorageWouldGrow`
- and `_textureUploadWouldMutate` are `transformStorageWouldGrow` and
- `textureUploadWouldMutate`, the retained replayer contracts
- (`_scanRetainedNodeIndexRange`, `_rebaseRetainedNodeIndices`,
- `_configureRetainedVao`, `_validateRetainedBatch`, `_replayRetainedBatch`) and
- `RetainedGroupBundle`'s `_patchTransformRow`/`_patchTintRow` drop the prefix
- the same way. Rename the call sites; there are no compatibility aliases. Every
- remaining underscore member is engine-private and no longer reaches the
- published `.d.ts` at all.
-
-- **Punctuation keys are named after their `code` on both input surfaces.**
- `Keyboard.Colon`, `Equals`, `Dash`, `QuestionMark`, `Tilde`, `OpenBracket`,
- `BackwardSlash`, `ClosedBracket` and `Quotes` are now `Semicolon`, `Equal`,
- `Minus`, `Slash`, `Backquote`, `BracketLeft`, `Backslash`, `BracketRight` and
- `Quote`, matching the pattern tokens (`keyboard.semicolon`, ...) and
- `KeyboardEvent.code`. Channel values, bindings and actions are unchanged.
-
-- **`ShaderFilter` sources get a `uOrientation` auto-bind, so one shader offsets
- along `v` the same way on both backends.** A WebGL2 render texture stores the
- effect domain bottom-up and a WebGPU one top-down, which used to make a
- directional `v` offset move the image up on one backend and down on the other.
- `uOrientation` is `+1` where `v` grows along the domain's y axis and `-1`
- where it grows against it (GLSL `uniform float uOrientation`, WGSL
- `@group(0) @binding(3) var uOrientation: f32`); multiply the v
- component of a directional offset by it. Existing sources that only sample
- their own texel need no change.
-
-- **Every remaining public duration input takes branded `Seconds` instead of a
- plain millisecond `number`.** `AudioBus.fadeIn`/`fadeOut`, `Voice.fade`/`stop`
- (and `crossFade`'s duration), `InputVoice.record`, `Envelope`'s
- `attack`/`decay`/`release`/`totalDuration` (renamed from the `*Ms` fields,
- with `trigger`'s `elapsed` following) and its `releaseAt` method (renamed
- from `release`, which now names the duration field instead),
- `View.shake`'s duration, `AnimatedSprite`'s `frameDuration`/`frameDurations`,
- and `PhasedSceneTransition`/`CrossFadeSceneTransition`'s `duration` all match
- the `Seconds` unit the rest of the engine already uses. Wrap existing
- millisecond literals with `Time.seconds(ms / 1000)` (or write the value
- directly in seconds); `Envelope.releaseAt` replaces `envelope.release(...)`
- now that `release` names the duration property.
-
-- **The published `.d.ts` no longer carries `@internal` members.** The
- declaration emit now strips them, so consumer autocomplete on `Loader`,
- `AssetRef`, `Text`, `Tween`, `AudioBus` and the rest shows the API instead of
- the engine's internals, and the published types finally agree with the
- published API reference. Types that a public or renderer-SDK signature
- genuinely exposes became part of the surface rather than disappearing with
- the tag: `CheckableWidget`, `TextEditWidget`, `UIBackgroundNode`, `Ticker`,
- `InputVoice`, `ShapeLike`, `PointerChannel`, `GamepadButtonChannel`,
- `GamepadAxisChannel`, `CatalogResourceLeaf`, `CatalogValueLeaf`,
- `OwnedNetworkHintSource` and `BmFontAdapter` on the root barrel;
- `RenderPlanBuilder`, `DrawCommand`, `MaterialKey`, `InstanceDataView`,
- `InstanceAttributeBinding`, `ShaderProgram`, `RenderPassCoordinator`,
- `RenderPassDescriptor`, `RenderPassLoad`, `StencilAttachmentMode`, the
- retained-group payload/replayer types and `WebGpuActiveRenderPass` on
- `@codexo/exojs/renderer-sdk`. Going the other way, `SpriteFlags` and
- `ViewFlags` - internal dirty-flag bitmasks that were exported by accident -
- are gone from the root barrel, `ObservableVector`'s owner constructor is
- internal (construct a plain vector with `new ObservableVector()`), and
- `onAudioContextReady` is typed as the `Signal<[AudioContext]>` it always
- was.
-
-- **`AnimatedSprite.defineClip` is now `addClip`, `Sound.defineSprite` is now
- `addSprite`, and `Spritesheet.addFrame`/`removeFrame` return `this`.** One
- verb pair, `add`/`remove`, for every named-registration mutator, and every
- one of them chainable. Rename the calls; the `clips`/`sprites` constructor
- options and `setClips`/`setSprites` are unchanged.
-
-- **`Assets.from` rejects a bare path whose suffix no asset type claims, at the
- literal.** `'hero.pgn'` used to type-check and hand back `unknown`; it now
- fails to compile with a message naming the path and the way out. Paths that
- only exist at runtime (`string`, not a literal) are unaffected.
-
-- **`BurstSpawn`'s `loop: boolean` is replaced by `interval: number`, the
- period in seconds between two runs of the schedule.** `loop: true` restarted
- the schedule in the same `apply()` call that exhausted it and zeroed the
- clock, so a schedule with nothing after its final burst re-fired every frame
- and the emitted count followed the frame rate rather than elapsed time. The
- period is now declared, the clock wraps by subtracting it so overshoot
- carries into the next cycle, and a long frame fires every period it covered.
- Replace `loop: true` with `interval: `; a schedule that used a
- trailing `{ time: period, count: 0 }` entry to fake a period can drop it.
-
-- **`TileSet._setDefinitions` is now `setDefinitions`.** The tilemap SDK
- contract for extension packages that build tilesets programmatically loses
- the leading underscore now that the method is the only cross-package member
- in the extension packages that isn't `@internal`; the other 25
- package-private members across `@codexo/exojs-physics` and
- `@codexo/exojs-tilemap` are now tagged `@internal` and no longer appear in
- the published `.d.ts`. Rename the call.
+- **BREAKING: Name the renderer and audio SDK seams, tag the rest @internal.** ([#672](https://github.com/Exoridus/ExoJS/pull/672))
+- **BREAKING: Name punctuation keys after their code on both surfaces.** ([#667](https://github.com/Exoridus/ExoJS/pull/667))
+- **BREAKING: Strip internals from the declaration emit.** ([#668](https://github.com/Exoridus/ExoJS/pull/668))
+- **BREAKING: Take every public duration input in seconds.** ([#666](https://github.com/Exoridus/ExoJS/pull/666))
+- **BREAKING: Unify add/remove mutator verbs and reject unknown asset suffixes at the literal.** ([#661](https://github.com/Exoridus/ExoJS/pull/661))
+- **Tag package-private members @internal.** ([#673](https://github.com/Exoridus/ExoJS/pull/673))
### Added
- **Show examples without guide markers and with one-line imports.** ([#683](https://github.com/Exoridus/ExoJS/pull/683))
- The playground and guide code blocks showed example sources verbatim,
- including the `// #region guide:...` markers that exist only for snippet
- extraction and the import lists Prettier had wrapped one specifier per
- line. The display source now drops the markers (with the blank line they
- would leave) and joins each wrapped import back onto one line. Execution
- source and files on disk are unchanged, so snippet extraction,
- typechecking and formatting keep working on the originals.
-
- **Add JobScheduler for frame-budgeted generator jobs.** ([#679](https://github.com/Exoridus/ExoJS/pull/679))
- Heavy work (world generation, batch pathfinding, visibility rebuilds) no
- longer has to choose between blocking a frame and an `async` update that
- resumes in a later microtask.
-
- - `JobScheduler` advances generator jobs one `yield` at a time inside a
- per-frame time budget (default 2 ms; at least one step per update so a
- job always progresses), in strict priority order with round-robin inside
- a priority. Instantiable with its own budget and order, so a scene can
- own one via `scene.systems.add(new JobScheduler())`.
- - `Job` handle: frame code polls `status` / `result` / `error`, async
- code awaits `done` (created lazily, so an unawaited failure never raises
- an unhandled rejection; cancellation rejects with an `AbortError`).
- `cancel()` stops the generator at its `yield` and runs `finally` blocks;
- `{ scope }` lets a `DestroyScope` own the job only while it runs.
- - `app.jobs` is the application-owned instance, ticked in the `update`
- phase at the new `SystemOrder.CoreJobs`.
- - Guide section "Spreading work over frames" in the Application chapter;
- API docs generated; export snapshot updated.
-
- Worker-backed jobs are deliberately not part of this: the handle is
- designed so a `WorkerPool` can hand out the same `Job` later.
-
- ***
-
-- **`@codexo/exojs-pathfinding`, the official pathfinding extension.** One
- search core - A\* over integer node handles - serving pluggable navigation
- spaces. `GridSpace` is a finite window of weighted cells with diagonal
- policies, `setCost`/`revision` for runtime edits, brushfire clearance for
- agents wider than one cell, and string-pulling smoothing; `WaypointGraph` is a
- directed graph whose edges carry a `kind` and a typed payload, which is what a
- platformer's jump and fall links need and what a grid cannot express, and
- which degrades to plain Dijkstra when its nodes have no positions.
- `Pathfinder.findPath`/`findPathBetween` return a `PathResult` whose `status`
- distinguishes `found`, `unreachable` and `budget-exceeded` instead of throwing
- or returning `null`, and `floodFrom` answers "everything reachable within this
- cost". Jump-point search self-enables on a uniform-cost grid and returns the
- same optimal path from a fraction of the expanded nodes. Paths are
- reproducible across runs and machines, and a search allocates nothing that
- scales with the nodes it visits. The package depends on `@codexo/exojs` alone:
- a tilemap reaches it through the cost callback `GridSpace.from` takes, not
- through a package edge.
-
-- **Browser-native shaping for bidirectional and contextual text.**
- `LayoutOptions.shaping` selects how a glyph's appearance is resolved:
- `'auto'` (the default) keeps ordinary content on the shared glyph atlas and
- hands text that needs its surroundings - a right-to-left base direction, an
- explicit bidi control, or any script outside a proven-safe allow-list - to
- the browser's canvas text engine one complete line at a time, which resolves
- the bidirectional order and the contextual forms. `'simple'` and `'browser'`
- force either path; `Text.shapingMode` reports which one settled. Shaped
- lines are rasterized into pages the node owns and released with it, so no
- process-wide cache of whole strings accumulates. No runtime dependency is
- added: the platform provides the segmentation, the shaping and the raster.
-
-- **`LayoutOptions.locale`.** The language tag Unicode segmentation runs
- under - which clusters count as one character, and where a line may break.
- It selects no font and loads nothing.
-
-- **`GlyphPlacement.sourceStart` / `sourceEnd` and the same pair on
- `TextLineMetrics`.** Every laid-out glyph and every laid-out line now carries
- the UTF-16 range of the string it came from. Nothing in a string marks a soft
- wrap, so this is what maps a caret, a selection or a hit test onto wrapped
- text; a glyph that stands for no source character (the ellipsis an overflow
- appended) reports an empty range at the point it replaced.
-
-- **`TextArea` wraps and scrolls.** A line too long for the field breaks at a
- word boundary instead of scrolling sideways, and a vertical scrollbar appears
- along the right edge once the value outgrows the field and drives the scroll
- position. `wrap: false` restores horizontal scrolling for content whose own
- line breaks are what matters; `scrollbar: false` and `scrollbarThickness`
- control the bar, and `TextArea.verticalScrollbar` exposes it. Caret motion,
- `Home`/`End`, `PageUp`/`PageDown`, hit testing and selection rectangles
- follow the laid-out lines, so a wrapped line behaves like the two lines it
- looks like while the value keeps exactly the line breaks the user typed.
-
-- **`when` on `SceneInteraction.observe()` and `scope()`.** Interaction
- registrations take the same `SceneAvailability` policy the input, tween and
- audio facades have. The default stays `'always'`, so a pause menu drawn by
- the paused scene keeps receiving pointer events; `'active'` detaches a
- registration while the scene is paused and `'paused'` attaches it only then.
-
-- **`DisplacementFilter`.** Warps the filtered node by a direction read out of a
- map texture - heat haze, water refraction, glass, shockwaves. `map`'s red and
- green channels are decoded to `[-1, 1]` and scaled by `scale` (one number or
- `[x, y]`, logical units, default `20`); `offsetU`/`offsetV` move where the map
- is sampled, so animating them scrolls the distortion. The reach is declared
- through `getOutputBounds`, and a fragment displaced past the effect domain
- comes out transparent rather than smearing the border.
-
-- **`PhasedSceneTransition.destroyPhaseState(state)`.** The release half of
- `createPhaseState()`, called exactly once per session on every exit path -
- normal completion, an abort before the commit, or the application being
- destroyed mid-transition. Override it when the phase state owns GPU-backed
- resources; plain scratch needs no override. A `{ enter, exit }` pair holds
- one state per side and each side is released through its own phase's hook.
-
-- **A guide chapter and a playground example for writing your own scene
- transition.** The **Writing your own transition** chapter spells out the
- lifecycle contract `SceneTransitionLifecycleError` enforces - the
- definition/session split, what the director guarantees per frame, why
- `commit()` does not switch the scene in the same call, and what an abort and
- a `destroy()` have to leave behind - and builds a complete bar-wipe
- transition against it, both as a full `SceneTransition` and as a
- `PhasedSceneTransition`. The matching `application-scenes/custom-transition`
- example runs that transition between two scenes. No API change.
-
-- **`DropShadowFilter`.** A soft, offset silhouette of the filtered node drawn
- behind it: `offsetX`/`offsetY`, `blur`, `quality`, `color` (alpha is the
- shadow opacity) and `shadowOnly` for glows and detached shadows. Composed from
- the stock colour-matrix and blur passes, so it runs on both backends and
- declares the extra reach it needs through `getOutputBounds`.
-
-- **`@codexo/exojs-lighting`, forward normal-mapped point lighting for
- sprites.** Lighting happens inside the sprite fragment stage, so a lit scene
- costs no extra render pass and no extra draw call - sprites sharing one
- `LitSpriteMaterial` stay in one batch. `PointLight` is plain mutable
- world-space data (`x`, `y`, `radius`, `color`, `intensity`, `height`);
- `LightingSystem` collects lights and packs them, together with the active
- count and the ambient term, into one `rgba32f` data texture per frame, and
- registers on any `SystemRegistry` like every other system. The light list
- being a texture rather than a uniform array is what makes the light count a
- shader loop bound instead of a compiled-in constant: a material user uniform
- is one `vec4` per name, which would have capped a scene at a handful of
- lights and needed a recompile to change. `LitSpriteMaterial` samples a
- tangent-space normal map next to the albedo and rotates the normal by the
- instance's local-to-world basis, so spinning and mirrored sprites keep their
- bumps facing the right way. One normal map per material (= per atlas) is the
- v1 contract; there is no deferred path yet. Two examples ship with it:
- `lighting/normal-mapped-sprites` and `lighting/many-lights`, which walks from
- 1 to 48 lights over a normal-mapped floor without leaving a single draw call.
-
-- **Custom sprite materials receive the fragment's world position and the
- instance's local-to-world basis.** `v_worldPosition` / `v_basis` (GLSL) and
- `worldPosition` / `basis` on `VertexOutput` (WGSL) let a fragment shade
- against world-space lights and rotate a tangent-space normal with the
- sprite, which is what a lighting effect needs and what the varyings did not
- carry before. The `lighting/normal-mapped-sprites` example lights a batch of
- spinning and mirrored sprites through one material and four point lights.
-
-- **`Scene.animations`, a scene-bound animation facade with the same `when`
- policy the tween and audio facades already have.** An `AnimatedSprite`
- attached to a scene tree kept advancing through `SceneDirector.pause()` and
- through retention, so a pause menu drawn over a "frozen" world still had
- moving sprites and a suspended scene kept burning frames.
- `this.animations.add(sprite, { when: 'active' })` binds playback to the
- scene: frozen while it is paused, frozen while it is retained, stopped when
- it ends. `when` takes the same `SceneAvailability` values with the same
- `'always'` default, so an untracked sprite behaves exactly as before.
-
-- **`TrailParticles`, a particle render mode that draws a motion trail behind
- every particle.** Where `RibbonParticles` connects the particles of one
- system into a single band, this gives each particle its own strip through the
- positions it recently occupied, kept in a per-particle ring buffer and drawn
- in one non-instanced draw. Positions are recorded on each particle's own
- clock (`interval`), so a trail covers the same travel at any frame rate;
- `points` sets how far back it reaches, `width` its thickness and `fade` how
- its alpha falls off towards the tail. CPU-only, like `RibbonParticles`.
+- **Unicode-safe layout, browser-native shaping, and soft-wrapping TextArea.** ([#677](https://github.com/Exoridus/ExoJS/pull/677))
+- **Add the @codexo/exojs-pathfinding extension package.** ([#676](https://github.com/Exoridus/ExoJS/pull/676))
+- **When policy for SceneInteraction, optional GLSL vertex stage, DropShadowFilter guide.** ([#671](https://github.com/Exoridus/ExoJS/pull/671))
+- **Transition authoring kit, and release phase state when a session ends.** ([#670](https://github.com/Exoridus/ExoJS/pull/670))
+- **V-axis orientation uniform for shader filters, and DisplacementFilter.** ([#669](https://github.com/Exoridus/ExoJS/pull/669))
+- **Add the @codexo/exojs-lighting extension package.** ([#664](https://github.com/Exoridus/ExoJS/pull/664))
+- **Expose world position and basis to custom sprite materials.** ([#659](https://github.com/Exoridus/ExoJS/pull/659))
+- **Add DropShadowFilter and fix the vertical mirror in WebGPU shader filter passes.** ([#660](https://github.com/Exoridus/ExoJS/pull/660))
+- **Periodic BurstSpawn and TrailParticles render mode.** ([#656](https://github.com/Exoridus/ExoJS/pull/656))
+- **Frame budgets, GPU frame timing and asset cache inspection.** ([#658](https://github.com/Exoridus/ExoJS/pull/658))
+- **DPR watcher, hit-ordered scroll wheel, pointer lock, animated widget backgrounds.** ([#657](https://github.com/Exoridus/ExoJS/pull/657))
### Fixed
- **Raise the contact push-out cap to its pixel-scale value and sleep settled piles.** ([#680](https://github.com/Exoridus/ExoJS/pull/680))
- The soft-constraint push-out was capped at 4 px/s. Box2D-v3's analogue
- is 3 m/s, which at the pixel scales ExoJS targets is 60-300 px/s, so the
- cap stood an order of magnitude below the speed it was meant to express.
- A gravity-driven pile rearranges faster than such a push-out can work:
- overlap accumulated instead of resolving, and 1000 dynamic circles
- settled at 4.7 px mean penetration across 4939 touching pairs where the
- geometry has about 2800. The cap is now 60 px/s, the low end of the
- honest conversion band and the point where measurement shows the scene
- stops fighting it.
-
- The sleep gate compounded the problem: it reset both bodies' timers
- while a contact's penetration exceeded the sleep tolerance, but a loaded
- contact rests at the depth its own soft-constraint deflection holds it
- at, which in a pile is several times a lone body's, so islands never
- slept and a fully settled scene kept being solved in full. The gate now
- reads push-out progress instead of depth: a contact deeper than the
- tolerance blocks sleep only while the last step actually moved its
- overlap, or while the point has no history at all, so geometry appearing
- inside a sleeping body still reopens the decision.
-
- Numbers (median ms/step, touching pairs): `many-dynamic` 1000 bodies
- 35.5 ms / 4875 pairs -> 16.1 ms / 2766; 4000 bodies 235.9 ms / 34 423 ->
- 99.1 ms / 10 894. A settling pile now sleeps completely at 2.4 ms/step
- instead of never at 5.2. `box-stack` 4000: 16.5 -> 9.7 ms with identical
- contact counts, which is the sleep gate alone. Three tests whose
- literals pinned the old cap were retuned deliberately and one was added:
- a contact resting deeper than the tolerance must still sleep once its
- push-out has stalled.
-
- Bench package, same change set:
- - planck.js joins the physics matrix as a second pure-JS peer
- (`lengthUnitsPerMeter = 30`; its default of 1 double-counts contacts in
- pixel coordinates), Rapier is labelled as the WASM reference rather than
- a peer, and `--engine` filters the physics domain. Contact counts now
- agree across exojs, matter-js, planck and rapier, which they previously
- did not.
- - New `composite` rendering archetype: scene into an offscreen render
- texture, blur sweeps, additive composite over the direct draw. ExoJS
- through the public `RenderPipeline`, Pixi hand-rolled with
- `RenderTexture` and filters. Structural baseline re-recorded for the two
- new cells; no other counter moved.
-
- ***
-
-- **Text no longer splits a grapheme cluster.** Layout counted code points, so
- a combining sequence, an emoji with a skin-tone modifier, a ZWJ sequence and
- a regional-indicator flag were each placed as several glyphs, could be broken
- in half by `breakWords` or `maxWidth`, and could be truncated to a dangling
- mark or a lone regional indicator by `overflow: 'ellipsis'`. The unit of
- layout is now the grapheme cluster throughout - placement, wrapping,
- truncation and the caret granularity of the editing widgets - resolved
- through `Intl.Segmenter`. Where a browser does not provide it, clusters
- degrade to code points and word boundaries to blank runs; no polyfill ships.
-
-- **Word wrapping is locale-aware rather than a split on spaces.** Text in a
- script written without inter-word spaces used to overflow as one unbreakable
- token; it now wraps at its own word boundaries. A run of blanks is one break
- candidate, and a run that stays inside a line is preserved verbatim.
-
-- **`FadeSceneTransition` no longer leaks a `QuadGeometry` per navigation.**
- Its per-session phase state allocates one, and nothing released it, so every
- faded scene change left a backend vertex/index buffer pair behind for the
- lifetime of the context. It now releases the quad through the new
- `destroyPhaseState` hook.
-
-- **WebGPU shader filters no longer mirror their input vertically.** The
- fullscreen pass sampled v = 0 at the bottom of the quad, which is texel row
- 0 on WebGL2 but the last row on WebGPU, so every `ShaderFilter` pass on
- WebGPU wrote its input upside down. Invisible while the effect domain equals
- the content bounds; wrong for a filter with asymmetric reach or one that
- samples away from its own texel.
-
-- **`Application.destroy()` now aborts the lifecycle signal of a navigation
- still inside `load()`.** Teardown already waited for an in-flight navigation
- to settle, but nothing told the incoming scene to stop: a `load()` awaiting
- `Scene.lifecycleSignal` never resolved, so the wait ran out the full
- five-second grace period and the backend went down while the scene was still
- preparing. The signal is aborted at the same point the navigation's
- generation is invalidated, so a cooperative `load()` returns immediately and
- the incoming scope's teardown completes before anything it depends on is
- released.
+- **Let the package policy accept Core's sideEffects allowlist.** ([#665](https://github.com/Exoridus/ExoJS/pull/665))
+- **Typed renderer bindings, buffered SceneAudio fades, Color.fromCss, LDtk enum fields.** ([#655](https://github.com/Exoridus/ExoJS/pull/655))
+- **Pause-aware scene animations and abortable in-flight navigation.** ([#654](https://github.com/Exoridus/ExoJS/pull/654))
### Documentation
- **Bring the README up to the 0.17 surface.** ([#684](https://github.com/Exoridus/ExoJS/pull/684))
- Package table and install list gain lighting, pathfinding and
- tilemap-physics, each package linked to its directory. The feature list
- covers custom sprite materials, the lighting package, the Unicode text
- stack with its new style properties, exact picking with `hitArea`, the
- frame-budgeted job scheduler, the transition authoring kit and the
- pathfinding package. The quickstart compiles again against the current
- API (`Seconds` instead of the removed `Time` type, a mounted canvas, a
- colour that still exists; verified with tsc against `src/`), and the
- roadmap names the 0.18 themes instead of items that have shipped.
-
- ***
+- **Show the brand mark and the companion.** ([#663](https://github.com/Exoridus/ExoJS/pull/663))
+- **Add the which-call-when table to the loading guide.** ([#662](https://github.com/Exoridus/ExoJS/pull/662))
## [0.16.2] - 2026-09-03
### Added
-- **`Spritesheet.removeFrame(name)` and `Stack.removeItem(item)`.** Both
- mirror their existing `add`-side methods, completing the add/remove pair
- every other mutator on these classes already has.
+- **`Spritesheet.removeFrame(name)` and `Stack.removeItem(item)`.**
### Fixed
-- **Two class doc comments no longer erase their class from the published
- declarations under `stripInternal`.** `SceneNode` and `Loader` mentioned
- `@internal` in prose, which TypeScript reads as a real tag on the class; the
- wording changed. The 22 backend, scene-graph, material, audio, asset-type and
- tile-set members that official extension packages reach through the SDK entry
- points are now documented as that SDK contract instead of being tagged
- `@internal`. The flag itself stays off: 41 internal types still appear in
- public signatures and must be made public first.
-- **A material drawn into a multi-attachment target now gets a dev-build
- warning when its fragment shader under-declares outputs.** The guard that
- refuses a drawable without a material never checked whether a material's
- own shader actually writes every attachment; a shader with fewer declared
- outputs than the target's attachment count silently left the extra
- attachments at their previous contents on WebGL2 (WebGPU already refuses
- pipeline creation for this). `ShaderSource.countFragmentOutputs` reflects
- the declared `@location`/`layout(location = n) out` count from the active
- backend's language and warns once per shader/attachment-count pairing when
- it falls short.
+- **Two class doc comments no longer erase their class from the published declarations under `stripInternal`.**
+- **A material drawn into a multi-attachment target now gets a dev-build warning when its fragment shader under-declares outputs.**
- **`RenderTexturePool` keys pooled textures by format as well as size.**
- `acquire()` matched on `width x height` alone, so a pool holding an
- `Rgba8` entry could hand it back for a request in a different format.
- Latent today (every caller acquires the default format), but silent
- the moment an HDR intermediate requests `Rgba16F`/`Rgba32F`. `acquire()`
- now takes an optional `format` parameter (defaulting to `Rgba8`) and
- matches on it too.
-- **`SharedAbort` dropped its unused multi-holder API.** `retain()`, `holders`
- and `aborted` had no caller anywhere in the tree - cancellation is actually
- decided by the claim refcount elsewhere - and the class documented an
- N-holder contract that was never wired up. Removed, along with the doc
- paragraph describing it.
+- **`SharedAbort` dropped its unused multi-holder API.**
- **A press that hits no interactive node now clears keyboard focus.**
- Nothing blurred the focused node on a click or tap that resolved to empty
- canvas, so an open `Dropdown` stayed open and a `TextInput` kept its caret
- and DOM transport after the user clicked away from it.
-- **An infinitely repeating `Tween` releases its target once that target is
- destroyed.** A tween had no link to its target's lifetime, so
- `repeat(-1)` kept interpolating and writing to a destroyed `SceneNode`
- forever, pinning it in memory. `Tween.update` now stops itself (and is
- released from `TweenSystem`) the frame after its target reports
- `destroyed === true`.
+- **An infinitely repeating `Tween` releases its target once that target is destroyed.**
- **The fixed-timestep spiral-of-death guard now scales with `fixedTimeStep`.**
- The catch-up cap was a constant 5 steps regardless of the configured step
- size, so a step smaller than the default silently ran the simulation slower
- than wall time once the frame rate dropped enough to hit the clamp - with no
- warning. The cap is now derived from the existing frame-delta clamp and the
- configured step, so a smaller step gets proportionally more catch-up steps.
-- **A `Sprite` whose frame was set before its texture finished loading gets the
- right UVs.** Texture coordinates are the frame divided by the texture's
- dimensions, so a frame chosen against a still-loading handle - what
- `Spritesheet` does on an atlas that has not arrived - was computed against
- 0x0 and never recomputed: the sprite sampled a single texel, and a retained
- product recorded around it kept the non-finite coordinates for the life of
- the root. The sprite now recomputes its coordinates and announces the change
- when the payload lands.
-- **Tile layers on WebGL2 sample their own tileset again when a sprite is drawn
- between them.** `@codexo/exojs-tilemap`'s WebGL2 chunk renderer skipped its
- texture bind and blend call whenever its private memo matched, but the memo
- outlived the batch it described - and a sprite drawn between two tile layers
- binds its own texture to the very unit the tile shader samples. The layer
- after it drew the sprite's pixels, with the sprite's blend mode. The WebGPU
- chunk renderer was never affected, so the backends visibly disagreed.
-- **`Text` and `BitmapText` honour `blendMode` on both backends.** The setter
- is public on every drawable and already broke the render batch, but neither
- text renderer applied it: WebGPU baked `Normal` into its pipeline, and WebGL2
- drew with whatever blend state the previously flushed renderer had left, so
- the same run could composite differently from frame to frame. A text batch
- now breaks on a blend change and draws with the mode it declares.
-- **Sprites and meshes on WebGL2 draw with the blend mode they declare, even
- when another renderer type is interleaved.** The WebGL2 blend state is one
- global the backend owns, but the sprite and mesh renderers kept a private
- copy and skipped the backend call whenever a batch declared the mode that
- copy already held. A `Graphics` or nine-slice drawn in between had changed
- the real state in the meantime, so the next batch composited with a foreign
- blend mode - visibly disagreeing with WebGPU, which resolves blend per
- pipeline. Each batch now establishes its blend mode at its own draw call.
-- **Nine-slice sprites on WebGL2 pick up a texture whose payload changed under
- a stable identity, and keep their own blend mode.** The renderer bound the
- batch texture only when the identity differed from the last one it had seen,
- and the bind is what carries the upload - so a skin repainted through
- `Texture.setSource()` / `updateSource()`, a canvas or `ImageBitmap` texture
- refreshed per frame, and a `RenderTexture` re-rendered into went on drawing
- the pixels of their first upload forever. The same memo skipped the live
- check that rejects a destroyed texture, and its blend counterpart let another
- renderer's blend mode through. Texture and blend state are now established at
- the batch's own draw call.
-- **Particle systems on WebGL2 pick up a texture whose payload arrives after
- the first draw.** `WebGl2ParticleRenderer` bound the system's texture only
- when its identity changed, which for a single-system scene meant exactly
- once - while the handle from `loader.get(...)` was still empty. The image
- landed a few frames later and never reached the GPU, so the system simulated
- and drew its quads against blank pixels for the rest of its life. The same
- memo could hold a stale blend mode after another renderer changed it. Both
- are now offered to the backend on every system, which already collapses a
- redundant bind and is the only holder of the live GL state.
-- **A payload of one to three bytes is sniffed as `text/plain` instead of
- throwing.** The MP4 magic-byte check read a 32-bit box size without checking
- that the buffer holds one, so a truncated or near-empty response surfaced as
- `Offset is outside the bounds of the DataView` wrapped in a load failure,
- rather than as the documented fallback.
-- **`WebStorageStore.set()` rejects on a full quota instead of throwing
- synchronously.** Web Storage is synchronous, so a `QuotaExceededError`
- escaped the returned promise and never reached the `.catch()` that is the
- only handler a write has - unlike the serialization failure two lines above
- it, which did reject.
+- **A `Sprite` whose frame was set before its texture finished loading gets the right UVs.**
+- **Tile layers on WebGL2 sample their own tileset again when a sprite is drawn between them.**
+- **`Text` and `BitmapText` honour `blendMode` on both backends.**
+- **Sprites and meshes on WebGL2 draw with the blend mode they declare, even when another renderer type is interleaved.**
+- **Nine-slice sprites on WebGL2 pick up a texture whose payload changed under a stable identity, and keep their own blend mode.**
+- **Particle systems on WebGL2 pick up a texture whose payload arrives after the first draw.**
+- **A payload of one to three bytes is sniffed as `text/plain` instead of throwing.**
+- **`WebStorageStore.set()` rejects on a full quota instead of throwing synchronously.**
- **A blocked IndexedDB open no longer leaks the connection it later gets.**
- The `blocked` event rejected the open, but the request still completed once
- the blocking connection went away, handing over an `IDBDatabase` nobody was
- waiting for and nothing closed - which then blocked every future upgrade in
- its turn. A connection arriving after a rejected open is closed.
- **A container that packs one source twice is rejected instead of unpacked.**
- Both entries resolved to a single asset identity, so the second payload
- replaced the first - and for a texture, video or music entry the replaced one
- owned a GPU upload or a media element that no owner could release.
-- **Two sources that differ only in a `|` are two assets again.** Resource and
- source keys joined their fields with an unescaped separator, so a URL
- carrying an unencoded `|` in its query could compose the very key another
- request composes from a source plus a discriminator - and the two then shared
- one residency entry, one fetch and one claim set. The fields are now escaped
- the way persisted cache record keys already were. Because the source key is
- part of a persisted record's identity, the default cache layout version is
- raised: records written by an earlier version are no longer found and are
- acquired again rather than read under the old spelling.
-- **`awaitBackground()` settles for every caller, and for a queue an eviction
- emptied.** The residency kept a single resolver slot, so a second concurrent
- call overwrote the first and that promise never settled - a loading screen
- and a scene preloader awaiting one drain deadlocked one of them, with no
- error. Dropping a queued entry because its last claim went away (a scene
- teardown, a cancelled load) also left the queue counted as unfinished
- forever, so a lone awaiting caller hung and `onProgress` stayed below its
- total. Both now settle.
-- **A `get()` or `load()` reaching a source while its container is unpacking
- joins the unpack.** An unpack registered no in-flight identity, so for the
- whole window between parsing the index and storing a payload the key looked
- unknown to the loader and a concurrent acquisition of the same source started
- a second, competing fetch whose payload overwrote the container's. Which one
- a consumer saw was timing-dependent, and the loser - a texture upload, a
- media element - was never released.
+- **Two sources that differ only in a `|` are two assets again.**
+- **`awaitBackground()` settles for every caller, and for a queue an eviction emptied.**
+- **A `get()` or `load()` reaching a source while its container is unpacking joins the unpack.**
- **A container that fails while unpacking releases what it already claimed.**
- `Loader.loadContainer` claims every entry up front and only then builds them,
- but a failure rejected without ever handing the caller the scope holding
- those claims, so every entry of a failed container stayed resident for the
- loader's lifetime with no owner able to free it.
-- **A destroyed `LoaderScope` refuses to claim.** `get`, `load` and
- `loadContainer` still registered claims after `destroy()`, and since destroy
- is idempotent by contract there was no way to release them: an async
- continuation that outlived a scene teardown pinned its assets for the
- application's lifetime, and `Loader.inspect()` listed a destroyed scope as an
- owner. They now throw instead.
-- **Releasing a font or an image frees what it owns.** Neither factory
- implemented the per-resource teardown its resources need: a released font
- stayed registered on `document.fonts` (so CSS and Canvas went on resolving
- its family) and pinned for the loader's lifetime, and a released image never
- closed the `ImageBitmap` it had decoded. Only a bitmap the engine decoded
- itself is closed.
-- **An asset unloaded mid-fetch frees the resource its factory had already
- built.** The store was skipped, as it must be, but the finished resource -
- which for a video or a music stream owns a media element - was handed back
- undisposed and stayed alive until the loader was torn down.
-- **A failed or cancelled `music`/`video` load cleans up after itself.** The
- element was registered with the factory before the source was attached, and a
- failure left it there with its object URL unrevoked - and since no resource
- was built, nothing would ever release it. A repeatedly retried blob-backed
- load accumulated one element and one live blob per attempt.
-- **A `fetchOptions.signal` set on the loader aborts asset loads too.** Every
- asset load replaced it with the loader's own cancellation signal, so an
- application-wide abort signal worked for `.exoa` containers and silently did
- nothing for every other asset. The two are composed now: either one aborts
- the request, and neither disables the other.
-- **Options a later `get()` loses are diagnosed in development.** `Loader.get`
- documents that the same source yields the same instance and that conflicting
- options on a later call are ignored with a one-time dev warning - but no such
- warning existed, so a second `get('x.png', { textureOptions })` dropped its
- sampler request with nothing to diagnose it. The warning now exists (once per
- source, stripped in production). Options that take part in asset identity are
- unaffected: they resolve to their own instance and lose nothing.
-- **A `stop()` made while `start()` is still loading is no longer overwritten
- when the startup run settles.** The startup continuation promoted the state
- to `Running` unconditionally, so a `stop()` that landed inside the loading
- window - where the frame loop is live but the state is still `Loading` - was
- undone the moment `start()` resolved. The application then advertised
- `Running` for a halted loop, and because both `start()` and `stop()`
- early-return on that state, the instance was permanently unusable. The
- promotion now happens only if the loop the run started is still the live one.
-- **A destroyed `Application` stays destroyed.** A `start()` that failed while
- `destroy()` was running reset the state to `Stopped` from its own catch, and
- because that continuation resumes after the teardown chain has finished,
- `Stopped` was the last value written. `state` then lied about a destroyed
- instance and `start()` accepted it, reinitializing an already-destroyed
- backend and restarting the frame loop over released subsystems. `Destroying`
- and `Destroyed` are now terminal - the only transition out of them is
- `Destroying` to `Destroyed` - so a late startup failure cannot resurrect the
- application and `start()` rejects as documented.
-- **`Application.destroy()` waits for a scene navigation that is still in
- flight.** `destroy()` documents that `scenes` is fully disposed before the
- Loader, rendering context, audio system and backend are released, but a
- `change()`/`restore()` (or the initial `start(Target)` navigation) still
- inside `load()` was tracked by nothing the disposal awaited: aborting it only
- invalidated its generation, so the incoming scene went on to run `init()`,
- `unload()` and `destroy()` after teardown had finished, against subsystems
- that no longer existed. The disposal now awaits the run it just aborted, the
- same way it already awaits a preload. A scene whose `load()` never settles is
- bounded by `destroy()`'s existing teardown grace period.
+- **A destroyed `LoaderScope` refuses to claim.**
+- **Releasing a font or an image frees what it owns.**
+- **An asset unloaded mid-fetch frees the resource its factory had already built.**
+- **A failed or cancelled `music`/`video` load cleans up after itself.**
+- **A `fetchOptions.signal` set on the loader aborts asset loads too.**
+- **Options a later `get()` loses are diagnosed in development.**
+- **A `stop()` made while `start()` is still loading is no longer overwritten when the startup run settles.**
+- **A destroyed `Application` stays destroyed.**
+- **`Application.destroy()` waits for a scene navigation that is still in flight.**
- **A destroyed scene graph is no longer pinned by the changed-record index.**
- The process-wide dirty index recycled a generation by resetting its logical
- length only, leaving every entry above it in place - and a `SceneNode` links
- to its parent while a `Container` links to its children, so one retired entry
- kept a whole destroyed scene alive for the life of the process, long after
- the index itself reported those marks as outside its window. A recycled
- generation now drops its references, and a node releases its entry when it is
- destroyed, which is what leaves nothing pinned once `Application.destroy()`
- has stopped advancing the index.
- **A system removed and added back inside the same frame stays registered.**
- `remove()` marks a registration inactive and queues the structural delete for
- the frame boundary, but left it in the registry - so the `add()` that
- followed hit the duplicate-registration no-op, and the queued removal then
- deleted the system at the end of the frame with no error anywhere. The
- pattern is what a system re-registering itself to change its order or phase
- does, and it silently lost the system. `add()` now cancels a pending removal
- and re-registers with the options that call asks for.
-- **`SystemRegistry.remove()` reports the truth for a buffered add.** Removing
- a system added earlier in the same frame - one that never became a
- registration, and for which `has()` answers `false` - returned `true`,
- contradicting the method's own "true if it was registered". The add is still
- cancelled; the return value now agrees with `has()`.
-- **Loader claims are released last on both scene teardown paths.** A scope
- torn down after a failed `load()`/`init()` released its loader claims before
- calling `scene.destroy()`, while the ordinary teardown - whose documentation
- names "release loader claims last" as the normative order - released them
- after it. A `Scene.destroy()` override reaching for `this.loader` therefore
- saw a live claim scope or a destroyed one depending on whether activation had
- succeeded, and a claim taken on the failed path was never released. Both
- paths now release last.
-- **A manual `Application.update()` no longer forks the frame loop.** The
- public tick rescheduled the next animation frame unconditionally, so calling
- it while the loop was live - from `onFrame`, from an external fixed-rate
- host, from a test harness that also lets the loop run - started a second
- chain alongside the first and silently doubled the frame rate. Scheduling now
- belongs to the loop's own callback: `update()` runs exactly one frame.
-- **`SceneNode.destroy()` and `RenderNode.destroy()` are idempotent.** Only
- `Container` guarded re-entry, while the two layers beneath it documented
- releasing state a second pass must not touch again - so a double `destroy()`
- on a leaf node took its transform, bounds and flags through a second
- teardown, and on a `RenderNode` re-ran the filter and signal release as well.
- A second call is now a no-op at every layer.
-- **Audio objects created while the context is locked again are set up on the
- next unlock.** `onAudioContextReady` latched after its very first dispatch,
- so a bus, listener, stream voice, worklet or effect constructed while the
- context sat suspended - after an iOS audio-session interruption or a bfcache
- restore - subscribed to a signal that could never fire and stayed silent for
- the rest of the session. The signal now dispatches once per run of the
- context, to whoever is subscribed at that moment.
-- **An effect that never finishes its setup no longer silences the bus it is
- attached to.** `AudioBus` rebuilt its effect chain by disconnecting the bus
- input first and only then reading each effect's nodes, so an effect still
- unready on the retry pass threw with the graph half torn down - inside a
- microtask, where no caller could see it - and the bus stayed cut from its pan
- stage for good. The chain is now resolved before anything is disconnected,
- and an unready effect is bypassed with one diagnostic naming it.
+- **`SystemRegistry.remove()` reports the truth for a buffered add.**
+- **Loader claims are released last on both scene teardown paths.**
+- **A manual `Application.update()` no longer forks the frame loop.**
+- **`SceneNode.destroy()` and `RenderNode.destroy()` are idempotent.**
+- **Audio objects created while the context is locked again are set up on the next unlock.**
+- **An effect that never finishes its setup no longer silences the bus it is attached to.**
- **A worklet effect whose module fails to load degrades to a passthrough.**
- `WorkletEffect.ready` rejected with nobody attached, so a blocked
- `addModule` - a Content-Security-Policy forbidding `blob:` worker sources is
- the realistic case - surfaced as an unhandled rejection. The failure is
- logged instead and `ready` resolves; the effect keeps passing dry signal.
-- **A rejected `AudioContext.resume()` is logged instead of surfacing as an
- unhandled rejection.** The autoplay-unlock gesture handler attached no
- rejection handler, so a context the browser refused to resume produced a bare
- rejection with no hint of where it came from.
-- **Audio zones only colour spatial voices.** `SpatialZones` documented that it
- reconciles the live set of spatial voices, but `AudioSystem` handed it every
- voice - so a reverb zone opened a send on UI blips and music beds that have no
- position in the world at all. A voice that stops being positional now has its
- zone sends closed instead of keeping them open untouched.
-- **`AudioZone.height` is documented as the half-band it is.** The option and
- the field described it two different ways; `height: 100` covers `z` from
- `-100` to `100`, which both now say.
-- **A per-call `repeat` on `AnimatedSprite.play()` no longer leaks into the next
- clip.** The override was written on every `play()` and never cleared, so
- `play('attack', { repeat: 2 })` followed by `play('idle')` stopped the
- indefinitely-looping idle clip after two cycles. An override now belongs to
- the playback run that set it.
-- **A single-frame `AnimatedSprite` clip completes.** `update()` returned before
- any timing ran for a clip with one frame - the shape an Aseprite frame tag
- covering a single frame exports - so `onComplete` never fired, the sprite
- stayed `playing`, and its `AnimationSystem` registration was never released.
- The frame is now held for its own duration and then takes the normal
- completion path.
-- **`TweenSystem.clear()` and `destroy()` stop the tickers they drop.** Tweens
- were stopped but registered tickers were only dropped, so a `TweenSequencer`
- kept reporting `Active` after an application teardown with nothing left to
- advance it.
+- **A rejected `AudioContext.resume()` is logged instead of surfacing as an unhandled rejection.**
+- **Audio zones only colour spatial voices.**
+- **`AudioZone.height` is documented as the half-band it is.**
+- **A per-call `repeat` on `AnimatedSprite.play()` no longer leaks into the next clip.**
+- **A single-frame `AnimatedSprite` clip completes.**
+- **`TweenSystem.clear()` and `destroy()` stop the tickers they drop.**
- **A `TweenSequencer` delay stage carries its overshoot into the next stage.**
- The time past a `wait()` stage's own duration was dropped, so a chain of
- waits drifted by up to one frame per stage and a repeated sequence
- accumulated the error - `Tween.update` already carries the same remainder
- into its next repeat cycle.
- **A rebuilt particle GPU state no longer delivers garbage death contexts.**
- `@codexo/exojs-particles` kept the queue of deaths still awaiting a readback
- when the GPU state was torn down - after a device loss, a backend change or a
- transition back to CPU modules - so the next state staged that many records
- out of a freshly zeroed device buffer and every death module saw a
- zero-valued context, firing `SpawnOnDeath` sub-emitters at the origin.
-- **`RateSpawn` recovers from a negative rate sample.** A distribution that can
- return a value below zero drove the accumulator down without bound, and the
- emitter never spawned again. The sampled rate is clamped at zero.
+- **`RateSpawn` recovers from a negative rate sample.**
- **An out-of-range `ParticleWriter.frame` no longer wraps onto a valid frame.**
- The frame channel is a `Uint16Array`, so `-1` became `65535` and `70000`
- became `4464` - both of which can be a frame the system declares, instead of
- the documented frame-0 fallback. Development builds throw with the offending
- index; production clamps.
-- **A particle system is no longer culled by the size of one particle.** A
- `ParticleSystem`'s local bounds cover one texture frame at its local origin -
- a single pixel for the default white texture - while its particles travel
- arbitrarily far from there, so the viewport check removed the whole cloud as
- soon as the emitter's origin scrolled out of view. Systems opt out of culling
- by default; `cullArea` is the documented way back in for a system whose reach
- is known.
+- **A particle system is no longer culled by the size of one particle.**
- **The WebGPU compute path samples the texture frame the CPU path does.**
- `@codexo/exojs-particles` baked a particle system's frame UVs into a uniform
- block once, when the GPU state was built, and assumed the whole texture
- whenever no atlas was declared - so `system.textureFrame` was ignored
- outright, and a texture swapped in later left the UVs divided by the previous
- texture's dimensions. The same scene drew a sub-rect on WebGL2 and the whole
- atlas on WebGPU. Both setters now re-bake the block.
-- **Two update modules of the same class are reported by name instead of
- breaking WebGPU only.** A module's WGSL `key` names one struct and one member
- of the composite compute shader's uniform block, so a second module under the
- same key made the codegen declare each twice - invalid WGSL that failed
- pipeline creation at the first `update()`, while WebGL2 ran the same scene.
- `@codexo/exojs-particles` now throws `ParticleModuleKeyCollisionError` naming
- both modules and the key, before the device is asked to build anything.
+- **Two update modules of the same class are reported by name instead of breaking WebGPU only.**
- **A focused text field no longer blanks the keyboard and wheel pipeline.**
- Focusing a field hands host focus to the platform's own text transport, which
- blurs the canvas - and keyboard and wheel input were gated on the canvas
- element holding focus alone. Every editing key the widget owns (caret motion,
- Home/End, selection extension, `Escape`, `Ctrl+A`, `Ctrl+Z`/`Y`, `Enter` in a
- single-line field) and all wheel scrolling were dead for as long as any field
- was focused, and the held keys of a running game were released. The gate now
- treats an engine-owned transport holding host focus as the application
- holding it, and closes again when focus really leaves - including to a
- foreign element of the embedding page. Leaving a field hands host focus back
- to the surface rather than dropping it on the document. A transport that
- reports an edit for a keystroke the widget also handles (Backspace, Delete,
- and `Enter` in a multi-line field) now applies it once instead of twice.
- `app.input.canvasFocused` and `onCanvasFocusChange` mean "this application
- holds keyboard focus"; a press on a surface the host refuses focus for
- reports the change instead of flipping the flag silently.
-- **Text input works again in browsers that ship `EditContext`.** The backend
- attached its context with `EditContext.attachToElement()`, a draft method no
- browser implements, and hung it on a `
)}
- {/* 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. */}
+ {/*
+ * No global load note. Every row states its own load in its own column,
+ * because the rows are not measured at one: a scenario carries its own
+ * ladder, and a sentence claiming one figure for the table was wrong for
+ * most of the rows under it and unwritable for the rest.
+ */}
{isRendering && renderingTable.rows.length > 0 && (
-
{renderingCountNote}, chosen from the archetype ladders before any timing was read.
)}
@@ -239,7 +228,7 @@ const libraries = (isRendering ? rendering?.libraries ?? [] : physics?.libraries
{physicsHost.host.cpuCount} logical cores, {physicsHost.host.os} {physicsHost.host.arch}, {physicsHost.browser}{' '}
{physicsHost.browserVersion}, fixed step {(physicsHost.fixedDelta * 1000).toFixed(2)} ms, clock resolution{' '}
- {physicsHost.clock.resolutionMs} ms
+ {physicsHost.clock.resolutionMs === null ? 'not observed' : `${String(physicsHost.clock.resolutionMs)} ms`}
{physicsHost.clock.crossOriginIsolated ? ', cross-origin isolated' : ''}
diff --git a/site/src/components/BenchResultCard.astro b/site/src/components/BenchResultCard.astro
index 110a704b1..876cc9138 100644
--- a/site/src/components/BenchResultCard.astro
+++ b/site/src/components/BenchResultCard.astro
@@ -7,17 +7,53 @@
* 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.
+ * like a large one. Where one arm is two orders of magnitude slower, the short
+ * bars stop separating and the printed figures carry the row instead - which is
+ * why the time column is the card's strongest text and not a caption beside the
+ * bar.
*
* 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.
+ *
+ * A bar here is a DURATION and not a comparison, which is what decides when one
+ * is drawn: any figure the profile published gets one, including a figure whose
+ * pair the clock could not separate, because how long that arm took is not in
+ * doubt - only whether the two times may be divided by one another is. What
+ * such a pair loses is the factor and the winner, and that is stated once under
+ * the row rather than stamped on each arm of it: the check is a property of the
+ * pair, so tagging four arms says the same thing four times about a comparison
+ * only some of them are in.
+ *
+ * The detail each load opens is rendered from that load's own comparisons, so
+ * the load, the backend and the profile a reader is looking at cannot come
+ * apart from the ones the numbers were read out of.
+ *
+ * Below the width at which the grid becomes one column, the card gives up its
+ * own frame and becomes a SECTION of the domain's single card, separated from
+ * its neighbours by a rule. Six framed cards in a column is six visual restarts
+ * of the same structure - border, padding, title block, gap - where the frame
+ * has nothing left to separate horizontally, and the section headings carry the
+ * grouping on their own. The chrome is only dropped, not the structure: the
+ * rows, the load control and the per-scenario detail are identical at both
+ * widths, and nothing here becomes a table.
*/
-import { archetypeDescription, archetypeTitle, formatMs, OUTCOME_LABELS } from '../lib/bench-profiles';
+import BenchMeasurementDetail from './BenchMeasurementDetail.astro';
+import { archetypeDescription, archetypeTitle, type CellOutcome, FRAME_BUDGET_NOTE, formatMs, OUTCOME_NOTES, OUTCOME_STATUS } from '../lib/bench-profiles';
import type { BenchCard } from '../lib/bench-cards';
import { openingLoad } from '../lib/bench-cards';
+/** A scenario description split into what the scene is and what it loads, at the semicolon the descriptions carry. */
+const splitAtClause = (text: string): readonly [string, string | undefined] => {
+ const at = text.indexOf('; ');
+
+ // The lead keeps no sentence-ending period: at full width the two clauses
+ // are printed back together as the one sentence they were written as, and
+ // the period belongs only to the shortened form.
+ return at === -1 ? [text, undefined] : [text.slice(0, at), text.slice(at + 2)];
+};
+
interface Props {
card: BenchCard;
/** Anchor prefix, so a rendering and a physics card of one name stay distinct. */
@@ -27,24 +63,113 @@ interface Props {
const { card, domain } = Astro.props;
const opening = openingLoad(card);
const description = archetypeDescription(card.id);
+const [descriptionLead, descriptionRest] = description === undefined ? [undefined, undefined] : splitAtClause(description);
+
+/**
+ * True where the lead clause only repeats the title.
+ *
+ * `Changing labels; stresses per-frame text invalidation and layout.` reduces
+ * to `Changing labels` under a heading that already says it. At full width the
+ * sentence is printed whole regardless - it was written as one sentence and
+ * reads as one - and only the shortened phone form has a line to lose to it.
+ */
+const leadRepeatsTitle =
+ descriptionLead !== undefined && descriptionLead.replace(/\.$/, '').toLowerCase() === archetypeTitle(card.id).toLowerCase();
+
+/** True where the shortened description would end without punctuation, so the phone layout can supply it. */
+const leadNeedsStop = descriptionLead !== undefined && !descriptionLead.endsWith('.');
+const anchor = `${domain}-${card.id}${card.backend === undefined ? '' : `-${card.backend}`}`;
/**
* 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:
+ * Strictly proportional, with no minimum: a bar is the duration it shows, and a
+ * floor under it would draw two arms two orders of magnitude apart as though
+ * they were within a few per cent of each other. An arm that is fast enough to
+ * disappear from the track is read from its figure, which is why the figure and
+ * not the bar is the strongest text in the row.
+ *
+ * A row with no published figure gets no bar 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));
+const widthOf = (ms: number | null, maxMs: number): number | null => (ms === null || !Number.isFinite(ms) || maxMs <= 0 ? null : (ms / maxMs) * 100);
+
+/**
+ * Width of the card's time column, in characters of its longest figure.
+ *
+ * Fixed per card rather than sized per row. An `auto` column takes its width
+ * from each row's own text, so `186` and `0.14` left their bars starting at
+ * different offsets and ending at different right edges - which makes two bars
+ * of the same length different lengths on screen, and the column is the one
+ * thing a reader scans straight down.
+ */
+const timeCharacters = Math.max(
+ ...card.loads.flatMap(load => load.arms.map(arm => (arm.ms === null ? OUTCOME_STATUS[arm.outcome].length : formatMs(arm.ms).length))),
+);
+
+/** True where any row of the card carries a marker beside its figure, which the column has to leave room for. */
+const hasMarkers = card.loads.some(load => load.arms.some(arm => arm.overFrameBudget));
+
+/** The states this card has to explain, so the notes are written once per card rather than per row. */
+const explained = [
+ ...(hasMarkers ? [{ id: 'frame-budget', label: 'Past the frame', note: FRAME_BUDGET_NOTE }] : []),
+ ...(card.loads.some(load => load.withheld !== undefined)
+ ? [
+ {
+ id: 'withheld',
+ label: 'Not comparable',
+ note: "Every library's own time is published; the comparison between them is not. See the reason below.",
+ },
+ ]
+ : []),
+ ...[...new Set(card.loads.flatMap(load => load.arms.filter(arm => !arm.quantitative).map(arm => arm.outcome)))].map(outcome => ({
+ id: outcome,
+ label: OUTCOME_STATUS[outcome],
+ note: OUTCOME_NOTES[outcome],
+ })),
+];
+
+/**
+ * Anchor for one note, unique across the page.
+ *
+ * Keyed by the LOAD as well as the card: every load renders its own copy of the
+ * notes, so an id carrying only the card's name appeared three times on a card
+ * with three loads - and the marker then opened the first copy, which sits in a
+ * load panel the reader is not looking at.
+ */
+const noteId = (loadId: string, id: string): string => `${anchor}-${loadId}-note-${id}`;
+
+/**
+ * The states of one load, in the card's own order.
+ *
+ * Read off the outcomes rather than off `quantitative`, so a comparison that
+ * was never drawn and one the clock demonstrably could not separate are not
+ * reported as the same finding.
+ */
+const statesOf = (load: BenchCard['loads'][number]): readonly CellOutcome[] => [
+ ...new Set(load.arms.filter(arm => !arm.quantitative && arm.ms !== null).map(arm => arm.outcome)),
+];
---
-
+
{archetypeTitle(card.id)}
- {description !== undefined &&
{description}
}
+ {/*
+ * Split at the semicolon the descriptions already carry: the clause
+ * before it says what the scene is, the clause after it says which
+ * part of the renderer it loads. Narrow, only the first is worth two
+ * lines of a phone screen, and the second is one tap away - so nothing
+ * is rewritten and nothing is lost.
+ */}
+ {description !== undefined && (
+
)}
+ {/*
+ * `bench-load-panel` rather than `panel`: the global stylesheet gives
+ * `.card, .panel` a border, a ground and a shadow, so naming the card's
+ * own inner container `panel` drew a second frame inside every card and
+ * narrowed the bars it holds.
+ */}
{card.loads.map(load => (
-
-
{load.label}
+
+ {/*
+ * One meta row: what the load is on the left, what to know about it
+ * on the right. These were three stacked paragraphs, which on a
+ * phone spent three lines saying what belongs on one - and the
+ * explanation each of them carried is one tap away in the details.
+ */}
+
{arm.label}
- {widthOf(arm.ms, load.maxMs) !== null && }
+ {/*
+ * A withheld row draws nothing. Its bars would be
+ * durations like any other, but side by side on one
+ * track they are read as the comparison the row is
+ * published without.
+ */}
+ {load.withheld === undefined && widthOf(arm.ms, load.maxMs) !== null && (
+
+ )}
- {arm.ms === null ? OUTCOME_LABELS[arm.outcome] : formatMs(arm.ms)}
- {arm.ms !== null && ms}
- {arm.overFrameBudget && !}
+ {arm.ms === null ? (
+
+ ) : (
+ <>
+ {/*
+ * The figure itself carries the mark. A glyph beside it
+ * needs a gutter every other row then has to leave empty,
+ * which breaks the one column a reader scans straight
+ * down - and it reads as a defect report rather than as
+ * what it is, a time larger than one frame.
+ */}
+ {arm.overFrameBudget ? (
+
+ ) : (
+ {formatMs(arm.ms)}
+ )}
+ ms
+
+ >
+ )}
))}
+
+ {/*
+ * One chip per state the load actually carries, and the state is
+ * read off the outcome rather than off "not quantitative": a
+ * comparison nothing measured and one whose clock demonstrably
+ * could not separate two durations are different findings, and
+ * labelling the first as timing-limited claims a measurement.
+ * The sentence lives in the note the chip opens.
+ */}
+
+ Details
+
+
+ {/* What every marker and state word on this card means, as text rather than as a tooltip a touch reader cannot reach. */}
+ {load.withheld !== undefined &&
{load.withheld}
}
+
+ {explained.length > 0 && (
+
+ {explained.map(entry => (
+
+
{entry.label}
+
{entry.note}
+
+ ))}
+
+ )}
+
+
+
))}
@@ -83,7 +300,7 @@ const widthOf = (ms: number | null, maxMs: number): number | null => (ms === nul
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1rem 1.1rem 1.1rem;
- background: var(--color-surface);
+ background: var(--color-card);
display: flex;
flex-direction: column;
gap: 0.65rem;
@@ -111,7 +328,7 @@ const widthOf = (ms: number | null, maxMs: number): number | null => (ms === nul
.load {
font: inherit;
font-size: 0.76rem;
- padding: 0.2rem 0.5rem;
+ padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
@@ -119,30 +336,82 @@ const widthOf = (ms: number | null, maxMs: number): number | null => (ms === nul
cursor: pointer;
}
+ /* Weight and a filled ground as well as hue: the selected load has to be readable without separating the two colours. */
.load[aria-pressed='true'] {
border-color: var(--color-accent);
- color: var(--color-accent);
- font-weight: 600;
+ background: color-mix(in srgb, var(--color-accent) 14%, transparent);
+ color: var(--color-text);
+ font-weight: 650;
+ }
+
+ .meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.3rem 0.75rem;
+ margin: 0 0 0.45rem;
+ font-size: 0.78rem;
+ color: var(--color-text-muted);
}
.at {
- margin: 0 0 0.5rem;
+ margin: 0;
+ }
+
+ /* The row's one fact about itself, on the right where the times end. */
+ .status {
+ font: inherit;
+ font-size: 0.72rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 0.3rem;
+ border: 1px solid var(--color-border);
+ background: transparent;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ margin-left: auto;
+ }
+
+ .status.quiet {
+ border-color: transparent;
+ padding-inline: 0;
+ cursor: default;
+ }
+
+ .why {
+ margin: 0 0 0.7rem;
font-size: 0.78rem;
+ line-height: 1.45;
color: var(--color-text-muted);
}
+ /* 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;
+ padding: 0;
+ background: none;
+ box-shadow: none;
+ }
+
+ /* No frame of its own: the card is already a box, and a second border around the bars only narrows them. */
.arms {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
- gap: 0.35rem;
+ gap: 0.4rem;
}
+ /*
+ * The time column is one width for the whole card, from its longest figure
+ * plus room for the unit and a marker. An `auto` column sized itself per
+ * row, which moved every bar's right edge with the length of the number
+ * beside it - so two equal durations drew as two different lengths.
+ */
.arm {
display: grid;
- grid-template-columns: 5.5rem 1fr auto;
+ grid-template-columns: 5.5rem 1fr calc(var(--bench-time-ch, 4) * 1ch + 3.4rem);
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
@@ -159,7 +428,7 @@ const widthOf = (ms: number | null, maxMs: number): number | null => (ms === nul
}
.track {
- height: 0.55rem;
+ height: 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 55%, transparent);
overflow: hidden;
@@ -172,48 +441,272 @@ const widthOf = (ms: number | null, maxMs: number): number | null => (ms === nul
background: color-mix(in srgb, var(--color-text-muted) 65%, transparent);
}
+ /* ExoJS keeps the brand hue across every card. It marks which row is the reference and never which row won. */
.arm.reference .bar {
background: var(--color-accent);
}
+ /* Where two bars are a few pixels apart the figure is the comparison, so it carries the weight the bar cannot. */
.time {
font-variant-numeric: tabular-nums;
white-space: nowrap;
+ font-size: 0.92rem;
+ display: inline-flex;
+ align-items: baseline;
+ justify-content: flex-end;
+ gap: 0.15rem;
+ }
+
+ .time b {
+ font-weight: 650;
+ color: var(--color-text);
+ }
+
+ /* A figure past the frame budget, drawn as the same figure in the warning hue - same box, same alignment, no glyph. */
+ .figure.over {
+ font: inherit;
+ font-weight: 650;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--amber);
+ cursor: pointer;
}
.arm.absent .time {
- color: var(--color-text-muted);
+ font-size: 0.78rem;
+ }
+
+ .state {
+ font: inherit;
font-size: 0.76rem;
+ padding: 0;
+ border: 0;
+ border-bottom: 1px dotted var(--color-border);
+ background: none;
+ color: var(--color-text-muted);
+ cursor: pointer;
}
.unit {
color: var(--color-text-muted);
- margin-left: 0.15rem;
font-size: 0.76rem;
}
- .over {
- color: var(--color-warning, #b45309);
- margin-left: 0.3rem;
- font-weight: 700;
+ /*
+ * A real control, not a glyph with a `title`: pressing it opens the card's
+ * details and moves the reader to the note that says what the mark means,
+ * which is reachable by touch and by keyboard. A `title` is neither.
+ */
+ .mark {
+ font: inherit;
+ font-size: 0.68rem;
+ line-height: 1;
+ padding: 0.15rem 0.35rem;
+ border-radius: 0.3rem;
+ border: 1px solid currentcolor;
+ background: transparent;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ }
+
+ .sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+
+ .notes {
+ margin: 0 0 0.7rem;
+ display: grid;
+ gap: 0.25rem;
+ font-size: 0.78rem;
+ }
+
+ .notes > div {
+ display: grid;
+ grid-template-columns: 8.5rem 1fr;
+ gap: 0.5rem;
+ align-items: baseline;
+ }
+
+ /* The note the marker sends the reader to is marked while it holds focus, so the jump is visible rather than silent. */
+ .notes > div:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+ }
+
+ .notes dt {
+ color: var(--color-text-muted);
+ }
+
+ .notes dd {
+ margin: 0;
+ }
+
+ /* Its own disclosure section, not a line trailing the results above it. */
+ .more {
+ margin-top: 0.65rem;
+ font-size: 0.82rem;
+ }
+
+ .more > summary {
+ cursor: pointer;
+ color: var(--color-text-muted);
+ width: fit-content;
+ }
+
+ .more[open] > summary {
+ margin-bottom: 0.5rem;
+ }
+
+ .context {
+ margin: 0 0 0.5rem;
+ font-size: 0.78rem;
+ color: var(--color-text-muted);
}
@media (max-width: 34rem) {
.arm {
- grid-template-columns: 4.5rem 1fr auto;
font-size: 0.78rem;
}
+
+ .time {
+ font-size: 0.86rem;
+ }
+
+ .notes > div {
+ grid-template-columns: 1fr;
+ gap: 0.1rem;
+ }
+ }
+
+ /*
+ * One card per domain, scenarios as sections inside it. The breakpoint is
+ * the one at which the grid collapses to a single column: above it the
+ * frames separate cards that sit side by side, below it they separate
+ * nothing and only cost height.
+ */
+ @media (max-width: 52rem) {
+ .card {
+ border: 0;
+ border-radius: 0;
+ padding: 0.75rem 0 0;
+ background: none;
+ box-shadow: none;
+ gap: 0.45rem;
+ }
+
+ /*
+ * One rule between sections, inset from the container's own edges: a
+ * line running the full width reads as the top border of another card,
+ * which is the impression the shared container exists to remove.
+ */
+ .card + .card {
+ border-top: 1px solid var(--color-border-soft);
+ margin-top: 0.35rem;
+ padding-top: 1rem;
+ }
+
+ .what-rest {
+ display: none;
+ }
+
+ /* The clause that carried the sentence's period is hidden here, so the half that remains ends itself. */
+ .what-lead.stop::after {
+ content: '.';
+ }
+
+ /* Nothing worth a line once the lead clause is only the title again and the rest is hidden. */
+ .what.repeats-title {
+ display: none;
+ }
+
+ .what {
+ margin-top: 0.1rem;
+ }
+
+ .load {
+ font-size: 0.72rem;
+ padding: 0.15rem 0.45rem;
+ }
+
+ .more > summary {
+ font-size: 0.78rem;
+ }
+
+ /*
+ * Label and time on one line, the bar on its own beneath it.
+ *
+ * A three-column row hands the bar whatever is left after a name and a
+ * figure, which at 390px is about half the card; given the row to
+ * itself it is the full width, and the two orders of magnitude between
+ * the fastest and the slowest arm have somewhere to be seen.
+ */
+ .arm {
+ grid-template-columns: 1fr auto;
+ align-items: baseline;
+ gap: 0.15rem 0.5rem;
+ }
+
+ /*
+ * Placed explicitly. The track sits between the name and the time in
+ * the DOM, so auto-placement pushes a full-width track onto its own row
+ * and the time onto a third one - three lines per library.
+ */
+ .name {
+ grid-area: 1 / 1;
+ color: var(--color-text);
+ }
+
+ .time {
+ grid-area: 1 / 2;
+ }
+
+ .track {
+ grid-area: 2 / 1 / 3 / -1;
+ height: 0.4rem;
+ }
+
+ .arms {
+ gap: 0.5rem;
+ }
}
+
+
diff --git a/site/src/components/pages/BenchMethodologyPage.astro b/site/src/components/pages/BenchMethodologyPage.astro
new file mode 100644
index 000000000..da2f49e35
--- /dev/null
+++ b/site/src/components/pages/BenchMethodologyPage.astro
@@ -0,0 +1,47 @@
+---
+/**
+ * BenchMethodologyPage - the terms every published benchmark number is read
+ * under, on a page of its own.
+ *
+ * The results page links here rather than carrying it: a reader consults these
+ * once a row surprises them, and several screens of prose above the cards
+ * answered a question nobody had asked yet.
+ */
+
+import BenchMethodology from '../BenchMethodology.astro';
+import DocsLayout from '../../layouts/DocsLayout.astro';
+import EnglishFallbackNotice from '../EnglishFallbackNotice.astro';
+
+interface Props {
+ locale: 'en' | 'de';
+}
+
+const { locale } = Astro.props;
+const base = import.meta.env.BASE_URL;
+---
+
+
+
+ {locale === 'de' && }
+
+
How these numbers are made
+
The terms the published measurements are read under, and what it takes to reproduce one.
+
+
+
diff --git a/site/src/components/pages/BenchmarksPage.astro b/site/src/components/pages/BenchmarksPage.astro
index db57d0e84..f128bdc94 100644
--- a/site/src/components/pages/BenchmarksPage.astro
+++ b/site/src/components/pages/BenchmarksPage.astro
@@ -1,22 +1,36 @@
---
/**
* BenchmarksPage - the published cross-library measurements, rendering and
- * physics on one page.
+ * physics on one page of scenario cards.
*
* 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 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 harness: a re-measurement rewrites the cards and the provenance together.
+ * Losses are published on the same terms as wins, nothing is aggregated into a
+ * score or an overall winner, and 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.
*
- * 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.
+ * The page carries cards and nothing else. Every row's own terms are one
+ * disclosure inside that row's card, and the full per-row report, the
+ * methodology and the raw profiles are their own pages: carrying them here made
+ * a reader scroll past several screens of prose that say the same thing
+ * whichever card raised the question.
+ *
+ * The page's axis is the BROWSER, not the machine. Switching machines changes
+ * the CPU, the GPU, the operating system, the driver stack and the browser
+ * engine at once, which is not an A/B of anything a reader can name; the
+ * browser names one dimension, and it is the one a reader shipping a web game
+ * chooses between.
+ *
+ * It is still not a browser benchmark, because each browser is currently
+ * measured on its own machine. So these are presented as separate reference
+ * profiles, each printing the machine it was measured on, and the page never
+ * invites a reader to divide one browser's figures by another's. Profiles are
+ * picked and never stacked: two of them share no comparison, so a second one
+ * appended under the first would read as more evidence about the first.
*/
-import BenchProfileReport from '../BenchProfileReport.astro';
import BenchResultCard from '../BenchResultCard.astro';
import BenchLocalProbe from '../BenchLocalProbe.astro';
import DocsLayout from '../../layouts/DocsLayout.astro';
@@ -25,13 +39,13 @@ import { appInfo } from '../../lib/app-info';
import { PHYSICS_HEADLINE_SCENARIOS, physicsCards, RENDERING_HEADLINE_SCENARIOS, renderingCards, selectCards } from '../../lib/bench-cards';
import {
BACKEND_LABELS,
- coversDomain,
+ browserLabel,
+ type BrowserProfiles,
+ deviceLabel,
formatDay,
- FRAME_BUDGET_MS,
- furtherProfiles,
- olderThanReference,
+ platformName,
+ profilesByBrowser,
type ProfileBackendName,
- referenceProfile,
} from '../../lib/bench-profiles';
interface Props {
@@ -39,30 +53,30 @@ interface Props {
}
const { locale } = Astro.props;
+const base = import.meta.env.BASE_URL;
/**
- * Runs the reproduction below pools. A published profile states the number it
- * was actually built from; until one exists the page can only quote the count
- * its own reproduction steps prescribe.
+ * Runs a reference profile is built from. A published profile states the number
+ * it was actually built from; until one exists the page can only quote the
+ * count its own reproduction steps prescribe.
*/
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'));
+/** Everything one browser's reference profile contributes to the page. */
+const viewOf = (group: BrowserProfiles) => ({
+ browser: group.browser,
+ document: group.reference,
+ others: group.others,
+ rendering: backends
+ .map(backend => ({ backend, ...selectCards(renderingCards(group.reference, backend), RENDERING_HEADLINE_SCENARIOS, 6) }))
+ .filter(entry => entry.headline.length > 0),
+ physics: selectCards(physicsCards(group.reference), PHYSICS_HEADLINE_SCENARIOS, 4),
+});
+
+const browsers = profilesByBrowser.map(viewOf);
+const leading = browsers[0];
---
@@ -71,373 +85,233 @@ const furtherMachines = furtherProfiles.filter(document => coversDomain(document
Benchmarks
-
- 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.
-
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.
+ pooled into a single profile file in the repository; until then there is nothing here to read.{' '}
+ The terms they will be published under already apply.
) : (
<>
-
-
-
Rendering
- {rendering.length > 1 && (
-
- {rendering.map((entry, index) => (
-
- ))}
-
- )}
-
+
+ {/* Offered only where a second browser has a published profile: one choice is not a choice. */}
+ {browsers.length > 1 && (
+
+ {/*
+ * The machine is provenance, not a mode. It is printed beside every
+ * set of figures because the browsers are measured on different
+ * machines, so one browser's numbers divided by another's would be
+ * a hardware ratio wearing a browser's name.
+ */}
+ {deviceLabel(profile)}
+ {platformName(profile)}
+ ExoJS {profile.engineVersion}
+ measured {formatDay(profile.measuredAt)}
+ {/* Stated once, here, rather than again inside the opening sentence: it is a fact about the run, not about the result. */}
+ median of {profile.runs} pooled runs
+ {entry.others.length > 0 && (
+
+ {entry.others.length} further {entry.others.length === 1 ? 'machine' : 'machines'} in this browser
+
+ )}
+
- 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.
-
- Measured with ExoJS {document.profile.engineVersion}; the newest published profile is {newer}. Compare it against the
- reference profile with that in mind.
-
+ ))}
+
+ {/*
+ * One legend for the page. The mark it explains is on a figure
+ * in almost every card, and repeating the sentence there would
+ * put it on screen more often than the thing it describes.
+ */}
+
+ Amber time = over a 16.7 ms frame budget at 60 fps. The measurement is valid; the scene does not fit in one frame.
+
-
-
How these numbers are made
-
- The terms the tables above are published under. They are here rather than in front of the numbers because they are what a reader consults
- once a row surprises them.
-
+ {/* Four destinations and no explanatory paragraphs: what each one is is what its name says. */}
+
-
- Methodology: pooling, medians, ranges, the ladder, the frame budget
-
-
- A published profile pools {pooledRuns} separate runs of the same matrix. One run cannot support a ratio: the same
- code measured twice on one idle machine moves a cell's median far enough to reverse which library a cell favours - a reversal this
- harness has produced in practice, which is why a single run is not published as a reference measurement.
-
-
- The published value is the median of the per-run medians - each run's own median over its timed window, then the
- median across the runs - so one unlucky run cannot set a number. Nothing is ever a mean, and nothing is aggregated across archetypes.
-
-
- Every number carries the spread its runs observed, printed under it as the factor between the slowest and the
- fastest run; the two millisecond values behind it are on the cell itself. A spread of 1.2 or more is marked: the measurement's own
- noise then reaches as wide as the band the ladder treats as no difference at all, which is something to know before reading the
- number, not after.
-
-
- Every median is published with the p95 of the same timed window beside it. The median is the amortised cost and the
- number every verdict is computed from - it is also what independent published comparisons state - while the p95 is the step or frame
- a player feels as a hitch. A pair far apart describes work that is periodically expensive, which a median alone would report as
- cheap. There is no p99: the largest cells time 120 steps, so a p99 there is the second-worst sample, an outlier rather than a
- percentile.
-
-
- A published median past {FRAME_BUDGET_MS} ms - a whole 60 fps frame - is marked. The line is the entire frame and
- not a fraction of it on purpose: how much of a frame you may spend on physics, or on the CPU side of rendering, depends on everything
- else your frame does and is your decision, while a single step or frame that costs more than the frame it has to fit in is unplayable
- whatever you decide. Nothing is derived from the mark - there is no "how many bodies at N ms" capacity figure, because that would
- interpolate between the ladder's rungs rather than report something measured.
-
-
- A verdict is published only when all {pooledRuns} runs independently reach it. Each run is placed on the ladder on
- its own, and the pooled medians have to land on the same rung as well. A cell whose runs disagreed publishes no verdict, and
- it is counted nowhere - not as a win, not as a loss, and not as a level row. That the pair cannot be separated on this machine is the
- finding. It still shows what was measured: the factor its two pooled medians work out to, printed with a tilde and plotted without a
- side. That factor is the one figure the page derives rather than reads, and it is never a verdict - the tilde, the hollow mark and
- the scoreboard all keep it out of the outcome counts.
-
-
- A ratio between 0.8 and 1.2 is reported as level: inside that band the difference is not
- distinguishable from machine mood.
-
-
- A gap of 5x or more is reported as leads clearly rather than leads. Below that factor a driver
- state or an unlucky window could still explain the result; at or above it, the difference is attributable to how the two libraries
- are built.
-
-
- Each comparison names the mechanism behind its difference, drawn from structural counters the harness collects -
- draw calls, texture binds, buffer uploads, solved contacts - and they are listed under the tables. A row whose difference could not
- be evidenced that way is not published as a comparison; it appears among the omissions instead.
-
-
- One profile describes one machine measured in one browser. Numbers from different machines are never mixed into one
- table or averaged together, and neither are numbers from different browsers - including for physics, which touches no GPU but whose
- per-step times depend on which JavaScript engine executed the steps.
-
-
- Physics is measured in the browser, not in the process that drives the harness. Nobody runs ExoJS physics in Node,
- and the runtime is part of the measurement: heap limits, garbage collection and WebAssembly compilation are the browser's. The
- fastest cells step in microseconds, close to the browser's clock resolution, so a step too fast to time on its own is timed in
- batches large enough to clear that resolution, with the timed-step budget unchanged.
-
-
- Phaser renders through a WebGL1 context, so its rows sit in their own block, compare CPU time only, and carry no
- mechanism - the structural probe cannot attach to that context to say whether the backend generation or the engine caused the gap.
-
-
- Rapier is a Rust/WASM reference ceiling, not a pure-JS peer. It measures what leaving JavaScript buys; matter.js and
- planck are the peers ExoJS is compared against.
-
-
Culling is configured to match across arms, so no arm wins a row by drawing less of the same scene.
-
- A row's node or body count is chosen from the archetype ladders before any timing is read - the largest rung at
- which every arm produced a valid cell - so it can never be picked to suit the outcome. A rendering backend goes further and uses one
- node count for all of its rows, because its archetypes share their ladders. An arm that produced no comparable cell at that count -
- because it sits the archetype out, or because it exceeded the runaway step budget - is shown as not comparable rather than
- omitted.
-
-
- Each physics archetype has its own body-count ladder, placed so its rungs straddle the frame budget: two inside the
- frame and one past it, so you can see both the slope and where the archetype stops being viable. They reach a frame at sizes that
- differ by nearly an order of magnitude, so one shared count would put most rows at a size chosen to suit a different scene. Every
- physics row therefore states its own count, and physics rows are not comparable with one another - only the arms
- within one row are, which is what the table was ever for.
-
-
-
-
-
- Reproduce: run the harness yourself and publish a profile for your machine
-
- The harness is in the repository, and so are the adapters. A reference profile is {REPRODUCTION_RUNS} separate runs, not
- one run repeated inside itself: repeating a matrix in one process shares JIT and heap state between the repetitions and measures the same
- warm state {REPRODUCTION_RUNS} times, which is the very effect the repetition exists to expose. Each run is its own invocation with its
- own output directory, and bench:compare pools them by taking each input flag once per run.
-
- The harness lives in packages/exojs-bench and its scripts are run from there; the root pnpm bench is the
- engine's own micro-benchmark suite and not this one. The -- separates the harness's flags from pnpm's own. Output
- directories are relative to the package. pnpm bootstrap installs the competitor libraries alongside the workspace, each
- pnpm bench runs one matrix into its own directory, and bench:compare --profile pools them into a profile named
- after your machine under packages/exojs-bench/results/. Repeating an input flag is what pools: a profile built from fewer
- than {REPRODUCTION_RUNS} runs is rejected by the repository's own check on this directory. Pooling is refused outright for runs that do
- not repeat one measurement - a different engine version, different library arms, a different matrix - because a spread over those would
- describe the difference between two runs rather than the noise of one. Measure on an otherwise idle machine: these are wall-clock
- comparisons and background load moves them.
-
-
- pnpm bench -- --browser=webkit measures in WebKit instead of the default Chromium, for both domains - a physics run takes
- the flag too, and the two domains of one profile have to agree on it. The browser is part of the profile's name, so the same machine
- measured in both publishes two files rather than overwriting one with the other, and the harness refuses to pool runs taken in different
- browsers. A backend the browser does not expose is published as not measured, never as a number: WebKit reaches WebGPU on macOS alone. A
- physics arm the browser cannot load is published the same way, with the reason it failed. WebKit also reports a constant in place of the
- GPU, so a WebKit profile is named after the machine's CPU model and needs a physics measurement of the same machine alongside the
- rendering one.
-
-
- On macOS and Linux, add --platform=26 to every run - or --platform=26-beta on a pre-release build. The profile's
- name carries the operating system's major version, so a beta measurement and the shipping platform's later one do not overwrite each
- other, and neither the version nor the beta status is readable at runtime there: os.release() reports the kernel version.
- Windows reports its own version and needs no flag. Every stamp records whether the version was read or declared, and whether the platform
- was pre-release and how that was established.
-
-
- A profile for a machine that is not published yet is welcome as a pull request containing that one file. Nothing else needs to change -
- this page picks it up from the directory.
-
-
-
-
- Fairness: how the arms are configured and what is never derived
-
- The adapter for every competitor is in the repository next to the ExoJS one, and each is configured at that library's own defaults with
- the deviations disclosed in the run's caveats. If a maintainer of one of these libraries thinks an adapter misrepresents it, an issue
- about that adapter is the right place to say so, and a fix changes the published numbers the next time the harness runs.
-
-
- Nothing here is aggregated into a score and nothing is declared an overall winner. The scoreboard counts outcomes per arm and never sums
- them across arms, because the arms answer different questions. Every cell where ExoJS trails is published with the same weight as one
- where it leads, with the same mechanism evidence attached, and the rows the harness could not compare are listed with the reason it could
- not.
-
-
-
+
+ >
+ )}
-
-
diff --git a/site/src/components/pages/HomePage.astro b/site/src/components/pages/HomePage.astro
index 34e4b1ede..e1a08db9d 100644
--- a/site/src/components/pages/HomePage.astro
+++ b/site/src/components/pages/HomePage.astro
@@ -5,6 +5,7 @@ import EnglishFallbackNotice from '../EnglishFallbackNotice.astro';
import ExampleThumb from '../../components/ExampleThumb.astro';
import { GuideExamplePreview } from '../../components/GuideExamplePreview';
import InstallCommand from '../../components/InstallCommand.astro';
+import { appInfo } from '../../lib/app-info';
import { getAllExamples, getExamplesForChapter } from '../../lib/examples-catalog';
import { getExampleExecutionSource } from '../../lib/example-sources';
import { GUIDE_PARTS } from '../../lib/guide-structure';
@@ -155,6 +156,10 @@ const examples = [
@@ -451,6 +456,39 @@ const examples = [
.hero-install {
margin-top: var(--s-5);
max-width: 420px;
+ display: grid;
+ gap: var(--s-3);
+ justify-items: start;
+ }
+
+ /*
+ * The archive itself, through GitHub's `/releases/latest/download/`
+ * redirect, so the button downloads instead of landing on release notes.
+ * It resolves against whatever release is newest, which is why the release
+ * workflow uploads a second copy of the Full ZIP under a version-less name:
+ * a URL carrying the version would have to be rebuilt for every release,
+ * and the version in `package.json` is the one being developed and is ahead
+ * of the last release for most of a cycle.
+ */
+ .hero-download {
+ display: grid;
+ gap: 2px;
+ text-decoration: none;
+ color: var(--fg-muted);
+ font-size: 13px;
+ }
+
+ .hero-download-label {
+ color: var(--accent);
+ font-weight: 500;
+ }
+
+ .hero-download:hover .hero-download-label {
+ text-decoration: underline;
+ }
+
+ .hero-download-note {
+ color: var(--fg-faint);
}
.home .btn {
diff --git a/site/src/content/api/distance-joint-options.json b/site/src/content/api/distance-joint-options.json
index 66b32b1f8..af6164eba 100644
--- a/site/src/content/api/distance-joint-options.json
+++ b/site/src/content/api/distance-joint-options.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 9,
+ "memberCount": 10,
"counts": {
"constructors": 0,
"methods": 0,
- "properties": 9,
+ "properties": 10,
"events": 0
},
"sections": [
@@ -144,6 +144,31 @@
"returnType": null,
"description": "Second body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected?: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": "?",
+ "kind": "punctuation"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether the two bodies this joint connects also collide with each other. Default true. false is what a chain, a ragdoll, a pendulum or a vehicle assembly usually wants, and what Box2D defaults to: a joint already decides how its two bodies may move relative to one another, so letting their touching colliders push each other apart at the same time gives the pair two constraint systems with different opinions, plus a contact per link that nothing needs. The default is nevertheless true, which is the behaviour every joint has had so far: an existing scene built against it would change shape under an update that flipped it. Nothing else argues for true any more - a chain of seven links and up used to gain energy without these contacts, and that defect is fixed in the joint solver rather than damped by them - so the default is a compatibility choice and is free to be revisited. Nothing about a contact is weakened under true: the narrow phase produces it, the contact graph holds it, collision events fire, a ContactModifier sees it and the solver resolves it, exactly as for any unjointed pair. It is fixed at construction, and it follows the joint's presence in the world rather than Joint.enabled: disabling a joint suspends its constraint, and a ragdoll whose joints are momentarily disabled must not start pushing its own limbs apart. Removing the joint from the world does let the pair collide again, and both bodies are woken so they respond to it."
+ },
{
"name": "dampingRatio",
"signature": "dampingRatio?: number",
diff --git a/site/src/content/api/distance-joint.json b/site/src/content/api/distance-joint.json
index 6e6d1b900..e9bbb9361 100644
--- a/site/src/content/api/distance-joint.json
+++ b/site/src/content/api/distance-joint.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 9,
+ "memberCount": 10,
"counts": {
"constructors": 1,
"methods": 0,
- "properties": 8,
+ "properties": 9,
"events": 0
},
"sections": [
@@ -126,6 +126,27 @@
"returnType": null,
"description": "Second constrained body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether bodyA and bodyB also collide with each other; see JointOptions.collideConnected."
+ },
{
"name": "dampingRatio",
"signature": "dampingRatio: number",
diff --git a/site/src/content/api/joint-options.json b/site/src/content/api/joint-options.json
new file mode 100644
index 000000000..42153fbc1
--- /dev/null
+++ b/site/src/content/api/joint-options.json
@@ -0,0 +1,75 @@
+{
+ "title": "JointOptions",
+ "description": "Options every joint accepts, whatever it constrains.",
+ "symbol": "JointOptions",
+ "kind": "interface",
+ "subsystem": "physics",
+ "importPath": "@codexo/exojs-physics",
+ "tier": "stable",
+ "memberCount": 1,
+ "counts": {
+ "constructors": 0,
+ "methods": 0,
+ "properties": 1,
+ "events": 0
+ },
+ "sections": [
+ {
+ "id": "import",
+ "title": "Import",
+ "members": [],
+ "paragraphs": [
+ "Options every joint accepts, whatever it constrains."
+ ],
+ "importLine": "import { JointOptions } from '@codexo/exojs-physics'",
+ "sourceLink": null
+ },
+ {
+ "id": "properties",
+ "title": "Properties",
+ "members": [
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected?: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": "?",
+ "kind": "punctuation"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether the two bodies this joint connects also collide with each other. Default true. false is what a chain, a ragdoll, a pendulum or a vehicle assembly usually wants, and what Box2D defaults to: a joint already decides how its two bodies may move relative to one another, so letting their touching colliders push each other apart at the same time gives the pair two constraint systems with different opinions, plus a contact per link that nothing needs. The default is nevertheless true, which is the behaviour every joint has had so far: an existing scene built against it would change shape under an update that flipped it. Nothing else argues for true any more - a chain of seven links and up used to gain energy without these contacts, and that defect is fixed in the joint solver rather than damped by them - so the default is a compatibility choice and is free to be revisited. Nothing about a contact is weakened under true: the narrow phase produces it, the contact graph holds it, collision events fire, a ContactModifier sees it and the solver resolves it, exactly as for any unjointed pair. It is fixed at construction, and it follows the joint's presence in the world rather than Joint.enabled: disabling a joint suspends its constraint, and a ragdoll whose joints are momentarily disabled must not start pushing its own limbs apart. Removing the joint from the world does let the pair collide again, and both bodies are woken so they respond to it."
+ }
+ ],
+ "paragraphs": [],
+ "importLine": null,
+ "sourceLink": null
+ },
+ {
+ "id": "source",
+ "title": "Source",
+ "members": [],
+ "paragraphs": [],
+ "importLine": null,
+ "sourceLink": {
+ "label": "packages/exojs-physics/src/joints/Joint.ts",
+ "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-physics/src/joints/Joint.ts"
+ }
+ }
+ ],
+ "sourcePath": "packages/exojs-physics/src/joints/Joint.ts",
+ "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-physics/src/joints/Joint.ts"
+}
diff --git a/site/src/content/api/joint.json b/site/src/content/api/joint.json
index 0856052fc..301c29512 100644
--- a/site/src/content/api/joint.json
+++ b/site/src/content/api/joint.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 4,
+ "memberCount": 5,
"counts": {
"constructors": 1,
"methods": 0,
- "properties": 3,
+ "properties": 4,
"events": 0
},
"sections": [
@@ -30,7 +30,7 @@
"members": [
{
"name": "new",
- "signature": "new(bodyA: PhysicsBody, bodyB: PhysicsBody): Joint",
+ "signature": "new(bodyA: PhysicsBody, bodyB: PhysicsBody, collideConnected: boolean): Joint",
"signatureTokens": [
{
"text": "new",
@@ -68,6 +68,22 @@
"text": "PhysicsBody",
"kind": "type"
},
+ {
+ "text": ", ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "collideConnected",
+ "kind": "param"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ },
{
"text": ")",
"kind": "punctuation"
@@ -91,6 +107,11 @@
"name": "bodyB",
"type": "PhysicsBody",
"optional": false
+ },
+ {
+ "name": "collideConnected",
+ "type": "boolean",
+ "optional": false
}
],
"returnType": "Joint",
@@ -147,6 +168,27 @@
"returnType": null,
"description": "Second constrained body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether bodyA and bodyB also collide with each other; see JointOptions.collideConnected."
+ },
{
"name": "enabled",
"signature": "enabled: boolean",
diff --git a/site/src/content/api/mouse-joint.json b/site/src/content/api/mouse-joint.json
index f56f8c25e..7042a9bad 100644
--- a/site/src/content/api/mouse-joint.json
+++ b/site/src/content/api/mouse-joint.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 8,
+ "memberCount": 9,
"counts": {
"constructors": 1,
"methods": 0,
- "properties": 7,
+ "properties": 8,
"events": 0
},
"sections": [
@@ -126,6 +126,27 @@
"returnType": null,
"description": "Second constrained body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether bodyA and bodyB also collide with each other; see JointOptions.collideConnected."
+ },
{
"name": "dampingRatio",
"signature": "dampingRatio: number",
diff --git a/site/src/content/api/prismatic-joint-options.json b/site/src/content/api/prismatic-joint-options.json
index da3d8a6b0..53d9192db 100644
--- a/site/src/content/api/prismatic-joint-options.json
+++ b/site/src/content/api/prismatic-joint-options.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 10,
+ "memberCount": 11,
"counts": {
"constructors": 0,
"methods": 0,
- "properties": 10,
+ "properties": 11,
"events": 0
},
"sections": [
@@ -136,6 +136,31 @@
"returnType": null,
"description": "Second body (the slider)."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected?: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": "?",
+ "kind": "punctuation"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether the two bodies this joint connects also collide with each other. Default true. false is what a chain, a ragdoll, a pendulum or a vehicle assembly usually wants, and what Box2D defaults to: a joint already decides how its two bodies may move relative to one another, so letting their touching colliders push each other apart at the same time gives the pair two constraint systems with different opinions, plus a contact per link that nothing needs. The default is nevertheless true, which is the behaviour every joint has had so far: an existing scene built against it would change shape under an update that flipped it. Nothing else argues for true any more - a chain of seven links and up used to gain energy without these contacts, and that defect is fixed in the joint solver rather than damped by them - so the default is a compatibility choice and is free to be revisited. Nothing about a contact is weakened under true: the narrow phase produces it, the contact graph holds it, collision events fire, a ContactModifier sees it and the solver resolves it, exactly as for any unjointed pair. It is fixed at construction, and it follows the joint's presence in the world rather than Joint.enabled: disabling a joint suspends its constraint, and a ragdoll whose joints are momentarily disabled must not start pushing its own limbs apart. Removing the joint from the world does let the pair collide again, and both bodies are woken so they respond to it."
+ },
{
"name": "enableLimit",
"signature": "enableLimit?: boolean",
diff --git a/site/src/content/api/prismatic-joint.json b/site/src/content/api/prismatic-joint.json
index 00c0fd3f9..1db512bdb 100644
--- a/site/src/content/api/prismatic-joint.json
+++ b/site/src/content/api/prismatic-joint.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 10,
+ "memberCount": 11,
"counts": {
"constructors": 1,
"methods": 0,
- "properties": 9,
+ "properties": 10,
"events": 0
},
"sections": [
@@ -126,6 +126,27 @@
"returnType": null,
"description": "Second constrained body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether bodyA and bodyB also collide with each other; see JointOptions.collideConnected."
+ },
{
"name": "enabled",
"signature": "enabled: boolean",
diff --git a/site/src/content/api/revolute-joint-options.json b/site/src/content/api/revolute-joint-options.json
index bad6a8e17..41875bd6f 100644
--- a/site/src/content/api/revolute-joint-options.json
+++ b/site/src/content/api/revolute-joint-options.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 11,
+ "memberCount": 12,
"counts": {
"constructors": 0,
"methods": 0,
- "properties": 11,
+ "properties": 12,
"events": 0
},
"sections": [
@@ -103,6 +103,31 @@
"returnType": null,
"description": "Second body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected?: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": "?",
+ "kind": "punctuation"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether the two bodies this joint connects also collide with each other. Default true. false is what a chain, a ragdoll, a pendulum or a vehicle assembly usually wants, and what Box2D defaults to: a joint already decides how its two bodies may move relative to one another, so letting their touching colliders push each other apart at the same time gives the pair two constraint systems with different opinions, plus a contact per link that nothing needs. The default is nevertheless true, which is the behaviour every joint has had so far: an existing scene built against it would change shape under an update that flipped it. Nothing else argues for true any more - a chain of seven links and up used to gain energy without these contacts, and that defect is fixed in the joint solver rather than damped by them - so the default is a compatibility choice and is free to be revisited. Nothing about a contact is weakened under true: the narrow phase produces it, the contact graph holds it, collision events fire, a ContactModifier sees it and the solver resolves it, exactly as for any unjointed pair. It is fixed at construction, and it follows the joint's presence in the world rather than Joint.enabled: disabling a joint suspends its constraint, and a ragdoll whose joints are momentarily disabled must not start pushing its own limbs apart. Removing the joint from the world does let the pair collide again, and both bodies are woken so they respond to it."
+ },
{
"name": "dampingRatio",
"signature": "dampingRatio?: number",
diff --git a/site/src/content/api/revolute-joint.json b/site/src/content/api/revolute-joint.json
index 9fc0bf250..65b69d7e9 100644
--- a/site/src/content/api/revolute-joint.json
+++ b/site/src/content/api/revolute-joint.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 12,
+ "memberCount": 13,
"counts": {
"constructors": 1,
"methods": 0,
- "properties": 11,
+ "properties": 12,
"events": 0
},
"sections": [
@@ -126,6 +126,27 @@
"returnType": null,
"description": "Second constrained body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether bodyA and bodyB also collide with each other; see JointOptions.collideConnected."
+ },
{
"name": "dampingRatio",
"signature": "dampingRatio: number",
diff --git a/site/src/content/api/weld-joint-options.json b/site/src/content/api/weld-joint-options.json
index f00328e55..56efd8faf 100644
--- a/site/src/content/api/weld-joint-options.json
+++ b/site/src/content/api/weld-joint-options.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 7,
+ "memberCount": 8,
"counts": {
"constructors": 0,
"methods": 0,
- "properties": 7,
+ "properties": 8,
"events": 0
},
"sections": [
@@ -132,6 +132,31 @@
"returnType": null,
"description": "Second body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected?: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": "?",
+ "kind": "punctuation"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether the two bodies this joint connects also collide with each other. Default true. false is what a chain, a ragdoll, a pendulum or a vehicle assembly usually wants, and what Box2D defaults to: a joint already decides how its two bodies may move relative to one another, so letting their touching colliders push each other apart at the same time gives the pair two constraint systems with different opinions, plus a contact per link that nothing needs. The default is nevertheless true, which is the behaviour every joint has had so far: an existing scene built against it would change shape under an update that flipped it. Nothing else argues for true any more - a chain of seven links and up used to gain energy without these contacts, and that defect is fixed in the joint solver rather than damped by them - so the default is a compatibility choice and is free to be revisited. Nothing about a contact is weakened under true: the narrow phase produces it, the contact graph holds it, collision events fire, a ContactModifier sees it and the solver resolves it, exactly as for any unjointed pair. It is fixed at construction, and it follows the joint's presence in the world rather than Joint.enabled: disabling a joint suspends its constraint, and a ragdoll whose joints are momentarily disabled must not start pushing its own limbs apart. Removing the joint from the world does let the pair collide again, and both bodies are woken so they respond to it."
+ },
{
"name": "dampingRatio",
"signature": "dampingRatio?: number",
diff --git a/site/src/content/api/weld-joint.json b/site/src/content/api/weld-joint.json
index 4baaacd08..84aeddd4d 100644
--- a/site/src/content/api/weld-joint.json
+++ b/site/src/content/api/weld-joint.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 8,
+ "memberCount": 9,
"counts": {
"constructors": 1,
"methods": 0,
- "properties": 7,
+ "properties": 8,
"events": 0
},
"sections": [
@@ -147,6 +147,27 @@
"returnType": null,
"description": "Second constrained body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether bodyA and bodyB also collide with each other; see JointOptions.collideConnected."
+ },
{
"name": "dampingRatio",
"signature": "dampingRatio: number",
diff --git a/site/src/content/api/wheel-joint-options.json b/site/src/content/api/wheel-joint-options.json
index 40ece07ae..33de82bd3 100644
--- a/site/src/content/api/wheel-joint-options.json
+++ b/site/src/content/api/wheel-joint-options.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 12,
+ "memberCount": 13,
"counts": {
"constructors": 0,
"methods": 0,
- "properties": 12,
+ "properties": 13,
"events": 0
},
"sections": [
@@ -136,6 +136,31 @@
"returnType": null,
"description": "Second body (the wheel)."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected?: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": "?",
+ "kind": "punctuation"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether the two bodies this joint connects also collide with each other. Default true. false is what a chain, a ragdoll, a pendulum or a vehicle assembly usually wants, and what Box2D defaults to: a joint already decides how its two bodies may move relative to one another, so letting their touching colliders push each other apart at the same time gives the pair two constraint systems with different opinions, plus a contact per link that nothing needs. The default is nevertheless true, which is the behaviour every joint has had so far: an existing scene built against it would change shape under an update that flipped it. Nothing else argues for true any more - a chain of seven links and up used to gain energy without these contacts, and that defect is fixed in the joint solver rather than damped by them - so the default is a compatibility choice and is free to be revisited. Nothing about a contact is weakened under true: the narrow phase produces it, the contact graph holds it, collision events fire, a ContactModifier sees it and the solver resolves it, exactly as for any unjointed pair. It is fixed at construction, and it follows the joint's presence in the world rather than Joint.enabled: disabling a joint suspends its constraint, and a ragdoll whose joints are momentarily disabled must not start pushing its own limbs apart. Removing the joint from the world does let the pair collide again, and both bodies are woken so they respond to it."
+ },
{
"name": "dampingRatio",
"signature": "dampingRatio?: number",
diff --git a/site/src/content/api/wheel-joint.json b/site/src/content/api/wheel-joint.json
index 159fa7492..2b2dc0984 100644
--- a/site/src/content/api/wheel-joint.json
+++ b/site/src/content/api/wheel-joint.json
@@ -6,11 +6,11 @@
"subsystem": "physics",
"importPath": "@codexo/exojs-physics",
"tier": "stable",
- "memberCount": 12,
+ "memberCount": 13,
"counts": {
"constructors": 1,
"methods": 0,
- "properties": 11,
+ "properties": 12,
"events": 0
},
"sections": [
@@ -126,6 +126,27 @@
"returnType": null,
"description": "Second constrained body."
},
+ {
+ "name": "collideConnected",
+ "signature": "collideConnected: boolean",
+ "signatureTokens": [
+ {
+ "text": "collideConnected",
+ "kind": "name"
+ },
+ {
+ "text": ": ",
+ "kind": "punctuation"
+ },
+ {
+ "text": "boolean",
+ "kind": "keyword"
+ }
+ ],
+ "params": [],
+ "returnType": null,
+ "description": "Whether bodyA and bodyB also collide with each other; see JointOptions.collideConnected."
+ },
{
"name": "dampingRatio",
"signature": "dampingRatio: number",
diff --git a/site/src/lib/bench-cards.ts b/site/src/lib/bench-cards.ts
index 2b030f756..97f37736a 100644
--- a/site/src/lib/bench-cards.ts
+++ b/site/src/lib/bench-cards.ts
@@ -13,8 +13,8 @@
* assembled to suit the numbers inside it.
*/
-import type { BenchProfileDocument, ProfileBackendName, ProfileCell, ProfileRow, ProfileSection } from './bench-profiles';
-import { armLabel, formatLoad, outcomeOf } from './bench-profiles';
+import type { BenchProfileDocument, LoadUnit, ProfileBackendName, ProfileCell, ProfileRow, ProfileSection } from './bench-profiles';
+import { armLabel, formatLoad, isQuantitative, orderArms, outcomeOf, publishedMs, withheldScenario } from './bench-profiles';
/** One arm's time on one load of one scenario. */
export interface CardArm {
@@ -22,7 +22,7 @@ export interface CardArm {
readonly id: string;
/** Human label, e.g. `PixiJS`. */
readonly label: string;
- /** Milliseconds, or `null` where the arm produced no comparable figure. */
+ /** Milliseconds, or `null` where the arm published no figure here. */
readonly ms: number | null;
/** 95th percentile of the same window, or `null`. */
readonly p95Ms: number | null;
@@ -32,6 +32,25 @@ export interface CardArm {
readonly overFrameBudget: boolean;
/** What the comparison this arm belongs to could establish; see `outcomeOf`. */
readonly outcome: ReturnType;
+ /**
+ * True where the figure may be drawn as a length.
+ *
+ * False is not a slow result but a comparison that was never drawn, so the
+ * row keeps its words and loses its bar. Plotting one would give the arm that
+ * produced nothing the shortest bar on the card, which reads as the fastest.
+ */
+ readonly quantitative: boolean;
+}
+
+/** One competitor's comparison on one load, for the detail a card opens. */
+export interface CardComparison {
+ /** Arm id, e.g. `pixi`. */
+ readonly id: string;
+ /** Human label, e.g. `PixiJS`. */
+ readonly label: string;
+ /** The published cell, verbatim - the detail and the row are the same measurement by construction. */
+ readonly cell: ProfileCell;
+ readonly outcome: ReturnType;
}
/** One selectable load of one scenario. */
@@ -42,10 +61,35 @@ export interface CardLoad {
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. */
+ /** ExoJS first, then the competitors in a fixed order; see `orderArms`. */
readonly arms: readonly CardArm[];
- /** Largest measured figure on this load, for scaling the bars. */
+ /** Largest plottable figure on this load, for scaling the bars. */
readonly maxMs: number;
+ /** Scene size this load was measured at. */
+ readonly count: number;
+ /** What `count` counts, where the row states one. */
+ readonly unit?: LoadUnit;
+ /** The published comparisons behind the row, in the same arm order. */
+ readonly comparisons: readonly CardComparison[];
+ /**
+ * 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;
+ /**
+ * How many libraries this load compares, where that is fewer than the block's
+ * widest row; `null` where it compares all of them.
+ *
+ * A card that silently shows two rows where its neighbours show four reads as
+ * a page that lost a library. The figure says how many were measured; why an
+ * arm is missing is a property of that arm's adapter and coverage and stays
+ * in the full results.
+ */
+ readonly measuredArms: number | null;
}
/** One scenario's card. */
@@ -54,6 +98,8 @@ export interface BenchCard {
readonly id: string;
/** The section the scenario is filed under. */
readonly category: string;
+ /** The rendering backend these loads were measured on; absent for physics, which has no backend axis. */
+ readonly backend?: ProfileBackendName;
/** Loads, in the order the harness measured them. */
readonly loads: readonly CardLoad[];
}
@@ -67,8 +113,12 @@ export interface BenchCard {
* become a selection of whatever ExoJS happened to win.
*/
export const RENDERING_HEADLINE_SCENARIOS: readonly string[] = [
- 'dynamic-all',
+ // Read as three pairs across a two-column row, each pair a contrast: a scene
+ // that never changes beside one where everything does, text beside tiles,
+ // an effect beside clipping. The pairing also keeps the two cards of a row
+ // close in height, which is what lets the rows sit on one rhythm.
'static-heavy',
+ 'dynamic-all',
'text-dynamic',
'tilemap-scroll',
'particles-lifecycle',
@@ -108,6 +158,27 @@ const headlineOrFirst = (cards: readonly BenchCard[], preferred: readonly string
return chosen;
};
+/**
+ * The arms of one comparable load, quickest first.
+ *
+ * The section says "lower is better", so the row reads top-down as best to
+ * worst; ExoJS is found by its colour rather than by always being the first
+ * line. Arms without a figure to rank by keep their canonical order behind
+ * the ranked ones, and a withheld load is never handed to this at all - it
+ * publishes no ranking, so it prints none.
+ */
+const fastestFirst = (arms: readonly CardArm[]): readonly CardArm[] =>
+ [...arms]
+ .map((arm, index) => ({ arm, index, ms: arm.quantitative && arm.ms !== null && Number.isFinite(arm.ms) ? arm.ms : null }))
+ .sort((a, b) => {
+ if (a.ms === null || b.ms === null) {
+ return (a.ms === null ? 1 : 0) - (b.ms === null ? 1 : 0) || a.index - b.index;
+ }
+
+ return a.ms - b.ms || a.index - b.index;
+ })
+ .map(entry => entry.arm);
+
/** 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];
@@ -119,22 +190,28 @@ const referenceArm = (cells: readonly ProfileCell[]): CardArm | null => {
return {
id: 'exojs',
label: armLabel('exojs'),
- ms: first.referenceMs,
- p95Ms: first.referenceP95Ms,
+ ms: publishedMs(first, first.referenceMs),
+ p95Ms: publishedMs(first, first.referenceP95Ms),
reference: true,
overFrameBudget: first.referenceOverFrameBudget,
outcome: outcomeOf(first),
+ // ExoJS's own figure belongs to every pair in the row, so it is plottable
+ // as soon as any one of them drew a comparison. Reading it off the first
+ // cell alone would hide the reference bar whenever the arm that happens to
+ // sort first is the one the clock could not separate.
+ quantitative: cells.some(cell => isQuantitative(outcomeOf(cell))),
};
};
const competitorArm = (cell: ProfileCell): CardArm => ({
id: cell.competitor,
label: armLabel(cell.competitor),
- ms: cell.competitorMs,
- p95Ms: cell.competitorP95Ms,
+ ms: publishedMs(cell, cell.competitorMs),
+ p95Ms: publishedMs(cell, cell.competitorP95Ms),
reference: false,
overFrameBudget: cell.competitorOverFrameBudget,
outcome: outcomeOf(cell),
+ quantitative: isQuantitative(outcomeOf(cell)),
});
/** One row becomes one selectable load. */
@@ -145,20 +222,34 @@ const loadOf = (row: ProfileRow): CardLoad | 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));
+ 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 }));
+ const arms = withheld === undefined ? fastestFirst(canonical) : 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,
+ // and leaving it out of the maximum would draw it past the end of its track.
+ // What is excluded is what was never published at all, which is already null.
+ const plotted = 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,
+ maxMs: plotted.length > 0 ? Math.max(...plotted) : 0,
+ count: row.count,
+ ...(row.unit !== undefined && { unit: row.unit }),
+ comparisons: cells.map(cell => ({ id: cell.competitor, label: armLabel(cell.competitor), cell, outcome: outcomeOf(cell) })),
+ measuredArms: null,
+ withheld,
};
};
/** Group a domain's sections into one card per scenario. */
-const cardsOf = (sections: readonly ProfileSection[]): readonly BenchCard[] => {
+const cardsOf = (sections: readonly ProfileSection[], backend?: ProfileBackendName): readonly BenchCard[] => {
const byScenario = new Map();
for (const section of sections) {
@@ -176,14 +267,24 @@ const cardsOf = (sections: readonly ProfileSection[]): readonly BenchCard[] => {
}
}
- return [...byScenario.entries()].map(([id, card]) => ({ id, category: card.category, loads: card.loads }));
+ // Marked against the widest row the same block published, not against a list
+ // of arms the page holds: what a comparison "should" carry is whatever that
+ // machine's run actually measured, and a profile taken against three arms
+ // must not report every one of its rows as short of a fourth.
+ const cards = [...byScenario.entries()].map(([id, card]) => ({ id, category: card.category, loads: card.loads, ...(backend !== undefined && { backend }) }));
+ const widest = Math.max(0, ...cards.flatMap(card => card.loads.map(load => load.arms.length)));
+
+ return cards.map(card => ({
+ ...card,
+ loads: card.loads.map(load => ({ ...load, measuredArms: load.arms.length < widest ? load.arms.length : null })),
+ }));
};
/** 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);
+ return block === undefined ? [] : cardsOf(block.sections, backend);
};
/** The physics cards of one profile. Physics has no backend axis. */
@@ -201,6 +302,10 @@ export const selectCards = (cards: readonly BenchCard[], preferred: readonly str
const headline = headlineOrFirst(cards, preferred, count);
const shown = new Set(headline.map(card => card.id));
+ // Both lists keep the order they were written in. Sorting cards by how ExoJS
+ // did on them puts a ranking on the page that the scenarios cannot support:
+ // a tile map and a particle effect are different work, and neither is
+ // "better" than the other for costing less.
return { headline, rest: cards.filter(card => !shown.has(card.id)) };
};
diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts
index 7fb874d6c..62002fced 100644
--- a/site/src/lib/bench-profiles.ts
+++ b/site/src/lib/bench-profiles.ts
@@ -256,8 +256,8 @@ export interface ProfileHost {
/** What the measuring page's clock could resolve, which decides how finely a step is timed. */
export interface PhysicsClock {
- /** Smallest non-zero `performance.now()` difference the page observed, in milliseconds. */
- readonly resolutionMs: number;
+ /** Smallest non-zero `performance.now()` difference the page observed, in milliseconds, or `null` where it observed none. */
+ readonly resolutionMs: number | null;
/** Whether the page reached a cross-origin-isolated context, which lifts the coarse clamp. */
readonly crossOriginIsolated: boolean;
}
@@ -402,9 +402,112 @@ export const referenceProfile: BenchProfileDocument | undefined = loaded[0];
/** Every profile except the reference one, in the same order. Empty until a second machine is contributed. */
export const furtherProfiles: readonly BenchProfileDocument[] = loaded.slice(1);
+/**
+ * Browsers in the order the page offers them, most practically relevant first.
+ *
+ * An editorial choice, fixed before any run and never derived from the results.
+ * Chromium leads because it is the broadest reference point a reader shipping a
+ * web game actually has; a browser with no published profile simply does not
+ * appear.
+ */
+const BROWSER_ORDER: readonly string[] = ['chromium', 'webkit', 'firefox'];
+
+/** One browser's published profiles: the one the page leads with, and the rest measured in the same browser. */
+export interface BrowserProfiles {
+ readonly browser: string;
+ /** The profile this browser is shown through - newest engine version, widest coverage; see `byRecency`. */
+ readonly reference: BenchProfileDocument;
+ /** Further machines measured in the same browser, in the same order. Usually empty. */
+ readonly others: readonly BenchProfileDocument[];
+}
+
+/**
+ * The published profiles grouped by the browser they were measured in.
+ *
+ * The page's primary axis, and deliberately not the machine. Switching machines
+ * changes the CPU, the GPU, the operating system, the driver stack AND the
+ * browser engine at once, so it is not an A/B of anything a reader can name;
+ * the browser at least names one dimension.
+ *
+ * It is still not a browser benchmark while each browser is measured on its own
+ * machine, which is why the page presents these as separate reference profiles
+ * and prints the machine beside every set of numbers rather than inviting a
+ * reader to divide one browser's figures by another's.
+ */
+export const profilesByBrowser: readonly BrowserProfiles[] = [...new Set(loaded.map(document => document.profile.browser))]
+ .sort((a, b) => {
+ const left = BROWSER_ORDER.indexOf(a);
+ const right = BROWSER_ORDER.indexOf(b);
+
+ return (left === -1 ? BROWSER_ORDER.length : left) - (right === -1 ? BROWSER_ORDER.length : right) || a.localeCompare(b);
+ })
+ .flatMap(browser => {
+ const [reference, ...others] = loaded.filter(document => document.profile.browser === browser);
+
+ return reference === undefined ? [] : [{ browser, reference, others }];
+ });
+
/** Display name for a rendering backend. */
export const BACKEND_LABELS: Readonly> = { webgl2: 'WebGL2', webgpu: 'WebGPU' };
+/** Browser engines as they are written, keyed by the slug the harness stores. */
+const BROWSER_LABELS: Readonly> = { chromium: 'Chromium', webkit: 'WebKit', firefox: 'Firefox' };
+
+/** A browser engine's published name, or its slug written out where none is known. */
+export const browserLabel = (browser: string): string => BROWSER_LABELS[browser] ?? deviceName(browser);
+
+/** Operating systems as they are written, keyed by the normalized platform name. */
+const OS_LABELS: Readonly> = { windows: 'Windows', macos: 'macOS', linux: 'Linux' };
+
+/**
+ * A hardware slug written out, e.g. `rtx-5070-ti` as `RTX 5070 Ti`.
+ *
+ * The slug is the harness's file-naming identifier and reads as one on a page.
+ * Only the shape is fixed here - hyphens become spaces, model designations
+ * stay upper case - because the set of machines is contributed and cannot be
+ * enumerated in advance; anything the rules do not recognise is title-cased
+ * rather than dropped.
+ */
+const DEVICE_WORDS: Readonly> = { rtx: 'RTX', gtx: 'GTX', rx: 'RX', ti: 'Ti', amd: 'AMD', apple: 'Apple', intel: 'Intel', arc: 'Arc' };
+
+const deviceName = (slug: string): string =>
+ slug
+ .split('-')
+ .map(word => DEVICE_WORDS[word] ?? (/^[a-z]\d/.test(word) || /^\d/.test(word) ? word.toUpperCase() : `${word.charAt(0).toUpperCase()}${word.slice(1)}`))
+ .join(' ');
+
+/**
+ * How a machine is named where a reader picks one.
+ *
+ * Short on purpose: the device and the browser engine are what distinguish the
+ * published profiles from each other, and a heading carrying the slug, the
+ * operating system, its version and the engine version is a filename rather
+ * than a name. Everything it leaves out stays on the profile itself.
+ */
+export const machineName = (profile: BenchProfile): string => `${deviceName(profile.gpu)} · ${BROWSER_LABELS[profile.browser] ?? deviceName(profile.browser)}`;
+
+/**
+ * Just the hardware, for a place that already names the browser.
+ *
+ * The benchmarks page picks a browser and then states the machine that browser
+ * was measured on, so repeating the engine inside the machine's own name says
+ * it twice on one line.
+ */
+export const deviceLabel = (profile: BenchProfile): string => deviceName(profile.gpu);
+
+/**
+ * The platform a profile was measured on, spelled out: `macOS 27 beta`.
+ *
+ * The pre-release marker is part of the name rather than a footnote - a beta
+ * platform's numbers are the beta's, and a reader comparing two machines has to
+ * see that before the figures and not after them.
+ */
+export const platformName = (profile: BenchProfile): string => {
+ const { name, version, prerelease } = profile.platform;
+
+ return `${OS_LABELS[name] ?? deviceName(name)} ${String(version)}${prerelease ? ' beta' : ''}`;
+};
+
/**
* How each arm is written where a reader sees it.
*
@@ -421,6 +524,7 @@ const ARM_LABELS: Readonly> = {
excalibur: 'Excalibur',
phaser: 'Phaser',
'matter-js': 'Matter.js',
+ 'nape-js': 'Nape-JS',
planck: 'Planck',
rapier: 'Rapier',
};
@@ -428,6 +532,30 @@ 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;
+/**
+ * The order arms are listed in, per domain, ExoJS first.
+ *
+ * Fixed here and never derived from the measurements. Ordering by time would
+ * move a library up or down the card whenever a re-measurement changed a
+ * number, so a reader following one arm across scenarios would have to find it
+ * again in every card - and a page whose rows reorder themselves around the
+ * result reads as a ranking rather than as a comparison.
+ */
+const ARM_ORDER: readonly string[] = ['exojs', 'exojs-physics', 'pixi', 'phaser', 'excalibur', 'matter-js', 'planck', 'nape-js', 'rapier'];
+
+/**
+ * Arms sorted into {@link ARM_ORDER}, with anything unknown kept behind them in
+ * the order it arrived.
+ *
+ * An arm the harness gained after this list was written must still appear, so
+ * an unknown slug is appended rather than dropped or guessed at a position.
+ */
+export const orderArms = (arms: readonly T[], idOf: (arm: T) => string): readonly T[] =>
+ [...arms]
+ .map((arm, index) => ({ arm, index, rank: ARM_ORDER.indexOf(idOf(arm)) }))
+ .sort((a, b) => (a.rank === -1 ? ARM_ORDER.length + a.index : a.rank) - (b.rank === -1 ? ARM_ORDER.length + b.index : b.rank))
+ .map(entry => entry.arm);
+
/**
* What each archetype's workload is, in one line.
*
@@ -476,6 +604,7 @@ const ARCHETYPE_DESCRIPTIONS: Readonly> = {
'particles-lifecycle': 'A steady particle effect: ageing, movement, fading and respawning.',
'interaction-picking': 'A block of point queries against a field of interactive rectangles; stresses the hit-test index.',
'fx-blur': 'A separable two-pass Gaussian over a fixed area; stresses the target passes a filter runs.',
+ 'ui-layout-update': 'Nested boxes of fixed-size widgets re-solved after a tenth of them resize; stresses the layout engine, and nothing is drawn.',
};
/**
@@ -517,6 +646,7 @@ const ARCHETYPE_TITLES: Readonly> = {
'particles-lifecycle': 'Particle effect',
'interaction-picking': 'Hit testing',
'fx-blur': 'Blur effect',
+ 'ui-layout-update': 'Interface layout',
'box-stack': 'Box stack',
'many-dynamic': 'Many active bodies',
'mixed-static-dynamic': 'Static level, falling bodies',
@@ -526,6 +656,28 @@ 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> = {
+ joints:
+ 'The published profiles were measured before the arms agreed on whether two jointed links also collide with each other. Each library defaulted differently, so ExoJS resolved one contact per jointed pair where Matter.js, Planck and Nape-JS resolved none and Rapier one per chain, and the arms were not doing the same work. The harness now configures every arm explicitly, and the comparison returns with the next reference measurement; until then these times stand on their own.',
+};
+
+/** 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;
@@ -571,13 +723,22 @@ export const FRAME_BUDGET_MS = 16.7;
*/
const SIGNIFICANT_DIGITS = 3;
+/** Most decimals a printed figure carries; also the smallest value that can be printed exactly. */
+const MOST_DECIMALS = 3;
+
/** 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)));
// 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)));
+ const fixed = value.toFixed(Math.max(0, Math.min(MOST_DECIMALS, SIGNIFICANT_DIGITS - 1 - magnitude)));
+
+ // A trailing zero is a digit the significant-figure rule reached for and the
+ // measurement does not fill: `0.140` and `10.0` claim a place past what
+ // separated the runs, and a column of them reads as precision rather than as
+ // padding. The digits that carry the value are untouched.
+ return fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
};
/**
@@ -597,8 +758,20 @@ export const formatFactor = (factor: number | null): string => {
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));
+/**
+ * A median in milliseconds, or a dash when the arm produced no comparable number.
+ *
+ * A positive value the formatter cannot reach is printed as a bound rather than
+ * rounded down: `0.000 ms` is the fastest figure the page can print, and an arm
+ * that took a measurable fraction of a microsecond must not be handed it.
+ */
+export const formatMs = (ms: number | null): string => {
+ if (ms === null || !Number.isFinite(ms)) return '-';
+
+ const printed = significant(ms);
+
+ return ms > 0 && Number.parseFloat(printed) === 0 ? `<${(10 ** -MOST_DECIMALS).toFixed(MOST_DECIMALS)}` : printed;
+};
/**
* True when the pair produced a comparison at all.
@@ -769,11 +942,56 @@ export const OUTCOME_LABELS: Readonly> = {
loss: 'loss',
'clear-loss': 'clear loss',
unstable: 'no clear lead',
- 'timer-limited': 'below the timer',
+ // Not "below the timer": that reads as a distance to the observed step size
+ // rather than as what happened, which is that the clock was too coarse to
+ // separate the two durations at all.
+ 'timer-limited': 'timing-limited',
'timer-unknown': 'timer not recorded',
absent: 'no shared cell',
};
+/**
+ * What a card prints in place of a figure, for the outcomes that publish none.
+ *
+ * Separate from {@link OUTCOME_LABELS}, which is written to be counted -
+ * `3 timing-limited` - while these stand alone on a row where a number would
+ * otherwise be, and have to read as a state rather than as a tally's noun.
+ */
+export const OUTCOME_STATUS: Readonly> = {
+ 'clear-lead': 'Clear lead',
+ lead: 'Lead',
+ level: 'Level',
+ loss: 'Loss',
+ 'clear-loss': 'Clear loss',
+ unstable: 'No clear lead',
+ 'timer-limited': 'Timing-limited',
+ 'timer-unknown': 'Timer not recorded',
+ absent: 'Not available',
+};
+
+/**
+ * One sentence on what each state means, for the marker a reader can reach by
+ * touch or keyboard.
+ *
+ * A coloured glyph and a two-word label are not a statement; these are, and
+ * they are the only place the distinction between a comparison that failed and
+ * a comparison that was never drawn is spelled out beside the row itself.
+ */
+export const OUTCOME_NOTES: Readonly> = {
+ 'clear-lead': 'ExoJS leads by a margin attributable to how the libraries are built.',
+ lead: 'ExoJS leads on this row.',
+ level: 'Inside the band this page treats as no difference.',
+ loss: 'The other library leads on this row.',
+ 'clear-loss': 'The other library leads by a margin attributable to how the libraries are built.',
+ unstable: 'The pooled runs reached different conclusions, so no verdict is published.',
+ 'timer-limited': "The browser's clock did not separate the two durations, so no comparison is drawn. It is not a failed test.",
+ 'timer-unknown': 'This profile was measured before the harness recorded what its clock resolved.',
+ absent: 'This library produced no comparable measurement here.',
+};
+
+/** What the frame-budget marker beside a figure means. */
+export const FRAME_BUDGET_NOTE = `Past a whole ${String(FRAME_BUDGET_MS)} ms frame at 60 fps: this scene alone does not fit in a frame.`;
+
/**
* What the timer check established about a comparison.
*
@@ -812,6 +1030,37 @@ export const outcomeOf = (cell: ProfileCell | null): CellOutcome => {
return cell.verdict.structural ? 'clear-loss' : 'loss';
};
+/**
+ * True where a cell's figures may be drawn as a quantity - a bar, a share of a
+ * frame, a winner.
+ *
+ * The three states that fail this are not slow results: they are comparisons
+ * that were never drawn. A cell whose clock did not separate the two durations,
+ * or that no run produced, still carries whatever times it has for a reader who
+ * opens the details, but plotting them would turn the absence of a comparison
+ * into the strongest-looking result on the card - a bar of almost no length
+ * beside arms that took milliseconds.
+ */
+export const isQuantitative = (outcome: CellOutcome): boolean => outcome !== 'timer-limited' && outcome !== 'timer-unknown' && outcome !== 'absent';
+
+/**
+ * One arm's figure as the page may print it, or `null` where the cell published
+ * none for that arm.
+ *
+ * Stricter than {@link measuredMs}, which only catches the stored zero. A
+ * comparison the clock refused also publishes a figure that cannot be read as a
+ * duration: the harness writes the arm's raw sample there, and below the grid
+ * the clock resolved that sample is as likely to be zero as to be the time the
+ * arm took.
+ */
+export const publishedMs = (cell: ProfileCell, ms: number | null): number | null => {
+ const measured = measuredMs(cell, ms);
+
+ if (measured === null) return null;
+
+ return outcomeOf(cell) === 'timer-limited' && measured === 0 ? null : measured;
+};
+
/** The lowest and highest `exojs / competitor` ratio the pooled runs can have produced. */
export interface RatioBand {
readonly low: number;
diff --git a/site/src/lib/bench-tables.ts b/site/src/lib/bench-tables.ts
index 166e0fb80..01e92bcd2 100644
--- a/site/src/lib/bench-tables.ts
+++ b/site/src/lib/bench-tables.ts
@@ -27,7 +27,9 @@ import {
armsOfSection,
BACKEND_LABELS,
type BenchProfileDocument,
+ formatLoad,
isWasmReferenceArm,
+ orderArms,
type ProfileBackend,
type ProfileCell,
type ProfileRow,
@@ -52,7 +54,7 @@ export interface ComparisonEntry {
readonly count: number | null;
}
-/** One archetype, across every column of a table. */
+/** One archetype at one load, across every column of a table. */
export interface ComparisonRow {
readonly key: string;
readonly archetype: string;
@@ -60,6 +62,10 @@ export interface ComparisonRow {
readonly section: string | null;
/** The size every column measured this row at, or `null` where they differ. */
readonly count: number | null;
+ /** How the load reads, with its unit: `10,000 sprites`. */
+ readonly load: string;
+ /** Whether this is the scenario's headline load - the one a card opens on. */
+ readonly primary: boolean;
readonly description?: string;
readonly entries: readonly ComparisonEntry[];
}
@@ -68,7 +74,7 @@ export interface ComparisonRow {
export interface ComparisonTable {
readonly columns: readonly ComparisonColumn[];
readonly rows: readonly ComparisonRow[];
- /** What a count is counted in: `nodes` for rendering, `bodies` for physics. */
+ /** What a count is counted in where a row states no unit of its own. */
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;
@@ -102,6 +108,9 @@ const preferredColumn = (columns: readonly ComparisonColumn[]): number => {
return 0;
};
+/** A row's load, as the identity a table keys on. */
+const loadKey = (row: ProfileRow): string => row.loadId ?? String(row.count);
+
const entryOf = (key: string, row: ProfileRow | undefined, arm: string): ComparisonEntry => ({
key,
cell: row?.cells.find(cell => cell.competitor === arm) ?? null,
@@ -112,51 +121,69 @@ const entryOf = (key: string, row: ProfileRow | undefined, arm: string): Compari
const rowsOf = (backend: ProfileBackend): readonly ProfileRow[] => backend.sections.flatMap(section => section.rows);
/**
- * The rendering table: one column per backend-and-arm pair.
+ * The rendering table: one column per backend-and-arm pair, one row per
+ * archetype and load.
*
- * Archetypes are collected in the order the first backend publishes them and
- * then extended by any a later backend adds, so the categories stay in the
- * order the harness wrote them rather than being sorted into a ranking.
+ * Rows are collected in the order the first backend publishes them and then
+ * extended by any a later backend adds, so the categories stay in the order the
+ * harness wrote them rather than being sorted into a ranking.
*/
export const renderingComparison = (document: BenchProfileDocument): ComparisonTable => {
const backends = document.rendering?.backends ?? [];
const columns = backends.flatMap(backend =>
- backend.competitors.map(arm => ({
+ orderArms(backend.competitors, arm => arm).map(arm => ({
key: `${backend.backend}-${arm}`,
group: BACKEND_LABELS[backend.backend],
overline: '',
label: armLabel(arm),
})),
);
- const archetypes: { archetype: string; section: string }[] = [];
+
+ /**
+ * Every archetype-and-load pair the backends published, in the order the
+ * first backend wrote them.
+ *
+ * The load is part of a row's identity and not a property of the table. An
+ * archetype is measured at several of them, and a table keyed on the
+ * archetype alone showed whichever load the harness happened to write first -
+ * so a card opening on its headline load and the table describing the same
+ * scenario were two different measurements under one name.
+ */
+ const keys: { archetype: string; loadId: string; section: string; row: ProfileRow }[] = [];
for (const backend of backends) {
for (const section of backend.sections) {
for (const row of section.rows) {
- if (!archetypes.some(entry => entry.archetype === row.archetype)) archetypes.push({ archetype: row.archetype, section: section.title });
+ const loadId = loadKey(row);
+
+ if (!keys.some(entry => entry.archetype === row.archetype && entry.loadId === loadId)) {
+ keys.push({ archetype: row.archetype, loadId, section: section.title, row });
+ }
}
}
}
- const rows = archetypes.map(({ archetype, section }) => {
+ const rows = keys.map(({ archetype, loadId, section, row: first }) => {
const entries = backends.flatMap(backend => {
- const row = rowsOf(backend).find(candidate => candidate.archetype === archetype);
+ const row = rowsOf(backend).find(candidate => candidate.archetype === archetype && loadKey(candidate) === loadId);
- return backend.competitors.map(arm => entryOf(`${backend.backend}-${arm}`, row, arm));
+ return orderArms(backend.competitors, arm => arm).map(arm => entryOf(`${backend.backend}-${arm}`, row, arm));
});
const counts = [...new Set(entries.map(entry => entry.count).filter((count): count is number => count !== null))];
return {
- key: archetype,
+ key: `${archetype}-${loadId}`,
archetype,
section,
count: counts.length === 1 ? (counts[0] ?? null) : null,
+ load: formatLoad(first),
+ primary: first.primary ?? false,
description: archetypeDescription(archetype),
entries,
};
});
- return { columns, rows, unit: 'nodes', countColumn: false, defaultColumn: preferredColumn(columns) };
+ return { columns, rows, unit: 'nodes', countColumn: true, defaultColumn: preferredColumn(columns) };
};
const singleBlockTable = (
@@ -169,10 +196,12 @@ const singleBlockTable = (
columns,
defaultColumn: preferredColumn(columns),
rows: rows.map(row => ({
- key: row.archetype,
+ key: `${row.archetype}-${loadKey(row)}`,
archetype: row.archetype,
section: null,
count: row.count,
+ load: formatLoad(row),
+ primary: row.primary ?? false,
description: archetypeDescription(row.archetype),
entries: arms.map(arm => entryOf(arm, row, arm)),
})),
@@ -192,7 +221,7 @@ export const physicsComparison = (document: BenchProfileDocument): ComparisonTab
if (section === undefined) return null;
- const arms = armsOfSection(section);
+ const arms = orderArms(armsOfSection(section), arm => arm);
return singleBlockTable(
section.rows,
@@ -220,13 +249,13 @@ export const physicsComparison = (document: BenchProfileDocument): ComparisonTab
export const webgl1Comparison = (backend: ProfileBackend): ComparisonTable | null => {
if (backend.webgl1.length === 0) return null;
- const arms = [...new Set(backend.webgl1.flatMap(row => row.cells.map(cell => cell.competitor)))].sort();
+ const arms = orderArms([...new Set(backend.webgl1.flatMap(row => row.cells.map(cell => cell.competitor)))], arm => arm);
return singleBlockTable(
backend.webgl1,
arms,
arms.map(arm => ({ key: arm, group: null, overline: 'WebGL1, CPU time only', label: armLabel(arm) })),
'nodes',
- false,
+ true,
);
};
diff --git a/site/src/pages/de/benchmarks/full/index.astro b/site/src/pages/de/benchmarks/full/index.astro
new file mode 100644
index 000000000..a2665b323
--- /dev/null
+++ b/site/src/pages/de/benchmarks/full/index.astro
@@ -0,0 +1,5 @@
+---
+import BenchFullResultsPage from '../../../../components/pages/BenchFullResultsPage.astro';
+---
+
+
diff --git a/site/src/pages/de/benchmarks/methodology/index.astro b/site/src/pages/de/benchmarks/methodology/index.astro
new file mode 100644
index 000000000..294d592df
--- /dev/null
+++ b/site/src/pages/de/benchmarks/methodology/index.astro
@@ -0,0 +1,5 @@
+---
+import BenchMethodologyPage from '../../../../components/pages/BenchMethodologyPage.astro';
+---
+
+
diff --git a/site/src/pages/en/benchmarks/full/index.astro b/site/src/pages/en/benchmarks/full/index.astro
new file mode 100644
index 000000000..c1619d7be
--- /dev/null
+++ b/site/src/pages/en/benchmarks/full/index.astro
@@ -0,0 +1,5 @@
+---
+import BenchFullResultsPage from '../../../../components/pages/BenchFullResultsPage.astro';
+---
+
+
diff --git a/site/src/pages/en/benchmarks/methodology/index.astro b/site/src/pages/en/benchmarks/methodology/index.astro
new file mode 100644
index 000000000..44c317a0b
--- /dev/null
+++ b/site/src/pages/en/benchmarks/methodology/index.astro
@@ -0,0 +1,5 @@
+---
+import BenchMethodologyPage from '../../../../components/pages/BenchMethodologyPage.astro';
+---
+
+
diff --git a/src/ui/Stack.ts b/src/ui/Stack.ts
index 2eaa66a65..df746b86a 100644
--- a/src/ui/Stack.ts
+++ b/src/ui/Stack.ts
@@ -290,7 +290,16 @@ export class Stack extends Widget {
this._natural.delete(child);
}
- child.setSize(isRow ? mainSize : crossSize, isRow ? crossSize : mainSize);
+ const width = isRow ? mainSize : crossSize;
+ const height = isRow ? crossSize : mainSize;
+
+ // Only write a size the child does not already have. Re-setting the same
+ // extents looks harmless and is not: a nested Stack reads any setSize as its
+ // caller pinning an explicit box, so a stack of stacks would stop sizing
+ // itself to its content the first time its parent laid it out.
+ if (child.uiWidth !== width || child.uiHeight !== height) {
+ child.setSize(width, height);
+ }
}
/** A child's layout extent: a widget's explicit size, or a drawn node's bounds. */
diff --git a/test/ci/push-scope.test.ts b/test/ci/push-scope.test.ts
new file mode 100644
index 000000000..d79e8a760
--- /dev/null
+++ b/test/ci/push-scope.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+
+import { pushScope } from '../../scripts/ci/push-scope.ts';
+
+// The logic under test is the SAME module .husky/pre-push branches on, so these
+// assertions exercise the real narrowing decision rather than a copy of it.
+
+describe('pre-push scope selection', () => {
+ it('verifies nothing when the range changes no tracked file', () => {
+ expect(pushScope([])).toBe('none');
+ expect(pushScope(['', ' '])).toBe('none');
+ });
+
+ it('narrows a published-profile change to its own gate', () => {
+ expect(pushScope(['packages/exojs-bench/results/rtx-5070-ti-windows-11-chromium.json'])).toBe('data');
+ expect(
+ pushScope([
+ 'packages/exojs-bench/results/apple-macos-27-beta-webkit.json',
+ 'packages/exojs-bench/results/m3-max-macos-27-beta-webkit.json',
+ 'packages/exojs-bench/results/README.md',
+ ]),
+ ).toBe('data');
+ });
+
+ it('reads Windows separators, which is how git diff reaches the hook there', () => {
+ expect(pushScope(['packages\\exojs-bench\\results\\rtx-5070-ti-windows-11-chromium.json'])).toBe('data');
+ });
+
+ it('takes the full path as soon as one file is not published data', () => {
+ expect(pushScope(['packages/exojs-bench/results/x.json', 'src/rendering/Renderer.ts'])).toBe('full');
+ // The harness that PRODUCES the profiles is code, and so is its baseline.
+ expect(pushScope(['packages/exojs-bench/src/suite/catalog.ts'])).toBe('full');
+ expect(pushScope(['packages/exojs-bench/baselines/structural.json'])).toBe('full');
+ });
+
+ it('does not narrow on a path that merely starts like the results directory', () => {
+ expect(pushScope(['packages/exojs-bench/results-notes.md'])).toBe('full');
+ });
+});
diff --git a/test/release/changelog-from-commits.test.ts b/test/release/changelog-from-commits.test.ts
index 8ec0e843a..0fe60b16e 100644
--- a/test/release/changelog-from-commits.test.ts
+++ b/test/release/changelog-from-commits.test.ts
@@ -59,7 +59,15 @@ describe('renderUnreleasedEntries', () => {
expect(rendered.indexOf('### Changed')).toBeLessThan(rendered.indexOf('### Added'));
expect(rendered.indexOf('BREAKING: Change a contract')).toBeLessThan(rendered.indexOf('Rename a thing'));
expect(rendered).toContain(`([#1](${REPO}/pull/1))`);
- expect(rendered).toContain(' It does things.');
+ });
+
+ test('renders the headline and the link, never the body', () => {
+ const body = ['It does things.', 'And here is the long reasoning.'].join('\n\n');
+ const rendered = renderUnreleasedEntries([classifyCommit(commit('feat: add a thing (#1)', body))!], '', REPO);
+
+ expect(rendered).toContain(`- **Add a thing.** ([#1](${REPO}/pull/1))`);
+ expect(rendered).not.toContain('It does things.');
+ expect(rendered).not.toContain('long reasoning');
});
test('skips entries whose pull request the section already names', () => {
diff --git a/test/site/bench-archetype-labels.test.ts b/test/site/bench-archetype-labels.test.ts
new file mode 100644
index 000000000..4c3b7c835
--- /dev/null
+++ b/test/site/bench-archetype-labels.test.ts
@@ -0,0 +1,33 @@
+/**
+ * Every scenario a published profile carries has to have a name and a sentence
+ * on the benchmarks page.
+ *
+ * `archetypeTitle` falls back to the raw archetype id, which is a reasonable
+ * thing for a renderer to do and a bad thing to ship: a row reading
+ * `ui-layout-update` next to rows reading `Hit testing` and `Blur effect` looks
+ * like a defect in the page rather than a label nobody wrote. The fallback stays
+ * - the page must render a profile measured by a newer harness than it knows -
+ * and this test is what stops the repository's own profiles reaching it.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { archetypeDescription, archetypeTitle, benchProfiles } from '../../site/src/lib/bench-profiles';
+
+const publishedArchetypes = (): readonly string[] => [
+ ...new Set(
+ benchProfiles.flatMap(profile =>
+ (profile.rendering?.backends ?? []).flatMap(backend => backend.sections.flatMap(section => section.rows.map(row => row.archetype))),
+ ),
+ ),
+];
+
+describe('benchmark page labels', () => {
+ it('names every rendering scenario the published profiles carry', () => {
+ expect(publishedArchetypes().filter(archetype => archetypeTitle(archetype) === archetype)).toStrictEqual([]);
+ });
+
+ it('describes every rendering scenario the published profiles carry', () => {
+ expect(publishedArchetypes().filter(archetype => archetypeDescription(archetype) === undefined)).toStrictEqual([]);
+ });
+});
diff --git a/test/site/bench-invalid-values.test.ts b/test/site/bench-invalid-values.test.ts
new file mode 100644
index 000000000..1d6e83868
--- /dev/null
+++ b/test/site/bench-invalid-values.test.ts
@@ -0,0 +1,159 @@
+/**
+ * What the benchmarks page may and may not print in place of a measurement.
+ *
+ * The failure this guards against shipped once: a physics arm the clock could
+ * not separate stored a zero, and the card drew it as `0.00 ms` with a bar -
+ * the fastest figure and the shortest bar on the page, for the arm that
+ * produced nothing. The same cell's detail said `not comparable` at the same
+ * moment, so the two views of one measurement disagreed in the reader's favour.
+ *
+ * These are about the published surface and not about the ladder: a comparison
+ * that was never drawn must reach the page as words, a real measurement must
+ * survive the formatter as a positive number, and both must say the same thing
+ * wherever they appear.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { openingLoad, physicsCards, renderingCards } from '../../site/src/lib/bench-cards';
+import { benchProfiles, formatMs, isQuantitative, outcomeOf, type ProfileCell, publishedMs, type TimerCheck } from '../../site/src/lib/bench-profiles';
+
+/** A cell whose competitor arm reported `ms` under the given timer verdict. */
+const cellOf = (ms: number, timer: TimerCheck, comparable = true): ProfileCell => ({
+ competitor: 'nape-js',
+ referenceMs: 5.24,
+ referenceP95Ms: 6.1,
+ referenceOverFrameBudget: false,
+ competitorMs: ms,
+ competitorP95Ms: ms,
+ competitorOverFrameBudget: false,
+ timer,
+ verdict: comparable
+ ? { side: 'competitor', ratio: 10, factor: 10, label: 'competitor leads clearly (10.00x)', structural: true }
+ : { side: 'neither', ratio: null, factor: null, label: 'not comparable', structural: false },
+ mechanism: null,
+ aggregate: {
+ runs: 3,
+ reference: { minMs: 4.98, maxMs: 5.7, ratio: 1.14 },
+ competitor: { minMs: ms, maxMs: ms, ratio: null },
+ stable: true,
+ rungs: ['not-comparable', 'not-comparable', 'not-comparable'],
+ },
+});
+
+describe('a figure the comparison never established', () => {
+ it('is withheld rather than published as a zero time', () => {
+ const cell = cellOf(0, 'limited', false);
+
+ expect(publishedMs(cell, cell.competitorMs)).toBeNull();
+ expect(formatMs(publishedMs(cell, cell.competitorMs))).toBe('-');
+ });
+
+ it('is withheld even where the ladder did reach a factor from it', () => {
+ // The harness can compute 23750x from a zero-ish sample; the timer check is
+ // what says the two durations were never separated, and it outranks it.
+ const cell = cellOf(0, 'limited');
+
+ expect(outcomeOf(cell)).toBe('timer-limited');
+ expect(publishedMs(cell, cell.competitorMs)).toBeNull();
+ });
+
+ it('gets no quantitative treatment on any of the three states that publish no comparison', () => {
+ expect(isQuantitative('timer-limited')).toBe(false);
+ expect(isQuantitative('timer-unknown')).toBe(false);
+ expect(isQuantitative('absent')).toBe(false);
+ });
+
+ it('leaves a settled comparison quantitative, including one the runs disagreed on', () => {
+ expect(isQuantitative('clear-loss')).toBe(true);
+ expect(isQuantitative('unstable')).toBe(true);
+ });
+});
+
+describe('a real measurement', () => {
+ it('survives the timer check when the clock did separate it', () => {
+ const cell = cellOf(0.42, 'resolved');
+
+ expect(publishedMs(cell, cell.competitorMs)).toBe(0.42);
+ });
+
+ it('is never rounded down to a zero time', () => {
+ expect(formatMs(0.0005)).toBe('0.001');
+ expect(formatMs(0.0004)).toBe('<0.001');
+ expect(formatMs(0.00001)).toBe('<0.001');
+ });
+
+ it('keeps a genuine zero apart from a value too small to print', () => {
+ expect(formatMs(0)).toBe('0');
+ });
+
+ it('prints no digit the measurement does not fill', () => {
+ expect(formatMs(0.14)).toBe('0.14');
+ expect(formatMs(0.9)).toBe('0.9');
+ expect(formatMs(5.8)).toBe('5.8');
+ expect(formatMs(10)).toBe('10');
+ expect(formatMs(186)).toBe('186');
+ });
+});
+
+describe('every published profile', () => {
+ const loads = benchProfiles
+ .flatMap(document => [...(['webgl2', 'webgpu'] as const).flatMap(backend => renderingCards(document, backend)), ...physicsCards(document)])
+ .flatMap(card => card.loads);
+
+ it('publishes at least one load to check', () => {
+ expect(loads.length).toBeGreaterThan(0);
+ });
+
+ it('publishes no arm at a zero time', () => {
+ // A stored zero is an arm that sat the comparison out. Whatever else a card
+ // does with it, it must never reach the page as the fastest figure on it.
+ expect(loads.flatMap(load => load.arms).filter(arm => arm.ms === 0)).toStrictEqual([]);
+ });
+
+ it('scales a row by every published figure, so no bar runs past its track', () => {
+ for (const load of loads) {
+ const published = load.arms.map(arm => arm.ms).filter((ms): ms is number => ms !== null);
+
+ expect(load.maxMs).toBe(published.length === 0 ? 0 : Math.max(...published));
+ }
+ });
+
+ it('lists the arms of a comparable load quickest first, and a withheld load in canonical order', () => {
+ for (const load of loads) {
+ const ranked = load.arms.filter(arm => arm.quantitative && arm.ms !== null).map(arm => arm.ms ?? 0);
+
+ if (load.withheld !== undefined) {
+ // No ranking is published for a withheld row, so none is printed: ExoJS
+ // leads the canonical order whatever its time.
+ expect(load.arms[0]?.reference).toBe(true);
+ continue;
+ }
+
+ // The section reads "lower is better", so the rows read best to worst.
+ expect(ranked).toStrictEqual([...ranked].sort((a, b) => a - b));
+ // Arms with nothing to rank by sit behind the ranked ones, never between them.
+ const firstUnranked = load.arms.findIndex(arm => !(arm.quantitative && arm.ms !== null));
+
+ if (firstUnranked !== -1) {
+ expect(load.arms.slice(firstUnranked).every(arm => !(arm.quantitative && arm.ms !== null))).toBe(true);
+ }
+ }
+ });
+
+ it('opens every card on a load whose detail describes that same load', () => {
+ for (const card of benchProfiles.flatMap(document => (['webgl2', 'webgpu'] as const).flatMap(backend => renderingCards(document, backend)))) {
+ const opening = openingLoad(card);
+
+ expect(opening).toBeDefined();
+ // The detail is rendered from the load's own comparisons, so identity is
+ // structural: every comparison the detail can show belongs to this load.
+ expect(opening?.comparisons.map(entry => entry.id).sort()).toStrictEqual(
+ opening?.arms
+ .filter(arm => !arm.reference)
+ .map(arm => arm.id)
+ .sort(),
+ );
+ }
+ });
+});
diff --git a/test/ui/layout.test.ts b/test/ui/layout.test.ts
index 158823fd5..211a6b0f0 100644
--- a/test/ui/layout.test.ts
+++ b/test/ui/layout.test.ts
@@ -119,6 +119,22 @@ describe('Stack reactivity', () => {
expect(two.uiWidth).toBe(95);
});
+ test('keeps a nested stack sizing to its content after its parent has laid it out', () => {
+ const outer = new Stack({ direction: 'column', spacing: 0 });
+ const inner = new Stack({ direction: 'row', spacing: 0 });
+ const child = new Panel({ width: 20, height: 10 });
+
+ outer.addChild(inner);
+ inner.addChild(child);
+
+ expect(inner.uiWidth).toBe(20);
+
+ child.setSize(50, 10);
+
+ expect(inner.uiWidth).toBe(50);
+ expect(outer.uiWidth).toBe(50);
+ });
+
test('drops a grow factor when it is cleared or its child leaves', () => {
const stack = new Stack({ direction: 'row', spacing: 0 });
const child = new Panel({ width: 20, height: 10 });