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 `