Group D: compressed textures, 32-bit mesh indices, spatial audio and MRT (HI-10, NEU-O44, HI-16, ME-24) - #632
Merged
Merged
Conversation
added 13 commits
August 28, 2026 01:12
A texture can now carry a block-compressed payload instead of a pixel source, and both backends upload it untouched: BC1-7, ETC2/EAC and four ASTC block sizes, mip chain included. A BC7 or ASTC 4x4 image occupies a quarter of the VRAM of the same image as RGBA8 and never decodes on the CPU. The payload lives on Texture rather than only on the CompressedTexture subclass, and is mutually exclusive with the pixel source. That is what lets one loader handle become either kind, so a device that cannot sample a format can be served an ordinary image under the same identity. Availability is per device and per backend, so RenderBackend gained supportedTextureFormats: WebGL2 probes the five compressed-texture extensions once per context, WebGPU requests the three optional families its adapter offers and reads them back off the granted device. Both order the result by one shared engine preference ranking, so selection cannot depend on which backend is live. Binding a format the device lacks throws RenderError with the new 'unsupported-format' code instead of handing the driver bytes it would misread.
The `texture` type now claims `.ktx2` and dispatches on the payload's
magic bytes rather than on the file suffix, so one asset type serves both
an image and a container. That keeps the caller-visible shape identical:
`loader.get('hero.ktx2')` hands out the same seamless `Texture` handle as
a PNG, which is the precondition for letting a variant rule swap one for
the other.
A hardware-format payload becomes a compressed texture; an uncompressed
RGBA8 payload is turned into an ordinary image source so it takes exactly
the same upload and premultiplication path as a PNG instead of becoming a
third payload kind in both backends. The seamless adapter transplants
either kind, and drops a compressed payload explicitly on eviction -
clearing the source alone is a no-op on a handle whose source was already
null.
BasisLZ, Zstandard and ZLIB supercompression are rejected with an
AssetDecodeError naming the scheme: transcoding needs a decoder Core does
not carry. Shipping one file per target format and selecting between them
is the supported route.
Until now a path was a path: one URL, one set of bytes, on every device. That is the wrong shape for a texture shipped once per compressed format family - no GPU supports them all - and once per display density, because the choice can only be made where the device is known. `loader.variants` declares candidates for a logical source, each stating the compressed format or the density it requires. Among the eligible ones the most preferred supported format wins, then the highest density, then declaration order; a candidate stating nothing is the unconditional fallback. Nothing is registered by default, so an unconfigured loader costs one map lookup per load. The loader consults it in two places, and both matter. Identity resolves against the chosen file, so two devices picking different bytes get different cache entries rather than one entry whose contents depend on who filled it last. Bare-path type inference resolves against it too, so a rule that swaps a `.png` for a `.ktx2` does not hand container bytes to the image decoder. The Application publishes the device profile after every successful backend initialization, the WebGPU-to-WebGL2 fallback included: the two backends do not support the same format families.
Regenerates the API reference for the compressed-texture and asset-variant surface, and adds an Assets chapter covering the pair: reading what the device implements, declaring one logical source per GPU family and density, why identity follows the chosen file, and the two upload options a compressed payload cannot honour.
`Geometry.indices` already accepted a `Uint32Array`, but `Mesh` narrowed it straight back and rejected a non-indexed mesh past 65 536 vertices - so a single mesh was capped at roughly 21 800 triangles however it was authored. That is fine for a hand-made leaf and wrong for generated or merged tile, trail, terrain or imported SVG geometry. A mesh now keeps the index width it was authored with, and `indexFormat` is the one derived answer both backends read. `Uint16` stays the default: it is half the bytes, and the overwhelmingly common mesh is far below the ceiling. A stream is deliberately never narrowed to fit - the declared width is the contract, and re-deriving it from the values would let one geometry change width when its content changes. Non-indexed meshes past the ceiling get implicit `Uint32` indices rather than a `drawArrays` path. The shared-buffer batching already synthesizes indices for every non-indexed mesh, and the retained and instanced paths on both backends are indexed throughout; a second unbatched draw route through all of that would buy an index buffer back for a case that is rare by construction. Widening the implicit indices makes the limit disappear instead of moving it. WebGL2 already carried an index type on its VAOs and its retained replay already drew with it - only the ordinary runtime hardcoded UNSIGNED_SHORT and nothing ever set the type. It is now set per draw on the shared dynamic VAO (without bumping the VAO version, which would re-specify every attribute pointer) and at creation on a static geometry VAO, with a re-pack that crosses the ceiling pushing the new width onto every VAO cached against that geometry. WebGPU packs many meshes into one index buffer, so its cursor is now a byte cursor and every block is 4-byte aligned. That satisfies `setIndexBuffer`'s per-format offset rule for both widths at once, which is what lets 16- and 32-bit meshes mix in one flush - packing tightly would put a uint32 block on a 2-byte boundary as soon as an odd uint16 block preceded it.
Adds an Index width section to the immediate-mode chapter - which width to pick and why, that a declared 32-bit stream is never narrowed back, and that a non-indexed mesh widens its synthesized indices on its own past 65 536 vertices. Regenerates the API reference for the widened mesh index types.
Most of what the original finding asked for already existed - real distance models, cone attenuation, HRTF, Doppler along the true line of sight, and a virtual per-application listener. Three things did not, and this adds them. **Height.** Every position and velocity was planar and the panner's Z param was pinned at zero. Sources and the listener now carry an `elevation`, and `position`/`velocity` additionally accept a `z`. The getters stay two-dimensional on purpose: the world plane is what the scene graph has and what `follow(node)` can fill in, so the third axis is something a caller states rather than something that appears in a `Vector` nothing else in the engine treats as 3D. A supplied point without a `z` leaves the current height alone, so following a node cannot silently drop a source back onto the plane. Doppler now projects in three dimensions, so a source rising straight up recedes. **Occlusion.** A caller-supplied `[0, 1]` amount driving a lowpass plus an attenuation, both ramped rather than stepped so a per-frame estimate does not click. The cutoff sweeps logarithmically, because a linear sweep spends half its range in the inaudible top octaves. Not derived from geometry: what counts as an obstruction is a game's decision. A voice that stays clear builds neither node, and one that returns to clear keeps them - rebuilding the chain on every threshold crossing would be audible. **Sends and zones.** `voice.addSend(bus, level)` is the missing primitive: an insert replaces a signal, so it cannot express a wet path beside a dry one. On top of it, `AudioZone` is a shape naming a bus and a send level, and `app.audio.zones` maintains one send per (voice, active zone), sampled at the LISTENER - reverb belongs to the environment it is heard from, not to each source. The zone layer owns no bus and no effect; what the bus does is the caller's. It is inert until a zone is added, and a boundary crossing is a level ramp on the existing send rather than a teardown and rebuild. The two Application test doubles gained a `variants` stub: the loader they stand in for receives the backend's capability profile at startup.
Extends the spatial-audio chapter with the four additions, and corrects two claims it no longer holds: the guide stated that listener and sources sit on one plane at Z=0 and that the elevation axis is unused. Regenerates the API reference for the widened spatial surface.
A render target could carry exactly one colour attachment, so a pass that had to produce two images - colour plus a selection id, a normal buffer, a velocity buffer - cost two full passes over the same geometry. `MultiRenderTarget` carries several. It OWNS its attachments, one `RenderTexture` per declared format, resized and destroyed together: a mismatched attachment size is a framebuffer-completeness error on WebGL2 and a validation error on WebGPU, which is not a failure worth handing to callers to avoid. `RenderTexture` is unchanged and remains the single-target form. `RenderBackend.maxColorAttachments` reports the ceiling - on WebGL2 the lower of MAX_COLOR_ATTACHMENTS and MAX_DRAW_BUFFERS, since an attachment nothing can write to is not usable capacity. WebGL2 attaches one texture per slot and declares them with `drawBuffers`, re-issued only when the attachment set changes, since that is framebuffer state. WebGPU sizes the pass descriptor from the bound target and resolves the load op once, on slot 0 - it answers whether the target has been drawn into this frame, which is a property of the target, and resolving per slot would consume the pending clear on the first one and leave the rest loading undefined contents. A custom mesh pipeline declares one target per attachment and keys its cache on the whole format list, because the same material in a one- and a two-attachment pass needs two pipelines. The work package required a concrete consumer before any of this was allowed to exist, so the capability ships with the only thing that can use it: a mesh material whose fragment shader declares one output per attachment. Everything else refuses - sprites, text, nine-slice, repeating sprites, video, the default mesh material, and mask and backdrop-blend compositing all declare a single output. WebGL2 would silently write slot 0 and leave the rest cleared while WebGPU rejects the draw, and one refusal on both beats two behaviours.
Adds a MultiRenderTarget section to the render-targets chapter - what it is for, that it owns its attachments, and the shader contract that is the only way to write one - and widens `RenderToOptions.target` to accept it, which the guide's own example needs. Regenerates the API reference.
`tsc --noEmit` does not cover `test/`, so three things only the dedicated gate sees: `_tickSpatial` is internal to the voice implementations rather than part of `Voice`, `Array.from` widened a `ColorTextureFormat[]` back to `TextureFormat[]`, and the shared render-backend double predates `supportedTextureFormats` and `maxColorAttachments`.
The multi-attachment support materialized a one-element attachment list on every render-target bind, and rebuilt the WebGPU format list per custom-material draw. A filter-heavy frame binds hundreds of targets, so both were per-frame garbage for the single-attachment case that is every frame in practice. Both paths now fill persistent scratch in place.
The general multi-attachment path stages one handle through a scratch list and compares lists on every render-target bind. A filter-heavy frame binds hundreds, and the allocation gate measured it at roughly 100 KB per frame on `filtered/100` alone - a case that has exactly one attachment, every frame, in practice. That case is now handled inline again; the list path is what a MultiRenderTarget takes.
Exoridus
enabled auto-merge (squash)
August 28, 2026 01:04
Bundle ReportChanges will increase total bundle size by 317.75kB (1.07%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: exo-esm-modules-esmAssets Changed:
view changes for bundle: exo-full-iife-min-Exo-iifeAssets Changed:
view changes for bundle: exo-iife-Exo-iifeAssets Changed:
view changes for bundle: exo-full-iife-Exo-iifeAssets Changed:
view changes for bundle: exo-iife-min-Exo-iifeAssets Changed:
view changes for bundle: exo-esm-esmAssets Changed:
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the four capability packages of Group D from the review-master backlog. Each was verified against the current code before any of it was written, and each ships with its own spec, tests with neutralization probes, and documentation.
P11 - Compressed textures and asset variants (
HI-10)Two layers, in the order the finding's 2026-08-17 addendum asked for: variant selection underneath, KTX2 as its first consumer.
loader.variantsdeclares candidates for one logical source, each stating the compressed format or the density it needs. The loader consults it in two places, and both matter: identity resolves against the chosen file, so two devices never share a cache entry whose contents depend on who filled it last; and bare-path type inference resolves against it too, so a rule that swaps a.pngfor a.ktx2does not hand container bytes to the image decoder.Texture, mutually exclusive with the pixel source. That is what lets one seamless loader handle become either kind, so a variant swap does not change the caller-visible shape. 16 formats (BC1-7, ETC2/EAC, four ASTC block sizes) upload on both backends, withRenderBackend.supportedTextureFormatsordered by one shared preference ranking so selection cannot depend on which backend is live..ktx2belongs to the ordinarytexturetype, dispatched on magic bytes rather than the suffix.Remaining limitation: BasisLZ/ETC1S, Zstandard and ZLIB supercompression are rejected with a named error. A transcoder is a multi-hundred-KB WASM module that does not belong in Core, and
AssetTypeRegistryforbids an extension from replacing thetexturetype, so a constructor-injected seam would be code with no installer. The variant layer removes the practical need.P12 - Dual 16/32-bit mesh index path (
NEU-O44)Both decisions the point asked for, made and argued:
Uint16default. A mesh keeps the width it was authored with; a declaredUint32Arrayis never narrowed, because the declaration is the contract and re-deriving it from the values would let one geometry change width when its content changes.Uint32indices, not adrawArrayspath - the shared-buffer batching already synthesizes indices for every non-indexed mesh, and a second unbatched route through the retained and instanced paths would buy an index buffer back for a case that is rare by construction.WebGL2 already carried an index type on its VAOs; only the runtime hardcoded
UNSIGNED_SHORT. WebGPU's cursor is now a byte cursor with 4-byte-aligned blocks, which is what lets both widths mix in one flush. All seven mandatory gates are covered.P13 - Spatial audio (
HI-16)Half the finding was stale, and saying so is part of the result: distance models, cone attenuation, HRTF, Doppler on the true line of sight and the virtual per-application listener all already existed. What did not:
position/velocityalso accepting az. The getters stay 2D deliberately - the world plane is what the scene graph has and whatfollow(node)can fill in. Doppler now projects in three dimensions.[0, 1]amount driving a lazily built lowpass plus attenuation, ramped and logarithmically swept.AudioSend, the missing primitive - an insert replaces a signal and cannot express a wet path beside a dry one - and on top of itAudioZoneplusapp.audio.zones, sampled at the listener because reverb belongs to the environment it is heard from.P14 - Multiple render targets (
ME-24)The work package required a concrete consumer first, and that requirement is met:
MultiRenderTargetships together with the only thing that can write one - aMeshMaterialwhose fragment shader declares an output per attachment - plus an end-to-end test and a guide section. Everything else refuses on both backends rather than having WebGL2 silently write slot 0 while WebGPU rejects the draw.Validation
scripts/ci/select-lanes.tspass, including the browser WebGL2/WebGPU lanes and the allocation gate.drawBuffers, WebGPU pass and pipeline sizing) confirmed red when disabled.Notable follow-up caught in review
The multi-attachment bind path staged one handle through a scratch list on every render-target bind, which the allocation gate measured at roughly 100 KB per frame on
filtered/100. The single-attachment case - every frame in practice - is back inline; the list path is what aMultiRenderTargettakes.