Skip to content

Group D: compressed textures, 32-bit mesh indices, spatial audio and MRT (HI-10, NEU-O44, HI-16, ME-24) - #632

Merged
Exoridus merged 13 commits into
mainfrom
capability/group-d
Aug 28, 2026
Merged

Group D: compressed textures, 32-bit mesh indices, spatial audio and MRT (HI-10, NEU-O44, HI-16, ME-24)#632
Exoridus merged 13 commits into
mainfrom
capability/group-d

Conversation

@Exoridus

Copy link
Copy Markdown
Owner

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.variants declares 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 .png for a .ktx2 does not hand container bytes to the image decoder.
  • Compressed payloads live on 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, with RenderBackend.supportedTextureFormats ordered by one shared preference ranking so selection cannot depend on which backend is live.
  • .ktx2 belongs to the ordinary texture type, 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 AssetTypeRegistry forbids an extension from replacing the texture type, 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:

  1. Dual path, Uint16 default. A mesh keeps the width it was authored with; a declared Uint32Array is 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.
  2. Large non-indexed meshes get implicit Uint32 indices, not a drawArrays path - 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:

  • Elevation on sources and the listener, with position/velocity also accepting a z. The getters stay 2D deliberately - the world plane is what the scene graph has and what follow(node) can fill in. Doppler now projects in three dimensions.
  • Occlusion, a caller-supplied [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 it AudioZone plus app.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: MultiRenderTarget ships together with the only thing that can write one - a MeshMaterial whose 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

  • All lanes selected by scripts/ci/select-lanes.ts pass, including the browser WebGL2/WebGPU lanes and the allocation gate.
  • 11 149 unit tests green; ~110 new cells across the four packages.
  • Neutralization probes for every new backend path (index width on both backends, compressed upload, occlusion, zone falloff, drawBuffers, WebGPU pass and pipeline sizing) confirmed red when disabled.
  • API reference regenerated; four guide chapters extended, and two stale claims in the spatial-audio chapter corrected.

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 a MultiRenderTarget takes.

Exoridus 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
Exoridus enabled auto-merge (squash) August 28, 2026 01:04
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 317.75kB (1.07%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
exo-esm-esm 1.08MB 24.89kB (2.36%) ⬆️
exo-esm-modules-esm 3.1MB 75.02kB (2.48%) ⬆️
exo-iife-min-Exo-iife 1.08MB 24.84kB (2.35%) ⬆️
exo-full-iife-Exo-iife 4.74MB 88.83kB (1.91%) ⬆️
exo-iife-Exo-iife 3.0MB 72.88kB (2.49%) ⬆️
exo-full-iife-min-Exo-iife 1.73MB 31.29kB (1.84%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: exo-esm-modules-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
rendering/webgpu/WebGpuBackend.js 7.32kB 88.13kB 9.05% ⚠️
rendering/webgl2/WebGl2Backend.js 6.14kB 76.28kB 8.76% ⚠️
rendering/webgpu/WebGpuMeshRenderer.js 1.17kB 75.91kB 1.57%
core/Application.js 666 bytes 66.05kB 1.02%
assets/Loader.js 661 bytes 36.8kB 1.83%
rendering/webgl2/WebGl2MeshRenderer.js 1.71kB 36.64kB 4.9%
audio/BaseVoice.js 4.92kB 22.89kB 27.41% ⚠️
index.js 1.09kB 19.83kB 5.8% ⚠️
audio/Sound.js 50 bytes 19.34kB 0.26%
audio/AudioManager.js 433 bytes 16.2kB 2.75%
core/scene/SceneAudio.js 1.93kB 16.09kB 13.64% ⚠️
rendering/webgpu/WebGpuPassCoordinator.js 170 bytes 12.19kB 1.41%
rendering/texture/Texture.js 1.38kB 10.75kB 14.74% ⚠️
rendering/mesh/Mesh.js -154 bytes 10.06kB -1.51%
rendering/texture/CompressedTextureFormat.js (New) 7.09kB 7.09kB 100.0% 🚀
assets/factories/ktx2.js (New) 6.61kB 6.61kB 100.0% 🚀
audio/AudioGenerator.js 50 bytes 6.4kB 0.79%
audio/AudioListener.js 603 bytes 6.01kB 11.16% ⚠️
audio/spatial-smoothing.js 311 bytes 5.94kB 5.53% ⚠️
audio/AudioStream.js 50 bytes 5.68kB 0.89%
assets/AssetVariants.js (New) 4.46kB 4.46kB 100.0% 🚀
audio/SpatialZones.js (New) 4.13kB 4.13kB 100.0% 🚀
rendering/MultiRenderTarget.js (New) 3.58kB 3.58kB 100.0% 🚀
audio/AudioSend.js (New) 3.35kB 3.35kB 100.0% 🚀
rendering/mesh/ImmediateMesh.js 137 bytes 3.05kB 4.7%
assets/seamless.js 108 bytes 2.98kB 3.77%
audio/AudioZone.js (New) 2.63kB 2.63kB 100.0% 🚀
rendering/webgl2/WebGl2VertexArrayObject.js 456 bytes 2.44kB 23.02% ⚠️
rendering/multiAttachmentGuard.js (New) 2.35kB 2.35kB 100.0% 🚀
audio/NoopVoice.js 571 bytes 2.29kB 33.29% ⚠️
rendering/webgpu/webgpuCompressedFormat.js (New) 2.28kB 2.28kB 100.0% 🚀
rendering/texture/compressedPayload.js (New) 2.13kB 2.13kB 100.0% 🚀
rendering/webgl2/webgl2CompressedFormat.js (New) 2.12kB 2.12kB 100.0% 🚀
audio/spatial-options.js 631 bytes 2.12kB 42.46% ⚠️
assets/factories/TextureFactory.js 1.25kB 2.1kB 147.46% ⚠️
assets/types/textureType.js 394 bytes 1.62kB 32.03% ⚠️
rendering/mesh/meshIndices.js (New) 1.38kB 1.38kB 100.0% 🚀
rendering/texture/CompressedTexture.js (New) 863 bytes 863 bytes 100.0% 🚀
view changes for bundle: exo-full-iife-min-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.full.iife.min.js 31.29kB 1.73MB 1.84%
view changes for bundle: exo-iife-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.iife.js 72.88kB 3.0MB 2.49%
view changes for bundle: exo-full-iife-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.full.iife.js 88.83kB 4.74MB 1.91%
view changes for bundle: exo-iife-min-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.iife.min.js 24.84kB 1.08MB 2.35%
view changes for bundle: exo-esm-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.esm.js 24.89kB 1.08MB 2.36%

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

@Exoridus
Exoridus merged commit f5094e9 into main Aug 28, 2026
21 checks passed
@Exoridus
Exoridus deleted the capability/group-d branch August 28, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant