diff --git a/site/src/content/api/asset-variant-profile.json b/site/src/content/api/asset-variant-profile.json new file mode 100644 index 000000000..b5f205304 --- /dev/null +++ b/site/src/content/api/asset-variant-profile.json @@ -0,0 +1,105 @@ +{ + "title": "AssetVariantProfile", + "description": "What the running device can accept, as variant rules see it. Filled from the live render backend once it is up - `textureFormats` is RenderBackend.supportedTextureFormats and `resolution` is its `rootResolution`. Before that it is the conservative empty profile, so a load started before the backend exists picks the unconditional fallback rather than a format nothing has confirmed.", + "symbol": "AssetVariantProfile", + "kind": "interface", + "subsystem": "assets", + "importPath": "@codexo/exojs", + "tier": "advanced", + "memberCount": 2, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 2, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "What the running device can accept, as variant rules see it.", + "Filled from the live render backend once it is up - `textureFormats` is RenderBackend.supportedTextureFormats and `resolution` is its `rootResolution`. Before that it is the conservative empty profile, so a load started before the backend exists picks the unconditional fallback rather than a format nothing has confirmed." + ], + "importLine": "import { AssetVariantProfile } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "resolution", + "signature": "resolution: number", + "signatureTokens": [ + { + "text": "resolution", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Device pixels per logical unit the application renders at." + }, + { + "name": "textureFormats", + "signature": "textureFormats: readonly CompressedTextureFormat[]", + "signatureTokens": [ + { + "text": "textureFormats", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Compressed texture formats the backend can sample, most preferred first. The order is the selection order, so it decides which of several supported candidates wins. Empty on a device with no compressed-format support." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/assets/AssetVariants.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/assets/AssetVariants.ts" + } + } + ], + "sourcePath": "src/assets/AssetVariants.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/assets/AssetVariants.ts" +} diff --git a/site/src/content/api/asset-variant-set.json b/site/src/content/api/asset-variant-set.json new file mode 100644 index 000000000..39acb87bd --- /dev/null +++ b/site/src/content/api/asset-variant-set.json @@ -0,0 +1,391 @@ +{ + "title": "AssetVariantSet", + "description": "Per-device selection between several files that stand for one logical asset. Without this layer a path is a path: one URL, one set of bytes, on every device. That is the wrong shape for two things a real project needs - 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, which is at load time. Selection happens before the source is canonicalized, so asset identity is keyed on the file that was actually chosen. Two devices picking different candidates therefore get different cache entries instead of one entry whose contents depend on who wrote it last. A source with no rule resolves to itself. Nothing is registered by default, so an application that never calls define pays one map lookup per load and nothing else.", + "symbol": "AssetVariantSet", + "kind": "class", + "subsystem": "assets", + "importPath": "@codexo/exojs", + "tier": "advanced", + "memberCount": 7, + "counts": { + "constructors": 1, + "methods": 5, + "properties": 1, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Per-device selection between several files that stand for one logical asset.", + "Without this layer a path is a path: one URL, one set of bytes, on every device. That is the wrong shape for two things a real project needs - 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, which is at load time.", + "Selection happens before the source is canonicalized, so asset identity is keyed on the file that was actually chosen. Two devices picking different candidates therefore get different cache entries instead of one entry whose contents depend on who wrote it last.", + "A source with no rule resolves to itself. Nothing is registered by default, so an application that never calls define pays one map lookup per load and nothing else." + ], + "importLine": "import { AssetVariantSet } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(): AssetVariantSet", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetVariantSet", + "kind": "type" + } + ], + "params": [], + "returnType": "AssetVariantSet", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "candidates", + "signature": "candidates(source: string): readonly AssetVariant[] | undefined", + "signatureTokens": [ + { + "text": "candidates", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "AssetVariant", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "undefined", + "kind": "keyword" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "readonly AssetVariant[] | undefined", + "description": "The candidates declared for source, or undefined." + }, + { + "name": "clear", + "signature": "clear(): this", + "signatureTokens": [ + { + "text": "clear", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "Forget every rule. The profile is left alone - it describes the device, not the content." + }, + { + "name": "define", + "signature": "define(source: string, variants: readonly AssetVariant[]): this", + "signatureTokens": [ + { + "text": "define", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "variants", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "AssetVariant", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + }, + { + "name": "variants", + "type": "readonly AssetVariant[]", + "optional": false + } + ], + "returnType": "this", + "description": "Declare the candidates for one logical source, replacing any previous rule for it. source is the name callers keep using; it never has to exist as a file. Order the candidates most-wanted first: it breaks ties, though a supported compressed format outranks declaration order (see resolve)." + }, + { + "name": "resolve", + "signature": "resolve(source: string): string", + "signatureTokens": [ + { + "text": "resolve", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "string", + "description": "The source a load of source should actually fetch. Among the eligible candidates the one carrying the most preferred compressed format wins, then the highest density, then the earliest declared. Format outranks density deliberately: it is what decides VRAM and transfer cost, and a project that wants density to dominate simply declares only the candidates it wants chosen. Returns source unchanged when it has no rule, and when it has one whose candidates are all ineligible." + }, + { + "name": "undefine", + "signature": "undefine(source: string): this", + "signatureTokens": [ + { + "text": "undefine", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "this", + "description": "Drop the rule for source, so it resolves to itself again." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "profile", + "signature": "profile: AssetVariantProfile", + "signatureTokens": [ + { + "text": "profile", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetVariantProfile", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Device capabilities selection is measured against. Written by the Application once the backend is initialized; assign it directly to override that - for a deterministic test, or to pin a format set a build already targets. Changing it does not re-resolve assets that are already resident: their identity was fixed by the profile in force when they were requested." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/assets/AssetVariants.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/assets/AssetVariants.ts" + } + } + ], + "sourcePath": "src/assets/AssetVariants.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/assets/AssetVariants.ts" +} diff --git a/site/src/content/api/asset-variant.json b/site/src/content/api/asset-variant.json new file mode 100644 index 000000000..8e9dc8fea --- /dev/null +++ b/site/src/content/api/asset-variant.json @@ -0,0 +1,122 @@ +{ + "title": "AssetVariant", + "description": "One candidate representation of a logical asset source. A candidate is eligible when every condition it states holds for the current AssetVariantProfile. A candidate that states none is the unconditional fallback and is always eligible - declare one, or a device that matches nothing falls back to the logical source itself.", + "symbol": "AssetVariant", + "kind": "interface", + "subsystem": "assets", + "importPath": "@codexo/exojs", + "tier": "advanced", + "memberCount": 3, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "One candidate representation of a logical asset source.", + "A candidate is eligible when every condition it states holds for the current AssetVariantProfile. A candidate that states none is the unconditional fallback and is always eligible - declare one, or a device that matches nothing falls back to the logical source itself." + ], + "importLine": "import { AssetVariant } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "resolution", + "signature": "resolution?: number", + "signatureTokens": [ + { + "text": "resolution", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Eligible only when the profile renders at this density or higher." + }, + { + "name": "source", + "signature": "source: string", + "signatureTokens": [ + { + "text": "source", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Source to load when this candidate wins. Resolved against the loader base path like any other." + }, + { + "name": "textureFormat", + "signature": "textureFormat?: CompressedTextureFormat", + "signatureTokens": [ + { + "text": "textureFormat", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Eligible only when the profile lists this format." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/assets/AssetVariants.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/assets/AssetVariants.ts" + } + } + ], + "sourcePath": "src/assets/AssetVariants.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/assets/AssetVariants.ts" +} diff --git a/site/src/content/api/audio-listener.json b/site/src/content/api/audio-listener.json index 5b633591d..d99ed2112 100644 --- a/site/src/content/api/audio-listener.json +++ b/site/src/content/api/audio-listener.json @@ -6,11 +6,11 @@ "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 6, + "memberCount": 8, "counts": { "constructors": 1, "methods": 2, - "properties": 3, + "properties": 5, "events": 0 }, "sections": [ @@ -154,6 +154,48 @@ "id": "properties", "title": "Properties", "members": [ + { + "name": "elevation", + "signature": "elevation: number", + "signatureTokens": [ + { + "text": "elevation", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Height of the observer above (positive) or below (negative) the world plane, in world units. Default 0. Not read from AudioListener.target: a scene node has no third axis, so this is the caller's to set - and it is preserved across every target tick rather than being reset to the plane each frame." + }, + { + "name": "elevationVelocity", + "signature": "elevationVelocity: number", + "signatureTokens": [ + { + "text": "elevationVelocity", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Vertical component of AudioListener.velocity, in world units per second. Default 0. Only Doppler reads it, and it is never auto-derived - nothing tracks elevation for the listener to derive it from." + }, { "name": "position", "signature": "position: Vector", diff --git a/site/src/content/api/audio-manager.json b/site/src/content/api/audio-manager.json index baace36b0..b184c8349 100644 --- a/site/src/content/api/audio-manager.json +++ b/site/src/content/api/audio-manager.json @@ -6,11 +6,11 @@ "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 23, + "memberCount": 24, "counts": { "constructors": 1, "methods": 14, - "properties": 8, + "properties": 9, "events": 0 }, "sections": [ @@ -892,6 +892,27 @@ "returnType": null, "description": "Tunable smoothing applied to per-frame panner/listener position updates, shared by the AudioListener and every spatial voice. Adjust smoothing (the setTargetAtTime time constant) or teleportThreshold (the snap-instead-of-ramp jump distance) to trade responsiveness against zipper-noise suppression (AU4). Reachable as app.audio.spatial." }, + { + "name": "zones", + "signature": "zones: SpatialZones", + "signatureTokens": [ + { + "text": "zones", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "SpatialZones", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Optional zone layer: regions of the world that contribute a parallel send while the listener is inside them - a reverb zone, a muffled corridor. Empty and inert until a zone is added; see SpatialZones." + }, { "name": "locked", "signature": "locked: boolean", diff --git a/site/src/content/api/audio-send.json b/site/src/content/api/audio-send.json new file mode 100644 index 000000000..a7923a991 --- /dev/null +++ b/site/src/content/api/audio-send.json @@ -0,0 +1,153 @@ +{ + "title": "AudioSend", + "description": "A parallel tap from one voice's output into an AudioBus, at its own level. A send is what an insert effect cannot express: the voice keeps playing into its own bus unchanged (the dry path) while a copy of the same signal also reaches another bus (the wet path). That is the shape reverb, echo and any shared ambience processing needs - one effect instance serving many voices, with each voice deciding how much of itself to contribute. Created by `voice.addSend(bus)` and owned by that voice: it is torn down when the voice ends, so a caller only has to remove one early if the routing itself should change. The target bus is not owned - it keeps serving whoever else sends into it.", + "symbol": "AudioSend", + "kind": "class", + "subsystem": "audio", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 4, + "counts": { + "constructors": 0, + "methods": 1, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A parallel tap from one voice's output into an AudioBus, at its own level.", + "A send is what an insert effect cannot express: the voice keeps playing into its own bus unchanged (the dry path) while a copy of the same signal also reaches another bus (the wet path). That is the shape reverb, echo and any shared ambience processing needs - one effect instance serving many voices, with each voice deciding how much of itself to contribute.", + "Created by `voice.addSend(bus)` and owned by that voice: it is torn down when the voice ends, so a caller only has to remove one early if the routing itself should change. The target bus is not owned - it keeps serving whoever else sends into it." + ], + "importLine": "import { AudioSend } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "destroy", + "signature": "destroy(): void", + "signatureTokens": [ + { + "text": "destroy", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "Disconnect and discard. Idempotent. The source node and the target bus are untouched - only this send's own gain node goes away." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "bus", + "signature": "bus: AudioBus", + "signatureTokens": [ + { + "text": "bus", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioBus", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "destroyed", + "signature": "destroyed: boolean", + "signatureTokens": [ + { + "text": "destroyed", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true once this send has been torn down; a torn-down send ignores further writes." + }, + { + "name": "level", + "signature": "level: number", + "signatureTokens": [ + { + "text": "level", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Contribution level, 0 and up. 1 sends the voice at its own volume; values above 1 amplify, as an aux send on a mixing desk does. Ramped rather than stepped, so a level driven per frame - by a zone the listener is walking into, say - does not click." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/audio/AudioSend.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioSend.ts" + } + } + ], + "sourcePath": "src/audio/AudioSend.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioSend.ts" +} diff --git a/site/src/content/api/audio-zone-circle.json b/site/src/content/api/audio-zone-circle.json new file mode 100644 index 000000000..9e172734c --- /dev/null +++ b/site/src/content/api/audio-zone-circle.json @@ -0,0 +1,113 @@ +{ + "title": "AudioZoneCircle", + "description": "A circular zone footprint: a centre on the world plane plus a radius.", + "symbol": "AudioZoneCircle", + "kind": "interface", + "subsystem": "audio", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 3, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A circular zone footprint: a centre on the world plane plus a radius." + ], + "importLine": "import { AudioZoneCircle } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "radius", + "signature": "radius: number", + "signatureTokens": [ + { + "text": "radius", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "x", + "signature": "x: number", + "signatureTokens": [ + { + "text": "x", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "y", + "signature": "y: number", + "signatureTokens": [ + { + "text": "y", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/audio/AudioZone.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioZone.ts" + } + } + ], + "sourcePath": "src/audio/AudioZone.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioZone.ts" +} diff --git a/site/src/content/api/audio-zone-options.json b/site/src/content/api/audio-zone-options.json new file mode 100644 index 000000000..20e6ea0fb --- /dev/null +++ b/site/src/content/api/audio-zone-options.json @@ -0,0 +1,192 @@ +{ + "title": "AudioZoneOptions", + "description": "Construction options for AudioZone.", + "symbol": "AudioZoneOptions", + "kind": "interface", + "subsystem": "audio", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 6, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 6, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Construction options for AudioZone." + ], + "importLine": "import { AudioZoneOptions } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "bus", + "signature": "bus: AudioBus", + "signatureTokens": [ + { + "text": "bus", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioBus", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Bus every voice's send is opened into while this zone is active. Not owned - the caller builds it, hangs whatever effect chain the zone represents on it, and destroys it." + }, + { + "name": "falloff", + "signature": "falloff?: number", + "signatureTokens": [ + { + "text": "falloff", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Distance outside the shape over which the weight ramps to zero. Default 0 - a hard edge." + }, + { + "name": "height", + "signature": "height?: number", + "signatureTokens": [ + { + "text": "height", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Height band the zone occupies, measured from the world plane. Default Infinity - the zone is a column." + }, + { + "name": "name", + "signature": "name?: string", + "signatureTokens": [ + { + "text": "name", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Free label for diagnostics." + }, + { + "name": "send", + "signature": "send?: number", + "signatureTokens": [ + { + "text": "send", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Send level at full weight. Default 1." + }, + { + "name": "shape", + "signature": "shape: AudioZoneShape", + "signatureTokens": [ + { + "text": "shape", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioZoneShape", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Footprint on the world plane." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/audio/AudioZone.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioZone.ts" + } + } + ], + "sourcePath": "src/audio/AudioZone.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioZone.ts" +} diff --git a/site/src/content/api/audio-zone-shape.json b/site/src/content/api/audio-zone-shape.json new file mode 100644 index 000000000..7b1f90acf --- /dev/null +++ b/site/src/content/api/audio-zone-shape.json @@ -0,0 +1,71 @@ +{ + "title": "AudioZoneShape", + "description": "Where an AudioZone applies - an axis-aligned Rectangle or a circle.", + "symbol": "AudioZoneShape", + "kind": "type", + "subsystem": "audio", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 0, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 0, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Where an AudioZone applies - an axis-aligned Rectangle or a circle." + ], + "importLine": "import { AudioZoneShape } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "definition", + "title": "Definition", + "members": [ + { + "name": "AudioZoneShape", + "signature": "AudioZoneCircle | Rectangle", + "signatureTokens": [ + { + "text": "AudioZoneCircle", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "Rectangle", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/audio/AudioZone.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioZone.ts" + } + } + ], + "sourcePath": "src/audio/AudioZone.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioZone.ts" +} diff --git a/site/src/content/api/audio-zone.json b/site/src/content/api/audio-zone.json new file mode 100644 index 000000000..8dbff96dc --- /dev/null +++ b/site/src/content/api/audio-zone.json @@ -0,0 +1,332 @@ +{ + "title": "AudioZone", + "description": "A region of the world that contributes a parallel send while the listener is inside it - a reverb zone, a muffled corridor, an underwater section. The zone owns geometry and a level, and nothing else: it does not route audio, hold effects or touch a voice. SpatialZones reads AudioZone.weightAt once per frame and maintains the sends. That split is deliberate - it means a zone is a plain value a level file can describe, and the effect chain behind it is an ordinary AudioBus the caller already knows how to build. Reverb belongs to the environment the LISTENER is in, not to each source, which is why the weight is sampled at the listener and applied to every audible voice.", + "symbol": "AudioZone", + "kind": "class", + "subsystem": "audio", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 8, + "counts": { + "constructors": 1, + "methods": 1, + "properties": 6, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A region of the world that contributes a parallel send while the listener is inside it - a reverb zone, a muffled corridor, an underwater section.", + "The zone owns geometry and a level, and nothing else: it does not route audio, hold effects or touch a voice. SpatialZones reads AudioZone.weightAt once per frame and maintains the sends. That split is deliberate - it means a zone is a plain value a level file can describe, and the effect chain behind it is an ordinary AudioBus the caller already knows how to build.", + "Reverb belongs to the environment the LISTENER is in, not to each source, which is why the weight is sampled at the listener and applied to every audible voice." + ], + "importLine": "import { AudioZone } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(__namedParameters: AudioZoneOptions): AudioZone", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "__namedParameters", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioZoneOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioZone", + "kind": "type" + } + ], + "params": [ + { + "name": "__namedParameters", + "type": "AudioZoneOptions", + "optional": false + } + ], + "returnType": "AudioZone", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "weightAt", + "signature": "weightAt(x: number, y: number, z: number): number", + "signatureTokens": [ + { + "text": "weightAt", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "z", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + }, + { + "name": "z", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "How strongly this zone applies at (x, y, z): 1 inside the shape, 0 beyond AudioZone.falloff, and a linear ramp between. The ramp is measured on the distance to the shape's boundary rather than to its centre, so a long corridor fades over the same distance at its middle as at its ends." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "bus", + "signature": "bus: AudioBus", + "signatureTokens": [ + { + "text": "bus", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioBus", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "falloff", + "signature": "falloff: number", + "signatureTokens": [ + { + "text": "falloff", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Distance outside the shape over which the weight ramps from 1 to 0." + }, + { + "name": "height", + "signature": "height: number", + "signatureTokens": [ + { + "text": "height", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Height band above and below the world plane the zone covers." + }, + { + "name": "name", + "signature": "name: string", + "signatureTokens": [ + { + "text": "name", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "send", + "signature": "send: number", + "signatureTokens": [ + { + "text": "send", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Send level at full weight." + }, + { + "name": "shape", + "signature": "shape: AudioZoneShape", + "signatureTokens": [ + { + "text": "shape", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioZoneShape", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/audio/AudioZone.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioZone.ts" + } + } + ], + "sourcePath": "src/audio/AudioZone.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/AudioZone.ts" +} diff --git a/site/src/content/api/compressed-block-layout.json b/site/src/content/api/compressed-block-layout.json new file mode 100644 index 000000000..6d7fe4f03 --- /dev/null +++ b/site/src/content/api/compressed-block-layout.json @@ -0,0 +1,114 @@ +{ + "title": "CompressedBlockLayout", + "description": "Block geometry of a compressed format: the texel footprint of one block and how many bytes that block occupies. Both backends need it for every upload - WebGL2 to size the level slice it hands `compressedTexImage2D`, WebGPU to compute a `bytesPerRow` that counts block rows rather than texel rows - and the container parser needs it to validate a level's declared byte length before trusting it.", + "symbol": "CompressedBlockLayout", + "kind": "interface", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 3, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Block geometry of a compressed format: the texel footprint of one block and how many bytes that block occupies.", + "Both backends need it for every upload - WebGL2 to size the level slice it hands `compressedTexImage2D`, WebGPU to compute a `bytesPerRow` that counts block rows rather than texel rows - and the container parser needs it to validate a level's declared byte length before trusting it." + ], + "importLine": "import { CompressedBlockLayout } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "blockHeight", + "signature": "blockHeight: number", + "signatureTokens": [ + { + "text": "blockHeight", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "blockWidth", + "signature": "blockWidth: number", + "signatureTokens": [ + { + "text": "blockWidth", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "bytesPerBlock", + "signature": "bytesPerBlock: number", + "signatureTokens": [ + { + "text": "bytesPerBlock", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/texture/CompressedTextureFormat.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/CompressedTextureFormat.ts" + } + } + ], + "sourcePath": "src/rendering/texture/CompressedTextureFormat.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/CompressedTextureFormat.ts" +} diff --git a/site/src/content/api/compressed-texture-format.json b/site/src/content/api/compressed-texture-format.json new file mode 100644 index 000000000..b22a706df --- /dev/null +++ b/site/src/content/api/compressed-texture-format.json @@ -0,0 +1,260 @@ +{ + "title": "CompressedTextureFormat", + "description": "A block-compressed GPU texture format. These are hardware formats: the GPU samples the compressed blocks directly, so a compressed texture costs a fraction of the VRAM and upload bandwidth of the same image as RGBA8 and stays compressed for its whole lifetime. No device supports all of them - desktop GPUs implement the BC family, mobile GPUs implement ETC2 and ASTC - so a project ships one file per target family and lets AssetVariantProfile pick per device rather than picking at build time. The values are the engine's own vocabulary, mapped per backend (`Bc7RgbaUnorm` becomes `bc7-rgba-unorm` on WebGPU and `COMPRESSED_RGBA_BPTC_UNORM_EXT` on WebGL2). Availability is read from RenderBackend.supportedTextureFormats.", + "symbol": "CompressedTextureFormat", + "kind": "enum", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 16, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 0, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A block-compressed GPU texture format.", + "These are hardware formats: the GPU samples the compressed blocks directly, so a compressed texture costs a fraction of the VRAM and upload bandwidth of the same image as RGBA8 and stays compressed for its whole lifetime. No device supports all of them - desktop GPUs implement the BC family, mobile GPUs implement ETC2 and ASTC - so a project ships one file per target family and lets AssetVariantProfile pick per device rather than picking at build time.", + "The values are the engine's own vocabulary, mapped per backend (`Bc7RgbaUnorm` becomes `bc7-rgba-unorm` on WebGPU and `COMPRESSED_RGBA_BPTC_UNORM_EXT` on WebGL2). Availability is read from RenderBackend.supportedTextureFormats." + ], + "importLine": "import { CompressedTextureFormat } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "members", + "title": "Members", + "members": [ + { + "name": "Astc4x4Unorm", + "signature": "Astc4x4Unorm", + "signatureTokens": [ + { + "text": "Astc4x4Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Astc5x5Unorm", + "signature": "Astc5x5Unorm", + "signatureTokens": [ + { + "text": "Astc5x5Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Astc6x6Unorm", + "signature": "Astc6x6Unorm", + "signatureTokens": [ + { + "text": "Astc6x6Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Astc8x8Unorm", + "signature": "Astc8x8Unorm", + "signatureTokens": [ + { + "text": "Astc8x8Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Bc1RgbaUnorm", + "signature": "Bc1RgbaUnorm", + "signatureTokens": [ + { + "text": "Bc1RgbaUnorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Bc2RgbaUnorm", + "signature": "Bc2RgbaUnorm", + "signatureTokens": [ + { + "text": "Bc2RgbaUnorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Bc3RgbaUnorm", + "signature": "Bc3RgbaUnorm", + "signatureTokens": [ + { + "text": "Bc3RgbaUnorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Bc4RUnorm", + "signature": "Bc4RUnorm", + "signatureTokens": [ + { + "text": "Bc4RUnorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Bc5RgUnorm", + "signature": "Bc5RgUnorm", + "signatureTokens": [ + { + "text": "Bc5RgUnorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Bc6hRgbUfloat", + "signature": "Bc6hRgbUfloat", + "signatureTokens": [ + { + "text": "Bc6hRgbUfloat", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Bc7RgbaUnorm", + "signature": "Bc7RgbaUnorm", + "signatureTokens": [ + { + "text": "Bc7RgbaUnorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "EacR11Unorm", + "signature": "EacR11Unorm", + "signatureTokens": [ + { + "text": "EacR11Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "EacRg11Unorm", + "signature": "EacRg11Unorm", + "signatureTokens": [ + { + "text": "EacRg11Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Etc2Rgb8A1Unorm", + "signature": "Etc2Rgb8A1Unorm", + "signatureTokens": [ + { + "text": "Etc2Rgb8A1Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Etc2Rgb8Unorm", + "signature": "Etc2Rgb8Unorm", + "signatureTokens": [ + { + "text": "Etc2Rgb8Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Etc2Rgba8Unorm", + "signature": "Etc2Rgba8Unorm", + "signatureTokens": [ + { + "text": "Etc2Rgba8Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/texture/CompressedTextureFormat.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/CompressedTextureFormat.ts" + } + } + ], + "sourcePath": "src/rendering/texture/CompressedTextureFormat.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/CompressedTextureFormat.ts" +} diff --git a/site/src/content/api/compressed-texture-level.json b/site/src/content/api/compressed-texture-level.json new file mode 100644 index 000000000..8f797c9e7 --- /dev/null +++ b/site/src/content/api/compressed-texture-level.json @@ -0,0 +1,114 @@ +{ + "title": "CompressedTextureLevel", + "description": "One mip level of a compressed texture payload: the block bytes exactly as the container stored them, plus the texel extent they decode to. `data` is uploaded verbatim - nothing decodes, re-packs or premultiplies it - so its length must be exactly `compressedLevelByteLength(format, width, height)`.", + "symbol": "CompressedTextureLevel", + "kind": "interface", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 3, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "One mip level of a compressed texture payload: the block bytes exactly as the container stored them, plus the texel extent they decode to.", + "`data` is uploaded verbatim - nothing decodes, re-packs or premultiplies it - so its length must be exactly `compressedLevelByteLength(format, width, height)`." + ], + "importLine": "import { CompressedTextureLevel } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "data", + "signature": "data: Uint8Array", + "signatureTokens": [ + { + "text": "data", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Uint8Array", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "height", + "signature": "height: number", + "signatureTokens": [ + { + "text": "height", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "width", + "signature": "width: number", + "signatureTokens": [ + { + "text": "width", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/texture/compressedPayload.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/compressedPayload.ts" + } + } + ], + "sourcePath": "src/rendering/texture/compressedPayload.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/compressedPayload.ts" +} diff --git a/site/src/content/api/compressed-texture-options.json b/site/src/content/api/compressed-texture-options.json new file mode 100644 index 000000000..b37bca358 --- /dev/null +++ b/site/src/content/api/compressed-texture-options.json @@ -0,0 +1,141 @@ +{ + "title": "CompressedTextureOptions", + "description": "Construction options for CompressedTexture.", + "symbol": "CompressedTextureOptions", + "kind": "interface", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 3, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Construction options for CompressedTexture." + ], + "importLine": "import { CompressedTextureOptions } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "format", + "signature": "format: CompressedTextureFormat", + "signatureTokens": [ + { + "text": "format", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "levels", + "signature": "levels: readonly CompressedTextureLevel[]", + "signatureTokens": [ + { + "text": "levels", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureLevel", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Mip chain, largest level first and at least one level long. The first level's extent is the texture's size; the chain is uploaded as given, so a partial chain stays partial." + }, + { + "name": "samplerOptions", + "signature": "samplerOptions?: Partial", + "signatureTokens": [ + { + "text": "samplerOptions", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Partial", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "SamplerOptions", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Sampling state. Upload state is not accepted - see CompressedTexturePayload." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/texture/CompressedTexture.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/CompressedTexture.ts" + } + } + ], + "sourcePath": "src/rendering/texture/CompressedTexture.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/CompressedTexture.ts" +} diff --git a/site/src/content/api/compressed-texture-payload.json b/site/src/content/api/compressed-texture-payload.json new file mode 100644 index 000000000..75bf8e369 --- /dev/null +++ b/site/src/content/api/compressed-texture-payload.json @@ -0,0 +1,109 @@ +{ + "title": "CompressedTexturePayload", + "description": "A texture payload already in a hardware block-compressed format, as carried by Texture.compressed. The saving is real in both directions: a BC7 or ASTC 4x4 image occupies a quarter of the VRAM of the same image as RGBA8 and a BC1 or ETC2 image an eighth, and it never has to be decoded on the CPU first. # Upload state does not apply `premultiplyAlpha` and `generateMipMap` are ignored for a compressed payload, and that is not a simplification: both operate on decoded texels, which is precisely what compressed blocks never become. Premultiplication has to happen in the authoring tool before compression, and a mip chain has to be compressed level by level and shipped inside the container. A payload with a single level therefore samples without mips however the sampler is configured. # Availability is per device No GPU implements every format. Binding a texture whose format the live backend does not support throws a `RenderError` with code `'unsupported-format'` rather than uploading something the driver would misread. Check RenderBackend.supportedTextureFormats, or - better - declare the alternatives as asset variants and let the loader choose.", + "symbol": "CompressedTexturePayload", + "kind": "interface", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 2, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 2, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A texture payload already in a hardware block-compressed format, as carried by Texture.compressed.", + "The saving is real in both directions: a BC7 or ASTC 4x4 image occupies a quarter of the VRAM of the same image as RGBA8 and a BC1 or ETC2 image an eighth, and it never has to be decoded on the CPU first.", + "# Upload state does not apply", + "`premultiplyAlpha` and `generateMipMap` are ignored for a compressed payload, and that is not a simplification: both operate on decoded texels, which is precisely what compressed blocks never become. Premultiplication has to happen in the authoring tool before compression, and a mip chain has to be compressed level by level and shipped inside the container. A payload with a single level therefore samples without mips however the sampler is configured.", + "# Availability is per device", + "No GPU implements every format. Binding a texture whose format the live backend does not support throws a `RenderError` with code `'unsupported-format'` rather than uploading something the driver would misread. Check RenderBackend.supportedTextureFormats, or - better - declare the alternatives as asset variants and let the loader choose." + ], + "importLine": "import { CompressedTexturePayload } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "format", + "signature": "format: CompressedTextureFormat", + "signatureTokens": [ + { + "text": "format", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "levels", + "signature": "levels: readonly CompressedTextureLevel[]", + "signatureTokens": [ + { + "text": "levels", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureLevel", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Mip chain, largest level first and at least one level long. The first level's extent is the texture's size; the chain is uploaded as given, so a partial chain stays partial." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/texture/compressedPayload.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/compressedPayload.ts" + } + } + ], + "sourcePath": "src/rendering/texture/compressedPayload.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/compressedPayload.ts" +} diff --git a/site/src/content/api/compressed-texture.json b/site/src/content/api/compressed-texture.json new file mode 100644 index 000000000..e3ba1b253 --- /dev/null +++ b/site/src/content/api/compressed-texture.json @@ -0,0 +1,1229 @@ +{ + "title": "CompressedTexture", + "description": "A Texture constructed directly from a compressed payload. Convenience only: a compressed payload is something a plain `Texture` can carry, so anything accepting a `Texture` accepts one of these, and a texture loaded from a container arrives as a plain `Texture` with Texture.compressed set. Code that needs to tell the difference reads that property rather than testing the class.", + "symbol": "CompressedTexture", + "kind": "class", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 36, + "counts": { + "constructors": 1, + "methods": 12, + "properties": 23, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A Texture constructed directly from a compressed payload.", + "Convenience only: a compressed payload is something a plain `Texture` can carry, so anything accepting a `Texture` accepts one of these, and a texture loaded from a container arrives as a plain `Texture` with Texture.compressed set. Code that needs to tell the difference reads that property rather than testing the class." + ], + "importLine": "import { CompressedTexture } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(__namedParameters: CompressedTextureOptions): CompressedTexture", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "__namedParameters", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTexture", + "kind": "type" + } + ], + "params": [ + { + "name": "__namedParameters", + "type": "CompressedTextureOptions", + "optional": false + } + ], + "returnType": "CompressedTexture", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "addDestroyListener", + "signature": "addDestroyListener(listener: () => void): this", + "signatureTokens": [ + { + "text": "addDestroyListener", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "listener", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ") => ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "listener", + "type": "() => void", + "optional": false + } + ], + "returnType": "this", + "description": "Register a callback to be invoked just before this texture is destroyed. Useful for backends to release their GPU-side texture objects." + }, + { + "name": "destroy", + "signature": "destroy(): void", + "signatureTokens": [ + { + "text": "destroy", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "" + }, + { + "name": "removeDestroyListener", + "signature": "removeDestroyListener(listener: () => void): this", + "signatureTokens": [ + { + "text": "removeDestroyListener", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "listener", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ") => ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "listener", + "type": "() => void", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setCompressed", + "signature": "setCompressed(payload: CompressedTexturePayload | null): this", + "signatureTokens": [ + { + "text": "setCompressed", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "payload", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTexturePayload", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "payload", + "type": "CompressedTexturePayload | null", + "optional": false + } + ], + "returnType": "this", + "description": "Install a compressed payload, replacing any pixel source, and resize to its base level. Pass null to drop it. Bumps version, so backends re-create their GPU texture - a format change cannot be patched into an existing one." + }, + { + "name": "setGenerateMipMap", + "signature": "setGenerateMipMap(generateMipMap: boolean): this", + "signatureTokens": [ + { + "text": "setGenerateMipMap", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "generateMipMap", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "generateMipMap", + "type": "boolean", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setPremultiplyAlpha", + "signature": "setPremultiplyAlpha(premultiplyAlpha: boolean): this", + "signatureTokens": [ + { + "text": "setPremultiplyAlpha", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "premultiplyAlpha", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "premultiplyAlpha", + "type": "boolean", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setScaleMode", + "signature": "setScaleMode(scaleMode: ScaleModes): this", + "signatureTokens": [ + { + "text": "setScaleMode", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "scaleMode", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ScaleModes", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "scaleMode", + "type": "ScaleModes", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setSize", + "signature": "setSize(width: number, height: number): this", + "signatureTokens": [ + { + "text": "setSize", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "width", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "height", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "width", + "type": "number", + "optional": false + }, + { + "name": "height", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setSource", + "signature": "setSource(source: TextureSource): this", + "signatureTokens": [ + { + "text": "setSource", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "TextureSource", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "source", + "type": "TextureSource", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setWrapMode", + "signature": "setWrapMode(wrapMode: WrapModes): this", + "signatureTokens": [ + { + "text": "setWrapMode", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "wrapMode", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "WrapModes", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "wrapMode", + "type": "WrapModes", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "updateSource", + "signature": "updateSource(): this", + "signatureTokens": [ + { + "text": "updateSource", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "Refresh the size from the current source and bump the version. Call after mutating the source's pixel data in place (e.g. drawing to a canvas) to notify backends that a re-upload is needed." + }, + { + "name": "fromColor", + "signature": "fromColor(color: Color | string, size: number): Texture", + "signatureTokens": [ + { + "text": "fromColor", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "color", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Color", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "size", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + } + ], + "params": [ + { + "name": "color", + "type": "Color | string", + "optional": false + }, + { + "name": "size", + "type": "number", + "optional": false + } + ], + "returnType": "Texture", + "description": "Create a solid-colour texture of the given square size (default 1×1). Accepts a Color instance or any CSS colour string; a Color with alpha below 1 is rendered with that alpha. Generalizes the fixed Texture.black/Texture.white helpers." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "defaultOptions", + "signature": "defaultOptions: TextureOptions", + "signatureTokens": [ + { + "text": "defaultOptions", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "TextureOptions", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "empty", + "signature": "empty: Texture", + "signatureTokens": [ + { + "text": "empty", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "compressed", + "signature": "compressed: CompressedTexturePayload | null", + "signatureTokens": [ + { + "text": "compressed", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTexturePayload", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Hardware-compressed payload this texture uploads instead of a pixel source, or null for the ordinary case. Mutually exclusive with source: installing one clears the other, so a texture is never ambiguous about what it uploads. A handle that arrives empty from the loader can become either, which is what lets an asset variant swap a PNG for a KTX2 file without changing what a caller holds." + }, + { + "name": "destroyed", + "signature": "destroyed: boolean", + "signatureTokens": [ + { + "text": "destroyed", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true once destroy has run - a destroyed texture must not be bound." + }, + { + "name": "error", + "signature": "error: Error | null", + "signatureTokens": [ + { + "text": "error", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Error", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "The error the last load failed with, or null outside 'failed'." + }, + { + "name": "flipY", + "signature": "flipY: boolean", + "signatureTokens": [ + { + "text": "flipY", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "generateMipMap", + "signature": "generateMipMap: boolean", + "signatureTokens": [ + { + "text": "generateMipMap", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "height", + "signature": "height: number", + "signatureTokens": [ + { + "text": "height", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "loaded", + "signature": "loaded: Promise", + "signatureTokens": [ + { + "text": "loaded", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Promise", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Promise that settles with this texture once its payload has loaded - resolved immediately for 'ready' textures, rejected with the load error for 'failed' ones. Re-materialized when a failed load is retried, so read it fresh from this getter rather than caching it across load cycles." + }, + { + "name": "loadState", + "signature": "loadState: LoadStateValue", + "signatureTokens": [ + { + "text": "loadState", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadStateValue", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Load lifecycle of this texture. Directly constructed textures are 'ready'; deferred handles returned by loader.get('hero.png') / loader.get(Asset.type('texture', src)) start 'loading' and become 'ready' once the payload fills in, or 'failed' (showing the Texture.missing checker) when the load errors." + }, + { + "name": "powerOfTwo", + "signature": "powerOfTwo: boolean", + "signatureTokens": [ + { + "text": "powerOfTwo", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Whether both dimensions are powers of two. Non-power-of-two textures may have limited wrap-mode support on some hardware." + }, + { + "name": "premultiplyAlpha", + "signature": "premultiplyAlpha: boolean", + "signatureTokens": [ + { + "text": "premultiplyAlpha", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "ready", + "signature": "ready: boolean", + "signatureTokens": [ + { + "text": "ready", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true exactly when state is 'ready'." + }, + { + "name": "scaleMode", + "signature": "scaleMode: ScaleModes", + "signatureTokens": [ + { + "text": "scaleMode", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ScaleModes", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "size", + "signature": "size: Size", + "signatureTokens": [ + { + "text": "size", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Size", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "source", + "signature": "source: TextureSource", + "signatureTokens": [ + { + "text": "source", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "TextureSource", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "state", + "signature": "state: LoadStateValue", + "signatureTokens": [ + { + "text": "state", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadStateValue", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Load lifecycle: 'idle' | 'loading' | 'ready' | 'failed'." + }, + { + "name": "version", + "signature": "version: number", + "signatureTokens": [ + { + "text": "version", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Monotonically increasing version counter. Incremented by any mutation that requires a GPU re-upload: a source, size, or upload-parameter change. Filter and wrap changes do not bump it - backends resolve sampling state separately, so changing it costs no upload." + }, + { + "name": "width", + "signature": "width: number", + "signatureTokens": [ + { + "text": "width", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "wrapMode", + "signature": "wrapMode: WrapModes", + "signatureTokens": [ + { + "text": "wrapMode", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "WrapModes", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "black", + "signature": "black: Texture", + "signatureTokens": [ + { + "text": "black", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "missing", + "signature": "missing: Texture", + "signatureTokens": [ + { + "text": "missing", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Shared 8×8 magenta/black checkerboard shown in place of assets that failed to load - a visible error beats an invisible hole, in production too. Lazily created; every access returns the same instance." + }, + { + "name": "white", + "signature": "white: Texture", + "signatureTokens": [ + { + "text": "white", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/texture/CompressedTexture.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/CompressedTexture.ts" + } + } + ], + "sourcePath": "src/rendering/texture/CompressedTexture.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/texture/CompressedTexture.ts" +} diff --git a/site/src/content/api/data-texture.json b/site/src/content/api/data-texture.json index b24fe83e1..c568b257a 100644 --- a/site/src/content/api/data-texture.json +++ b/site/src/content/api/data-texture.json @@ -6,11 +6,11 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 38, + "memberCount": 40, "counts": { "constructors": 1, - "methods": 13, - "properties": 24, + "methods": 14, + "properties": 25, "events": 0 }, "sections": [ @@ -408,6 +408,61 @@ "returnType": "this", "description": "" }, + { + "name": "setCompressed", + "signature": "setCompressed(payload: CompressedTexturePayload | null): this", + "signatureTokens": [ + { + "text": "setCompressed", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "payload", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTexturePayload", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "payload", + "type": "CompressedTexturePayload | null", + "optional": false + } + ], + "returnType": "this", + "description": "Install a compressed payload, replacing any pixel source, and resize to its base level. Pass null to drop it. Bumps version, so backends re-create their GPU texture - a format change cannot be patched into an existing one." + }, { "name": "setGenerateMipMap", "signature": "setGenerateMipMap(generateMipMap: boolean): this", @@ -921,6 +976,35 @@ "returnType": null, "description": "" }, + { + "name": "compressed", + "signature": "compressed: CompressedTexturePayload | null", + "signatureTokens": [ + { + "text": "compressed", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTexturePayload", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Hardware-compressed payload this texture uploads instead of a pixel source, or null for the ordinary case. Mutually exclusive with source: installing one clears the other, so a texture is never ambiguous about what it uploads. A handle that arrives empty from the loader can become either, which is what lets an asset variant swap a PNG for a KTX2 file without changing what a caller holds." + }, { "name": "destroyed", "signature": "destroyed: boolean", diff --git a/site/src/content/api/extension-kind-map.json b/site/src/content/api/extension-kind-map.json index 1c7c67683..69d6a259b 100644 --- a/site/src/content/api/extension-kind-map.json +++ b/site/src/content/api/extension-kind-map.json @@ -6,11 +6,11 @@ "subsystem": "assets", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 19, + "memberCount": 20, "counts": { "constructors": 0, "methods": 0, - "properties": 19, + "properties": 20, "events": 0 }, "sections": [ @@ -196,6 +196,27 @@ "returnType": null, "description": "" }, + { + "name": "ktx2", + "signature": "ktx2: \"texture\"", + "signatureTokens": [ + { + "text": "ktx2", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "\"texture\"", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, { "name": "m4a", "signature": "m4a: \"sound\"", diff --git a/site/src/content/api/functions.json b/site/src/content/api/functions.json index d8dfd2fda..a9583ceb4 100644 --- a/site/src/content/api/functions.json +++ b/site/src/content/api/functions.json @@ -6,11 +6,11 @@ "subsystem": "core", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 66, + "memberCount": 76, "counts": { "constructors": 0, - "methods": 36, - "properties": 30, + "methods": 44, + "properties": 32, "events": 0 }, "sections": [ @@ -252,6 +252,278 @@ "returnType": "number", "description": "Clamp value to the closed interval [min, max]." }, + { + "name": "compressedBlockLayout", + "signature": "compressedBlockLayout(format: CompressedTextureFormat): CompressedBlockLayout", + "signatureTokens": [ + { + "text": "compressedBlockLayout", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "format", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedBlockLayout", + "kind": "type" + } + ], + "params": [ + { + "name": "format", + "type": "CompressedTextureFormat", + "optional": false + } + ], + "returnType": "CompressedBlockLayout", + "description": "Block geometry of format." + }, + { + "name": "compressedBlocksAcross", + "signature": "compressedBlocksAcross(format: CompressedTextureFormat, width: number): number", + "signatureTokens": [ + { + "text": "compressedBlocksAcross", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "format", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "width", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "format", + "type": "CompressedTextureFormat", + "optional": false + }, + { + "name": "width", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Number of block columns a mip level of width texels occupies." + }, + { + "name": "compressedBlocksDown", + "signature": "compressedBlocksDown(format: CompressedTextureFormat, height: number): number", + "signatureTokens": [ + { + "text": "compressedBlocksDown", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "format", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "height", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "format", + "type": "CompressedTextureFormat", + "optional": false + }, + { + "name": "height", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Number of block rows a mip level of height texels occupies." + }, + { + "name": "compressedLevelByteLength", + "signature": "compressedLevelByteLength(format: CompressedTextureFormat, width: number, height: number): number", + "signatureTokens": [ + { + "text": "compressedLevelByteLength", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "format", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "width", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "height", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "format", + "type": "CompressedTextureFormat", + "optional": false + }, + { + "name": "width", + "type": "number", + "optional": false + }, + { + "name": "height", + "type": "number", + "optional": false + } + ], + "returnType": "number", + "description": "Exact byte length one mip level of width x height texels occupies in format. A level is padded out to whole blocks, so a 5x5 BC7 level costs four blocks, not one and a half. Container parsers compare this against the byte length the file declares: a mismatch means the file is truncated or its format was mis-identified, and uploading it would hand the driver a short buffer." + }, { "name": "createArcadeStickGamepadMapping", "signature": "createArcadeStickGamepadMapping(): GamepadMapping", @@ -1220,6 +1492,53 @@ "returnType": "boolean", "description": "Return true if the global AudioContext has been created and is currently in the running state. Safe to call before getAudioContext; returns false if no context exists yet." }, + { + "name": "isCompressedTextureFormat", + "signature": "isCompressedTextureFormat(value: string): value", + "signatureTokens": [ + { + "text": "isCompressedTextureFormat", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "value", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "value", + "kind": "type" + } + ], + "params": [ + { + "name": "value", + "type": "string", + "optional": false + } + ], + "returnType": "value", + "description": "Whether value names a format this engine knows how to upload." + }, { "name": "isDomCanvas", "signature": "isDomCanvas(surface: RenderSurface): surface", @@ -1458,6 +1777,200 @@ "returnType": "number", "description": "Linear interpolation between startValue and endValue at normalized ratio ∈ [0, 1]." }, + { + "name": "meshIndexBytes", + "signature": "meshIndexBytes(format: MeshIndexFormat): number", + "signatureTokens": [ + { + "text": "meshIndexBytes", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "format", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "MeshIndexFormat", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "format", + "type": "MeshIndexFormat", + "optional": false + } + ], + "returnType": "number", + "description": "Bytes one index occupies in format." + }, + { + "name": "meshIndexFormatFor", + "signature": "meshIndexFormatFor(indices: MeshIndexArray | null, vertexCount: number): MeshIndexFormat", + "signatureTokens": [ + { + "text": "meshIndexFormatFor", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "indices", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "MeshIndexArray", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "vertexCount", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "MeshIndexFormat", + "kind": "type" + } + ], + "params": [ + { + "name": "indices", + "type": "MeshIndexArray | null", + "optional": false + }, + { + "name": "vertexCount", + "type": "number", + "optional": false + } + ], + "returnType": "MeshIndexFormat", + "description": "The index width a mesh drawn from indices over vertexCount vertices needs. An authored stream keeps the width it was authored with - narrowing a Uint32Array that happens to fit would make the format depend on the values rather than on the declaration, so the same geometry could change width when its content changes. A non-indexed mesh has its indices synthesized, so its width follows purely from how many vertices there are to address." + }, + { + "name": "orderCompressedFormats", + "signature": "orderCompressedFormats(supported: Iterable): readonly CompressedTextureFormat[]", + "signatureTokens": [ + { + "text": "orderCompressedFormats", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "supported", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Iterable", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "supported", + "type": "Iterable", + "optional": false + } + ], + "returnType": "readonly CompressedTextureFormat[]", + "description": "supported in the engine's preference order. Backends collect what their device reports in whatever order they probe it; this puts both of them on one ranking so variant selection cannot depend on which backend is live." + }, { "name": "registerAudioWorkletProcessor", "signature": "registerAudioWorkletProcessor(audioContext: BaseAudioContext, processorName: string, source: string): Promise", @@ -2099,6 +2612,39 @@ "returnType": null, "description": "The shared miss result. Frozen and reusable, so a store that misses allocates nothing." }, + { + "name": "compressedFormatPreference", + "signature": "compressedFormatPreference: readonly CompressedTextureFormat[]", + "signatureTokens": [ + { + "text": "compressedFormatPreference", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "CompressedTextureFormat", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Order in which the engine prefers compressed formats when several are available, best first. Both backends filter this to the formats their device actually implements, so RenderBackend.supportedTextureFormats - and therefore variant selection - ranks identically on WebGL2 and WebGPU. The order runs from the highest-quality RGBA formats down to the cheapest, with the single- and two-channel formats last: they are not interchangeable with an RGBA format, so they only ever rank against each other." + }, { "name": "coreAssetTypes", "signature": "coreAssetTypes: readonly AnyAssetType[]", @@ -2362,6 +2908,27 @@ "returnType": null, "description": "Maximum number of simultaneous tracked pointers (mouse / touch / pen)." }, + { + "name": "maxUint16VertexCount", + "signature": "maxUint16VertexCount: 65536", + "signatureTokens": [ + { + "text": "maxUint16VertexCount", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "65536", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Largest vertex count a 16-bit index stream can address. A Uint16Array index holds 0..65535, so a mesh of exactly this many vertices is still fully addressable; one vertex more is not." + }, { "name": "musicType", "signature": "musicType: MusicAssetType", diff --git a/site/src/content/api/loader.json b/site/src/content/api/loader.json index cfefbc509..ab4bacfe2 100644 --- a/site/src/content/api/loader.json +++ b/site/src/content/api/loader.json @@ -6,11 +6,11 @@ "subsystem": "assets", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 37, + "memberCount": 38, "counts": { "constructors": 1, "methods": 26, - "properties": 2, + "properties": 3, "events": 8 }, "sections": [ @@ -2058,6 +2058,27 @@ "id": "properties", "title": "Properties", "members": [ + { + "name": "variants", + "signature": "variants: AssetVariantSet", + "signatureTokens": [ + { + "text": "variants", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetVariantSet", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Per-device selection between several files standing for one logical source - a texture shipped once per compressed format family, once per display density, or both. Empty by default, so a loader nobody configures resolves every source to itself. The Application publishes the device profile here once its render backend is up." + }, { "name": "basePath", "signature": "basePath: string", diff --git a/site/src/content/api/mesh-index-array.json b/site/src/content/api/mesh-index-array.json new file mode 100644 index 000000000..8d4f3735c --- /dev/null +++ b/site/src/content/api/mesh-index-array.json @@ -0,0 +1,71 @@ +{ + "title": "MeshIndexArray", + "description": "The typed-array kinds a mesh index stream can be supplied as.", + "symbol": "MeshIndexArray", + "kind": "type", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 0, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 0, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "The typed-array kinds a mesh index stream can be supplied as." + ], + "importLine": "import { MeshIndexArray } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "definition", + "title": "Definition", + "members": [ + { + "name": "MeshIndexArray", + "signature": "Uint16Array | Uint32Array", + "signatureTokens": [ + { + "text": "Uint16Array", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "Uint32Array", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/mesh/meshIndices.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/mesh/meshIndices.ts" + } + } + ], + "sourcePath": "src/rendering/mesh/meshIndices.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/mesh/meshIndices.ts" +} diff --git a/site/src/content/api/mesh-index-format.json b/site/src/content/api/mesh-index-format.json new file mode 100644 index 000000000..161fa76f3 --- /dev/null +++ b/site/src/content/api/mesh-index-format.json @@ -0,0 +1,73 @@ +{ + "title": "MeshIndexFormat", + "description": "Width of a mesh's index stream. `'uint16'` is the default and the cheaper one - half the index bytes to upload and to keep resident - and covers any mesh addressing at most 65 536 vertices. `'uint32'` exists for generated or merged geometry that legitimately exceeds that: batched tile, trail, terrain or imported SVG meshes. The values are the strings WebGPU accepts verbatim; WebGL2 maps them onto its own `UNSIGNED_SHORT`/`UNSIGNED_INT` element types.", + "symbol": "MeshIndexFormat", + "kind": "type", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 0, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 0, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Width of a mesh's index stream.", + "`'uint16'` is the default and the cheaper one - half the index bytes to upload and to keep resident - and covers any mesh addressing at most 65 536 vertices. `'uint32'` exists for generated or merged geometry that legitimately exceeds that: batched tile, trail, terrain or imported SVG meshes.", + "The values are the strings WebGPU accepts verbatim; WebGL2 maps them onto its own `UNSIGNED_SHORT`/`UNSIGNED_INT` element types." + ], + "importLine": "import { MeshIndexFormat } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "definition", + "title": "Definition", + "members": [ + { + "name": "MeshIndexFormat", + "signature": "\"uint16\" | \"uint32\"", + "signatureTokens": [ + { + "text": "\"uint16\"", + "kind": "keyword" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "\"uint32\"", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/mesh/meshIndices.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/mesh/meshIndices.ts" + } + } + ], + "sourcePath": "src/rendering/mesh/meshIndices.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/mesh/meshIndices.ts" +} diff --git a/site/src/content/api/mesh-options.json b/site/src/content/api/mesh-options.json index 04acf7c02..563c06c48 100644 --- a/site/src/content/api/mesh-options.json +++ b/site/src/content/api/mesh-options.json @@ -98,7 +98,7 @@ }, { "name": "indices", - "signature": "indices?: Uint16Array", + "signature": "indices?: MeshIndexArray", "signatureTokens": [ { "text": "indices", @@ -113,25 +113,13 @@ "kind": "punctuation" }, { - "text": "Uint16Array", + "text": "MeshIndexArray", "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "ArrayBufferLike", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" } ], "params": [], "returnType": null, - "description": "" + "description": "Triangle indices. A Uint16Array is the cheaper default; supply a Uint32Array for geometry addressing more than 65 536 vertices. The width is kept as authored - see Mesh.indexFormat." }, { "name": "material", diff --git a/site/src/content/api/mesh.json b/site/src/content/api/mesh.json index 0a487b8ea..38b4055f8 100644 --- a/site/src/content/api/mesh.json +++ b/site/src/content/api/mesh.json @@ -6,11 +6,11 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 94, + "memberCount": 95, "counts": { "constructors": 1, "methods": 36, - "properties": 43, + "properties": 44, "events": 14 }, "sections": [ @@ -2215,11 +2215,11 @@ "description": "Number of indices to draw: indices.length for indexed meshes, vertexCount otherwise." }, { - "name": "indices", - "signature": "indices: Uint16Array | null", + "name": "indexFormat", + "signature": "indexFormat: MeshIndexFormat", "signatureTokens": [ { - "text": "indices", + "text": "indexFormat", "kind": "name" }, { @@ -2227,21 +2227,30 @@ "kind": "punctuation" }, { - "text": "Uint16Array", + "text": "MeshIndexFormat", "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Width the index stream is drawn with, 'uint16' unless the mesh needs more. Derived once at construction from the authored index array - or, for a non-indexed mesh, from how many vertices its synthesized indices have to address. Both backends read this rather than re-deriving it, so a mesh can never be uploaded as one width and drawn as the other." + }, + { + "name": "indices", + "signature": "indices: MeshIndexArray | null", + "signatureTokens": [ + { + "text": "indices", + "kind": "name" }, { - "text": "<", + "text": ": ", "kind": "punctuation" }, { - "text": "ArrayBufferLike", + "text": "MeshIndexArray", "kind": "type" }, - { - "text": ">", - "kind": "punctuation" - }, { "text": " | ", "kind": "punctuation" diff --git a/site/src/content/api/multi-render-target-options.json b/site/src/content/api/multi-render-target-options.json new file mode 100644 index 000000000..c93fe3b5d --- /dev/null +++ b/site/src/content/api/multi-render-target-options.json @@ -0,0 +1,208 @@ +{ + "title": "MultiRenderTargetOptions", + "description": "Construction options for MultiRenderTarget.", + "symbol": "MultiRenderTargetOptions", + "kind": "interface", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 6, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 6, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Construction options for MultiRenderTarget." + ], + "importLine": "import { MultiRenderTargetOptions } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "flipY", + "signature": "flipY?: boolean", + "signatureTokens": [ + { + "text": "flipY", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Whether the source content is stored bottom-up. Resolved when UVs are packed rather than by flipping pixels at upload time, so it costs nothing per frame but does invalidate geometry recorded against the old value." + }, + { + "name": "formats", + "signature": "formats: readonly ColorTextureFormat[]", + "signatureTokens": [ + { + "text": "formats", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "ColorTextureFormat", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Colour format per attachment, in the order the fragment shader's outputs are declared. At least one; at most RenderBackend.maxColorAttachments on the backend that will draw into it." + }, + { + "name": "generateMipMap", + "signature": "generateMipMap?: boolean", + "signatureTokens": [ + { + "text": "generateMipMap", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Whether to generate a full mipmap chain after upload." + }, + { + "name": "premultiplyAlpha", + "signature": "premultiplyAlpha?: boolean", + "signatureTokens": [ + { + "text": "premultiplyAlpha", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Whether pixel values are premultiplied by their alpha before uploading to the GPU." + }, + { + "name": "scaleMode", + "signature": "scaleMode?: ScaleModes", + "signatureTokens": [ + { + "text": "scaleMode", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ScaleModes", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Minification and magnification filter applied when sampling the texture." + }, + { + "name": "wrapMode", + "signature": "wrapMode?: WrapModes", + "signatureTokens": [ + { + "text": "wrapMode", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "WrapModes", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Behaviour when UV coordinates exceed [0, 1]." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/MultiRenderTarget.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/MultiRenderTarget.ts" + } + } + ], + "sourcePath": "src/rendering/MultiRenderTarget.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/MultiRenderTarget.ts" +} diff --git a/site/src/content/api/multi-render-target.json b/site/src/content/api/multi-render-target.json new file mode 100644 index 000000000..7c6284737 --- /dev/null +++ b/site/src/content/api/multi-render-target.json @@ -0,0 +1,946 @@ +{ + "title": "MultiRenderTarget", + "description": "An off-screen render target with several colour attachments, written in one pass. One draw can produce more than one image: a colour pass that also writes a selection id, a normal buffer, a velocity buffer. Without it the same information costs one full pass per output, re-transforming and re-rasterizing the same geometry each time. Each attachment is an ordinary RenderTexture and is sampled like any other texture once the pass has run. They are OWNED by this target - created, resized and destroyed with it - because a mismatched attachment size is a framebuffer-completeness error on WebGL2 and a validation error on WebGPU, and that is not a failure worth handing to callers to avoid. # What can draw into one Only a Mesh with a MeshMaterial whose fragment shader declares one output per attachment. Every other renderer - sprites, text, nine-slice, repeating sprites, video - and the default mesh material declare a single output, so on WebGPU their pipelines cannot satisfy a multi-attachment pass at all; drawing one into this target throws a `RenderError` naming the reason rather than minting pipeline variants nothing writes to. Mask and backdrop-blend compositing stay single-target for the same reason. A single-attachment target is still just a RenderTexture - reach for this only when one pass genuinely has to produce two images.", + "symbol": "MultiRenderTarget", + "kind": "class", + "subsystem": "rendering", + "importPath": "@codexo/exojs", + "tier": "advanced", + "memberCount": 22, + "counts": { + "constructors": 1, + "methods": 11, + "properties": 10, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "An off-screen render target with several colour attachments, written in one pass.", + "One draw can produce more than one image: a colour pass that also writes a selection id, a normal buffer, a velocity buffer. Without it the same information costs one full pass per output, re-transforming and re-rasterizing the same geometry each time.", + "Each attachment is an ordinary RenderTexture and is sampled like any other texture once the pass has run. They are OWNED by this target - created, resized and destroyed with it - because a mismatched attachment size is a framebuffer-completeness error on WebGL2 and a validation error on WebGPU, and that is not a failure worth handing to callers to avoid.", + "# What can draw into one", + "Only a Mesh with a MeshMaterial whose fragment shader declares one output per attachment. Every other renderer - sprites, text, nine-slice, repeating sprites, video - and the default mesh material declare a single output, so on WebGPU their pipelines cannot satisfy a multi-attachment pass at all; drawing one into this target throws a `RenderError` naming the reason rather than minting pipeline variants nothing writes to. Mask and backdrop-blend compositing stay single-target for the same reason.", + "A single-attachment target is still just a RenderTexture - reach for this only when one pass genuinely has to produce two images." + ], + "importLine": "import { MultiRenderTarget } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(width: number, height: number, options: MultiRenderTargetOptions): MultiRenderTarget", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "width", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "height", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "MultiRenderTargetOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "MultiRenderTarget", + "kind": "type" + } + ], + "params": [ + { + "name": "width", + "type": "number", + "optional": false + }, + { + "name": "height", + "type": "number", + "optional": false + }, + { + "name": "options", + "type": "MultiRenderTargetOptions", + "optional": false + } + ], + "returnType": "MultiRenderTarget", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "_touch", + "signature": "_touch(): void", + "signatureTokens": [ + { + "text": "_touch", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "" + }, + { + "name": "addDestroyListener", + "signature": "addDestroyListener(listener: () => void): this", + "signatureTokens": [ + { + "text": "addDestroyListener", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "listener", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ") => ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "listener", + "type": "() => void", + "optional": false + } + ], + "returnType": "this", + "description": "Subscribe to the moment this target (or any subclass - including RenderTexture) is destroyed. Backends use this to release GPU-side resources (framebuffer + attached texture) tied to the target. Listeners fire exactly once." + }, + { + "name": "attachment", + "signature": "attachment(index: number): RenderTexture", + "signatureTokens": [ + { + "text": "attachment", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "index", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderTexture", + "kind": "type" + } + ], + "params": [ + { + "name": "index", + "type": "number", + "optional": false + } + ], + "returnType": "RenderTexture", + "description": "The attachment at index, in the order its format was declared - the same order the fragment shader's outputs are in." + }, + { + "name": "destroy", + "signature": "destroy(): void", + "signatureTokens": [ + { + "text": "destroy", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "" + }, + { + "name": "getViewport", + "signature": "getViewport(view: View): Rectangle", + "signatureTokens": [ + { + "text": "getViewport", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "view", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "View", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Rectangle", + "kind": "type" + } + ], + "params": [ + { + "name": "view", + "type": "View", + "optional": false + } + ], + "returnType": "Rectangle", + "description": "" + }, + { + "name": "mapCoordsToPixel", + "signature": "mapCoordsToPixel(point: Vector, view: View): Vector", + "signatureTokens": [ + { + "text": "mapCoordsToPixel", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "point", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "view", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "View", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + } + ], + "params": [ + { + "name": "point", + "type": "Vector", + "optional": false + }, + { + "name": "view", + "type": "View", + "optional": false + } + ], + "returnType": "Vector", + "description": "" + }, + { + "name": "mapPixelToCoords", + "signature": "mapPixelToCoords(point: Vector, view: View): Vector", + "signatureTokens": [ + { + "text": "mapPixelToCoords", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "point", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "view", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "View", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + } + ], + "params": [ + { + "name": "point", + "type": "Vector", + "optional": false + }, + { + "name": "view", + "type": "View", + "optional": false + } + ], + "returnType": "Vector", + "description": "" + }, + { + "name": "removeDestroyListener", + "signature": "removeDestroyListener(listener: () => void): this", + "signatureTokens": [ + { + "text": "removeDestroyListener", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "listener", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ") => ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "listener", + "type": "() => void", + "optional": false + } + ], + "returnType": "this", + "description": "Remove a previously registered destroy listener. No-op if absent." + }, + { + "name": "resize", + "signature": "resize(width: number, height: number): this", + "signatureTokens": [ + { + "text": "resize", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "width", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "height", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "width", + "type": "number", + "optional": false + }, + { + "name": "height", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setView", + "signature": "setView(view: View | null): this", + "signatureTokens": [ + { + "text": "setView", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "view", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "View", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "view", + "type": "View | null", + "optional": false + } + ], + "returnType": "this", + "description": "Point this target at view, or back at its own default view when passed null. The view is caller-owned - neither this call nor destroy releases it." + }, + { + "name": "updateViewport", + "signature": "updateViewport(): this", + "signatureTokens": [ + { + "text": "updateViewport", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "attachments", + "signature": "attachments: readonly RenderTexture[]", + "signatureTokens": [ + { + "text": "attachments", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "RenderTexture", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "needsStencil", + "signature": "needsStencil: boolean", + "signatureTokens": [ + { + "text": "needsStencil", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Whether this target needs a stencil attachment for geometric stencil clipping (RenderNode.clip with a Geometry clipShape). Set by the WebGL2 backend when such a clip is rendered into an offscreen RenderTexture, so its framebuffer gets a depth/stencil renderbuffer; the WebGL2 on-screen root uses the default framebuffer's stencil (requested at context creation), so the flag only affects offscreen WebGL2 targets. The WebGPU backend does not consult this flag: it allocates a separate depth24plus-stencil8 attachment per clipped target (root included) on demand, sized to the colour attachment's physical pixels." + }, + { + "name": "destroyed", + "signature": "destroyed: boolean", + "signatureTokens": [ + { + "text": "destroyed", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true once destroy has run. A destroyed target must not be rendered into - the backend throws rather than drawing into released GPU state." + }, + { + "name": "formats", + "signature": "formats: readonly ColorTextureFormat[]", + "signatureTokens": [ + { + "text": "formats", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "ColorTextureFormat", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Colour format of each attachment, in declaration order." + }, + { + "name": "height", + "signature": "height: number", + "signatureTokens": [ + { + "text": "height", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "root", + "signature": "root: boolean", + "signatureTokens": [ + { + "text": "root", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "size", + "signature": "size: Size", + "signatureTokens": [ + { + "text": "size", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Size", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "version", + "signature": "version: number", + "signatureTokens": [ + { + "text": "version", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "view", + "signature": "view: View", + "signatureTokens": [ + { + "text": "view", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "View", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "width", + "signature": "width: number", + "signatureTokens": [ + { + "text": "width", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/rendering/MultiRenderTarget.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/MultiRenderTarget.ts" + } + } + ], + "sourcePath": "src/rendering/MultiRenderTarget.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/MultiRenderTarget.ts" +} diff --git a/site/src/content/api/play-options.json b/site/src/content/api/play-options.json index fc4792fd2..c533e920f 100644 --- a/site/src/content/api/play-options.json +++ b/site/src/content/api/play-options.json @@ -6,11 +6,11 @@ "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 18, + "memberCount": 22, "counts": { "constructors": 0, "methods": 0, - "properties": 18, + "properties": 22, "events": 0 }, "sections": [ @@ -178,6 +178,56 @@ "returnType": null, "description": "Initial distance-attenuation model. Default 'linear'." }, + { + "name": "elevation", + "signature": "elevation?: number", + "signatureTokens": [ + { + "text": "elevation", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Initial height above the world plane. Default 0." + }, + { + "name": "elevationVelocity", + "signature": "elevationVelocity?: number", + "signatureTokens": [ + { + "text": "elevationVelocity", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Initial vertical velocity for Doppler. Default 0." + }, { "name": "loop", "signature": "loop?: boolean", @@ -253,6 +303,31 @@ "returnType": null, "description": "Start muted (volume 0)." }, + { + "name": "occlusion", + "signature": "occlusion?: number", + "signatureTokens": [ + { + "text": "occlusion", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Initial occlusion amount in [0, 1]. Default 0 (clear path)." + }, { "name": "orientation", "signature": "orientation?: number", @@ -330,7 +405,7 @@ }, { "name": "position", - "signature": "position?: Vector | { x: number; y: number }", + "signature": "position?: SpatialPoint | Vector", "signatureTokens": [ { "text": "position", @@ -345,7 +420,7 @@ "kind": "punctuation" }, { - "text": "Vector", + "text": "SpatialPoint", "kind": "type" }, { @@ -353,40 +428,8 @@ "kind": "punctuation" }, { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "x", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "number", - "kind": "keyword" - }, - { - "text": "; ", - "kind": "punctuation" - }, - { - "text": "y", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "number", - "kind": "keyword" - }, - { - "text": " }", - "kind": "punctuation" + "text": "Vector", + "kind": "type" } ], "params": [], @@ -444,11 +487,11 @@ "description": "Initial rolloff factor. Default 1." }, { - "name": "time", - "signature": "time?: number", + "name": "sends", + "signature": "sends?: readonly { bus: AudioBus; level?: number }[]", "signatureTokens": [ { - "text": "time", + "text": "sends", "kind": "name" }, { @@ -460,45 +503,40 @@ "kind": "punctuation" }, { - "text": "number", + "text": "readonly", "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "Seek offset in seconds before starting playback." - }, - { - "name": "velocity", - "signature": "velocity?: Vector | { x: number; y: number }", - "signatureTokens": [ + }, { - "text": "velocity", - "kind": "name" + "text": " ", + "kind": "punctuation" }, { - "text": "?", + "text": "{ ", "kind": "punctuation" }, + { + "text": "bus", + "kind": "name" + }, { "text": ": ", "kind": "punctuation" }, { - "text": "Vector", + "text": "AudioBus", "kind": "type" }, { - "text": " | ", + "text": "; ", "kind": "punctuation" }, { - "text": "{ ", - "kind": "punctuation" + "text": "level", + "kind": "name" }, { - "text": "x", - "kind": "name" + "text": "?", + "kind": "punctuation" }, { "text": ": ", @@ -509,13 +547,30 @@ "kind": "keyword" }, { - "text": "; ", + "text": " }", "kind": "punctuation" }, { - "text": "y", + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Parallel sends to open on the voice right after play - one per bus." + }, + { + "name": "time", + "signature": "time?: number", + "signatureTokens": [ + { + "text": "time", "kind": "name" }, + { + "text": "?", + "kind": "punctuation" + }, { "text": ": ", "kind": "punctuation" @@ -523,10 +578,39 @@ { "text": "number", "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Seek offset in seconds before starting playback." + }, + { + "name": "velocity", + "signature": "velocity?: SpatialPoint | Vector", + "signatureTokens": [ + { + "text": "velocity", + "kind": "name" }, { - "text": " }", + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", "kind": "punctuation" + }, + { + "text": "SpatialPoint", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" } ], "params": [], diff --git a/site/src/content/api/render-error-code.json b/site/src/content/api/render-error-code.json index 4b8f769b9..bca99bf7f 100644 --- a/site/src/content/api/render-error-code.json +++ b/site/src/content/api/render-error-code.json @@ -30,7 +30,7 @@ "members": [ { "name": "RenderErrorCode", - "signature": "\"device-recovery-failed\" | \"internal\" | \"out-of-memory\" | \"pipeline-creation\" | \"shader-compile\" | \"shader-link\" | \"validation\"", + "signature": "\"device-recovery-failed\" | \"internal\" | \"out-of-memory\" | \"pipeline-creation\" | \"shader-compile\" | \"shader-link\" | \"unsupported-format\" | \"validation\"", "signatureTokens": [ { "text": "\"device-recovery-failed\"", @@ -80,6 +80,14 @@ "text": " | ", "kind": "punctuation" }, + { + "text": "\"unsupported-format\"", + "kind": "keyword" + }, + { + "text": " | ", + "kind": "punctuation" + }, { "text": "\"validation\"", "kind": "keyword" diff --git a/site/src/content/api/render-to-options.json b/site/src/content/api/render-to-options.json index 037125dbe..1c11b73cc 100644 --- a/site/src/content/api/render-to-options.json +++ b/site/src/content/api/render-to-options.json @@ -1,6 +1,6 @@ { "title": "RenderToOptions", - "description": "Options for DrawContext.renderTo: a caller-owned, per-frame off-screen RenderTexture target, reused across frames (no per-call allocation). Unlike RenderingContext.capture, the texture is supplied by the caller.", + "description": "Options for DrawContext.renderTo: a caller-owned, per-frame off-screen target, reused across frames (no per-call allocation). Unlike RenderingContext.capture, the target is supplied by the caller.", "symbol": "RenderToOptions", "kind": "interface", "subsystem": "rendering", @@ -19,7 +19,7 @@ "title": "Import", "members": [], "paragraphs": [ - "Options for DrawContext.renderTo: a caller-owned, per-frame off-screen RenderTexture target, reused across frames (no per-call allocation). Unlike RenderingContext.capture, the texture is supplied by the caller." + "Options for DrawContext.renderTo: a caller-owned, per-frame off-screen target, reused across frames (no per-call allocation). Unlike RenderingContext.capture, the target is supplied by the caller." ], "importLine": "import { RenderToOptions } from '@codexo/exojs'", "sourceLink": null @@ -55,7 +55,7 @@ }, { "name": "target", - "signature": "target: RenderTexture", + "signature": "target: RenderTarget | RenderTexture", "signatureTokens": [ { "text": "target", @@ -65,6 +65,14 @@ "text": ": ", "kind": "punctuation" }, + { + "text": "RenderTarget", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, { "text": "RenderTexture", "kind": "type" @@ -72,7 +80,7 @@ ], "params": [], "returnType": null, - "description": "Destination texture, owned and kept stable by the caller." + "description": "Destination, owned and kept stable by the caller: a RenderTexture, or a MultiRenderTarget when one pass has to fill several colour attachments." }, { "name": "view", diff --git a/site/src/content/api/sound-play-options.json b/site/src/content/api/sound-play-options.json index 7ce6072f1..44f8c96d9 100644 --- a/site/src/content/api/sound-play-options.json +++ b/site/src/content/api/sound-play-options.json @@ -6,11 +6,11 @@ "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 19, + "memberCount": 23, "counts": { "constructors": 0, "methods": 0, - "properties": 19, + "properties": 23, "events": 0 }, "sections": [ @@ -178,6 +178,56 @@ "returnType": null, "description": "Initial distance-attenuation model. Default 'linear'." }, + { + "name": "elevation", + "signature": "elevation?: number", + "signatureTokens": [ + { + "text": "elevation", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Initial height above the world plane. Default 0." + }, + { + "name": "elevationVelocity", + "signature": "elevationVelocity?: number", + "signatureTokens": [ + { + "text": "elevationVelocity", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Initial vertical velocity for Doppler. Default 0." + }, { "name": "loop", "signature": "loop?: boolean", @@ -253,6 +303,31 @@ "returnType": null, "description": "Start muted (volume 0)." }, + { + "name": "occlusion", + "signature": "occlusion?: number", + "signatureTokens": [ + { + "text": "occlusion", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Initial occlusion amount in [0, 1]. Default 0 (clear path)." + }, { "name": "orientation", "signature": "orientation?: number", @@ -330,7 +405,7 @@ }, { "name": "position", - "signature": "position?: Vector | { x: number; y: number }", + "signature": "position?: SpatialPoint | Vector", "signatureTokens": [ { "text": "position", @@ -345,7 +420,7 @@ "kind": "punctuation" }, { - "text": "Vector", + "text": "SpatialPoint", "kind": "type" }, { @@ -353,40 +428,8 @@ "kind": "punctuation" }, { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "x", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "number", - "kind": "keyword" - }, - { - "text": "; ", - "kind": "punctuation" - }, - { - "text": "y", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "number", - "kind": "keyword" - }, - { - "text": " }", - "kind": "punctuation" + "text": "Vector", + "kind": "type" } ], "params": [], @@ -469,11 +512,11 @@ "description": "Initial rolloff factor. Default 1." }, { - "name": "time", - "signature": "time?: number", + "name": "sends", + "signature": "sends?: readonly { bus: AudioBus; level?: number }[]", "signatureTokens": [ { - "text": "time", + "text": "sends", "kind": "name" }, { @@ -485,45 +528,40 @@ "kind": "punctuation" }, { - "text": "number", + "text": "readonly", "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "Seek offset in seconds before starting playback." - }, - { - "name": "velocity", - "signature": "velocity?: Vector | { x: number; y: number }", - "signatureTokens": [ + }, { - "text": "velocity", - "kind": "name" + "text": " ", + "kind": "punctuation" }, { - "text": "?", + "text": "{ ", "kind": "punctuation" }, + { + "text": "bus", + "kind": "name" + }, { "text": ": ", "kind": "punctuation" }, { - "text": "Vector", + "text": "AudioBus", "kind": "type" }, { - "text": " | ", + "text": "; ", "kind": "punctuation" }, { - "text": "{ ", - "kind": "punctuation" + "text": "level", + "kind": "name" }, { - "text": "x", - "kind": "name" + "text": "?", + "kind": "punctuation" }, { "text": ": ", @@ -534,13 +572,30 @@ "kind": "keyword" }, { - "text": "; ", + "text": " }", "kind": "punctuation" }, { - "text": "y", + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Parallel sends to open on the voice right after play - one per bus." + }, + { + "name": "time", + "signature": "time?: number", + "signatureTokens": [ + { + "text": "time", "kind": "name" }, + { + "text": "?", + "kind": "punctuation" + }, { "text": ": ", "kind": "punctuation" @@ -548,10 +603,39 @@ { "text": "number", "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Seek offset in seconds before starting playback." + }, + { + "name": "velocity", + "signature": "velocity?: SpatialPoint | Vector", + "signatureTokens": [ + { + "text": "velocity", + "kind": "name" }, { - "text": " }", + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", "kind": "punctuation" + }, + { + "text": "SpatialPoint", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" } ], "params": [], diff --git a/site/src/content/api/spatial-point.json b/site/src/content/api/spatial-point.json new file mode 100644 index 000000000..debca11ed --- /dev/null +++ b/site/src/content/api/spatial-point.json @@ -0,0 +1,118 @@ +{ + "title": "SpatialPoint", + "description": "A point in the audio world: the 2D world plane, plus an optional out-of-plane height in the same units. `z` is optional everywhere it appears - a 2D game never supplies it, and a scene node cannot, because the scene graph has no third axis.", + "symbol": "SpatialPoint", + "kind": "interface", + "subsystem": "audio", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 3, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A point in the audio world: the 2D world plane, plus an optional out-of-plane height in the same units.", + "`z` is optional everywhere it appears - a 2D game never supplies it, and a scene node cannot, because the scene graph has no third axis." + ], + "importLine": "import { SpatialPoint } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "x", + "signature": "x: number", + "signatureTokens": [ + { + "text": "x", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "y", + "signature": "y: number", + "signatureTokens": [ + { + "text": "y", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "z", + "signature": "z?: number", + "signatureTokens": [ + { + "text": "z", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/audio/Playable.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/Playable.ts" + } + } + ], + "sourcePath": "src/audio/Playable.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/Playable.ts" +} diff --git a/site/src/content/api/spatial-smoothing-settings.json b/site/src/content/api/spatial-smoothing-settings.json index 132185d0d..a1149b2e8 100644 --- a/site/src/content/api/spatial-smoothing-settings.json +++ b/site/src/content/api/spatial-smoothing-settings.json @@ -6,11 +6,11 @@ "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 5, + "memberCount": 7, "counts": { "constructors": 0, "methods": 0, - "properties": 5, + "properties": 7, "events": 0 }, "sections": [ @@ -49,6 +49,48 @@ "returnType": null, "description": "Doppler pitch-shift strength multiplier. 0 (default) disables Doppler entirely - even when velocity data is available on a voice or the listener, no playbackRate modulation is applied unless this is set above zero. 1 is physically scaled (relative to speedOfSound); many games deliberately exaggerate beyond 1 for player feedback." }, + { + "name": "occlusionAttenuation", + "signature": "occlusionAttenuation: number", + "signatureTokens": [ + { + "text": "occlusionAttenuation", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Linear gain a voice at Spatializable.occlusion 1 is attenuated to. Default DEFAULT_OCCLUSION_ATTENUATION. Not 0: a fully occluded source that goes silent reads as a bug rather than as an obstruction." + }, + { + "name": "occlusionCutoff", + "signature": "occlusionCutoff: number", + "signatureTokens": [ + { + "text": "occlusionCutoff", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Lowpass cutoff (Hz) a voice at Spatializable.occlusion 1 is filtered to. Default DEFAULT_OCCLUSION_CUTOFF (400 Hz) - roughly \"behind a closed door\". Interpolation between clear and fully occluded is logarithmic, matching how pitch is perceived." + }, { "name": "panningModel", "signature": "panningModel: PanningModelType", diff --git a/site/src/content/api/spatial-zones.json b/site/src/content/api/spatial-zones.json new file mode 100644 index 000000000..5d1ec7c3e --- /dev/null +++ b/site/src/content/api/spatial-zones.json @@ -0,0 +1,279 @@ +{ + "title": "SpatialZones", + "description": "The optional zone layer: which AudioZones exist, and the sends they currently hold open. Owned by AudioManager and reachable as `app.audio.zones`. Completely inert until a zone is added - the per-frame tick returns immediately, so an application that never uses zones pays one branch. Once a zone exists, each frame: 1. every zone's weight is sampled at the LISTENER's position, because that is where an environment is heard from; 2. every audible voice gets one send per zone whose weight is above zero, at `weight * zone.send`; 3. a send whose zone has faded out, or whose voice has ended, is dropped. Sends are opened lazily and reused across frames, so walking into a zone costs one `GainNode` per voice and then nothing per frame but a level write - and the level is ramped, so the boundary is a crossfade rather than a switch. The zone layer never owns a bus or an effect. A zone names a bus the caller built; what that bus does - a convolution reverb, a lowpass, a pitch shift - is entirely the caller's, and two zones may legitimately name the same one.", + "symbol": "SpatialZones", + "kind": "class", + "subsystem": "audio", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 6, + "counts": { + "constructors": 1, + "methods": 3, + "properties": 2, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "The optional zone layer: which AudioZones exist, and the sends they currently hold open.", + "Owned by AudioManager and reachable as `app.audio.zones`. Completely inert until a zone is added - the per-frame tick returns immediately, so an application that never uses zones pays one branch.", + "Once a zone exists, each frame:", + "1. every zone's weight is sampled at the LISTENER's position, because that is where an environment is heard from; 2. every audible voice gets one send per zone whose weight is above zero, at `weight * zone.send`; 3. a send whose zone has faded out, or whose voice has ended, is dropped.", + "Sends are opened lazily and reused across frames, so walking into a zone costs one `GainNode` per voice and then nothing per frame but a level write - and the level is ramped, so the boundary is a crossfade rather than a switch.", + "The zone layer never owns a bus or an effect. A zone names a bus the caller built; what that bus does - a convolution reverb, a lowpass, a pitch shift - is entirely the caller's, and two zones may legitimately name the same one." + ], + "importLine": "import { SpatialZones } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(): SpatialZones", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "SpatialZones", + "kind": "type" + } + ], + "params": [], + "returnType": "SpatialZones", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "add", + "signature": "add(zone: AudioZone): this", + "signatureTokens": [ + { + "text": "add", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "zone", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioZone", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "zone", + "type": "AudioZone", + "optional": false + } + ], + "returnType": "this", + "description": "Register zone. Adding the same zone twice is a no-op." + }, + { + "name": "clear", + "signature": "clear(): this", + "signatureTokens": [ + { + "text": "clear", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "Unregister every zone and close every send." + }, + { + "name": "remove", + "signature": "remove(zone: AudioZone): this", + "signatureTokens": [ + { + "text": "remove", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "zone", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioZone", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "zone", + "type": "AudioZone", + "optional": false + } + ], + "returnType": "this", + "description": "Unregister zone and close every send it holds open. The zone's bus is untouched: it belongs to the caller, who may still be using it for something else." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "active", + "signature": "active: boolean", + "signatureTokens": [ + { + "text": "active", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Whether any zone is registered. false means the per-frame tick does nothing at all." + }, + { + "name": "zones", + "signature": "zones: readonly AudioZone[]", + "signatureTokens": [ + { + "text": "zones", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "AudioZone", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "The zones currently registered, in the order they were added." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/audio/SpatialZones.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/SpatialZones.ts" + } + } + ], + "sourcePath": "src/audio/SpatialZones.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/audio/SpatialZones.ts" +} diff --git a/site/src/content/api/spatializable.json b/site/src/content/api/spatializable.json index ee0a32cc0..2bae3e57a 100644 --- a/site/src/content/api/spatializable.json +++ b/site/src/content/api/spatializable.json @@ -1,16 +1,16 @@ { "title": "Spatializable", - "description": "A voice that can be positioned in 2D space and optionally track a node.", + "description": "A voice that can be positioned in space and optionally track a node.", "symbol": "Spatializable", "kind": "interface", "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 12, + "memberCount": 15, "counts": { "constructors": 0, "methods": 1, - "properties": 11, + "properties": 14, "events": 0 }, "sections": [ @@ -19,7 +19,7 @@ "title": "Import", "members": [], "paragraphs": [ - "A voice that can be positioned in 2D space and optionally track a node." + "A voice that can be positioned in space and optionally track a node." ], "importLine": "import { Spatializable } from '@codexo/exojs'", "sourceLink": null @@ -176,6 +176,48 @@ "returnType": null, "description": "Distance-attenuation model. Default 'linear'." }, + { + "name": "elevation", + "signature": "elevation: number", + "signatureTokens": [ + { + "text": "elevation", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Height of the source above (positive) or below (negative) the world plane, in world units. Default 0. Independent of Spatializable.position, and preserved across a position change that does not carry a z. It contributes to distance attenuation, to the panner's own directionality, and to Doppler - a source rising straight up recedes." + }, + { + "name": "elevationVelocity", + "signature": "elevationVelocity: number", + "signatureTokens": [ + { + "text": "elevationVelocity", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Vertical component of Spatializable.velocity, in world units per second. Default 0. Only Doppler reads it." + }, { "name": "maxDistance", "signature": "maxDistance: number", @@ -197,6 +239,27 @@ "returnType": null, "description": "For the 'linear' model: distance at which volume reaches zero. Default 1000." }, + { + "name": "occlusion", + "signature": "occlusion: number", + "signatureTokens": [ + { + "text": "occlusion", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "How obstructed the path from this source to the listener is, in [0, 1]. 0 (default) is a clear path; 1 is fully obstructed. Caller-supplied: the engine does not trace geometry, because what counts as an obstruction is a game's decision (a wall, a closed door, a crowd). Write an estimate as often as you like - it is ramped, not stepped, so a per-frame value does not click. Realized as a lowpass plus an attenuation, tuned by app.audio.spatial.occlusionCutoff / .occlusionAttenuation. A voice whose occlusion stays 0 builds neither node." + }, { "name": "orientation", "signature": "orientation: number", @@ -316,7 +379,7 @@ ], "params": [], "returnType": null, - "description": "World-space position of the source, or null when not spatialized." + "description": "World-plane position of the source, or null when not spatialized. Two-dimensional, because the world plane is: the third axis lives on Spatializable.elevation, which Spatializable.follow cannot fill in and which most 2D games never touch." }, { "name": "velocity", diff --git a/site/src/content/api/texture-asset-options.json b/site/src/content/api/texture-asset-options.json index 8d48de637..bf829002f 100644 --- a/site/src/content/api/texture-asset-options.json +++ b/site/src/content/api/texture-asset-options.json @@ -51,7 +51,7 @@ ], "params": [], "returnType": null, - "description": "MIME type for the intermediate blob. Inferred from the magic bytes when omitted." + "description": "MIME type for the intermediate blob. Inferred from the magic bytes when omitted. Ignored for a KTX2 payload." }, { "name": "textureOptions", @@ -88,7 +88,7 @@ ], "params": [], "returnType": null, - "description": "Sampling and upload state forwarded to the Texture constructor; any subset." + "description": "Sampling and upload state forwarded to the Texture constructor; any subset. A KTX2 payload in a hardware format takes the sampling half only - premultiplication and mip generation cannot apply to compressed blocks." } ], "paragraphs": [], diff --git a/site/src/content/api/texture-asset-type.json b/site/src/content/api/texture-asset-type.json index e626ceb02..6d5608637 100644 --- a/site/src/content/api/texture-asset-type.json +++ b/site/src/content/api/texture-asset-type.json @@ -1,6 +1,6 @@ { "title": "TextureAssetType", - "description": "GPU-ready Textures decoded from PNG, JPG, WebP, AVIF and GIF bytes.", + "description": "GPU-ready Textures decoded from PNG, JPG, WebP, AVIF and GIF bytes, or from a KTX2 container holding a hardware-compressed payload. One type covers both because the payload kind is a property of the bytes, not of the asset: an AssetVariantSet rule may resolve one logical source to a compressed container where the device supports the format and to an image elsewhere, and a caller holding the handle sees a `Texture` either way.", "symbol": "TextureAssetType", "kind": "class", "subsystem": "assets", @@ -19,7 +19,8 @@ "title": "Import", "members": [], "paragraphs": [ - "GPU-ready Textures decoded from PNG, JPG, WebP, AVIF and GIF bytes." + "GPU-ready Textures decoded from PNG, JPG, WebP, AVIF and GIF bytes, or from a KTX2 container holding a hardware-compressed payload.", + "One type covers both because the payload kind is a property of the bytes, not of the asset: an AssetVariantSet rule may resolve one logical source to a compressed container where the device supports the format and to an image elsewhere, and a caller holding the handle sees a `Texture` either way." ], "importLine": "import { TextureAssetType } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/texture.json b/site/src/content/api/texture.json index be1bd0d5e..e6ae077ef 100644 --- a/site/src/content/api/texture.json +++ b/site/src/content/api/texture.json @@ -6,11 +6,11 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 34, + "memberCount": 36, "counts": { "constructors": 1, - "methods": 11, - "properties": 22, + "methods": 12, + "properties": 23, "events": 0 }, "sections": [ @@ -262,6 +262,61 @@ "returnType": "this", "description": "" }, + { + "name": "setCompressed", + "signature": "setCompressed(payload: CompressedTexturePayload | null): this", + "signatureTokens": [ + { + "text": "setCompressed", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "payload", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTexturePayload", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "payload", + "type": "CompressedTexturePayload | null", + "optional": false + } + ], + "returnType": "this", + "description": "Install a compressed payload, replacing any pixel source, and resize to its base level. Pass null to drop it. Bumps version, so backends re-create their GPU texture - a format change cannot be patched into an existing one." + }, { "name": "setGenerateMipMap", "signature": "setGenerateMipMap(generateMipMap: boolean): this", @@ -721,6 +776,35 @@ "returnType": null, "description": "" }, + { + "name": "compressed", + "signature": "compressed: CompressedTexturePayload | null", + "signatureTokens": [ + { + "text": "compressed", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CompressedTexturePayload", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Hardware-compressed payload this texture uploads instead of a pixel source, or null for the ordinary case. Mutually exclusive with source: installing one clears the other, so a texture is never ambiguous about what it uploads. A handle that arrives empty from the loader can become either, which is what lets an asset variant swap a PNG for a KTX2 file without changing what a caller holds." + }, { "name": "destroyed", "signature": "destroyed: boolean", diff --git a/site/src/content/api/voice.json b/site/src/content/api/voice.json index a0a16d28a..41c2a6878 100644 --- a/site/src/content/api/voice.json +++ b/site/src/content/api/voice.json @@ -6,11 +6,11 @@ "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 21, + "memberCount": 27, "counts": { "constructors": 0, - "methods": 5, - "properties": 16, + "methods": 7, + "properties": 20, "events": 0 }, "sections": [ @@ -78,6 +78,78 @@ "returnType": "this", "description": "Insert a per-voice AudioEffect into this voice's output chain (after the volume gain, before the bus). Effects are applied in insertion order." }, + { + "name": "addSend", + "signature": "addSend(bus: AudioBus, level?: number): AudioSend", + "signatureTokens": [ + { + "text": "addSend", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "bus", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioBus", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "level", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioSend", + "kind": "type" + } + ], + "params": [ + { + "name": "bus", + "type": "AudioBus", + "optional": false + }, + { + "name": "level", + "type": "number", + "optional": true + } + ], + "returnType": "AudioSend", + "description": "Open a parallel send from this voice's output into bus at level (default 1). The dry path is untouched: the voice keeps playing into its own Voice.bus, and a copy of the same signal additionally reaches bus. Use it for shared ambience processing - one reverb serving many voices - which an insert effect cannot express, because an insert replaces the signal rather than duplicating it. The returned AudioSend is owned by this voice and torn down with it; remove one early with Voice.removeSend only to change the routing." + }, { "name": "fade", "signature": "fade(to: number, ms: number): void", @@ -248,6 +320,53 @@ "returnType": "this", "description": "Remove a previously added per-voice effect. The caller still owns it and must destroy() it." }, + { + "name": "removeSend", + "signature": "removeSend(send: AudioSend): this", + "signatureTokens": [ + { + "text": "removeSend", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "send", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AudioSend", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "send", + "type": "AudioSend", + "optional": false + } + ], + "returnType": "this", + "description": "Tear down a send opened on this voice. Idempotent; a send from another voice is ignored." + }, { "name": "stop", "signature": "stop(fadeMs?: number): void", @@ -413,6 +532,48 @@ "returnType": null, "description": "Distance-attenuation model. Default 'linear'." }, + { + "name": "elevation", + "signature": "elevation: number", + "signatureTokens": [ + { + "text": "elevation", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Height of the source above (positive) or below (negative) the world plane, in world units. Default 0. Independent of Spatializable.position, and preserved across a position change that does not carry a z. It contributes to distance attenuation, to the panner's own directionality, and to Doppler - a source rising straight up recedes." + }, + { + "name": "elevationVelocity", + "signature": "elevationVelocity: number", + "signatureTokens": [ + { + "text": "elevationVelocity", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Vertical component of Spatializable.velocity, in world units per second. Default 0. Only Doppler reads it." + }, { "name": "ended", "signature": "ended: boolean", @@ -455,6 +616,27 @@ "returnType": null, "description": "For the 'linear' model: distance at which volume reaches zero. Default 1000." }, + { + "name": "occlusion", + "signature": "occlusion: number", + "signatureTokens": [ + { + "text": "occlusion", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "How obstructed the path from this source to the listener is, in [0, 1]. 0 (default) is a clear path; 1 is fully obstructed. Caller-supplied: the engine does not trace geometry, because what counts as an obstruction is a game's decision (a wall, a closed door, a crowd). Write an estimate as often as you like - it is ramped, not stepped, so a per-frame value does not click. Realized as a lowpass plus an attenuation, tuned by app.audio.spatial.occlusionCutoff / .occlusionAttenuation. A voice whose occlusion stays 0 builds neither node." + }, { "name": "onEnd", "signature": "onEnd: Signal", @@ -589,6 +771,39 @@ "returnType": null, "description": "Falloff rate. Higher = steeper attenuation. Default 1." }, + { + "name": "sends", + "signature": "sends: readonly AudioSend[]", + "signatureTokens": [ + { + "text": "sends", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "AudioSend", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Live view of this voice's open sends, in the order they were opened." + }, { "name": "volume", "signature": "volume: number", @@ -637,7 +852,7 @@ ], "params": [], "returnType": null, - "description": "World-space position of the source, or null when not spatialized." + "description": "World-plane position of the source, or null when not spatialized. Two-dimensional, because the world plane is: the third axis lives on Spatializable.elevation, which Spatializable.follow cannot fill in and which most 2D games never touch." }, { "name": "velocity", diff --git a/site/src/content/guide/assets/device-variants.mdx b/site/src/content/guide/assets/device-variants.mdx new file mode 100644 index 000000000..69f53327d --- /dev/null +++ b/site/src/content/guide/assets/device-variants.mdx @@ -0,0 +1,111 @@ +--- +title: 'Compressed textures and device variants' +description: 'Ship one texture per GPU family and display density, and let the loader pick what the running device can actually use.' +--- + +import Callout from '../../../components/Callout.astro'; + +# Compressed textures and device variants + +A PNG is decoded on the CPU and lands in VRAM as RGBA8: four bytes per pixel, whatever the image looked like. A block-compressed texture is handed to the GPU untouched and stays compressed for its whole lifetime - a quarter of that for BC7 or ASTC 4x4, an eighth for BC1 or ETC2. + +The catch is that **no GPU implements every format**. Desktop GPUs implement the BC family, mobile GPUs implement ETC2 and ASTC, and WebGPU only carries a family that was requested when the device was created. So a project ships one file per target family and decides per device - which can only happen at load time, where the device is known. + +That is two separate things, and they are two separate parts of the API: a texture that can be compressed, and a rule that decides which file to fetch. + +## What the device supports + +```ts +import { type Application, CompressedTextureFormat } from '@codexo/exojs'; + +const report = (app: Application): void => { + // Most preferred first, or empty on a device with no compressed formats. + console.log(app.backend.supportedTextureFormats); + console.log(app.backend.supportedTextureFormats.includes(CompressedTextureFormat.Bc7RgbaUnorm)); +}; +``` + +The order is the engine's own preference ranking and is identical on both backends, so what gets picked never depends on which backend happens to be live. + + +The list comes from the initialized backend. Before `app.initialize()` resolves there is no device to ask, and the answer is the empty one. + + +## Declaring the variants + +`loader.variants` maps one **logical source** - the name your code keeps using - to an ordered list of candidates: + +```ts +import { type Application, CompressedTextureFormat } from '@codexo/exojs'; + +const declareTerrain = (app: Application): void => { + app.loader.variants.define('terrain.png', [ + { source: 'terrain.bc7.ktx2', textureFormat: CompressedTextureFormat.Bc7RgbaUnorm }, + { source: 'terrain.astc.ktx2', textureFormat: CompressedTextureFormat.Astc4x4Unorm }, + { source: 'terrain.etc2.ktx2', textureFormat: CompressedTextureFormat.Etc2Rgba8Unorm }, + { source: 'terrain@2x.png', resolution: 2 }, + { source: 'terrain.png' }, + ]); +}; +``` + +Nothing else changes. `app.loader.load('terrain.png')` still asks for `terrain.png`, and what comes back is a `Texture` either way. + +A candidate is eligible when every condition it states holds: + +- `textureFormat` - only when the device lists that format. +- `resolution` - only when the device renders at that density **or higher**. +- Neither - the unconditional fallback. Declare one, or a device that matches nothing falls back to the logical source itself. + +Among the eligible candidates the most preferred supported format wins, then the highest density, then declaration order. Format outranks density on purpose: it is what decides VRAM and transfer cost. A project that wants density to dominate declares only the candidates it wants chosen. + + +The last line of that rule is the whole reason compressed textures are usable at all. A device with no compressed-format support is not a special case to detect - it simply matches the PNG. + + +## Identity follows the chosen file + +Variant selection happens **before** the source is canonicalized, so asset identity is keyed on the file that was actually fetched. Two devices that pick different candidates get different cache entries rather than one entry whose contents depend on which of them filled it last. + +It also happens before the asset type is inferred from the suffix, which is what lets a rule swap a `.png` for a `.ktx2`: the type follows the file the device gets, not the name you wrote. + +## KTX2 files + +`.ktx2` is claimed by the ordinary `texture` type, and the payload kind is read from the file's magic bytes rather than its suffix. So a KTX2 asset behaves like any other texture: + +```ts +import type { Application } from '@codexo/exojs'; + +const load = async (app: Application): Promise => { + const terrain = await app.loader.load('terrain.bc7.ktx2'); + + console.log(terrain.width, terrain.compressed?.format); +}; +``` + +`texture.compressed` is `null` for an ordinary image and carries the format and mip chain otherwise. A container holding uncompressed RGBA8 is read as an ordinary image - it takes exactly the same path as a PNG. + +Two things do not apply to a compressed payload, and ignoring them is not a simplification: + +- **`premultiplyAlpha`** operates on decoded texels. Premultiply in the authoring tool, before compression. +- **`generateMipMap`** cannot derive a mip level from compressed blocks. Compress the chain level by level and ship it inside the container; a file with one level samples without mips however the sampler is configured. + + +A BasisLZ/ETC1S or UASTC "universal" KTX2 file needs a transcoder, which the engine does not carry, and loading one fails with an `AssetDecodeError` naming the scheme. Encode one file per target format instead - `toktx --target_type RGBA --encode uastc` and its per-format equivalents - and let a variant rule choose. Zstandard- and ZLIB-supercompressed files are rejected the same way. + + +## Constructing one directly + +A texture built in code takes the same payload: + +```ts +import { CompressedTexture, CompressedTextureFormat, compressedLevelByteLength } from '@codexo/exojs'; + +const format = CompressedTextureFormat.Bc7RgbaUnorm; +const texture = new CompressedTexture({ + format, + levels: [{ data: new Uint8Array(compressedLevelByteLength(format, 64, 64)), width: 64, height: 64 }], +}); +``` + +The payload is validated on the spot: a level whose byte length does not match its extent, or a base level that is not a whole number of blocks, throws here rather than at first bind. Binding a format the device does not implement throws a `RenderError` with code `'unsupported-format'` - it never uploads bytes the driver would misread. diff --git a/site/src/content/guide/audio/spatial-audio.mdx b/site/src/content/guide/audio/spatial-audio.mdx index 152886852..45c335647 100644 --- a/site/src/content/guide/audio/spatial-audio.mdx +++ b/site/src/content/guide/audio/spatial-audio.mdx @@ -9,10 +9,10 @@ import Callout from '../../../components/Callout.astro'; # Spatial audio -Spatial audio in ExoJS is 2D: a single shared listener and any number of sound sources, each with a world-space position. The engine maps the 2D coordinates to the Web Audio API's 3D panner behind the scenes — you work in the same pixel coordinates your sprites and containers use, and the audio system pans and attenuates accordingly. +Spatial audio in ExoJS is 2D by default: a single shared listener and any number of sound sources, each with a world-space position. The engine maps the coordinates to the Web Audio API's 3D panner behind the scenes — you work in the same pixel coordinates your sprites and containers use, and the audio system pans and attenuates accordingly. A third axis is available when you want it, as an [elevation](#elevation-the-third-axis) you set explicitly. - -The listener and every source sit on one plane (Z=0), panned with the Web Audio `equalpower` model — right for a top-down or side-on scene, but not a precise stereo mixer. When you need an exact, listener-independent left/right balance, keep the sound non-spatial (`position = null`) and set the bus `pan` instead. + +Sources are panned with the Web Audio `equalpower` model — right for a top-down or side-on scene, but not a precise stereo mixer. When you need an exact, listener-independent left/right balance, keep the sound non-spatial (`position = null`) and set the bus `pan` instead. ## The listener @@ -256,9 +256,108 @@ const voice = application.audio.play(waterfall, { Spatial updates happen automatically — each frame the `AudioManager` updates the listener from its target and ticks every spatial `Voice`, writing the resolved position (relative to that application's listener) to the Web Audio `PannerNode`. You do not need to call any per-frame update on spatial sources: pass `position` in `PlayOptions` at play time, set it live on the returned `Voice`, or call `voice.follow(node)`, and the engine handles the rest. +## Elevation: the third axis + +The scene graph has no third axis, so height is something you state rather than something the engine can read off a node. Every source and the listener carry an `elevation` in the same world units as x and y, `0` by default: + +```ts +import { type Application, Sound } from '@codexo/exojs'; + +declare const application: Application; +declare const bell: Sound; + +application.audio.listener.elevation = 0; + +// Two equivalent ways to put a source 200 units up. +const voice = application.audio.play(bell, { position: { x: 400, y: 300 }, elevation: 200 }); + +voice.position = { x: 400, y: 300, z: 200 }; +``` + +`position` stays two-dimensional when you read it — that is the world plane, and it is the part `follow(node)` can fill in. A point you pass **without** a `z` leaves the current height alone, so following a node never drops a source back onto the plane behind your back. + +Elevation contributes to distance attenuation, to panning, and to Doppler: `voice.elevationVelocity` (and `listener.elevationVelocity`) is the vertical component, so a source rising straight away from the listener genuinely pitches down. + + +`orientation` is a single in-plane angle, so a cone always points along the world plane however high its source sits. Tilting one would need a second angle, which no API takes today. + + +## Occlusion + +`voice.occlusion` is how obstructed the path to the listener is, from `0` (clear, the default) to `1` (fully obstructed). It muffles the source with a lowpass and attenuates it: + +```ts +import type { Spatializable, Voice } from '@codexo/exojs'; + +declare const voice: Voice & Spatializable; +declare const wallsBetweenSourceAndListener: number; + +voice.occlusion = Math.min(wallsBetweenSourceAndListener * 0.5, 1); +``` + +You supply the estimate — the engine does not trace geometry, because what counts as an obstruction is a game's decision (a wall, a closed door, a crowd). Write it as often as you like: both the cutoff and the gain are ramped, not stepped, so a per-frame value never clicks. + +Tune the endpoints on `app.audio.spatial`: `occlusionCutoff` (default 400 Hz) is where a fully occluded voice's lowpass lands, and `occlusionAttenuation` (default `0.25`) is how far its gain drops. The sweep between clear and occluded is logarithmic, matching how pitch is heard. + +A voice whose occlusion never leaves `0` builds no filter at all, so this costs nothing when unused. + +## Sends: one effect for many voices + +An insert effect **replaces** a signal, so it cannot express "keep playing dry, and also feed a shared reverb". A send can: + +```ts +import { AudioBus, type Voice } from '@codexo/exojs'; + +declare const voice: Voice; +declare const myConvolver: import('@codexo/exojs').AudioEffect; + +const reverb = new AudioBus('reverb'); + +reverb.addEffect(myConvolver); + +const send = voice.addSend(reverb, 0.4); // 40 % of the voice also reaches the reverb + +send.level = 0.8; // ramped, not stepped +``` + +The voice keeps playing into its own bus unchanged; a copy of the same signal additionally reaches the send's bus. Sends are owned by the voice and torn down with it, so you only remove one (`voice.removeSend(send)`) to change routing. `PlayOptions.sends` opens them at play time. + +## Reverb zones + +An `AudioZone` is a region of the world that contributes a send while the listener is inside it. The zone owns geometry and a level and nothing else — it never routes audio, holds effects or touches a voice: + +```ts +import { AudioBus, AudioZone, type Application, Rectangle } from '@codexo/exojs'; + +declare const application: Application; +declare const myConvolver: import('@codexo/exojs').AudioEffect; + +const caveBus = new AudioBus('cave-reverb'); + +caveBus.addEffect(myConvolver); + +application.audio.zones.add( + new AudioZone({ + shape: new Rectangle(0, 0, 800, 600), + bus: caveBus, + send: 0.6, + falloff: 120, + }), +); +``` + +`app.audio.zones` samples every zone's weight at the **listener** each frame and maintains one send per voice per active zone. That is deliberate: reverb is a property of the environment a scene is heard from, not of each individual source. + +- `shape` is a `Rectangle` or a `{ x, y, radius }` circle. +- `falloff` is the distance outside the shape over which the send ramps to zero — `0` gives a hard edge. +- `height` bounds the zone vertically; the default is a column of infinite height. +- Overlapping zones each contribute their own send. + +Crossing a boundary is a level ramp on the existing send, not a teardown and rebuild, so walking in and out of a cave crossfades. The zone layer is inert until a zone is added, and the bus stays yours — two zones may legitimately name the same one, and destroying it is your call. + ## Browser constraints -The Web Audio `PannerNode` maps to 3D space. ExoJS sets Z=0 for both listener and sources and uses forward=-Z with up=+Y, which produces correct left/right panning for 2D scenes. The elevation axis (up/down) is unused — all audio is co-planar with the listener. +The Web Audio `PannerNode` maps to 3D space. ExoJS uses forward=-Z with up=+Y, which produces correct left/right panning for 2D scenes, and writes the source's `elevation` relative to the listener's as the panner's Z. A scene that never sets an elevation is therefore exactly co-planar, as before. `AudioContext.listener`, though, is a property of the process-wide `AudioContext`: there is exactly one, shared by every `Application`. ExoJS therefore pins it at the origin and pans each voice by its offset from *its own* application's `app.audio.listener` — so two applications in one page each keep their own viewpoint instead of overwriting one another every frame. Distance, attenuation and the distance model are unaffected; the only observable difference is that `app.audio.spatial.teleportThreshold` is measured on the source-to-listener offset, so warping the listener snaps every spatial voice rather than snapping one listener. diff --git a/site/src/content/guide/rendering/immediate-mode.mdx b/site/src/content/guide/rendering/immediate-mode.mdx index c92544c73..2746cdf46 100644 --- a/site/src/content/guide/rendering/immediate-mode.mdx +++ b/site/src/content/guide/rendering/immediate-mode.mdx @@ -66,6 +66,28 @@ const triangle = new Geometry({ Build the geometry once in `Scene.init` and keep it — it carries no transform, so the same shape is reused at any position. Geometry must use `triangle-list` topology (the default) for the immediate path; custom per-vertex attributes beyond position/texcoord/color are dropped. +### Index width + +`indices` are optional — without them the vertex stream is drawn as a flat triangle list. When you do supply them, the array kind you pass **is** the width the GPU draws with: + +```ts +import { Geometry } from '@codexo/exojs'; + +const attributes = [{ name: 'a_position', size: 2, type: 'f32', normalized: false, offset: 0 } as const]; + +// The default: cheaper to upload and to keep resident. +const small = new Geometry({ attributes, vertexData: new ArrayBuffer(8 * 8), stride: 8, indices: new Uint16Array([0, 1, 2]) }); + +// Generated or merged geometry addressing more than 65 536 vertices. +const large = new Geometry({ attributes, vertexData: new ArrayBuffer(8 * 8), stride: 8, indices: new Uint32Array([0, 1, 2]) }); + +console.log(small.indices?.BYTES_PER_ELEMENT, large.indices?.BYTES_PER_ELEMENT); +``` + +Prefer `Uint16Array` — it is half the index bytes, and almost every hand-authored mesh fits. Reach for `Uint32Array` where the vertex count genuinely exceeds what a 16-bit index can address: batched tile chunks, trail ribbons, generated terrain, an imported SVG path. + +A `Uint32Array` is never narrowed back for you, even when its values would fit. The declared width is the contract, so a geometry cannot change index width later just because its content changed. A **non-indexed** mesh is the one case the engine decides for you: its indices are synthesized, so they widen on their own once there are more than 65 536 vertices to address. Both widths may appear in the same frame — each draw binds its own. + ## Drawing one shape: drawGeometry `drawGeometry(geometry, transform, options?)` draws the geometry with `transform` as its raw world matrix. The matrix is taken verbatim as `a, b, c, d, tx, ty` — there is no position / rotation / scale / origin composition the way a node would apply. You build the world matrix yourself, which is the point: full control, zero overhead. diff --git a/site/src/content/guide/rendering/render-targets.mdx b/site/src/content/guide/rendering/render-targets.mdx index 33d782627..e825e7fa7 100644 --- a/site/src/content/guide/rendering/render-targets.mdx +++ b/site/src/content/guide/rendering/render-targets.mdx @@ -3,6 +3,7 @@ title: 'Render targets' description: 'Render into intermediate textures and reuse those outputs in scene composition.' --- +import Callout from '../../../components/Callout.astro'; import ExamplePreview from '../../../components/ExamplePreview.astro'; import SourceSnippet from '../../../components/SourceSnippet.astro'; @@ -107,6 +108,51 @@ The `setSize()` method changes the texture dimensions. `powerOfTwo` reports whet **Staging for post-processing.** Render a scene into a `RenderTexture`, apply a filter to the sprite that displays it, and render the result to the canvas. The [Post-processing](/ExoJS/en/guide/effects/post-processing/) chapter covers this pattern in detail. +## Several attachments in one pass: MultiRenderTarget + +Some passes have to produce more than one image from the same geometry — colour plus a selection id, a normal buffer, a velocity buffer. Rendering the scene twice pays for the same transforms and rasterisation twice. A `MultiRenderTarget` carries several colour attachments and fills them in one pass: + +```ts +import { MultiRenderTarget, TextureFormat, type RenderingContext, type RenderNode } from '@codexo/exojs'; + +declare const context: RenderingContext; +declare const scene: RenderNode; + +const gbuffer = new MultiRenderTarget(512, 512, { + formats: [TextureFormat.Rgba8, TextureFormat.Rgba8], +}); + +context.renderTo(scene, { target: gbuffer }); + +const albedo = gbuffer.attachment(0); +const ids = gbuffer.attachment(1); +``` + +Each attachment is an ordinary `RenderTexture` and is sampled like any other texture afterwards. The target **owns** them: it creates them, resizes them with itself, and destroys them with itself. Read `app.backend.maxColorAttachments` for the ceiling on the current device. + + +A fragment shader has to declare one output per attachment. Sprites, text, nine-slice and repeating sprites, video, and the default mesh material all declare exactly one, so drawing any of them into a multi-attachment target throws — as does alpha-mask or backdrop-blend compositing. Give the geometry a `MeshMaterial` whose shader writes every slot: + +```wgsl +struct FragmentOut { + @location(0) color: vec4, + @location(1) id: vec4, +}; + +@fragment +fn fragmentMain(input: VertexOutput) -> FragmentOut { + var out: FragmentOut; + out.color = vec4(1.0, 0.0, 0.0, 1.0); + out.id = vec4(0.25, 0.0, 0.0, 1.0); + return out; +} +``` + +The GLSL counterpart declares `layout(location = 0) out vec4 outColor;` and `layout(location = 1) out vec4 outId;`. + + +If one pass only ever produces one image, a plain `RenderTexture` is the right tool — this exists for the case where it genuinely produces two. + ## RenderTexture vs. cacheAsTexture `cacheAsTexture` on a `RenderNode` bakes the node's subtree into an internal texture automatically. Use `cacheAsTexture` when the cached content doesn't need to be repositioned, scaled, filtered, or displayed in multiple places — it's a simple on/off toggle. Use a `RenderTexture` when you need explicit control over when the cache updates, what view it uses, or how the result is displayed (multiple sprites, custom sampler settings, composited with blend modes). @@ -130,7 +176,7 @@ Changing `cacheResolution` invalidates the cache, as does anything that moves th ## Lifecycle -`RenderTexture` owns GPU resources. Call `destroy()` when the texture is no longer needed to release the backing framebuffer and texture. The engine does not garbage-collect GPU objects automatically. +`RenderTexture` owns GPU resources. Call `destroy()` when the texture is no longer needed to release the backing framebuffer and texture. The engine does not garbage-collect GPU objects automatically. Destroying a `MultiRenderTarget` destroys its attachments too, so do not `destroy()` one of those separately. ## Examples diff --git a/site/src/lib/guide-structure.ts b/site/src/lib/guide-structure.ts index 598b32aaf..1b3422cf4 100644 --- a/site/src/lib/guide-structure.ts +++ b/site/src/lib/guide-structure.ts @@ -230,6 +230,17 @@ const RAW_PARTS: ReadonlyArray = [ examples: ['sprites-textures/texture-loader'], apiLinks: ['loader', 'texture'], }, + { + slug: 'device-variants', + level: 'advanced', + learningGoals: [ + 'read which compressed texture formats the running device implements', + 'declare one logical source that resolves to a file per GPU family and density', + 'know what a compressed payload does not honour, and why', + ], + prerequisites: ['assets/loading-and-resources'], + apiLinks: ['loader', 'texture', 'compressed-texture', 'compressed-texture-format', 'asset-variant-set'], + }, { slug: 'offline', level: 'advanced', @@ -346,10 +357,14 @@ const RAW_PARTS: ReadonlyArray = [ { slug: 'render-targets', level: 'advanced', - learningGoals: ['render a scene into an intermediate texture', 'reuse render-target output in composition'], + learningGoals: [ + 'render a scene into an intermediate texture', + 'reuse render-target output in composition', + 'fill several colour attachments from one pass', + ], prerequisites: ['rendering/sprites'], examples: ['render-targets/render-to-texture', 'render-targets/mini-map'], - apiLinks: ['render-target', 'render-texture'], + apiLinks: ['render-target', 'render-texture', 'multi-render-target', 'mesh-material'], }, { slug: 'pixel-snapping', @@ -369,11 +384,12 @@ const RAW_PARTS: ReadonlyArray = [ 'draw procedural geometry without a scene node via drawGeometry', 'instance thousands of like items as one draw call with RenderBatch', 'drive a batch with a custom material and your own per-instance attributes', + 'pick an index width, and know which one the engine picks for you', 'know when immediate rendering beats the retained scene graph', ], prerequisites: ['rendering/graphics'], examples: ['geometry-graphics/immediate-mode-rendering'], - apiLinks: ['rendering-context', 'render-batch', 'geometry', 'mesh-material', 'shader-source', 'matrix', 'color'], + apiLinks: ['rendering-context', 'render-batch', 'geometry', 'mesh', 'mesh-material', 'shader-source', 'matrix', 'color'], }, { slug: 'retained-containers', @@ -523,10 +539,16 @@ const RAW_PARTS: ReadonlyArray = [ { slug: 'spatial-audio', level: 'intermediate', - learningGoals: ['place a listener and sources in space', 'tune directional falloff'], + learningGoals: [ + 'place a listener and sources in space', + 'tune directional falloff', + 'lift a source off the world plane with elevation', + 'muffle an obstructed source with occlusion', + 'feed one shared reverb from many voices, and gate it on a zone', + ], prerequisites: ['audio/audio-basics'], examples: ['spatial-audio/listener-and-source', 'spatial-audio/moving-source', 'spatial-audio/falloff-curves'], - apiLinks: ['audio-listener', 'audio-manager'], + apiLinks: ['audio-listener', 'audio-manager', 'audio-send', 'audio-zone', 'spatial-zones'], }, { slug: 'audio-effects', diff --git a/src/assets/AssetDefinitions.ts b/src/assets/AssetDefinitions.ts index 3efc6c97d..a39aaa1bf 100644 --- a/src/assets/AssetDefinitions.ts +++ b/src/assets/AssetDefinitions.ts @@ -159,6 +159,7 @@ export interface ExtensionKindMap { webp: 'texture'; avif: 'texture'; gif: 'texture'; + ktx2: 'texture'; ogg: 'sound'; mp3: 'sound'; wav: 'sound'; diff --git a/src/assets/AssetVariants.ts b/src/assets/AssetVariants.ts new file mode 100644 index 000000000..0bc7a894c --- /dev/null +++ b/src/assets/AssetVariants.ts @@ -0,0 +1,184 @@ +import type { CompressedTextureFormat } from '#rendering/texture/CompressedTextureFormat'; + +/** + * What the running device can accept, as variant rules see it. + * + * Filled from the live render backend once it is up - `textureFormats` is + * {@link RenderBackend.supportedTextureFormats} and `resolution` is its + * `rootResolution`. Before that it is the conservative empty profile, so a load + * started before the backend exists picks the unconditional fallback rather + * than a format nothing has confirmed. + * @advanced + */ +export interface AssetVariantProfile { + /** + * Compressed texture formats the backend can sample, most preferred first. + * The order is the selection order, so it decides which of several supported + * candidates wins. Empty on a device with no compressed-format support. + */ + readonly textureFormats: readonly CompressedTextureFormat[]; + /** Device pixels per logical unit the application renders at. */ + readonly resolution: number; +} + +/** + * One candidate representation of a logical asset source. + * + * A candidate is eligible when every condition it states holds for the current + * {@link AssetVariantProfile}. A candidate that states none is the + * unconditional fallback and is always eligible - declare one, or a device that + * matches nothing falls back to the logical source itself. + * @advanced + */ +export interface AssetVariant { + /** Source to load when this candidate wins. Resolved against the loader base path like any other. */ + readonly source: string; + /** Eligible only when the profile lists this format. */ + readonly textureFormat?: CompressedTextureFormat; + /** Eligible only when the profile renders at this density or higher. */ + readonly resolution?: number; +} + +/** The profile a set starts on: nothing confirmed, logical density. */ +const conservativeProfile: AssetVariantProfile = Object.freeze({ textureFormats: Object.freeze([]), resolution: 1 }); + +/** + * Per-device selection between several files that stand for one logical asset. + * + * Without this layer a path is a path: one URL, one set of bytes, on every + * device. That is the wrong shape for two things a real project needs - 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, which is at load time. + * + * Selection happens before the source is canonicalized, so asset identity is + * keyed on the file that was actually chosen. Two devices picking different + * candidates therefore get different cache entries instead of one entry whose + * contents depend on who wrote it last. + * + * A source with no rule resolves to itself. Nothing is registered by default, + * so an application that never calls {@link define} pays one map lookup per + * load and nothing else. + * + * @example + * ```ts + * app.loader.variants.define('terrain.png', [ + * { source: 'terrain.bc7.ktx2', textureFormat: CompressedTextureFormat.Bc7RgbaUnorm }, + * { source: 'terrain.astc.ktx2', textureFormat: CompressedTextureFormat.Astc4x4Unorm }, + * { source: 'terrain@2x.png', resolution: 2 }, + * { source: 'terrain.png' }, + * ]); + * + * // Loads whichever of the four this device can actually use. + * const terrain = app.loader.load('terrain.png'); + * ``` + * @advanced + */ +export class AssetVariantSet { + private readonly _rules = new Map(); + private _profile: AssetVariantProfile = conservativeProfile; + + /** + * Device capabilities selection is measured against. Written by the + * {@link Application} once the backend is initialized; assign it directly to + * override that - for a deterministic test, or to pin a format set a + * build already targets. + * + * Changing it does not re-resolve assets that are already resident: their + * identity was fixed by the profile in force when they were requested. + */ + public get profile(): AssetVariantProfile { + return this._profile; + } + + public set profile(value: AssetVariantProfile) { + this._profile = value; + } + + /** + * Declare the candidates for one logical source, replacing any previous rule + * for it. + * + * `source` is the name callers keep using; it never has to exist as a file. + * Order the candidates most-wanted first: it breaks ties, though a supported + * compressed format outranks declaration order (see {@link resolve}). + */ + public define(source: string, variants: readonly AssetVariant[]): this { + this._rules.set(source, variants); + + return this; + } + + /** Drop the rule for `source`, so it resolves to itself again. */ + public undefine(source: string): this { + this._rules.delete(source); + + return this; + } + + /** The candidates declared for `source`, or `undefined`. */ + public candidates(source: string): readonly AssetVariant[] | undefined { + return this._rules.get(source); + } + + /** Forget every rule. The profile is left alone - it describes the device, not the content. */ + public clear(): this { + this._rules.clear(); + + return this; + } + + /** + * The source a load of `source` should actually fetch. + * + * Among the eligible candidates the one carrying the most preferred + * compressed format wins, then the highest density, then the earliest + * declared. Format outranks density deliberately: it is what decides VRAM and + * transfer cost, and a project that wants density to dominate simply declares + * only the candidates it wants chosen. + * + * Returns `source` unchanged when it has no rule, and when it has one whose + * candidates are all ineligible. + */ + public resolve(source: string): string { + const candidates = this._rules.get(source); + + if (candidates === undefined) { + return source; + } + + const { textureFormats, resolution } = this._profile; + // An uncompressed candidate ranks behind every supported format rather than + // ahead of an unsupported one, so `length` (not -1) is its rank. + const uncompressedRank = textureFormats.length; + let best: AssetVariant | undefined; + let bestRank = Number.POSITIVE_INFINITY; + let bestResolution = -1; + + for (const candidate of candidates) { + if (candidate.resolution !== undefined && candidate.resolution > resolution) { + continue; + } + + let rank = uncompressedRank; + + if (candidate.textureFormat !== undefined) { + rank = textureFormats.indexOf(candidate.textureFormat); + + if (rank === -1) { + continue; + } + } + + const candidateResolution = candidate.resolution ?? 1; + + if (rank < bestRank || (rank === bestRank && candidateResolution > bestResolution)) { + best = candidate; + bestRank = rank; + bestResolution = candidateResolution; + } + } + + return best?.source ?? source; + } +} diff --git a/src/assets/Loader.ts b/src/assets/Loader.ts index 535b2cff3..48212e8b5 100644 --- a/src/assets/Loader.ts +++ b/src/assets/Loader.ts @@ -14,6 +14,7 @@ import { type AssetInspection, AssetResidency, type AssetResidencySignals } from import { _normalizeEntry, type Assets, AssetsImpl, type InferAssetsProperties } from './Assets'; import type { AnyAssetType } from './AssetType'; import { AssetTypeRegistry } from './AssetTypeRegistry'; +import { AssetVariantSet } from './AssetVariants'; import type { CacheLayout } from './CacheLayout'; import type { CacheStore } from './CacheStore'; import { type AssetLocator, type CanonicalAsset, canonicalizeSource, type ResourceKey, resourceKey, type SourceKey, sourceKey } from './canonicalKey'; @@ -175,22 +176,28 @@ export class Loader { * {@link onError}. */ private _resolveBarePath(input: string): { type: AssetTypeName; source: string; ctor: AssetConstructor } { - const type = this._typeRegistry._resolveTypeForPath(input); + // Variant selection runs ahead of the type lookup, so the type follows the + // file this device actually gets. A rule may legitimately swap a `.png` for a + // `.ktx2`, and inferring the type from the name the caller wrote would then + // hand compressed-container bytes to the image decoder. + const source = this.variants.resolve(input); + const named = source === input ? `"${input}"` : `"${source}" (selected as a variant of "${input}")`; + const type = this._typeRegistry._resolveTypeForPath(source); if (type === undefined) { throw new Error( - `Loader: no installed asset type claims any extension of "${input}". Install a type that does, map the suffix with ` + - `loader.registerType(extension, type), or name the type explicitly with Asset.type(type, "${input}").`, + `Loader: no installed asset type claims any extension of ${named}. Install a type that does, map the suffix with ` + + `loader.registerType(extension, type), or name the type explicitly with Asset.type(type, "${source}").`, ); } const ctor = this._typeRegistry.resolveTypeName(type); if (ctor === undefined) { - throw new Error(`Loader: no asset type "${type}" is installed on this application (inferred from "${input}").`); + throw new Error(`Loader: no asset type "${type}" is installed on this application (inferred from ${named}).`); } - return { type, source: input, ctor }; + return { type, source, ctor }; } /** @@ -204,21 +211,27 @@ export class Loader { * @internal */ public _canonicalize(type: AssetConstructor, source: string, options?: unknown): CanonicalAsset { - const locator = canonicalizeSource(this._decoder.basePath, source); + // Variant selection happens here, ahead of the locator, so identity is keyed + // on the file this device actually fetches. Resolving it any later would let + // two devices share one cache entry whose contents depend on which of them + // filled it, and would leave a reference the asset itself carries resolving + // against a path that was never loaded. + const selected = this.variants.resolve(source); + const locator = canonicalizeSource(this._decoder.basePath, selected); // The resource discriminator is deliberately absent from the source key: two // resources that differ only in how one download is interpreted must resolve // to one acquisition, or the same bytes would be fetched once per // interpretation. The reverse containment is structural - the resource key is // built ON the source key - so distinct source data can never share a // resident resource even if a type forgets to repeat the distinction. - const acquired = sourceKey(locator, this._typeRegistry._sourceDiscriminator(type, source, options)); + const acquired = sourceKey(locator, this._typeRegistry._sourceDiscriminator(type, selected, options)); return { - key: resourceKey(this._typeRegistry._typeIdentity(type), acquired, this._typeRegistry._identityDiscriminator(type, source, options)), + key: resourceKey(this._typeRegistry._typeIdentity(type), acquired, this._typeRegistry._identityDiscriminator(type, selected, options)), sourceKey: acquired, locator, type, - source, + source: selected, }; } @@ -365,6 +378,17 @@ export class Loader { */ public readonly onCacheError = new Signal<[error: AssetCacheError]>(); + /** + * Per-device selection between several files standing for one logical source - + * a texture shipped once per compressed format family, once per display + * density, or both. + * + * Empty by default, so a loader nobody configures resolves every source to + * itself. The {@link Application} publishes the device profile here once its + * render backend is up. + */ + public readonly variants = new AssetVariantSet(); + public constructor(options: LoaderOptions = {}) { const cache = options.cache; diff --git a/src/assets/factories/TextureFactory.ts b/src/assets/factories/TextureFactory.ts index 67863c4d4..96bb18555 100644 --- a/src/assets/factories/TextureFactory.ts +++ b/src/assets/factories/TextureFactory.ts @@ -1,22 +1,33 @@ import type { AssetFactory, AssetFactoryContext } from '#assets/AssetFactory'; import { determineMimeType } from '#assets/utils'; +import { CompressedTexture } from '#rendering/texture/CompressedTexture'; import { Texture } from '#rendering/texture/Texture'; -import type { TextureOptions } from '#rendering/texture/TextureOptions'; +import type { SamplerOptions, TextureOptions } from '#rendering/texture/TextureOptions'; import { decodeImageBlob } from './decodeImageBlob'; +import { isKtx2, parseKtx2 } from './ktx2'; import { ObjectUrlPool } from './ObjectUrlPool'; /** Options accepted by an asset of the built-in `texture` type. */ export interface TextureAssetOptions { - /** MIME type for the intermediate blob. Inferred from the magic bytes when omitted. */ + /** MIME type for the intermediate blob. Inferred from the magic bytes when omitted. Ignored for a KTX2 payload. */ mimeType?: string; - /** Sampling and upload state forwarded to the {@link Texture} constructor; any subset. */ + /** + * Sampling and upload state forwarded to the {@link Texture} constructor; any + * subset. A KTX2 payload in a hardware format takes the sampling half only - + * premultiplication and mip generation cannot apply to compressed blocks. + */ textureOptions?: Partial; } /** - * Decodes raster image bytes (PNG, JPG, WebP, AVIF, ...) into a GPU-ready - * {@link Texture}. + * Decodes texture bytes into a GPU-ready {@link Texture}: raster image formats + * (PNG, JPG, WebP, AVIF, GIF) through the browser's image decoder, and KTX2 + * containers into a compressed payload the GPU samples directly. + * + * The two are distinguished by the payload's magic bytes rather than by the file + * suffix, so an asset variant is free to resolve one logical source to a + * container on a device that supports the format and to an image elsewhere. * @internal */ export class TextureFactory implements AssetFactory { @@ -24,6 +35,11 @@ export class TextureFactory implements AssetFactory): Promise { const { mimeType, textureOptions } = context.options ?? {}; + + if (isKtx2(new Uint8Array(source))) { + return this._createFromKtx2(source, context.source, textureOptions); + } + const blob = new Blob([source], { type: mimeType ?? determineMimeType(source) }); return new Texture(await decodeImageBlob(blob, this._objectUrls), textureOptions); @@ -32,4 +48,33 @@ export class TextureFactory implements AssetFactory | undefined): Promise { + const payload = parseKtx2(source, name); + + if (payload.kind === 'compressed') { + // Copied key by key rather than picked with a destructure: a key present + // with an `undefined` value still wins a spread, so it would erase the + // texture defaults instead of falling through to them. + const samplerOptions: Partial = {}; + + if (textureOptions?.scaleMode !== undefined) { + samplerOptions.scaleMode = textureOptions.scaleMode; + } + + if (textureOptions?.wrapMode !== undefined) { + samplerOptions.wrapMode = textureOptions.wrapMode; + } + + return new CompressedTexture({ format: payload.format, levels: payload.levels, samplerOptions }); + } + + // An uncompressed container is turned into an ordinary image source rather + // than kept as raw bytes: that way it takes exactly the same upload, + // premultiplication and seamless-fill path as a PNG, instead of becoming a + // third payload kind every backend would have to special-case. + const bitmap = await createImageBitmap(new ImageData(new Uint8ClampedArray(payload.data), payload.width, payload.height)); + + return new Texture(bitmap, textureOptions); + } } diff --git a/src/assets/factories/ktx2.ts b/src/assets/factories/ktx2.ts new file mode 100644 index 000000000..01547b160 --- /dev/null +++ b/src/assets/factories/ktx2.ts @@ -0,0 +1,215 @@ +import { AssetDecodeError } from '#assets/AssetDecodeError'; +import type { CompressedTextureLevel } from '#rendering/texture/compressedPayload'; +import { compressedLevelByteLength, CompressedTextureFormat as Format } from '#rendering/texture/CompressedTextureFormat'; + +/** `«KTX 20»\r\n\x1A\n` - the 12-byte KTX2 file identifier. */ +const identifier = Object.freeze([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** + * `VkFormat` values a KTX2 payload may carry, mapped onto this engine's format + * vocabulary. + * + * The sRGB and UNORM variants of one block format map to the same entry: the + * blocks are bit-identical and the engine's managed textures are linear-sampled + * `rgba8unorm` throughout, so honouring the distinction here would make + * compressed textures the only ones on a different transfer function. + * + * `BC1_RGB` maps to the RGBA form because that is the only BC1 format WebGPU + * exposes, and the two differ solely in whether the punch-through alpha bit is + * honoured. + */ +const formatByVkFormat = new Map([ + [131, Format.Bc1RgbaUnorm], + [132, Format.Bc1RgbaUnorm], + [133, Format.Bc1RgbaUnorm], + [134, Format.Bc1RgbaUnorm], + [135, Format.Bc2RgbaUnorm], + [136, Format.Bc2RgbaUnorm], + [137, Format.Bc3RgbaUnorm], + [138, Format.Bc3RgbaUnorm], + [139, Format.Bc4RUnorm], + [141, Format.Bc5RgUnorm], + [143, Format.Bc6hRgbUfloat], + [145, Format.Bc7RgbaUnorm], + [146, Format.Bc7RgbaUnorm], + [147, Format.Etc2Rgb8Unorm], + [148, Format.Etc2Rgb8Unorm], + [149, Format.Etc2Rgb8A1Unorm], + [150, Format.Etc2Rgb8A1Unorm], + [151, Format.Etc2Rgba8Unorm], + [152, Format.Etc2Rgba8Unorm], + [153, Format.EacR11Unorm], + [155, Format.EacRg11Unorm], + [157, Format.Astc4x4Unorm], + [158, Format.Astc4x4Unorm], + [161, Format.Astc5x5Unorm], + [162, Format.Astc5x5Unorm], + [165, Format.Astc6x6Unorm], + [166, Format.Astc6x6Unorm], + [171, Format.Astc8x8Unorm], + [172, Format.Astc8x8Unorm], +]); + +/** `VK_FORMAT_R8G8B8A8_UNORM` and `..._SRGB` - the one uncompressed payload this parser accepts. */ +const vkFormatRgba8Unorm = 37; +const vkFormatRgba8Srgb = 43; + +/** Supercompression schemes, by their KTX2 numeric id. */ +const supercompressionNames = new Map([ + [1, 'BasisLZ'], + [2, 'Zstandard'], + [3, 'ZLIB'], +]); + +/** A KTX2 payload whose levels are already in a hardware format. */ +export interface Ktx2CompressedPayload { + readonly kind: 'compressed'; + readonly format: Format; + readonly levels: readonly CompressedTextureLevel[]; +} + +/** A KTX2 payload storing plain 8-bit RGBA texels, level 0 only. */ +export interface Ktx2UncompressedPayload { + readonly kind: 'rgba8'; + readonly width: number; + readonly height: number; + readonly data: Uint8Array; +} + +/** What {@link parseKtx2} produces. */ +export type Ktx2Payload = Ktx2CompressedPayload | Ktx2UncompressedPayload; + +/** + * Whether `bytes` begin with the KTX2 identifier. + * + * Sniffed from the payload rather than trusted from the file suffix: the + * `texture` type accepts both container and image bytes under one identity, and + * a variant rule may hand it either. + */ +export const isKtx2 = (bytes: Uint8Array): boolean => bytes.length >= identifier.length && identifier.every((expected, index) => bytes[index] === expected); + +const fail = (source: string, message: string): never => { + throw new AssetDecodeError({ message: `KTX2 file "${source}": ${message}`, assetType: 'ktx2' }); +}; + +/** + * Parse a KTX2 container into an uploadable payload. + * + * Every level is located through the level index rather than by walking the + * payload: KTX2 stores the image data smallest level first, so the byte order in + * the file is the reverse of the mip order. + * + * `source` only names the file in error messages. + * + * @throws AssetDecodeError - not a KTX2 file, a supercompression scheme this + * engine does not carry (BasisLZ, Zstandard, ZLIB), a `vkFormat` outside the + * supported set, a non-2D target (array layers, cube faces, depth), or a level + * whose declared byte length does not match its extent. + */ +export const parseKtx2 = (buffer: ArrayBuffer, source: string): Ktx2Payload => { + const headerBytes = 80; + + if (buffer.byteLength < headerBytes) { + return fail(source, `file is ${buffer.byteLength} bytes, too short to hold a header.`); + } + + const bytes = new Uint8Array(buffer); + + if (!isKtx2(bytes)) { + return fail(source, 'file does not start with the KTX2 identifier.'); + } + + const view = new DataView(buffer); + const vkFormat = view.getUint32(12, true); + const pixelWidth = view.getUint32(20, true); + const pixelHeight = view.getUint32(24, true); + const pixelDepth = view.getUint32(28, true); + const layerCount = view.getUint32(32, true); + const faceCount = view.getUint32(36, true); + // A stored `levelCount` of 0 means "the mip chain is to be generated", which + // for a compressed payload is not possible - so it is read as the single level + // the file does contain rather than rejected. + const levelCount = Math.max(view.getUint32(40, true), 1); + const supercompressionScheme = view.getUint32(44, true); + + if (supercompressionScheme !== 0) { + const name = supercompressionNames.get(supercompressionScheme) ?? `scheme ${supercompressionScheme}`; + + return fail( + source, + `payload uses ${name} supercompression, which this engine does not decode. Ship the file in a hardware format per target ` + + `(and select between them with loader.variants) instead of a universal one.`, + ); + } + + if (pixelDepth > 1 || layerCount > 1 || faceCount > 1) { + return fail(source, `only 2D single-layer textures are supported, but the file declares depth ${pixelDepth}, ${layerCount} layers and ${faceCount} faces.`); + } + + if (pixelWidth === 0 || pixelHeight === 0) { + return fail(source, `declares an empty extent of ${pixelWidth}x${pixelHeight}.`); + } + + const levelIndexBytes = levelCount * 24; + + if (buffer.byteLength < headerBytes + levelIndexBytes) { + return fail(source, `declares ${levelCount} levels, but the file is too short to hold their index.`); + } + + const readLevel = (index: number): { readonly offset: number; readonly length: number } => { + const entry = headerBytes + index * 24; + // Both fields are 64-bit. A level beyond 2^53 bytes cannot exist, so reading + // them as `BigUint64` and narrowing is pointless - but the high word still + // has to be checked, or a corrupt header would silently truncate to a + // plausible offset. + const offsetHigh = view.getUint32(entry + 4, true); + const lengthHigh = view.getUint32(entry + 12, true); + + if (offsetHigh !== 0 || lengthHigh !== 0) { + fail(source, `level ${index} declares an offset or length above 4 GiB.`); + } + + return { offset: view.getUint32(entry, true), length: view.getUint32(entry + 8, true) }; + }; + + const sliceLevel = (index: number, expected: number): Uint8Array => { + const { offset, length } = readLevel(index); + + if (length !== expected) { + fail(source, `level ${index} declares ${length} bytes but its extent needs exactly ${expected}.`); + } + + if (offset + length > buffer.byteLength) { + fail(source, `level ${index} runs past the end of the file.`); + } + + return bytes.subarray(offset, offset + length); + }; + + if (vkFormat === vkFormatRgba8Unorm || vkFormat === vkFormatRgba8Srgb) { + if (levelCount > 1) { + return fail(source, 'an uncompressed RGBA8 payload is only read as a single level, but the file declares a mip chain.'); + } + + return { kind: 'rgba8', width: pixelWidth, height: pixelHeight, data: sliceLevel(0, pixelWidth * pixelHeight * 4) }; + } + + const format = formatByVkFormat.get(vkFormat); + + if (format === undefined) { + return fail(source, `vkFormat ${vkFormat} is not a texture format this engine can upload.`); + } + + const levels: CompressedTextureLevel[] = []; + + // The level index runs mip 0 first, so it is read forwards; the mip extents + // halve and never drop below one texel. + for (let index = 0; index < levelCount; index++) { + const width = Math.max(pixelWidth >> index, 1); + const height = Math.max(pixelHeight >> index, 1); + + levels.push({ data: sliceLevel(index, compressedLevelByteLength(format, width, height)), width, height }); + } + + return { kind: 'compressed', format, levels }; +}; diff --git a/src/assets/index.ts b/src/assets/index.ts index c0c335223..b8f1173f8 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -31,6 +31,8 @@ export { binarySourceCodec, jsonSourceCodec, textSourceCodec } from './AssetSour export type { AssetStatus } from './AssetStatus'; export type { AnyAssetType, AssetLeaf, AssetRequest } from './AssetType'; export { AssetType } from './AssetType'; +export type { AssetVariant, AssetVariantProfile } from './AssetVariants'; +export { AssetVariantSet } from './AssetVariants'; export type { CacheLayout, CacheLayoutContext } from './CacheLayout'; export { CacheFirstPolicy, CacheOnlyPolicy, NetworkFirstPolicy, NetworkOnlyPolicy } from './cachePolicies'; export type { CacheContext, CachePolicy } from './CachePolicy'; diff --git a/src/assets/seamless.ts b/src/assets/seamless.ts index fa9850680..cdf907a2d 100644 --- a/src/assets/seamless.ts +++ b/src/assets/seamless.ts @@ -87,9 +87,16 @@ export const textureSeamlessAdapter: SeamlessAdapter = { const expected = presizes.get(handle); presizes.delete(handle); - // Transplant ONLY the decoded source - the handle keeps the per-handle + // Transplant ONLY the decoded payload - the handle keeps the per-handle // sampler state applied at createPlaceholder (do NOT copy the donor's). - handle.setSource(donor.source); + // Either kind of payload can arrive: an asset variant may resolve one + // logical source to a compressed container on one device and an image on + // another, and a caller holding the handle must not have to care. + if (donor.compressed !== null) { + handle.setCompressed(donor.compressed); + } else { + handle.setSource(donor.source); + } if (expected !== undefined && (handle.width !== expected.width || handle.height !== expected.height)) { logger.warn(`Texture pre-size (${expected.width}×${expected.height}) does not match the loaded payload (${handle.width}×${handle.height}).`, { @@ -112,6 +119,10 @@ export const textureSeamlessAdapter: SeamlessAdapter = { // on its next bind, which may never come for a handle nothing is // currently drawing. releaseGpu() frees the backend's GPU texture now. handle.setSource(null); + // `setSource(null)` is a no-op on a handle whose payload was compressed - + // its source was already null - so the payload has to be dropped explicitly + // or an evicted handle would keep reporting the content it just released. + handle.setCompressed(null); handle.releaseGpu(); handle._loadState.begin(); }, diff --git a/src/assets/types/textureType.ts b/src/assets/types/textureType.ts index abf3552a5..455bc0f87 100644 --- a/src/assets/types/textureType.ts +++ b/src/assets/types/textureType.ts @@ -8,10 +8,18 @@ import { type TextureAssetOptions, TextureFactory } from '#assets/factories/Text import { textureSeamlessAdapter } from '#assets/seamless'; import { Texture } from '#rendering/texture/Texture'; -/** GPU-ready {@link Texture}s decoded from PNG, JPG, WebP, AVIF and GIF bytes. */ +/** + * GPU-ready {@link Texture}s decoded from PNG, JPG, WebP, AVIF and GIF bytes, or + * from a KTX2 container holding a hardware-compressed payload. + * + * One type covers both because the payload kind is a property of the bytes, not + * of the asset: an {@link AssetVariantSet} rule may resolve one logical source to + * a compressed container where the device supports the format and to an image + * elsewhere, and a caller holding the handle sees a `Texture` either way. + */ export class TextureAssetType extends AssetType { public readonly id = 'texture'; - public override readonly extensions = ['png', 'jpg', 'jpeg', 'webp', 'avif', 'gif']; + public override readonly extensions = ['png', 'jpg', 'jpeg', 'webp', 'avif', 'gif', 'ktx2']; public override readonly leaf = textureSeamlessAdapter; public override readonly _token: AssetConstructor = Texture; public override readonly codec: AssetSourceCodec = binarySourceCodec; diff --git a/src/audio/AudioGenerator.ts b/src/audio/AudioGenerator.ts index 2c2737fe1..9c5a57629 100644 --- a/src/audio/AudioGenerator.ts +++ b/src/audio/AudioGenerator.ts @@ -7,7 +7,7 @@ import type { Envelope } from './Envelope'; import { NoopVoice } from './NoopVoice'; import type { Playable, PlayOptions, Voice } from './Playable'; import { SoundPoolStrategy } from './Sound'; -import { seedVoiceFromPlayOptions } from './spatial-options'; +import { seedVoiceFromPlayOptions, seedVoiceSends } from './spatial-options'; export type OscillatorType = 'sine' | 'square' | 'sawtooth' | 'triangle'; @@ -166,6 +166,7 @@ export class AudioGenerator implements Playable { }); seedVoiceFromPlayOptions(voice, options); + seedVoiceSends(voice, options); const pooled: PooledGeneratorVoice = { voice, startedAt: audioContext.currentTime }; voice.onEnd.add((): void => { diff --git a/src/audio/AudioListener.ts b/src/audio/AudioListener.ts index 93b24f6f6..1c70b4110 100644 --- a/src/audio/AudioListener.ts +++ b/src/audio/AudioListener.ts @@ -51,6 +51,23 @@ export class AudioListener { public readonly position: Vector = new Vector(0, 0); public target: AudioListenerTarget = null; + /** + * Height of the observer above (positive) or below (negative) the world plane, + * in world units. Default `0`. + * + * Not read from {@link AudioListener.target}: a scene node has no third axis, + * so this is the caller's to set - and it is preserved across every target + * tick rather than being reset to the plane each frame. + */ + public elevation = 0; + + /** + * Vertical component of {@link AudioListener.velocity}, in world units per + * second. Default `0`. Only Doppler reads it, and it is never auto-derived - + * nothing tracks elevation for the listener to derive it from. + */ + public elevationVelocity = 0; + private readonly _velocity: Vector = new Vector(0, 0); private _explicitVelocity = false; private readonly _velocitySample: VelocitySample = createVelocitySample(); diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 6c19ae441..a7a07a8c7 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -11,6 +11,7 @@ import { InputVoice } from './InputVoice'; import type { Playable, PlayOptions, Voice } from './Playable'; import type { Sound, SoundPlayOptions } from './Sound'; import { createSpatialSmoothingSettings, type SpatialSmoothingSettings } from './spatial-smoothing'; +import { SpatialZones } from './SpatialZones'; /** * The signal behind {@link AudioManager.onUnlock}. A plain one-shot `Signal` @@ -135,6 +136,14 @@ export class AudioManager { * zipper-noise suppression (AU4). Reachable as `app.audio.spatial`. */ public readonly spatial: SpatialSmoothingSettings = createSpatialSmoothingSettings(); + + /** + * Optional zone layer: regions of the world that contribute a parallel send + * while the listener is inside them - a reverb zone, a muffled corridor. + * + * Empty and inert until a zone is added; see {@link SpatialZones}. + */ + public readonly zones: SpatialZones = new SpatialZones(); /** * Fires once when the AudioContext transitions to "running" - i.e. the first * user gesture unlocks audio under the browser's autoplay policy. This is the @@ -376,6 +385,12 @@ export class AudioManager { } voice._tickSpatial(); } + + // After the listener moved and the voices followed it, so a zone crossing is + // reconciled against this frame's positions rather than last frame's. + if (this.zones.active) { + this.zones._tick(this.listener, this._voices); + } } /** @@ -403,6 +418,9 @@ export class AudioManager { /** Internal: drop a voice that has ended. Called from the voice's own teardown. */ public _unregisterVoice(voice: Voice): void { this._voices.delete(voice); + // The voice destroys its own sends; this only drops the zone layer's map + // entry, which would otherwise keep the ended voice reachable. + this.zones._forget(voice); } /** @@ -542,6 +560,9 @@ export class AudioManager { } this._voices.clear(); + // Before the buses: a zone send is an edge into a bus, and the voices that + // held those edges have just been stopped. + this.zones.clear(); this.listener.destroy(); this._spatial.clear(); for (const bus of this._registered.values()) { diff --git a/src/audio/AudioSend.ts b/src/audio/AudioSend.ts new file mode 100644 index 000000000..03139e97a --- /dev/null +++ b/src/audio/AudioSend.ts @@ -0,0 +1,134 @@ +import type { AudioBus } from './AudioBus'; + +/** + * A parallel tap from one voice's output into an {@link AudioBus}, at its own + * level. + * + * A send is what an insert effect cannot express: the voice keeps playing into + * its own bus unchanged (the dry path) while a copy of the same signal also + * reaches another bus (the wet path). That is the shape reverb, echo and any + * shared ambience processing needs - one effect instance serving many voices, + * with each voice deciding how much of itself to contribute. + * + * Created by `voice.addSend(bus)` and owned by that voice: it is torn down when + * the voice ends, so a caller only has to remove one early if the routing itself + * should change. The target bus is not owned - it keeps serving whoever else + * sends into it. + * @stable + */ +export class AudioSend { + public readonly bus: AudioBus; + + private readonly _gain: GainNode; + private readonly _audioContext: AudioContext; + private _source: AudioNode; + private _level: number; + private _destroyed = false; + /** Unsubscribe for a connection deferred while the target bus was still locked. */ + private _pendingBusSetup: (() => void) | null = null; + + /** + * @param source - the node whose signal is copied; a voice's output gain. + * @internal - built by {@link Voice.addSend}, which owns the lifecycle. + */ + public constructor(audioContext: AudioContext, source: AudioNode, bus: AudioBus, level: number) { + this._audioContext = audioContext; + this.bus = bus; + this._level = Math.max(level, 0); + this._gain = audioContext.createGain(); + this._gain.gain.value = this._level; + this._source = source; + + source.connect(this._gain); + this._connect(); + } + + /** + * Move this send to a different source node, keeping its identity, level and + * bus connection. + * + * Exists for the deferred voice a scene hands out before the asset is ready: + * the send is opened against a placeholder and re-pointed at the real voice on + * flush, so the handle the caller already holds stays the right one. + * @internal + */ + public _retarget(source: AudioNode): void { + if (this._destroyed || source === this._source) { + return; + } + + this._source.disconnect(this._gain); + this._source = source; + source.connect(this._gain); + } + + /** + * Contribution level, `0` and up. `1` sends the voice at its own volume; + * values above `1` amplify, as an aux send on a mixing desk does. + * + * Ramped rather than stepped, so a level driven per frame - by a zone the + * listener is walking into, say - does not click. + */ + public get level(): number { + return this._level; + } + + public set level(value: number) { + const next = Math.max(value, 0); + + if (next === this._level || this._destroyed) { + return; + } + + this._level = next; + this._gain.gain.setTargetAtTime(next, this._audioContext.currentTime, 0.01); + } + + /** `true` once this send has been torn down; a torn-down send ignores further writes. */ + public get destroyed(): boolean { + return this._destroyed; + } + + /** + * Disconnect and discard. Idempotent. The source node and the target bus are + * untouched - only this send's own gain node goes away. + */ + public destroy(): void { + if (this._destroyed) { + return; + } + + this._destroyed = true; + this._pendingBusSetup?.(); + this._pendingBusSetup = null; + this._gain.disconnect(); + } + + private _connect(): void { + const input = this.bus._getInputNode(); + + if (input !== null) { + this._gain.connect(input); + + return; + } + + // The bus has no nodes yet because the AudioContext is still locked. Unlike a + // voice's dry path there is nothing to fall back to - routing a send to the + // destination would bypass the very effect chain it exists to reach, and + // would be audible. So it stays silent until the bus comes online. + this._pendingBusSetup = this.bus.onceSetup((): void => { + this._pendingBusSetup = null; + + if (this._destroyed) { + return; + } + + const node = this.bus._getInputNode(); + + if (node !== null) { + this._gain.connect(node); + } + }); + } +} diff --git a/src/audio/AudioStream.ts b/src/audio/AudioStream.ts index ff81c05a4..4c76ad71c 100644 --- a/src/audio/AudioStream.ts +++ b/src/audio/AudioStream.ts @@ -7,7 +7,7 @@ import { getAudioContext } from './audio-context'; import type { AudioManager } from './AudioManager'; import { AudioStreamVoice } from './AudioStreamVoice'; import type { Playable, PlayOptions, Voice } from './Playable'; -import { seedVoiceFromPlayOptions } from './spatial-options'; +import { seedVoiceFromPlayOptions, seedVoiceSends } from './spatial-options'; /** * Streaming long-form audio backed by an `HTMLAudioElement` - background @@ -140,6 +140,7 @@ export class AudioStream implements Playable { }); seedVoiceFromPlayOptions(voice, options); + seedVoiceSends(voice, options); this._activeVoice = voice; voice.onEnd.add((): void => { diff --git a/src/audio/AudioZone.ts b/src/audio/AudioZone.ts new file mode 100644 index 000000000..f39fae7ec --- /dev/null +++ b/src/audio/AudioZone.ts @@ -0,0 +1,128 @@ +import type { Rectangle } from '#math/Rectangle'; + +import type { AudioBus } from './AudioBus'; + +/** A circular zone footprint: a centre on the world plane plus a radius. */ +export interface AudioZoneCircle { + readonly x: number; + readonly y: number; + readonly radius: number; +} + +/** Where an {@link AudioZone} applies - an axis-aligned {@link Rectangle} or a circle. */ +export type AudioZoneShape = Rectangle | AudioZoneCircle; + +/** Construction options for {@link AudioZone}. */ +export interface AudioZoneOptions { + /** Footprint on the world plane. */ + readonly shape: AudioZoneShape; + /** + * Bus every voice's send is opened into while this zone is active. Not owned - + * the caller builds it, hangs whatever effect chain the zone represents on it, + * and destroys it. + */ + readonly bus: AudioBus; + /** Send level at full weight. Default `1`. */ + readonly send?: number; + /** Distance outside the shape over which the weight ramps to zero. Default `0` - a hard edge. */ + readonly falloff?: number; + /** Height band the zone occupies, measured from the world plane. Default `Infinity` - the zone is a column. */ + readonly height?: number; + /** Free label for diagnostics. */ + readonly name?: string; +} + +const isCircle = (shape: AudioZoneShape): shape is AudioZoneCircle => (shape as AudioZoneCircle).radius !== undefined; + +/** + * A region of the world that contributes a parallel send while the listener is + * inside it - a reverb zone, a muffled corridor, an underwater section. + * + * The zone owns geometry and a level, and nothing else: it does not route audio, + * hold effects or touch a voice. {@link SpatialZones} reads + * {@link AudioZone.weightAt} once per frame and maintains the sends. That split + * is deliberate - it means a zone is a plain value a level file can describe, and + * the effect chain behind it is an ordinary {@link AudioBus} the caller already + * knows how to build. + * + * Reverb belongs to the environment the LISTENER is in, not to each source, which + * is why the weight is sampled at the listener and applied to every audible + * voice. + * + * @example + * ```ts + * const cave = new AudioBus('cave-reverb'); + * cave.addEffect(myConvolver); + * + * app.audio.zones.add(new AudioZone({ shape: caveBounds, bus: cave, send: 0.6, falloff: 120 })); + * ``` + * @stable + */ +export class AudioZone { + public readonly bus: AudioBus; + public readonly name: string; + + public shape: AudioZoneShape; + /** Send level at full weight. */ + public send: number; + /** Distance outside the shape over which the weight ramps from `1` to `0`. */ + public falloff: number; + /** Height band above and below the world plane the zone covers. */ + public height: number; + + public constructor({ shape, bus, send = 1, falloff = 0, height = Number.POSITIVE_INFINITY, name = 'zone' }: AudioZoneOptions) { + this.shape = shape; + this.bus = bus; + this.send = send; + this.falloff = Math.max(falloff, 0); + this.height = Math.max(height, 0); + this.name = name; + } + + /** + * How strongly this zone applies at `(x, y, z)`: `1` inside the shape, `0` + * beyond {@link AudioZone.falloff}, and a linear ramp between. + * + * The ramp is measured on the distance to the shape's boundary rather than to + * its centre, so a long corridor fades over the same distance at its middle as + * at its ends. + */ + public weightAt(x: number, y: number, z = 0): number { + if (Math.abs(z) > this.height) { + return 0; + } + + const distance = this._distanceToEdge(x, y); + + if (distance <= 0) { + return 1; + } + + if (this.falloff === 0 || distance >= this.falloff) { + return 0; + } + + return 1 - distance / this.falloff; + } + + /** Distance from `(x, y)` to the shape's boundary; `<= 0` inside. */ + private _distanceToEdge(x: number, y: number): number { + const shape = this.shape; + + if (isCircle(shape)) { + return Math.hypot(x - shape.x, y - shape.y) - shape.radius; + } + + // Outside a rectangle the distance is the length of the componentwise + // overshoot; inside, both components are negative and the closest edge is the + // larger (least negative) of the two. + const dx = Math.max(shape.left - x, x - shape.right); + const dy = Math.max(shape.top - y, y - shape.bottom); + + if (dx <= 0 && dy <= 0) { + return Math.max(dx, dy); + } + + return Math.hypot(Math.max(dx, 0), Math.max(dy, 0)); + } +} diff --git a/src/audio/BaseVoice.ts b/src/audio/BaseVoice.ts index d100afcd3..2aefa7cf0 100644 --- a/src/audio/BaseVoice.ts +++ b/src/audio/BaseVoice.ts @@ -8,7 +8,8 @@ import type { AudioBus } from './AudioBus'; import type { AudioEffect } from './AudioEffect'; import { isEffectReady } from './AudioEffect'; import type { AudioManager } from './AudioManager'; -import type { DistanceModel, Spatializable, Voice } from './Playable'; +import { AudioSend } from './AudioSend'; +import type { DistanceModel, Spatializable, SpatialPoint, Voice } from './Playable'; import { createVelocitySample, deriveVelocity, @@ -96,6 +97,13 @@ export abstract class BaseVoice implements Voice, SpatialVoice { private _spatialRegistered = false; private _velocity: Vector | null = null; private _explicitVelocity = false; + private _elevation = 0; + private _elevationVelocity = 0; + private _occlusion = 0; + /** Lowpass + attenuation for {@link BaseVoice.occlusion}; both `null` until it leaves `0`. */ + private _occlusionFilter: BiquadFilterNode | null = null; + private _occlusionGain: GainNode | null = null; + private readonly _sends: AudioSend[] = []; private readonly _velocitySample: VelocitySample = createVelocitySample(); private readonly _smoothX = new SmoothedAudioParam(); private readonly _smoothY = new SmoothedAudioParam(); @@ -257,7 +265,7 @@ export abstract class BaseVoice implements Voice, SpatialVoice { return this._position; } - public set position(value: Vector | { x: number; y: number } | null) { + public set position(value: Vector | SpatialPoint | null) { if (this._ended) return; if (value === null) { @@ -275,6 +283,15 @@ export abstract class BaseVoice implements Voice, SpatialVoice { this._position.set(value.x, value.y); } + // Only a supplied `z` writes elevation. A point without one leaves the + // current height alone rather than resetting it, so `follow()` and a plain + // `{ x, y }` write cannot silently drop a source back to the plane. + const z = (value as SpatialPoint).z; + + if (z !== undefined) { + this._elevation = z; + } + this._ensurePanner(); this._tickSpatial(); } @@ -420,7 +437,7 @@ export abstract class BaseVoice implements Voice, SpatialVoice { return this._velocity; } - public set velocity(value: Vector | { x: number; y: number } | null) { + public set velocity(value: Vector | SpatialPoint | null) { if (this._ended) return; if (value === null) { @@ -442,9 +459,125 @@ export abstract class BaseVoice implements Voice, SpatialVoice { } else { this._velocity.set(value.x, value.y); } + + const z = (value as SpatialPoint).z; + + if (z !== undefined) { + this._elevationVelocity = z; + } + this._explicitVelocity = true; } + public get elevation(): number { + return this._elevation; + } + + public set elevation(value: number) { + if (!Number.isFinite(value) || value === this._elevation) { + return; + } + + this._elevation = value; + // A voice positioned only by elevation is still spatial - without this a + // caller who sets height before position would get no panner at all. + this._ensurePanner(); + this._tickSpatial(); + } + + public get elevationVelocity(): number { + return this._elevationVelocity; + } + + public set elevationVelocity(value: number) { + if (!Number.isFinite(value)) { + return; + } + + this._elevationVelocity = value; + this._explicitVelocity = true; + } + + public get occlusion(): number { + return this._occlusion; + } + + public set occlusion(value: number) { + const clamped = clamp(Number.isFinite(value) ? value : 0, 0, 1); + + if (clamped === this._occlusion) { + return; + } + + const wasClear = this._occlusion === 0; + + this._occlusion = clamped; + + if (this._ended) { + return; + } + + // Nothing is built for a voice that stays clear, and nothing is torn down + // when it returns to clear: the two nodes are cheap to leave in place and + // rebuilding the chain on every threshold crossing would be audible. + if (wasClear && this._occlusionFilter === null) { + this._buildOcclusionStage(); + } + + this._writeOcclusion(); + } + + public get sends(): readonly AudioSend[] { + return this._sends; + } + + public addSend(bus: AudioBus, level = 1): AudioSend { + const send = new AudioSend(this._audioContext, this._output, bus, level); + + this._sends.push(send); + + // A send on an already-finished voice is legal but pointless; destroying it + // straight away keeps the invariant that a voice's sends never outlive it. + if (this._ended) { + send.destroy(); + } + + return send; + } + + /** + * Take ownership of a send opened elsewhere against this voice's output, so it + * is torn down with this voice like any other. + * + * Used by the deferred voice a scene hands out: the send exists before the real + * voice does, and re-creating it at flush would invalidate the handle the + * caller already holds. + * @internal + */ + public _adoptSend(send: AudioSend): void { + if (this._sends.includes(send)) { + return; + } + + send._retarget(this._output); + this._sends.push(send); + + if (this._ended) { + send.destroy(); + } + } + + public removeSend(send: AudioSend): this { + const index = this._sends.indexOf(send); + + if (index !== -1) { + this._sends.splice(index, 1); + send.destroy(); + } + + return this; + } + /** @internal Called once per frame by {@link AudioManager.update} for spatial voices. */ public _tickSpatial(): void { if (this._panner === null || this._ended) return; @@ -482,9 +615,11 @@ export abstract class BaseVoice implements Voice, SpatialVoice { // {@link AudioListener}). With the listener at the origin the offset vector // is all a panner needs: distance, attenuation and the distance model come // out mathematically identical to writing absolute positions. - const listenerPosition = this._manager.listener.position; + const listener = this._manager.listener; + const listenerPosition = listener.position; const relativeX = x - listenerPosition.x; const relativeY = y - listenerPosition.y; + const relativeZ = this._elevation - listener.elevation; if (panner.positionX) { // Route through the smoothing layer (setTargetAtTime + epsilon-skip + @@ -493,16 +628,16 @@ export abstract class BaseVoice implements Voice, SpatialVoice { // centrally on the listener's own params. this._smoothX.write(panner.positionX, relativeX, t, settings); this._smoothY.write(panner.positionY!, relativeY, t, settings); - this._smoothZ.write(panner.positionZ!, 0, t, settings); + this._smoothZ.write(panner.positionZ!, relativeZ, t, settings); } else if (panner.setPosition) { // Legacy AudioParam-less API: snap only (no smoothing available). - panner.setPosition(relativeX, relativeY, 0); + panner.setPosition(relativeX, relativeY, relativeZ); } this._writeOrientation(); // Doppler stays in ABSOLUTE world coordinates: it projects both velocities // onto the true line of sight and never touches a panner position param. - this._tickDoppler(x, y, t, settings); + this._tickDoppler(x, y, this._elevation, t, settings); } /** @@ -527,7 +662,7 @@ export abstract class BaseVoice implements Voice, SpatialVoice { * arbitrarily high/low, so the clamp is what actually keeps that case sane, * not the formula itself. */ - private _tickDoppler(x: number, y: number, now: number, settings: SpatialSmoothingSettings): void { + private _tickDoppler(x: number, y: number, z: number, now: number, settings: SpatialSmoothingSettings): void { if (settings.dopplerFactor <= 0) { this._setDopplerRatio(1); return; @@ -535,6 +670,9 @@ export abstract class BaseVoice implements Voice, SpatialVoice { let vx: number; let vy: number; + // Only ever explicit: a derived velocity comes from the tracked position, + // and nothing tracks elevation - `follow()` reads a 2D scene node. + const vz = this._explicitVelocity ? this._elevationVelocity : 0; if (this._explicitVelocity && this._velocity !== null) { vx = this._velocity.x; @@ -548,7 +686,8 @@ export abstract class BaseVoice implements Voice, SpatialVoice { const listener = this._manager.listener; const dx = x - listener.position.x; const dy = y - listener.position.y; - const distance = Math.hypot(dx, dy); + const dz = z - listener.elevation; + const distance = Math.hypot(dx, dy, dz); // Coincident with the listener - no defined line of sight to project onto. if (distance < POSITION_EPSILON) { this._setDopplerRatio(1); @@ -557,12 +696,13 @@ export abstract class BaseVoice implements Voice, SpatialVoice { const ux = dx / distance; const uy = dy / distance; + const uz = dz / distance; // Positive = source moving away from the listener along the line of sight. - const sourceRecedeSpeed = vx * ux + vy * uy; + const sourceRecedeSpeed = vx * ux + vy * uy + vz * uz; const listenerVelocity = listener.velocity; // Positive = listener moving toward the source along the same line. - const listenerApproachSpeed = listenerVelocity.x * ux + listenerVelocity.y * uy; + const listenerApproachSpeed = listenerVelocity.x * ux + listenerVelocity.y * uy + listener.elevationVelocity * uz; const speedOfSound = Math.max(POSITION_EPSILON, settings.speedOfSound); const rawRatio = 1 + settings.dopplerFactor * ((listenerApproachSpeed - sourceRecedeSpeed) / speedOfSound); @@ -582,9 +722,12 @@ export abstract class BaseVoice implements Voice, SpatialVoice { /** * Convert `_orientation` (degrees, `SceneNode.rotation` convention) to a - * unit XY vector (Z fixed at 0 - no Z axis in this engine) and write it - * through the same smoothing layer used for position, so a fast-rotating - * emitter's cone direction never zippers. + * unit XY vector and write it through the same smoothing layer used for + * position, so a fast-rotating emitter's cone direction never zippers. + * + * Z stays 0 even for an elevated source: `orientation` is a single in-plane + * angle, so a cone always points along the world plane. Tilting one would need + * a second angle, which no caller can supply today. */ private _writeOrientation(): void { if (this._panner === null || this._ended) return; @@ -615,10 +758,71 @@ export abstract class BaseVoice implements Voice, SpatialVoice { // Internals // ------------------------------------------------------------------------- - /** The last node in the voice chain before the bus - the output gain, or the last effect. */ + /** The last node in the voice chain before the bus - the output gain, the occlusion stage, or the last effect. */ protected _tail(): AudioNode { const lastEffect = this._effects[this._effects.length - 1]; - return lastEffect !== undefined ? lastEffect.outputNode : this._output; + + if (lastEffect !== undefined) { + return lastEffect.outputNode; + } + + return this._occlusionGain ?? this._output; + } + + /** + * Create the occlusion lowpass and attenuation and splice them in as + * `output -> lowpass -> gain -> [effects] -> bus`. + * + * Before the caller's own effect chain on purpose: occlusion describes the path + * from the source to the listener, so it belongs with the source, and an insert + * the caller added is meant to hear what the listener would. + */ + private _buildOcclusionStage(): void { + const filter = this._audioContext.createBiquadFilter(); + + filter.type = 'lowpass'; + filter.frequency.value = this._openCutoff(); + filter.Q.value = 0.7071; + + const gain = this._audioContext.createGain(); + + gain.gain.value = 1; + + this._occlusionFilter = filter; + this._occlusionGain = gain; + this._rebuildEffectChain(); + } + + /** Highest cutoff the context can express - a lowpass above Nyquist is a no-op, not an error. */ + private _openCutoff(): number { + return this._audioContext.sampleRate / 2; + } + + /** + * Write the current occlusion amount onto the lowpass and the attenuation. + * + * The cutoff sweeps LOGARITHMICALLY between the open value and + * `spatial.occlusionCutoff`. A linear sweep spends most of its range in the + * inaudible top octaves, so half the parameter would do almost nothing. + */ + private _writeOcclusion(): void { + const filter = this._occlusionFilter; + const gain = this._occlusionGain; + + if (filter === null || gain === null) { + return; + } + + const settings = this._manager.spatial; + const now = this._audioContext.currentTime; + const open = this._openCutoff(); + const closed = clamp(settings.occlusionCutoff, 20, open); + const cutoff = open * (closed / open) ** this._occlusion; + const attenuated = clamp(settings.occlusionAttenuation, 0, 1); + const timeConstant = Math.max(settings.smoothing, POSITION_EPSILON); + + filter.frequency.setTargetAtTime(cutoff, now, timeConstant); + gain.gain.setTargetAtTime(1 + (attenuated - 1) * this._occlusion, now, timeConstant); } protected _connectOutput(): void { @@ -657,11 +861,20 @@ export abstract class BaseVoice implements Voice, SpatialVoice { if (this._ended) return; this._output.disconnect(); + this._occlusionFilter?.disconnect(); + this._occlusionGain?.disconnect(); for (const effect of this._effects) { effect.outputNode.disconnect(); } let prev: AudioNode = this._output; + + if (this._occlusionFilter !== null && this._occlusionGain !== null) { + prev.connect(this._occlusionFilter); + this._occlusionFilter.connect(this._occlusionGain); + prev = this._occlusionGain; + } + for (const effect of this._effects) { prev.connect(effect.inputNode); prev = effect.outputNode; @@ -741,7 +954,17 @@ export abstract class BaseVoice implements Voice, SpatialVoice { this._teardownSource(); this._panner?.disconnect(); + // Sends read `_output`, so they go before it is disconnected - the shared + // bus each one feeds must not be left with a live tap on a dead voice. + for (const send of this._sends) { + send.destroy(); + } + this._sends.length = 0; this._output.disconnect(); + this._occlusionFilter?.disconnect(); + this._occlusionGain?.disconnect(); + this._occlusionFilter = null; + this._occlusionGain = null; // Detach per-voice effects from the chain (the caller still owns them). // Skipped for an effect whose own nodes have not been created yet - same diff --git a/src/audio/NoopVoice.ts b/src/audio/NoopVoice.ts index 74d63a39e..1b687a1c5 100644 --- a/src/audio/NoopVoice.ts +++ b/src/audio/NoopVoice.ts @@ -5,7 +5,11 @@ import type { Vector } from '#math/Vector'; import { getAudioContext } from './audio-context'; import type { AudioBus } from './AudioBus'; import type { AudioEffect } from './AudioEffect'; -import type { DistanceModel, Voice } from './Playable'; +import type { AudioSend } from './AudioSend'; +import type { DistanceModel, SpatialPoint, Voice } from './Playable'; + +/** Shared empty list, so `sends` never allocates on a voice that can never have one. */ +const emptySends: readonly AudioSend[] = Object.freeze([]); /** * An already-ended {@link Voice} returned for degenerate play calls - a seek @@ -64,6 +68,27 @@ export class NoopVoice implements Voice { return this; } + public get sends(): readonly AudioSend[] { + return emptySends; + } + + public addSend(_bus: AudioBus, _level?: number): AudioSend { + // A send on a voice that already ended would tap a graph with nothing in it. + // Refusing loudly beats handing back a dead object the caller has to check. + throw new Error('Cannot open an audio send on a voice that has already ended.'); + } + + public removeSend(_send: AudioSend): this { + return this; + } + + /** @internal */ + public _adoptSend(send: AudioSend): void { + // Nothing to adopt it into - this voice has already ended, so the send would + // tap a graph with nothing in it. + send.destroy(); + } + // Spatializable - inert like the rest of this class. A voice that has // already ended has nowhere to place a panner, but the getters still answer // with the documented defaults so a caller that positions a voice without @@ -73,7 +98,23 @@ export class NoopVoice implements Voice { return null; } - public set position(_value: Vector | { x: number; y: number } | null) { + public set position(_value: Vector | SpatialPoint | null) { + // inert - the voice already ended + } + + public get elevation(): number { + return 0; + } + + public set elevation(_value: number) { + // inert - the voice already ended + } + + public get occlusion(): number { + return 0; + } + + public set occlusion(_value: number) { // inert - the voice already ended } @@ -157,7 +198,15 @@ export class NoopVoice implements Voice { return null; } - public set velocity(_value: Vector | { x: number; y: number } | null) { + public set velocity(_value: Vector | SpatialPoint | null) { + // inert - the voice already ended + } + + public get elevationVelocity(): number { + return 0; + } + + public set elevationVelocity(_value: number) { // inert - the voice already ended } } diff --git a/src/audio/Playable.ts b/src/audio/Playable.ts index 2e9539e7d..a3808a89f 100644 --- a/src/audio/Playable.ts +++ b/src/audio/Playable.ts @@ -5,6 +5,7 @@ import type { Vector } from '#math/Vector'; import type { AudioBus } from './AudioBus'; import type { AudioEffect } from './AudioEffect'; import type { AudioManager } from './AudioManager'; +import type { AudioSend } from './AudioSend'; /** * A live playback instance in the audio graph with a control surface. @@ -59,6 +60,34 @@ export interface Voice extends Spatializable { addEffect(effect: AudioEffect): this; /** Remove a previously added per-voice effect. The caller still owns it and must `destroy()` it. */ removeEffect(effect: AudioEffect): this; + /** + * Open a parallel send from this voice's output into `bus` at `level` + * (default `1`). + * + * The dry path is untouched: the voice keeps playing into its own + * {@link Voice.bus}, and a copy of the same signal additionally reaches `bus`. + * Use it for shared ambience processing - one reverb serving many voices - + * which an insert effect cannot express, because an insert replaces the signal + * rather than duplicating it. + * + * The returned {@link AudioSend} is owned by this voice and torn down with it; + * remove one early with {@link Voice.removeSend} only to change the routing. + */ + addSend(bus: AudioBus, level?: number): AudioSend; + /** Tear down a send opened on this voice. Idempotent; a send from another voice is ignored. */ + removeSend(send: AudioSend): this; + /** Live view of this voice's open sends, in the order they were opened. */ + readonly sends: readonly AudioSend[]; + /** + * Take ownership of a send opened against a different node, re-pointing it at + * this voice's output. + * + * Exists for a deferred voice: a scene hands one out before the asset is ready, + * so a send opened on it is wired to a placeholder and has to be handed over - + * re-creating it would invalidate the handle the caller already holds. + * @internal + */ + _adoptSend(send: AudioSend): void; } /** A voice whose playhead can be read and moved. */ @@ -104,12 +133,59 @@ export interface RatePitched { */ export type DistanceModel = 'linear' | 'inverse' | 'exponential'; -/** A voice that can be positioned in 2D space and optionally track a node. */ +/** + * A point in the audio world: the 2D world plane, plus an optional out-of-plane + * height in the same units. + * + * `z` is optional everywhere it appears - a 2D game never supplies it, and a + * scene node cannot, because the scene graph has no third axis. + */ +export interface SpatialPoint { + readonly x: number; + readonly y: number; + readonly z?: number; +} + +/** A voice that can be positioned in space and optionally track a node. */ export interface Spatializable { - /** World-space position of the source, or `null` when not spatialized. */ + /** + * World-plane position of the source, or `null` when not spatialized. + * + * Two-dimensional, because the world plane is: the third axis lives on + * {@link Spatializable.elevation}, which {@link Spatializable.follow} cannot + * fill in and which most 2D games never touch. + */ get position(): Vector | null; - /** Accepts any `{ x, y }` point - implementations copy the values. */ - set position(value: Vector | { x: number; y: number } | null); + /** + * Accepts any `{ x, y }` point - implementations copy the values. A supplied + * `z` is written to {@link Spatializable.elevation}, so a caller who thinks in + * three axes can pass one point instead of two properties. + */ + set position(value: Vector | SpatialPoint | null); + /** + * Height of the source above (positive) or below (negative) the world plane, + * in world units. Default `0`. + * + * Independent of {@link Spatializable.position}, and preserved across a + * position change that does not carry a `z`. It contributes to distance + * attenuation, to the panner's own directionality, and to Doppler - a source + * rising straight up recedes. + */ + elevation: number; + /** + * How obstructed the path from this source to the listener is, in `[0, 1]`. + * `0` (default) is a clear path; `1` is fully obstructed. + * + * Caller-supplied: the engine does not trace geometry, because what counts as + * an obstruction is a game's decision (a wall, a closed door, a crowd). Write + * an estimate as often as you like - it is ramped, not stepped, so a per-frame + * value does not click. + * + * Realized as a lowpass plus an attenuation, tuned by + * `app.audio.spatial.occlusionCutoff` / `.occlusionAttenuation`. A voice whose + * occlusion stays `0` builds neither node. + */ + occlusion: number; /** * Track a {@link SceneNode}: the voice reads the node's global translation * each frame. Pass `null` to stop following and fall back to @@ -150,8 +226,16 @@ export interface Spatializable { * position delta instead. */ get velocity(): Vector | null; - /** Accepts any `{ x, y }` point - implementations copy the values. */ - set velocity(value: Vector | { x: number; y: number } | null); + /** + * Accepts any `{ x, y }` point - implementations copy the values. A supplied + * `z` is written to {@link Spatializable.elevationVelocity}. + */ + set velocity(value: Vector | SpatialPoint | null); + /** + * Vertical component of {@link Spatializable.velocity}, in world units per + * second. Default `0`. Only Doppler reads it. + */ + elevationVelocity: number; } /** @@ -173,7 +257,11 @@ export interface PlayOptions { /** Start muted (volume 0). */ muted?: boolean; /** Initial spatial position - equivalent to setting `voice.position` right after play. */ - position?: { x: number; y: number } | Vector; + position?: SpatialPoint | Vector; + /** Initial height above the world plane. Default `0`. */ + elevation?: number; + /** Initial occlusion amount in `[0, 1]`. Default `0` (clear path). */ + occlusion?: number; /** Initial distance-attenuation model. Default `'linear'`. */ distanceModel?: DistanceModel; /** Initial reference distance. Default `50`. */ @@ -193,7 +281,11 @@ export interface PlayOptions { /** Initial gain outside the outer cone. Default `0`. */ coneOuterGain?: number; /** Initial velocity for Doppler. See {@link Spatializable.velocity}. */ - velocity?: { x: number; y: number } | Vector; + velocity?: SpatialPoint | Vector; + /** Initial vertical velocity for Doppler. Default `0`. */ + elevationVelocity?: number; + /** Parallel sends to open on the voice right after play - one per bus. */ + sends?: ReadonlyArray<{ readonly bus: AudioBus; readonly level?: number }>; } /** diff --git a/src/audio/Sound.ts b/src/audio/Sound.ts index 9a1ff9f63..e8477676b 100644 --- a/src/audio/Sound.ts +++ b/src/audio/Sound.ts @@ -8,7 +8,7 @@ import type { AudioManager } from './AudioManager'; import { NoopVoice } from './NoopVoice'; import type { Playable, PlayOptions, Voice } from './Playable'; import { SoundVoice, type SoundVoiceWindow } from './SoundVoice'; -import { seedVoiceFromPlayOptions } from './spatial-options'; +import { seedVoiceFromPlayOptions, seedVoiceSends } from './spatial-options'; /** * Eviction strategy used when the pool is full and a new play is requested. @@ -617,6 +617,7 @@ export class Sound implements Playable { }); seedVoiceFromPlayOptions(voice, options); + seedVoiceSends(voice, options); const startedAt = audioContext.currentTime; const effectiveDuration = loop ? Infinity : window.end - offset; diff --git a/src/audio/SpatialZones.ts b/src/audio/SpatialZones.ts new file mode 100644 index 000000000..778caf4c1 --- /dev/null +++ b/src/audio/SpatialZones.ts @@ -0,0 +1,173 @@ +import type { AudioListener } from './AudioListener'; +import type { AudioSend } from './AudioSend'; +import type { AudioZone } from './AudioZone'; +import type { Voice } from './Playable'; + +/** Send level below which a zone's contribution is dropped entirely rather than left at near-silence. */ +const inaudibleSend = 0.0005; + +/** + * The optional zone layer: which {@link AudioZone}s exist, and the sends they + * currently hold open. + * + * Owned by {@link AudioManager} and reachable as `app.audio.zones`. Completely + * inert until a zone is added - the per-frame tick returns immediately, so an + * application that never uses zones pays one branch. + * + * Once a zone exists, each frame: + * + * 1. every zone's weight is sampled at the LISTENER's position, because that is + * where an environment is heard from; + * 2. every audible voice gets one send per zone whose weight is above zero, at + * `weight * zone.send`; + * 3. a send whose zone has faded out, or whose voice has ended, is dropped. + * + * Sends are opened lazily and reused across frames, so walking into a zone costs + * one `GainNode` per voice and then nothing per frame but a level write - and the + * level is ramped, so the boundary is a crossfade rather than a switch. + * + * The zone layer never owns a bus or an effect. A zone names a bus the caller + * built; what that bus does - a convolution reverb, a lowpass, a pitch shift - is + * entirely the caller's, and two zones may legitimately name the same one. + * @stable + */ +export class SpatialZones { + private readonly _zones: AudioZone[] = []; + /** One entry per voice that currently has at least one zone send open. */ + private readonly _sends = new Map>(); + + /** The zones currently registered, in the order they were added. */ + public get zones(): readonly AudioZone[] { + return this._zones; + } + + /** Whether any zone is registered. `false` means the per-frame tick does nothing at all. */ + public get active(): boolean { + return this._zones.length > 0; + } + + /** Register `zone`. Adding the same zone twice is a no-op. */ + public add(zone: AudioZone): this { + if (!this._zones.includes(zone)) { + this._zones.push(zone); + } + + return this; + } + + /** + * Unregister `zone` and close every send it holds open. + * + * The zone's bus is untouched: it belongs to the caller, who may still be using + * it for something else. + */ + public remove(zone: AudioZone): this { + const index = this._zones.indexOf(zone); + + if (index === -1) { + return this; + } + + this._zones.splice(index, 1); + + for (const [voice, byZone] of this._sends) { + const send = byZone.get(zone); + + if (send !== undefined) { + voice.removeSend(send); + byZone.delete(zone); + } + + if (byZone.size === 0) { + this._sends.delete(voice); + } + } + + return this; + } + + /** Unregister every zone and close every send. */ + public clear(): this { + for (const zone of [...this._zones]) { + this.remove(zone); + } + + return this; + } + + /** + * Reconcile the open sends against the listener's current position. + * + * `voices` is the live set of spatial voices; a voice that has ended is skipped + * and its sends released - the voice tears them down itself, so this only drops + * the bookkeeping. + * @internal - driven once per frame by {@link AudioManager.preUpdate}. + */ + public _tick(listener: AudioListener, voices: Iterable): void { + if (this._zones.length === 0) { + // Nothing registered and nothing left over: the common case, and the reason + // an application that never uses zones pays nothing per frame. + if (this._sends.size > 0) { + this._sends.clear(); + } + + return; + } + + const { x, y } = listener.position; + const elevation = listener.elevation; + + for (const voice of voices) { + if (voice.ended) { + this._sends.delete(voice); + continue; + } + + this._reconcileVoice(voice, x, y, elevation); + } + } + + /** Drop the bookkeeping for a voice; the voice itself owns the send objects. @internal */ + public _forget(voice: Voice): void { + this._sends.delete(voice); + } + + private _reconcileVoice(voice: Voice, x: number, y: number, z: number): void { + let byZone = this._sends.get(voice); + + for (const zone of this._zones) { + const level = zone.weightAt(x, y, z) * zone.send; + const existing = byZone?.get(zone); + + if (level <= inaudibleSend) { + // Closed rather than left at zero: an open send is a live graph edge into + // a shared bus, and a scene that walks past a hundred zones would keep + // one per zone per voice alive for nothing. + if (existing !== undefined) { + voice.removeSend(existing); + byZone?.delete(zone); + } + + continue; + } + + if (existing !== undefined) { + existing.level = level; + continue; + } + + byZone ??= new Map(); + byZone.set(zone, voice.addSend(zone.bus, level)); + } + + if (byZone === undefined) { + return; + } + + if (byZone.size === 0) { + this._sends.delete(voice); + } else { + this._sends.set(voice, byZone); + } + } +} diff --git a/src/audio/index.ts b/src/audio/index.ts index f08faa841..ffa1d62a7 100644 --- a/src/audio/index.ts +++ b/src/audio/index.ts @@ -8,8 +8,11 @@ export type { AudioInputOptions } from './AudioInput'; export { AudioInput } from './AudioInput'; export { AudioListener, type AudioListenerTarget } from './AudioListener'; export { AudioManager } from './AudioManager'; +export { AudioSend } from './AudioSend'; export { AudioStream } from './AudioStream'; export { AudioUnsupportedError } from './AudioUnsupportedError'; +export type { AudioZoneCircle, AudioZoneOptions, AudioZoneShape } from './AudioZone'; +export { AudioZone } from './AudioZone'; export { BiquadEffect, type BiquadEffectOptions } from './BiquadEffect'; export type { CrossFadeOptions } from './crossFade'; export { crossFade } from './crossFade'; @@ -17,9 +20,10 @@ export type { EnvelopeOptions } from './Envelope'; export { Envelope } from './Envelope'; export type { HighpassFilterOptions, LowpassFilterOptions } from './filters'; export { HighpassFilter, LowpassFilter } from './filters'; -export type { DistanceModel, Loopable, Pausable, Playable, PlayOptions, RatePitched, Seekable, Spatializable, Voice } from './Playable'; +export type { DistanceModel, Loopable, Pausable, Playable, PlayOptions, RatePitched, Seekable, Spatializable, SpatialPoint, Voice } from './Playable'; export type { AudioSpriteClip, SoundOptions, SoundPlayOptions } from './Sound'; export { Sound, SoundPoolStrategy } from './Sound'; export type { SpatialSmoothingSettings } from './spatial-smoothing'; +export { SpatialZones } from './SpatialZones'; export { WorkletEffect } from './WorkletEffect'; export { registerAudioWorkletProcessor } from '#audio/worklet/registerWorklet'; diff --git a/src/audio/spatial-options.ts b/src/audio/spatial-options.ts index 61e644e8c..c5948e963 100644 --- a/src/audio/spatial-options.ts +++ b/src/audio/spatial-options.ts @@ -1,4 +1,4 @@ -import type { PlayOptions, Spatializable } from './Playable'; +import type { PlayOptions, Spatializable, Voice } from './Playable'; /** * Apply every spatial {@link PlayOptions} field present on `options` to @@ -22,7 +22,28 @@ export const seedVoiceFromPlayOptions = (voice: Spatializable, options: PlayOpti if (options.coneInnerAngle !== undefined) voice.coneInnerAngle = options.coneInnerAngle; if (options.coneOuterAngle !== undefined) voice.coneOuterAngle = options.coneOuterAngle; if (options.coneOuterGain !== undefined) voice.coneOuterGain = options.coneOuterGain; + // Before `position`, for the same reason the cone fields are: setting position + // creates the panner, and the first relative write should already carry the + // final height rather than snapping to the plane and ramping up from there. + if (options.elevation !== undefined) voice.elevation = options.elevation; if (options.position !== undefined) voice.position = options.position; if (options.panningModel !== undefined) voice.panningModel = options.panningModel; if (options.velocity !== undefined) voice.velocity = options.velocity; + if (options.elevationVelocity !== undefined) voice.elevationVelocity = options.elevationVelocity; + if (options.occlusion !== undefined) voice.occlusion = options.occlusion; +}; + +/** + * Open the parallel sends a play call asked for. Separate from + * {@link seedVoiceFromPlayOptions} because a send is a lifecycle-owning object + * rather than a value, and only a live {@link Voice} can hold one. + */ +export const seedVoiceSends = (voice: Voice, options: PlayOptions): void => { + if (options.sends === undefined) { + return; + } + + for (const { bus, level } of options.sends) { + voice.addSend(bus, level); + } }; diff --git a/src/audio/spatial-smoothing.ts b/src/audio/spatial-smoothing.ts index 318cc24a6..f2e171ffd 100644 --- a/src/audio/spatial-smoothing.ts +++ b/src/audio/spatial-smoothing.ts @@ -22,9 +22,9 @@ * time constant, which is robust to a variable frame rate and converges within * ~3τ. * - * This is the tactical smoothing layer for today's panner/listener surface; the - * forthcoming 3D-spatializer design (`S2-audio-spatializer`) extends the same - * settings object with distance/panning defaults. + * The same settings object carries the app-wide spatial defaults - panning model, + * Doppler scale, and the two occlusion endpoints - because they are tuned + * together and read on the same per-frame path. */ /** Time constant (seconds) for `setTargetAtTime`. 20 ms - see module docs. */ @@ -40,6 +40,12 @@ export const DEFAULT_TELEPORT_THRESHOLD = 400; */ export const POSITION_EPSILON = 0.01; +/** Lowpass cutoff (Hz) a fully occluded voice is filtered to. */ +export const DEFAULT_OCCLUSION_CUTOFF = 400; + +/** Linear gain a fully occluded voice is attenuated to. */ +export const DEFAULT_OCCLUSION_ATTENUATION = 0.25; + /** * Tunable smoothing settings shared by the listener and all spatial voices. Owned * by {@link AudioManager} and reachable as `app.audio.spatial`. @@ -88,6 +94,19 @@ export interface SpatialSmoothingSettings { * speeds produce a noticeable but not extreme shift. */ speedOfSound: number; + /** + * Lowpass cutoff (Hz) a voice at {@link Spatializable.occlusion} `1` is + * filtered to. Default {@link DEFAULT_OCCLUSION_CUTOFF} (400 Hz) - roughly + * "behind a closed door". Interpolation between clear and fully occluded is + * logarithmic, matching how pitch is perceived. + */ + occlusionCutoff: number; + /** + * Linear gain a voice at {@link Spatializable.occlusion} `1` is attenuated to. + * Default {@link DEFAULT_OCCLUSION_ATTENUATION}. Not `0`: a fully occluded + * source that goes silent reads as a bug rather than as an obstruction. + */ + occlusionAttenuation: number; } /** Construct the default spatial smoothing settings. */ @@ -97,6 +116,8 @@ export const createSpatialSmoothingSettings = (): SpatialSmoothingSettings => ({ panningModel: 'equalpower', dopplerFactor: 0, speedOfSound: 1000, + occlusionCutoff: DEFAULT_OCCLUSION_CUTOFF, + occlusionAttenuation: DEFAULT_OCCLUSION_ATTENUATION, }); /** diff --git a/src/core/Application.ts b/src/core/Application.ts index bd6c736b1..d480d9cd3 100644 --- a/src/core/Application.ts +++ b/src/core/Application.ts @@ -2196,6 +2196,7 @@ export class Application = {}> { private async initializeBackend(): Promise { try { await this._backend.initialize(); + this.publishAssetVariantProfile(); } catch (error) { if (this.options.backend?.type !== 'auto' || this._backendType !== 'webgpu') { throw error; @@ -2226,9 +2227,26 @@ export class Application = {}> { this._rendering.resize(this._logicalWidth, this._logicalHeight); await this._backend.initialize(); + this.publishAssetVariantProfile(); } } + /** + * Hand the loader what the initialized backend can actually accept, so a + * variant rule can pick a compressed format or a density per device. + * + * Runs after every successful backend initialization, the WebGPU-to-WebGL2 + * fallback included: the two backends do not support the same format families, + * and a profile left over from the abandoned attempt would offer files the live + * backend refuses. + */ + private publishAssetVariantProfile(): void { + this.loader.variants.profile = { + textureFormats: this._backend.supportedTextureFormats, + resolution: this._backend.rootResolution, + }; + } + /** * Whether `backend: 'auto'` should pick WebGPU. Presence of `navigator.gpu` * is necessary but not sufficient: WebKit ships a WebGPU implementation that diff --git a/src/core/scene/SceneAudio.ts b/src/core/scene/SceneAudio.ts index 6f5a9d9e8..cb36e7224 100644 --- a/src/core/scene/SceneAudio.ts +++ b/src/core/scene/SceneAudio.ts @@ -1,7 +1,8 @@ import { getAudioContext } from '#audio/audio-context'; import type { AudioBus } from '#audio/AudioBus'; import type { AudioEffect } from '#audio/AudioEffect'; -import type { DistanceModel, Pausable, Playable, PlayOptions, Spatializable, Voice } from '#audio/Playable'; +import { AudioSend } from '#audio/AudioSend'; +import type { DistanceModel, Pausable, Playable, PlayOptions, Spatializable, SpatialPoint, Voice } from '#audio/Playable'; import type { Application } from '#core/Application'; import { SceneAvailability } from '#core/SceneAvailability'; import type { SceneNode } from '#core/SceneNode'; @@ -49,6 +50,9 @@ interface BufferedSpatialWrites { coneInnerAngle?: number; coneOuterAngle?: number; coneOuterGain?: number; + elevation?: number; + elevationVelocity?: number; + occlusion?: number; } /** @@ -56,7 +60,7 @@ interface BufferedSpatialWrites { * needed. Mirrors how `BaseVoice` stores its own spatial points: the caller's * object is never retained. */ -const copyPoint = (target: Vector | null, value: Vector | { x: number; y: number } | null): Vector | null => { +const copyPoint = (target: Vector | null, value: Vector | SpatialPoint | null): Vector | null => { if (value === null) { target?.destroy(); @@ -91,6 +95,12 @@ class PendingVoice implements Voice { private _volume: number; private _bus: AudioBus | undefined; private readonly _pendingEffects: AudioEffect[] = []; + /** + * Sends opened before the real voice existed. Each is wired to + * {@link PendingVoice._dummyOutput} and re-pointed at the real output on flush, + * so the handle the caller already holds stays valid. + */ + private readonly _sendList: AudioSend[] = []; private readonly _dummyOutput: AudioNode; private readonly _spatial: BufferedSpatialWrites = {}; private _followTarget: SceneNode | null | undefined; @@ -201,7 +211,7 @@ class PendingVoice implements Voice { return this._real?.position ?? this._position; } - public set position(value: Vector | { x: number; y: number } | null) { + public set position(value: Vector | SpatialPoint | null) { this._positionWritten = true; this._position = copyPoint(this._position, value); @@ -326,11 +336,94 @@ class PendingVoice implements Voice { } } + public get elevation(): number { + return this._real?.elevation ?? this._spatial.elevation ?? 0; + } + + public set elevation(value: number) { + this._spatial.elevation = value; + + if (this._real) { + this._real.elevation = value; + } + } + + public get elevationVelocity(): number { + return this._real?.elevationVelocity ?? this._spatial.elevationVelocity ?? 0; + } + + public set elevationVelocity(value: number) { + this._spatial.elevationVelocity = value; + + if (this._real) { + this._real.elevationVelocity = value; + } + } + + public get occlusion(): number { + return this._real?.occlusion ?? this._spatial.occlusion ?? 0; + } + + public set occlusion(value: number) { + this._spatial.occlusion = value; + + if (this._real) { + this._real.occlusion = value; + } + } + + public get sends(): readonly AudioSend[] { + return this._real?.sends ?? this._sendList; + } + + public addSend(bus: AudioBus, level = 1): AudioSend { + if (this._real) { + return this._real.addSend(bus, level); + } + + // Wired to the placeholder output, which nothing feeds, so the send is silent + // until flush re-points it at the real voice. + const send = new AudioSend(getAudioContext(), this._dummyOutput, bus, level); + + this._sendList.push(send); + + return send; + } + + /** @internal */ + public _adoptSend(send: AudioSend): void { + if (this._real) { + this._real._adoptSend(send); + + return; + } + + send._retarget(this._dummyOutput); + this._sendList.push(send); + } + + public removeSend(send: AudioSend): this { + if (this._real) { + this._real.removeSend(send); + + return this; + } + + const index = this._sendList.indexOf(send); + + if (index !== -1) { + this._sendList.splice(index, 1); + send.destroy(); + } + + return this; + } + public get velocity(): Vector | null { return this._real?.velocity ?? this._velocity; } - public set velocity(value: Vector | { x: number; y: number } | null) { + public set velocity(value: Vector | SpatialPoint | null) { this._velocityWritten = true; this._velocity = copyPoint(this._velocity, value); @@ -365,6 +458,13 @@ class PendingVoice implements Voice { } this._pendingEffects.length = 0; + + // Handed over rather than re-created: the caller already holds these objects. + for (const send of this._sendList) { + real._adoptSend(send); + } + + this._sendList.length = 0; this._replaySpatial(real); real.onEnd.add((): void => { this.onEnd.dispatch(); @@ -394,6 +494,18 @@ class PendingVoice implements Voice { const spatial = this._spatial; + if (spatial.elevation !== undefined) { + real.elevation = spatial.elevation; + } + + if (spatial.elevationVelocity !== undefined) { + real.elevationVelocity = spatial.elevationVelocity; + } + + if (spatial.occlusion !== undefined) { + real.occlusion = spatial.occlusion; + } + if (spatial.distanceModel !== undefined) { real.distanceModel = spatial.distanceModel; } diff --git a/src/rendering/DrawContext.ts b/src/rendering/DrawContext.ts index 84f59945a..3ffa942b9 100644 --- a/src/rendering/DrawContext.ts +++ b/src/rendering/DrawContext.ts @@ -7,17 +7,21 @@ import type { RenderBackend } from './RenderBackend'; import type { RenderBatch } from './RenderBatch'; import type { DrawBatchOptions, DrawGeometryOptions, RenderOptions } from './RenderingContext'; import type { RenderNode } from './RenderNode'; +import type { RenderTarget } from './RenderTarget'; import type { View } from './View'; /** * Options for {@link DrawContext.renderTo}: a caller-owned, per-frame - * off-screen {@link RenderTexture} target, reused across frames (no per-call - * allocation). Unlike {@link RenderingContext.capture}, the texture is - * supplied by the caller. + * off-screen target, reused across frames (no per-call allocation). Unlike + * {@link RenderingContext.capture}, the target is supplied by the caller. */ export interface RenderToOptions { - /** Destination texture, owned and kept stable by the caller. */ - target: RenderTexture; + /** + * Destination, owned and kept stable by the caller: a {@link RenderTexture}, + * or a `MultiRenderTarget` when one pass has to fill several colour + * attachments. + */ + target: RenderTexture | RenderTarget; /** View to render with. Defaults to the target's own view. */ view?: View; /** Clear the target to this colour before rendering; omitted preserves its contents. */ diff --git a/src/rendering/MultiRenderTarget.ts b/src/rendering/MultiRenderTarget.ts new file mode 100644 index 000000000..72d8455cf --- /dev/null +++ b/src/rendering/MultiRenderTarget.ts @@ -0,0 +1,117 @@ +import { assert } from '#core/dev'; +import { RenderTarget } from '#rendering/RenderTarget'; +import { RenderTexture } from '#rendering/texture/RenderTexture'; +import type { ColorTextureFormat } from '#rendering/types'; + +import type { TextureOptions } from './texture/TextureOptions'; + +/** Construction options for {@link MultiRenderTarget}. */ +export interface MultiRenderTargetOptions extends Partial { + /** + * Colour format per attachment, in the order the fragment shader's outputs are + * declared. At least one; at most {@link RenderBackend.maxColorAttachments} on + * the backend that will draw into it. + */ + readonly formats: readonly ColorTextureFormat[]; +} + +/** + * An off-screen render target with several colour attachments, written in one + * pass. + * + * One draw can produce more than one image: a colour pass that also writes a + * selection id, a normal buffer, a velocity buffer. Without it the same + * information costs one full pass per output, re-transforming and re-rasterizing + * the same geometry each time. + * + * Each attachment is an ordinary {@link RenderTexture} and is sampled like any + * other texture once the pass has run. They are OWNED by this target - created, + * resized and destroyed with it - because a mismatched attachment size is a + * framebuffer-completeness error on WebGL2 and a validation error on WebGPU, and + * that is not a failure worth handing to callers to avoid. + * + * # What can draw into one + * + * Only a {@link Mesh} with a {@link MeshMaterial} whose fragment shader declares + * one output per attachment. Every other renderer - sprites, text, nine-slice, + * repeating sprites, video - and the default mesh material declare a single + * output, so on WebGPU their pipelines cannot satisfy a multi-attachment pass at + * all; drawing one into this target throws a `RenderError` naming the reason + * rather than minting pipeline variants nothing writes to. Mask and + * backdrop-blend compositing stay single-target for the same reason. + * + * A single-attachment target is still just a {@link RenderTexture} - reach for + * this only when one pass genuinely has to produce two images. + * + * @example + * ```ts + * const gbuffer = new MultiRenderTarget(512, 512, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba8] }); + * + * context.renderTo(scene, { target: gbuffer }); + * + * const albedo = gbuffer.attachment(0); + * const ids = gbuffer.attachment(1); + * ``` + * @advanced + */ +export class MultiRenderTarget extends RenderTarget { + public readonly attachments: readonly RenderTexture[]; + + public constructor(width: number, height: number, options: MultiRenderTargetOptions) { + assert(width > 0 && height > 0, `MultiRenderTarget dimensions must be positive (got ${width}x${height})`); + assert(options.formats.length > 0, 'MultiRenderTarget needs at least one colour format.'); + super(width, height, false); + + const { formats, ...textureOptions } = options; + + this.attachments = Object.freeze(formats.map(format => new RenderTexture(width, height, { ...textureOptions, format }))); + } + + /** Colour format of each attachment, in declaration order. */ + public get formats(): readonly ColorTextureFormat[] { + return this.attachments.map(attachment => attachment.format); + } + + /** + * The attachment at `index`, in the order its format was declared - the same + * order the fragment shader's outputs are in. + */ + public attachment(index: number): RenderTexture { + const attachment = this.attachments[index]; + + assert(attachment !== undefined, `MultiRenderTarget has ${this.attachments.length} attachment(s); ${index} is out of range.`); + + return attachment; + } + + public override resize(width: number, height: number): this { + super.resize(width, height); + + // Attachments follow unconditionally rather than lazily: an attachment left + // at the old size makes the whole framebuffer incomplete, and the symptom + // would surface on the next unrelated draw into it. + for (const attachment of this.attachments) { + attachment.resize(width, height); + } + + return this; + } + + public override destroy(): void { + if (this.destroyed) { + return; + } + + // Before the base call, which fires the destroy listeners the backends use + // to drop their framebuffer - that framebuffer references these textures. + for (const attachment of this.attachments) { + attachment.destroy(); + } + + super.destroy(); + } +} + +/** Whether `target` writes more than one colour attachment. @internal */ +export const isMultiAttachmentTarget = (target: RenderTarget): target is MultiRenderTarget => + target instanceof MultiRenderTarget && target.attachments.length > 1; diff --git a/src/rendering/RenderBackend.ts b/src/rendering/RenderBackend.ts index 99702800d..9cb762d0f 100644 --- a/src/rendering/RenderBackend.ts +++ b/src/rendering/RenderBackend.ts @@ -5,6 +5,7 @@ import type { Rectangle } from '#math/Rectangle'; import type { Geometry } from '#rendering/geometry/Geometry'; import type { Mesh } from '#rendering/mesh/Mesh'; import type { InstanceDataView } from '#rendering/RenderBatch'; +import type { CompressedTextureFormat } from '#rendering/texture/CompressedTextureFormat'; import type { RenderTexture } from '#rendering/texture/RenderTexture'; import type { Texture } from '#rendering/texture/Texture'; import type { ColorTextureFormat } from '#rendering/types'; @@ -68,6 +69,33 @@ export interface RenderBackend { */ readonly maxTextureSize: number; + /** + * Block-compressed texture formats this device can sample, most preferred + * first, or empty when it supports none. + * + * Availability is per device and per backend: desktop GPUs implement the BC + * family, mobile GPUs ETC2 and ASTC, and WebGPU only carries a family that was + * requested when the device was created. The order is the engine's own + * preference ranking, identical on both backends, and is what + * {@link AssetVariantProfile} selection ranks candidates by. + * + * Read it to decide what to ship or construct; binding a {@link CompressedTexture} + * in a format absent from this list throws a {@link RenderError} with code + * `'unsupported-format'`. + */ + readonly supportedTextureFormats: readonly CompressedTextureFormat[]; + + /** + * Colour attachments this device accepts in one render pass - the upper bound + * on a {@link MultiRenderTarget}'s attachment count. + * + * At least `1` everywhere, and `1` before the backend is initialized. WebGL2 + * reports the lower of `MAX_COLOR_ATTACHMENTS` and `MAX_DRAW_BUFFERS`, since an + * attachment nothing can write to is not usable capacity; WebGPU reports + * `limits.maxColorAttachments`. + */ + readonly maxColorAttachments: number; + /** * Dispatched when the backend detects a GPU error that does not surface as a * synchronous exception - WGSL compilation errors, WebGPU uncaptured diff --git a/src/rendering/RenderError.ts b/src/rendering/RenderError.ts index 06d026adf..1f5f1c1b9 100644 --- a/src/rendering/RenderError.ts +++ b/src/rendering/RenderError.ts @@ -11,6 +11,7 @@ export type RenderErrorCode = | 'pipeline-creation' // WebGPU pipeline/bind-group-layout creation failure | 'validation' // WebGPU uncaptured validation error (draw/submit time) | 'out-of-memory' // GPUOutOfMemoryError / GL OOM + | 'unsupported-format' // a texture format this device does not implement (compressed families are per-device) | 'internal' // GPUInternalError / anything unclassifiable | 'device-recovery-failed'; // WebGPU device loss recovery exhausted all retry attempts diff --git a/src/rendering/mesh/ImmediateMesh.ts b/src/rendering/mesh/ImmediateMesh.ts index b67377166..9195185ee 100644 --- a/src/rendering/mesh/ImmediateMesh.ts +++ b/src/rendering/mesh/ImmediateMesh.ts @@ -4,6 +4,7 @@ import type { Geometry } from '#rendering/geometry/Geometry'; import type { MeshMaterial } from '#rendering/material/MeshMaterial'; import { Mesh, readGeometry } from './Mesh'; +import { meshIndexFormatFor } from './meshIndices'; // A degenerate triangle that satisfies the Mesh constructor's validation; it is // overwritten on the first configure() before the mesh is ever drawn. @@ -88,6 +89,10 @@ export class ImmediateMesh extends Mesh { this._uvs = data.uvs; this._colors = data.colors; this._indices = data.indices; + // Re-derived with the data, not carried over: a pooled mesh is reused across + // geometries, and a stale format would draw the new index stream at the old + // width. + this._indexFormat = meshIndexFormatFor(data.indices, data.vertices.length / 2); this._sourceGeometry = geometry; this._sourceVersion = geometry.version; } diff --git a/src/rendering/mesh/Mesh.ts b/src/rendering/mesh/Mesh.ts index 73562200f..0a40bd742 100644 --- a/src/rendering/mesh/Mesh.ts +++ b/src/rendering/mesh/Mesh.ts @@ -5,13 +5,8 @@ import type { MeshMaterial } from '#rendering/material/MeshMaterial'; import type { RenderTexture } from '#rendering/texture/RenderTexture'; import type { Texture } from '#rendering/texture/Texture'; -const maxUint16VertexCount = 0x10000; - -const assertImplicitIndexRange = (vertexCount: number): void => { - if (vertexCount > maxUint16VertexCount) { - throw new Error(`Non-indexed Mesh vertex count ${vertexCount} exceeds the 16-bit implicit-index limit of ${maxUint16VertexCount} vertices.`); - } -}; +import type { MeshIndexArray, MeshIndexFormat } from './meshIndices'; +import { meshIndexFormatFor } from './meshIndices'; /** * Construction-time options for a {@link Mesh}. @@ -40,7 +35,12 @@ const assertImplicitIndexRange = (vertexCount: number): void => { */ export interface MeshOptions { readonly vertices?: Float32Array; - readonly indices?: Uint16Array; + /** + * Triangle indices. A `Uint16Array` is the cheaper default; supply a + * `Uint32Array` for geometry addressing more than 65 536 vertices. The width + * is kept as authored - see {@link Mesh.indexFormat}. + */ + readonly indices?: MeshIndexArray; readonly uvs?: Float32Array; readonly colors?: Uint32Array; /** Interleaved geometry source; mutually exclusive with `vertices`. */ @@ -90,7 +90,8 @@ export class Mesh extends Drawable { // reconfigure them in place; the public getters keep the data read-only for // every external consumer (the v1 immutable-after-construction contract). protected _vertices: Float32Array; - protected _indices: Uint16Array | null; + protected _indices: MeshIndexArray | null; + protected _indexFormat: MeshIndexFormat; protected _uvs: Float32Array | null; protected _colors: Uint32Array | null; protected _material: MeshMaterial | null; @@ -100,7 +101,19 @@ export class Mesh extends Drawable { return this._vertices; } - public get indices(): Uint16Array | null { + /** + * Width the index stream is drawn with, `'uint16'` unless the mesh needs more. + * + * Derived once at construction from the authored index array - or, for a + * non-indexed mesh, from how many vertices its synthesized indices have to + * address. Both backends read this rather than re-deriving it, so a mesh can + * never be uploaded as one width and drawn as the other. + */ + public get indexFormat(): MeshIndexFormat { + return this._indexFormat; + } + + public get indices(): MeshIndexArray | null { return this._indices; } @@ -130,7 +143,7 @@ export class Mesh extends Drawable { const { texture = null, material = null } = options; let vertices: Float32Array; - let indices: Uint16Array | null; + let indices: MeshIndexArray | null; let uvs: Float32Array | null; let colors: Uint32Array | null; let geometry: Geometry | null; @@ -187,16 +200,13 @@ export class Mesh extends Drawable { throw new Error(`Mesh index ${indices[i]!} at position ${i} is out of range for vertex count ${vertexCount}.`); } } - } else { - assertImplicitIndexRange(vertexCount); - - if (vertexCount % 3 !== 0) { - throw new Error(`Non-indexed Mesh requires a vertex count that is a multiple of 3 (got ${vertexCount}).`); - } + } else if (vertexCount % 3 !== 0) { + throw new Error(`Non-indexed Mesh requires a vertex count that is a multiple of 3 (got ${vertexCount}).`); } this._vertices = vertices; this._indices = indices; + this._indexFormat = meshIndexFormatFor(indices, vertexCount); this._uvs = uvs; this._colors = colors; this._material = material; @@ -276,7 +286,7 @@ export const readGeometry = ( vertices: Float32Array; uvs: Float32Array | null; colors: Uint32Array | null; - indices: Uint16Array | null; + indices: MeshIndexArray | null; } => { if (geometry.topology !== 'triangle-list') { throw new Error(`Mesh only supports triangle-list geometry (got "${geometry.topology}").`); @@ -301,10 +311,6 @@ export const readGeometry = ( const color = findAttribute(geometry.attributes, colorAttributeNames); const vertexCount = geometry.vertexCount; - if (geometry.indices === null) { - assertImplicitIndexRange(vertexCount); - } - const { stride } = geometry; const source = geometry.vertexData; const view = source instanceof Float32Array ? new DataView(source.buffer, source.byteOffset, source.byteLength) : new DataView(source); @@ -329,9 +335,10 @@ export const readGeometry = ( } } - const indices = readIndices(geometry.indices, vertexCount); - - return { vertices, uvs, colors, indices }; + // Kept as authored rather than narrowed to 16 bits: the declared width is the + // contract, and re-deriving it from the values would let the same geometry + // change index width when its content changes. + return { vertices, uvs, colors, indices: geometry.indices }; }; /** Pack a geometry color attribute into the mesh's RGBA8 u32 representation. */ @@ -359,22 +366,6 @@ const readPackedColor = (view: DataView, offset: number, attribute: GeometryAttr throw new Error('Mesh geometry color attribute must be u8x4, u32x1, or f32x4.'); }; -const readIndices = (indices: Uint16Array | Uint32Array | null, vertexCount: number): Uint16Array | null => { - if (indices === null) { - return null; - } - - if (indices instanceof Uint16Array) { - return indices; - } - - if (vertexCount > 0xffff) { - throw new Error(`Mesh geometry with ${vertexCount} vertices exceeds the 16-bit index limit.`); - } - - return Uint16Array.from(indices); -}; - const clamp01 = (value: number): number => { if (value < 0) return 0; if (value > 1) return 1; diff --git a/src/rendering/mesh/meshIndices.ts b/src/rendering/mesh/meshIndices.ts new file mode 100644 index 000000000..a1c864e73 --- /dev/null +++ b/src/rendering/mesh/meshIndices.ts @@ -0,0 +1,48 @@ +/** + * Width of a mesh's index stream. + * + * `'uint16'` is the default and the cheaper one - half the index bytes to upload + * and to keep resident - and covers any mesh addressing at most 65 536 vertices. + * `'uint32'` exists for generated or merged geometry that legitimately exceeds + * that: batched tile, trail, terrain or imported SVG meshes. + * + * The values are the strings WebGPU accepts verbatim; WebGL2 maps them onto its + * own `UNSIGNED_SHORT`/`UNSIGNED_INT` element types. + * @stable + */ +export type MeshIndexFormat = 'uint16' | 'uint32'; + +/** The typed-array kinds a mesh index stream can be supplied as. */ +export type MeshIndexArray = Uint16Array | Uint32Array; + +/** Bytes one index occupies in `format`. */ +export const meshIndexBytes = (format: MeshIndexFormat): number => (format === 'uint32' ? 4 : 2); + +/** + * Largest vertex count a 16-bit index stream can address. + * + * A `Uint16Array` index holds `0..65535`, so a mesh of exactly this many + * vertices is still fully addressable; one vertex more is not. + */ +export const maxUint16VertexCount = 0x10000; + +/** + * The index width a mesh drawn from `indices` over `vertexCount` vertices needs. + * + * An authored stream keeps the width it was authored with - narrowing a + * `Uint32Array` that happens to fit would make the format depend on the values + * rather than on the declaration, so the same geometry could change width when + * its content changes. A non-indexed mesh has its indices synthesized, so its + * width follows purely from how many vertices there are to address. + */ +export const meshIndexFormatFor = (indices: MeshIndexArray | null, vertexCount: number): MeshIndexFormat => { + if (indices !== null) { + return indices instanceof Uint32Array ? 'uint32' : 'uint16'; + } + + return vertexCount > maxUint16VertexCount ? 'uint32' : 'uint16'; +}; + +/** A zero-filled index array of `length` entries in `format`. */ +export const createIndexArray = (format: MeshIndexFormat, length: number): MeshIndexArray => + format === 'uint32' ? new Uint32Array(length) : new Uint16Array(length); diff --git a/src/rendering/multiAttachmentGuard.ts b/src/rendering/multiAttachmentGuard.ts new file mode 100644 index 000000000..78a5d1abd --- /dev/null +++ b/src/rendering/multiAttachmentGuard.ts @@ -0,0 +1,54 @@ +import { Mesh } from '#rendering/mesh/Mesh'; + +import type { Drawable } from './Drawable'; +import type { RenderBackendType } from './RenderBackendType'; +import { RenderError } from './RenderError'; + +/** + * Refuse a drawable that cannot write every colour attachment of the active + * multi-attachment target. + * + * Only a mesh with a custom material qualifies: every other renderer, and the + * default mesh material, declares a single fragment output. On WebGPU a pipeline + * must declare one target per attachment of the pass it runs in, so those paths + * could not satisfy such a pass without pipeline variants that write nothing to + * the extra slots. WebGL2 would silently accept them and leave the other + * attachments at their cleared contents - a difference in behaviour between the + * backends is worse than a refusal on both. + * + * Only reached while a multi-attachment target is bound; the backends keep that + * as a cached flag so an ordinary frame never pays for the check. + * @internal + */ +export const assertDrawsAllAttachments = (drawable: Drawable, attachmentCount: number, backendType: RenderBackendType): void => { + if (drawable instanceof Mesh && drawable.material !== null) { + return; + } + + throw new RenderError({ + code: 'unsupported-format', + backendType, + message: + `The active render target has ${attachmentCount} colour attachments, which only a Mesh with a MeshMaterial can write. ` + + `Give the drawable a material whose fragment shader declares one output per attachment, or render it into a single-attachment RenderTexture.`, + }); +}; + +/** + * Refuse alpha-mask or backdrop-blend compositing into a multi-attachment target. + * + * Both composite through their own single-output shader, so they can no more + * satisfy such a pass than an ordinary sprite can. Refused rather than silently + * writing slot 0 only, which is what WebGL2 would do while WebGPU rejects the + * draw outright. + * @internal + */ +export const assertSingleAttachmentCompose = (operation: string, attachmentCount: number, backendType: RenderBackendType): void => { + throw new RenderError({ + code: 'unsupported-format', + backendType, + message: + `${operation} cannot run into a render target with ${attachmentCount} colour attachments - it composites through a single-output shader. ` + + `Compose into a single-attachment RenderTexture and draw the result into the multi-attachment target with a material of your own.`, + }); +}; diff --git a/src/rendering/public.ts b/src/rendering/public.ts index 55cbef68e..0a6473049 100644 --- a/src/rendering/public.ts +++ b/src/rendering/public.ts @@ -13,6 +13,8 @@ export { CallbackRenderPass } from './CallbackRenderPass'; export { Container } from './Container'; export { Drawable } from './Drawable'; export type { DrawContext, RenderToOptions } from './DrawContext'; +export type { MultiRenderTargetOptions } from './MultiRenderTarget'; +export { MultiRenderTarget } from './MultiRenderTarget'; export { PassContext } from './PassContext'; export { PixelSnapMode } from './pixelSnap'; export { RenderBackendType } from './RenderBackendType'; @@ -69,6 +71,8 @@ export { ShaderSource } from '#rendering/material/ShaderSource'; export { SpriteMaterial } from '#rendering/material/SpriteMaterial'; export type { MeshOptions } from '#rendering/mesh/Mesh'; export { Mesh } from '#rendering/mesh/Mesh'; +export type { MeshIndexArray, MeshIndexFormat } from '#rendering/mesh/meshIndices'; +export { maxUint16VertexCount, meshIndexBytes, meshIndexFormatFor } from '#rendering/mesh/meshIndices'; export { Graphics } from '#rendering/primitives/Graphics'; export { INSTANCE_TRANSFORM_GLSL, INSTANCE_TRANSFORM_WGSL } from '#rendering/shader/instanceContract'; export type { ShaderProgram } from '#rendering/shader/Shader'; @@ -110,6 +114,20 @@ export type { TextPageQuads, TextSize, } from '#rendering/text/types'; +export type { CompressedTextureLevel, CompressedTexturePayload } from '#rendering/texture/compressedPayload'; +export type { CompressedTextureOptions } from '#rendering/texture/CompressedTexture'; +export { CompressedTexture } from '#rendering/texture/CompressedTexture'; +export type { CompressedBlockLayout } from '#rendering/texture/CompressedTextureFormat'; +export { + compressedBlockLayout, + compressedBlocksAcross, + compressedBlocksDown, + compressedFormatPreference, + compressedLevelByteLength, + CompressedTextureFormat, + isCompressedTextureFormat, + orderCompressedFormats, +} from '#rendering/texture/CompressedTextureFormat'; export type { DataTextureBuffer, DataTextureDirtyRegion, DataTextureFormat, DataTextureOptions } from '#rendering/texture/DataTexture'; export { DataTexture } from '#rendering/texture/DataTexture'; export { RenderTexture } from '#rendering/texture/RenderTexture'; diff --git a/src/rendering/texture/CompressedTexture.ts b/src/rendering/texture/CompressedTexture.ts new file mode 100644 index 000000000..ae4a22d7c --- /dev/null +++ b/src/rendering/texture/CompressedTexture.ts @@ -0,0 +1,27 @@ +import type { CompressedTexturePayload } from './compressedPayload'; +import { Texture } from './Texture'; +import type { SamplerOptions } from './TextureOptions'; + +/** Construction options for {@link CompressedTexture}. */ +export interface CompressedTextureOptions extends CompressedTexturePayload { + /** Sampling state. Upload state is not accepted - see {@link CompressedTexturePayload}. */ + readonly samplerOptions?: Partial; +} + +/** + * A {@link Texture} constructed directly from a compressed payload. + * + * Convenience only: a compressed payload is something a plain `Texture` can + * carry, so anything accepting a `Texture` accepts one of these, and a texture + * loaded from a container arrives as a plain `Texture` with + * {@link Texture.compressed} set. Code that needs to tell the difference reads + * that property rather than testing the class. + * @stable + */ +export class CompressedTexture extends Texture { + public constructor({ format, levels, samplerOptions }: CompressedTextureOptions) { + super(null, { ...samplerOptions, premultiplyAlpha: false, generateMipMap: false }); + + this.setCompressed({ format, levels }); + } +} diff --git a/src/rendering/texture/CompressedTextureFormat.ts b/src/rendering/texture/CompressedTextureFormat.ts new file mode 100644 index 000000000..64e48d0fc --- /dev/null +++ b/src/rendering/texture/CompressedTextureFormat.ts @@ -0,0 +1,154 @@ +/** + * A block-compressed GPU texture format. + * + * These are hardware formats: the GPU samples the compressed blocks directly, + * so a compressed texture costs a fraction of the VRAM and upload bandwidth of + * the same image as RGBA8 and stays compressed for its whole lifetime. No + * device supports all of them - desktop GPUs implement the BC family, mobile + * GPUs implement ETC2 and ASTC - so a project ships one file per target family + * and lets {@link AssetVariantProfile} pick per device rather than picking at + * build time. + * + * The values are the engine's own vocabulary, mapped per backend + * (`Bc7RgbaUnorm` becomes `bc7-rgba-unorm` on WebGPU and + * `COMPRESSED_RGBA_BPTC_UNORM_EXT` on WebGL2). Availability is read from + * {@link RenderBackend.supportedTextureFormats}. + * @stable + */ +export enum CompressedTextureFormat { + /** BC1 / DXT1: RGB, 1-bit alpha cutout, 4:1 ratio. The cheapest BC format. */ + Bc1RgbaUnorm = 'bc1-rgba-unorm', + /** BC2 / DXT3: RGB with 4-bit explicit alpha. Rarely worth choosing over BC3. */ + Bc2RgbaUnorm = 'bc2-rgba-unorm', + /** BC3 / DXT5: RGB with interpolated alpha, 4:1 ratio. The classic RGBA workhorse. */ + Bc3RgbaUnorm = 'bc3-rgba-unorm', + /** BC4: single channel, 2:1 ratio. Masks, height and occlusion maps. */ + Bc4RUnorm = 'bc4-r-unorm', + /** BC5: two channels, 2:1 ratio. Tangent-space normal maps. */ + Bc5RgUnorm = 'bc5-rg-unorm', + /** BC6H: HDR RGB half-float, 2:1 ratio. No alpha channel. */ + Bc6hRgbUfloat = 'bc6h-rgb-ufloat', + /** BC7: RGBA, 2:1 ratio, the highest BC quality. Preferred wherever BC exists. */ + Bc7RgbaUnorm = 'bc7-rgba-unorm', + /** ETC2: RGB, no alpha, 4:1 ratio. Mandatory in OpenGL ES 3.0. */ + Etc2Rgb8Unorm = 'etc2-rgb8unorm', + /** ETC2: RGB with a 1-bit alpha cutout, 4:1 ratio. */ + Etc2Rgb8A1Unorm = 'etc2-rgb8a1unorm', + /** ETC2 + EAC: RGB with 8-bit alpha, 2:1 ratio. */ + Etc2Rgba8Unorm = 'etc2-rgba8unorm', + /** EAC: single channel, 2:1 ratio. */ + EacR11Unorm = 'eac-r11unorm', + /** EAC: two channels, 1:1 ratio. */ + EacRg11Unorm = 'eac-rg11unorm', + /** ASTC 4x4: RGBA at 8 bits per pixel. Highest ASTC quality. */ + Astc4x4Unorm = 'astc-4x4-unorm', + /** ASTC 5x5: RGBA at ~5.12 bits per pixel. */ + Astc5x5Unorm = 'astc-5x5-unorm', + /** ASTC 6x6: RGBA at ~3.56 bits per pixel. */ + Astc6x6Unorm = 'astc-6x6-unorm', + /** ASTC 8x8: RGBA at 2 bits per pixel. Highest ASTC compression. */ + Astc8x8Unorm = 'astc-8x8-unorm', +} + +/** + * Block geometry of a compressed format: the texel footprint of one block and + * how many bytes that block occupies. + * + * Both backends need it for every upload - WebGL2 to size the level slice it + * hands `compressedTexImage2D`, WebGPU to compute a `bytesPerRow` that counts + * block rows rather than texel rows - and the container parser needs it to + * validate a level's declared byte length before trusting it. + */ +export interface CompressedBlockLayout { + readonly blockWidth: number; + readonly blockHeight: number; + readonly bytesPerBlock: number; +} + +const blockLayouts: Readonly> = Object.freeze({ + [CompressedTextureFormat.Bc1RgbaUnorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 8 }, + [CompressedTextureFormat.Bc2RgbaUnorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 16 }, + [CompressedTextureFormat.Bc3RgbaUnorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 16 }, + [CompressedTextureFormat.Bc4RUnorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 8 }, + [CompressedTextureFormat.Bc5RgUnorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 16 }, + [CompressedTextureFormat.Bc6hRgbUfloat]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 16 }, + [CompressedTextureFormat.Bc7RgbaUnorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 16 }, + [CompressedTextureFormat.Etc2Rgb8Unorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 8 }, + [CompressedTextureFormat.Etc2Rgb8A1Unorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 8 }, + [CompressedTextureFormat.Etc2Rgba8Unorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 16 }, + [CompressedTextureFormat.EacR11Unorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 8 }, + [CompressedTextureFormat.EacRg11Unorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 16 }, + [CompressedTextureFormat.Astc4x4Unorm]: { blockWidth: 4, blockHeight: 4, bytesPerBlock: 16 }, + [CompressedTextureFormat.Astc5x5Unorm]: { blockWidth: 5, blockHeight: 5, bytesPerBlock: 16 }, + [CompressedTextureFormat.Astc6x6Unorm]: { blockWidth: 6, blockHeight: 6, bytesPerBlock: 16 }, + [CompressedTextureFormat.Astc8x8Unorm]: { blockWidth: 8, blockHeight: 8, bytesPerBlock: 16 }, +}); + +/** Block geometry of `format`. */ +export const compressedBlockLayout = (format: CompressedTextureFormat): CompressedBlockLayout => blockLayouts[format]; + +/** Whether `value` names a format this engine knows how to upload. */ +export const isCompressedTextureFormat = (value: string): value is CompressedTextureFormat => Object.hasOwn(blockLayouts, value); + +/** Number of block columns a mip level of `width` texels occupies. */ +export const compressedBlocksAcross = (format: CompressedTextureFormat, width: number): number => + Math.ceil(Math.max(width, 1) / blockLayouts[format].blockWidth); + +/** Number of block rows a mip level of `height` texels occupies. */ +export const compressedBlocksDown = (format: CompressedTextureFormat, height: number): number => + Math.ceil(Math.max(height, 1) / blockLayouts[format].blockHeight); + +/** + * Exact byte length one mip level of `width` x `height` texels occupies in + * `format`. + * + * A level is padded out to whole blocks, so a 5x5 BC7 level costs four blocks, + * not one and a half. Container parsers compare this against the byte length + * the file declares: a mismatch means the file is truncated or its format was + * mis-identified, and uploading it would hand the driver a short buffer. + */ +export const compressedLevelByteLength = (format: CompressedTextureFormat, width: number, height: number): number => + compressedBlocksAcross(format, width) * compressedBlocksDown(format, height) * blockLayouts[format].bytesPerBlock; + +/** + * Order in which the engine prefers compressed formats when several are + * available, best first. + * + * Both backends filter this to the formats their device actually implements, so + * {@link RenderBackend.supportedTextureFormats} - and therefore variant + * selection - ranks identically on WebGL2 and WebGPU. The order runs from the + * highest-quality RGBA formats down to the cheapest, with the single- and + * two-channel formats last: they are not interchangeable with an RGBA format, + * so they only ever rank against each other. + */ +export const compressedFormatPreference: readonly CompressedTextureFormat[] = Object.freeze([ + CompressedTextureFormat.Bc7RgbaUnorm, + CompressedTextureFormat.Astc4x4Unorm, + CompressedTextureFormat.Astc5x5Unorm, + CompressedTextureFormat.Astc6x6Unorm, + CompressedTextureFormat.Astc8x8Unorm, + CompressedTextureFormat.Etc2Rgba8Unorm, + CompressedTextureFormat.Bc3RgbaUnorm, + CompressedTextureFormat.Bc2RgbaUnorm, + CompressedTextureFormat.Etc2Rgb8A1Unorm, + CompressedTextureFormat.Etc2Rgb8Unorm, + CompressedTextureFormat.Bc1RgbaUnorm, + CompressedTextureFormat.Bc6hRgbUfloat, + CompressedTextureFormat.Bc5RgUnorm, + CompressedTextureFormat.EacRg11Unorm, + CompressedTextureFormat.Bc4RUnorm, + CompressedTextureFormat.EacR11Unorm, +]); + +/** + * `supported` in the engine's preference order. + * + * Backends collect what their device reports in whatever order they probe it; + * this puts both of them on one ranking so variant selection cannot depend on + * which backend is live. + */ +export const orderCompressedFormats = (supported: Iterable): readonly CompressedTextureFormat[] => { + const available = new Set(supported); + + return Object.freeze(compressedFormatPreference.filter(format => available.has(format))); +}; diff --git a/src/rendering/texture/Texture.ts b/src/rendering/texture/Texture.ts index 325055f7f..11aad8a8b 100644 --- a/src/rendering/texture/Texture.ts +++ b/src/rendering/texture/Texture.ts @@ -7,6 +7,8 @@ import { isPowerOfTwo } from '#math/utils'; import { ScaleModes, WrapModes } from '#rendering/types'; import { createCanvas, createCheckerCanvas } from '#rendering/utils'; +import type { CompressedTexturePayload } from './compressedPayload'; +import { validateCompressedPayload } from './compressedPayload'; import type { TextureOptions } from './TextureOptions'; /** @@ -88,6 +90,7 @@ export class Texture { private _version = 0; private _source: TextureSource = null; + private _compressed: CompressedTexturePayload | null = null; private _size: Size = new Size(0, 0); private _isDestroyed = false; private readonly _destroyListeners: Set<() => void> = new Set<() => void>(); @@ -125,6 +128,48 @@ export class Texture { this.setSource(source); } + /** + * Hardware-compressed payload this texture uploads instead of a pixel source, + * or `null` for the ordinary case. + * + * Mutually exclusive with {@link source}: installing one clears the other, so a + * texture is never ambiguous about what it uploads. A handle that arrives + * empty from the loader can become either, which is what lets an asset variant + * swap a PNG for a KTX2 file without changing what a caller holds. + */ + public get compressed(): CompressedTexturePayload | null { + return this._compressed; + } + + /** + * Install a compressed payload, replacing any pixel source, and resize to its + * base level. + * + * Pass `null` to drop it. Bumps {@link version}, so backends re-create their + * GPU texture - a format change cannot be patched into an existing one. + * @throws Error - the payload has no levels, a base level that is not a whole + * number of blocks, or a level whose byte length does not match its extent. + */ + public setCompressed(payload: CompressedTexturePayload | null): this { + if (payload === null) { + if (this._compressed !== null) { + this._compressed = null; + this._touch(); + } + + return this; + } + + const base = validateCompressedPayload(payload); + + this._compressed = payload; + this._source = null; + this.setSize(base.width, base.height); + this._touch(); + + return this; + } + public get size(): Size { return this._size; } @@ -322,6 +367,10 @@ export class Texture { public setSource(source: TextureSource): this { if (this._source !== source) { this._source = source; + // A pixel source and a compressed payload are two answers to the same + // question, and the backends pick the compressed one - so leaving a stale + // payload in place would make this call silently do nothing. + this._compressed = null; this.updateSource(); } @@ -374,6 +423,7 @@ export class Texture { this._releaseListeners.clear(); this._size.destroy(); this._source = null; + this._compressed = null; } /** diff --git a/src/rendering/texture/compressedPayload.ts b/src/rendering/texture/compressedPayload.ts new file mode 100644 index 000000000..518f6384c --- /dev/null +++ b/src/rendering/texture/compressedPayload.ts @@ -0,0 +1,106 @@ +import type { CompressedTextureFormat } from './CompressedTextureFormat'; +import { compressedBlockLayout, compressedLevelByteLength } from './CompressedTextureFormat'; + +/** + * One mip level of a compressed texture payload: the block bytes exactly as the + * container stored them, plus the texel extent they decode to. + * + * `data` is uploaded verbatim - nothing decodes, re-packs or premultiplies it - + * so its length must be exactly `compressedLevelByteLength(format, width, height)`. + */ +export interface CompressedTextureLevel { + readonly data: Uint8Array; + readonly width: number; + readonly height: number; +} + +/** + * A texture payload already in a hardware block-compressed format, as carried by + * {@link Texture.compressed}. + * + * The saving is real in both directions: a BC7 or ASTC 4x4 image occupies a + * quarter of the VRAM of the same image as RGBA8 and a BC1 or ETC2 image an + * eighth, and it never has to be decoded on the CPU first. + * + * # Upload state does not apply + * + * `premultiplyAlpha` and `generateMipMap` are ignored for a compressed payload, + * and that is not a simplification: both operate on decoded texels, which is + * precisely what compressed blocks never become. Premultiplication has to happen + * in the authoring tool before compression, and a mip chain has to be compressed + * level by level and shipped inside the container. A payload with a single level + * therefore samples without mips however the sampler is configured. + * + * # Availability is per device + * + * No GPU implements every format. Binding a texture whose format the live + * backend does not support throws a `RenderError` with code + * `'unsupported-format'` rather than uploading something the driver would + * misread. Check {@link RenderBackend.supportedTextureFormats}, or - better - + * declare the alternatives as asset variants and let the loader choose. + * @stable + */ +export interface CompressedTexturePayload { + readonly format: CompressedTextureFormat; + /** + * Mip chain, largest level first and at least one level long. The first + * level's extent is the texture's size; the chain is uploaded as given, so a + * partial chain stays partial. + */ + readonly levels: readonly CompressedTextureLevel[]; +} + +/** + * Validate a compressed payload and return its base level. + * + * Called on every path that installs one, so a malformed payload is rejected + * where it enters rather than at first bind, where the only symptom would be a + * driver-side read past the end of a level. + * @internal + */ +export const validateCompressedPayload = ({ format, levels }: CompressedTexturePayload): CompressedTextureLevel => { + const [base] = levels; + + if (base === undefined) { + throw new Error('A compressed texture payload needs at least one mip level.'); + } + + const { blockWidth, blockHeight } = compressedBlockLayout(format); + + // WebGPU refuses to create a compressed texture whose base extent is not a + // whole number of blocks, while WebGL2 silently pads it. Rejecting it here + // keeps the two backends telling the same story instead of one failing at first + // bind on a payload the other accepted. Smaller mip levels are exempt: a chain + // from a block-aligned base legitimately ends below one block. + if (base.width % blockWidth !== 0 || base.height % blockHeight !== 0) { + throw new Error( + `A ${format} payload is compressed in ${blockWidth}x${blockHeight} blocks, so its base level must be a multiple of that on both axes, but it is ${base.width}x${base.height}.`, + ); + } + + for (const [index, level] of levels.entries()) { + const expected = compressedLevelByteLength(format, level.width, level.height); + + if (level.data.byteLength !== expected) { + throw new Error( + `Compressed mip level ${index} of a ${format} payload is ${level.width}x${level.height}, which occupies ${expected} bytes, but carries ${level.data.byteLength}.`, + ); + } + } + + return base; +}; + +/** + * The compressed payload of a texture-like value, or `null` when it carries none + * - including for a value with no such property at all. + * + * Read structurally rather than against a `Texture` parameter type: the backends + * handle a `Texture | RenderTexture` union of which only one arm can carry a + * payload, and importing either class here would make the payload module depend + * on the module that stores it. A structural parameter type cannot express that + * either - TypeScript rejects an all-optional target that shares no property + * with the argument - so the read is asserted instead of the parameter narrowed. + */ +export const compressedPayloadOf = (texture: object): CompressedTexturePayload | null => + (texture as { readonly compressed?: CompressedTexturePayload | null }).compressed ?? null; diff --git a/src/rendering/webgl2/WebGl2Backend.ts b/src/rendering/webgl2/WebGl2Backend.ts index fecffbd30..acbb3632f 100644 --- a/src/rendering/webgl2/WebGl2Backend.ts +++ b/src/rendering/webgl2/WebGl2Backend.ts @@ -11,6 +11,8 @@ import type { Drawable } from '#rendering/Drawable'; import type { Geometry } from '#rendering/geometry/Geometry'; import { dataTextureBytesPerPixel, estimateTextureBytes, GpuResourceAccountant } from '#rendering/GpuResourceAccountant'; import type { Mesh } from '#rendering/mesh/Mesh'; +import { assertDrawsAllAttachments, assertSingleAttachmentCompose } from '#rendering/multiAttachmentGuard'; +import { isMultiAttachmentTarget, MultiRenderTarget } from '#rendering/MultiRenderTarget'; import type { PersistentSlotBundle } from '#rendering/plan/PersistentSlotDraw'; import { type DrawCommand, drawCommandUsesSharedTransform, RenderEntryKind } from '#rendering/plan/RenderCommand'; import type { RenderRootSource } from '#rendering/plan/RenderRootSource'; @@ -29,7 +31,7 @@ import { RenderBackendType } from '#rendering/RenderBackendType'; import type { InstanceDataView } from '#rendering/RenderBatch'; import type { Renderer } from '#rendering/Renderer'; import { RendererRegistry } from '#rendering/RendererRegistry'; -import type { RenderError } from '#rendering/RenderError'; +import { RenderError } from '#rendering/RenderError'; import type { RenderStats } from '#rendering/RenderStats'; import { createRenderStats, resetRenderStats } from '#rendering/RenderStats'; import { RenderTarget } from '#rendering/RenderTarget'; @@ -42,6 +44,8 @@ import { type TransformTextureLayout, transformTextureRect, } from '#rendering/shader/transformTextureLayout'; +import { compressedPayloadOf } from '#rendering/texture/compressedPayload'; +import type { CompressedTextureFormat } from '#rendering/texture/CompressedTextureFormat'; import { DataTexture, type DataTextureFormat } from '#rendering/texture/DataTexture'; import { RenderTexture } from '#rendering/texture/RenderTexture'; import { Texture } from '#rendering/texture/Texture'; @@ -51,6 +55,7 @@ import { BlendModes, type ColorTextureFormat, TextureFormat } from '#rendering/t import type { View } from '#rendering/View'; import { WebGl2BackdropBlendCompositor } from './WebGl2BackdropBlendCompositor'; +import { probeWebgl2CompressedFormats, type Webgl2CompressedFormatSupport } from './webgl2CompressedFormat'; import { WebGl2MaskCompositor } from './WebGl2MaskCompositor'; import { WebGl2MeshRenderer } from './WebGl2MeshRenderer'; import { WebGl2PassCoordinator } from './WebGl2PassCoordinator'; @@ -135,10 +140,29 @@ interface ManagedTextureState { partialUploadScratch: Float32Array | Uint8Array | null; } +/** + * Colour attachments this context accepts in one framebuffer. + * + * The lower of the two limits: a framebuffer may carry `MAX_COLOR_ATTACHMENTS` + * textures, but a draw can only write `MAX_DRAW_BUFFERS` of them, and an + * attachment nothing can write to is not usable capacity. + */ +const readMaxColorAttachments = (gl: WebGL2RenderingContext): number => { + const attachments = gl.getParameter(gl.MAX_COLOR_ATTACHMENTS) as number; + const drawBuffers = gl.getParameter(gl.MAX_DRAW_BUFFERS) as number; + + return Math.max(Math.min(typeof attachments === 'number' ? attachments : 1, typeof drawBuffers === 'number' ? drawBuffers : 1), 1); +}; + interface ManagedRenderTargetState { framebuffer: WebGLFramebuffer | null; version: number; - attachedTexture: WebGLTexture | null; + /** + * GL texture currently attached at each colour slot. One entry for an ordinary + * `RenderTexture`, one per attachment for a {@link MultiRenderTarget} - so a + * re-attach is decided per slot rather than for the framebuffer as a whole. + */ + attachedTextures: Array; stencilRenderbuffer: WebGLRenderbuffer | null; stencilWidth: number; stencilHeight: number; @@ -302,6 +326,19 @@ export class WebGl2Backend implements RenderBackend { // with GL_INVALID_VALUE and leave every transform fetch reading an incomplete // texture (a black frame). Re-read after a context restore. private _maxTextureSize = 0; + /** + * Compressed-format table of the live context, rebuilt whenever the context + * is - a restored context re-enables its extensions from scratch, and a stale + * table would hand `compressedTexImage2D` an internal format the new context + * never enabled. + */ + private _compressedFormats: Webgl2CompressedFormatSupport = { formats: [], internalFormats: new Map() }; + private _maxColorAttachments = 1; + /** Whether the bound target writes more than one colour attachment - see {@link draw}. */ + private _multiAttachmentTarget = false; + /** Reused per-bind scratch for the colour-attachment handles and the draw-buffer list. */ + private readonly _attachmentHandleScratch: WebGLTexture[] = []; + private readonly _drawBufferScratch: number[] = []; /** The application's `canvas.pixelRatio`, sanitized once - see {@link surfacePixelRatio}. */ private readonly _surfacePixelRatio: number; private _renderTarget: RenderTarget; @@ -386,6 +423,8 @@ export class WebGl2Backend implements RenderBackend { // enable call; without it, RGBA16F/RGBA32F are not color-renderable in WebGL2. this._floatRenderable = this._context.getExtension('EXT_color_buffer_float') !== null; this._maxTextureSize = this._context.getParameter(this._context.MAX_TEXTURE_SIZE) as number; + this._compressedFormats = probeWebgl2CompressedFormats(this._context); + this._maxColorAttachments = readMaxColorAttachments(this._context); // Grab the lose-context extension up front so a later restore can act on the // live instance (see the field comment). `null` on backends that don't @@ -465,6 +504,14 @@ export class WebGl2Backend implements RenderBackend { return this._maxTextureSize; } + public get supportedTextureFormats(): readonly CompressedTextureFormat[] { + return this._compressedFormats.formats; + } + + public get maxColorAttachments(): number { + return this._maxColorAttachments; + } + public get clearColor(): Color { return this._clearColor; } @@ -785,6 +832,12 @@ export class WebGl2Backend implements RenderBackend { } public draw(drawable: Drawable): this { + // Only consulted while a multi-attachment target is bound, so an ordinary + // frame pays one boolean read per drawable. + if (this._multiAttachmentTarget) { + assertDrawsAllAttachments(drawable, (this._renderTarget as MultiRenderTarget).attachments.length, RenderBackendType.WebGl2); + } + const renderer = this.rendererRegistry.resolve(drawable); // Belt-and-braces for retained recording: the recordability @@ -855,6 +908,7 @@ export class WebGl2Backend implements RenderBackend { if (changed) { this._flushActiveRenderer(); this._renderTarget = renderTarget; + this._multiAttachmentTarget = isMultiAttachmentTarget(renderTarget); this._stats.renderTargetChanges++; } @@ -995,6 +1049,10 @@ export class WebGl2Backend implements RenderBackend { height: number, blendMode: BlendModes, ): this { + if (this._multiAttachmentTarget) { + assertSingleAttachmentCompose('Alpha-mask compositing', (this._renderTarget as MultiRenderTarget).attachments.length, RenderBackendType.WebGl2); + } + if (width <= 0 || height <= 0) { return this; } @@ -1015,6 +1073,10 @@ export class WebGl2Backend implements RenderBackend { } public composeWithBackdropBlend(source: RenderTexture, x: number, y: number, width: number, height: number, mode: BlendModes): this { + if (this._multiAttachmentTarget) { + assertSingleAttachmentCompose('Backdrop-blend compositing', (this._renderTarget as MultiRenderTarget).attachments.length, RenderBackendType.WebGl2); + } + if (width <= 0 || height <= 0) { return this; } @@ -2020,6 +2082,8 @@ export class WebGl2Backend implements RenderBackend { // being color-renderable until this is re-fetched on the fresh context. this._floatRenderable = gl.getExtension('EXT_color_buffer_float') !== null; this._maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number; + this._compressedFormats = probeWebgl2CompressedFormats(gl); + this._maxColorAttachments = readMaxColorAttachments(gl); // Drop the cached transform layout: it was derived from the LOST context's // limit, and the restored one may report a different one. this._transformTextureLayout = null; @@ -2190,7 +2254,7 @@ export class WebGl2Backend implements RenderBackend { const state: ManagedRenderTargetState = { framebuffer: target.root ? null : this._createFramebuffer(), version: -1, - attachedTexture: null, + attachedTextures: [], stencilRenderbuffer: null, stencilWidth: 0, stencilHeight: 0, @@ -2390,32 +2454,141 @@ export class WebGl2Backend implements RenderBackend { } } - private _prepareRenderTarget(target: RenderTarget): ManagedRenderTargetState { - if (target instanceof RenderTexture && target.format !== TextureFormat.Rgba8 && !this._floatRenderable) { + /** Reject a colour format this context cannot render into. */ + private _assertColorFormatRenderable(format: ColorTextureFormat): void { + if (format !== TextureFormat.Rgba8 && !this._floatRenderable) { throw new Error( - `RenderTexture: format '${target.format}' requires the WebGL2 extension 'EXT_color_buffer_float', which this context does not support. Check backend.supportsColorFormat() and fall back to TextureFormat.Rgba8.`, + `Render target: format '${format}' requires the WebGL2 extension 'EXT_color_buffer_float', which this context does not support. Check backend.supportsColorFormat() and fall back to TextureFormat.Rgba8.`, ); } + } - const state = this._getRenderTargetState(target); + /** + * Attach `handles` to the framebuffer's colour slots and declare them as the + * draw buffers. + * + * The multi-attachment path only: a single attachment is handled inline in + * {@link _prepareRenderTarget}, which is the case that runs every frame. + * + * `drawBuffers` is framebuffer state, so it is re-issued only when the + * attachment set actually changes rather than on every bind. Without the call a + * multi-attachment framebuffer would write slot 0 only, which is GL's default + * draw-buffer list. + */ + private _syncColorAttachments(state: ManagedRenderTargetState, handles: readonly WebGLTexture[]): void { + const attached = state.attachedTextures; + let changed = attached.length !== handles.length; + + for (let i = 0; i < handles.length && !changed; i++) { + changed = attached[i] !== handles[i]; + } + + if (!changed) { + return; + } + + const gl = this._context; + const previousFramebuffer = this._boundFramebuffer; - if (target instanceof RenderTexture && state.framebuffer) { - const previousFramebuffer = this._boundFramebuffer; + gl.bindFramebuffer(gl.FRAMEBUFFER, state.framebuffer); + + // Detach the slots this target no longer uses: a stale attachment keeps its + // texture alive, and once the counts differ it makes the framebuffer + // incomplete for the shorter draw-buffer list. + for (let i = handles.length; i < attached.length; i++) { + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, null, 0); + } + + const buffers = this._drawBufferScratch; + + buffers.length = 0; + + for (let i = 0; i < handles.length; i++) { + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, handles[i]!, 0); + buffers.push(gl.COLOR_ATTACHMENT0 + i); + } + + // Left alone for the single-attachment case, which is GL's default anyway - + // the call is only needed to widen the list, and to narrow it back. + if (handles.length > 1 || attached.length > 1) { + gl.drawBuffers(buffers); + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, previousFramebuffer); + + attached.length = handles.length; + + for (let i = 0; i < handles.length; i++) { + attached[i] = handles[i]!; + } + } + + private _prepareRenderTarget(target: RenderTarget): ManagedRenderTargetState { + // Both branches are written out rather than resolved through one array of + // attachments: this runs on every render-target bind, and a filter-heavy + // frame binds hundreds, so materializing a one-element list per bind would + // be pure per-frame garbage for the single-attachment case that is every + // frame in practice. + const multi = target instanceof MultiRenderTarget ? target : null; + const single = multi === null && target instanceof RenderTexture ? target : null; + + if (multi !== null) { + for (const attachment of multi.attachments) { + this._assertColorFormatRenderable(attachment.format); + } + } else if (single !== null) { + this._assertColorFormatRenderable(single.format); + } + + const state = this._getRenderTargetState(target); + const attachmentCount = multi?.attachments.length ?? (single === null ? 0 : 1); + + if (attachmentCount > 0 && state.framebuffer) { + if (attachmentCount > this._maxColorAttachments) { + throw new RenderError({ + code: 'unsupported-format', + backendType: RenderBackendType.WebGl2, + message: `This context accepts ${this._maxColorAttachments} colour attachment(s), but the target declares ${attachmentCount}. Check backend.maxColorAttachments.`, + }); + } const previousUnit = this._textureUnit; this._setTextureUnit(renderTargetTextureSyncUnit); - const textureState = this._syncTexture(target); - this._setTextureUnit(previousUnit); - if (state.attachedTexture !== textureState.handle) { - const gl = this._context; + if (multi === null) { + // Single attachment inline, and deliberately not routed through the + // general path below. This runs on every render-target bind, and a + // filter-heavy frame binds hundreds; measured against the allocation + // gate, staging one handle through a scratch list and comparing lists + // costs about 100 KB per frame on `filtered/100` alone. The one case + // that is every frame in practice pays for nothing it does not need. + const textureState = this._syncTexture(single!); + + this._setTextureUnit(previousUnit); - gl.bindFramebuffer(gl.FRAMEBUFFER, state.framebuffer); - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, textureState.handle, 0); - gl.bindFramebuffer(gl.FRAMEBUFFER, previousFramebuffer); + if (state.attachedTextures[0] !== textureState.handle) { + const gl = this._context; + const previousFramebuffer = this._boundFramebuffer; + + gl.bindFramebuffer(gl.FRAMEBUFFER, state.framebuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, textureState.handle, 0); + gl.bindFramebuffer(gl.FRAMEBUFFER, previousFramebuffer); + + state.attachedTextures.length = 1; + state.attachedTextures[0] = textureState.handle; + } + } else { + const handles = this._attachmentHandleScratch; - state.attachedTexture = textureState.handle; + handles.length = 0; + + for (const attachment of multi.attachments) { + handles.push(this._syncTexture(attachment).handle); + } + + this._setTextureUnit(previousUnit); + this._syncColorAttachments(state, handles); } // Reset the on-demand flag for pooled RenderTexture targets, so a @@ -2594,6 +2767,7 @@ export class WebGl2Backend implements RenderBackend { */ private _syncTextureUpload(texture: Texture | RenderTexture, state: ManagedTextureState, version: number): ManagedTextureState { const gl = this._context; + const compressedPayload = compressedPayloadOf(texture); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, texture.premultiplyAlpha); @@ -2695,6 +2869,30 @@ export class WebGl2Backend implements RenderBackend { gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, texture.width, texture.height, info.format, info.type, texture.source); this._accountant.recordTextureUpload(texture.width * texture.height * info.bytesPerPixel); } + } else if (compressedPayload !== null) { + const { format, levels } = compressedPayload; + const internalFormat = this._compressedFormats.internalFormats.get(format); + + if (internalFormat === undefined) { + throw new RenderError({ + code: 'unsupported-format', + backendType: RenderBackendType.WebGl2, + message: `This context cannot sample the compressed texture format "${format}". Declare an asset variant this device supports, or check backend.supportedTextureFormats before constructing the texture.`, + }); + } + + let uploadedBytes = 0; + + for (const [level, { data, width, height }] of levels.entries()) { + gl.compressedTexImage2D(gl.TEXTURE_2D, level, internalFormat, width, height, 0, data); + uploadedBytes += data.byteLength; + } + + // Booked from the payload rather than through `_bookTextureStorage`: that + // helper derives its size from a bytes-per-pixel figure and a synthesized + // mip count, and a compressed chain knows both exactly. + state.accountedBytes = this._accountant.reallocate(state.accountedBytes, uploadedBytes); + this._accountant.recordTextureUpload(uploadedBytes); } else if (texture.source) { if (state.version === -1 || state.width !== texture.width || state.height !== texture.height) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, texture.source); diff --git a/src/rendering/webgl2/WebGl2MeshRenderer.ts b/src/rendering/webgl2/WebGl2MeshRenderer.ts index 816a7b202..20f366ecb 100644 --- a/src/rendering/webgl2/WebGl2MeshRenderer.ts +++ b/src/rendering/webgl2/WebGl2MeshRenderer.ts @@ -3,12 +3,14 @@ import type { Drawable } from '#rendering/Drawable'; import type { Geometry } from '#rendering/geometry/Geometry'; import type { Material, UniformValue } from '#rendering/material/Material'; import type { Mesh } from '#rendering/mesh/Mesh'; +import type { MeshIndexArray, MeshIndexFormat } from '#rendering/mesh/meshIndices'; +import { createIndexArray } from '#rendering/mesh/meshIndices'; import { type DrawCommand, RenderEntryKind } from '#rendering/plan/RenderCommand'; import type { InstanceDataView } from '#rendering/RenderBatch'; import { Shader } from '#rendering/shader/Shader'; import type { RenderTexture } from '#rendering/texture/RenderTexture'; import { Texture } from '#rendering/texture/Texture'; -import { BlendModes, BufferTypes, BufferUsage, RenderingPrimitives } from '#rendering/types'; +import { BlendModes, BufferTypes, BufferUsage, IndexElementTypes, RenderingPrimitives } from '#rendering/types'; import { AbstractWebGl2Renderer } from './AbstractWebGl2Renderer'; import fragmentSource from './glsl/mesh.frag'; @@ -61,6 +63,9 @@ interface PendingMeshDraw { supportsInstancing: boolean; } +/** WebGL2 element type for a mesh index width. */ +const glIndexType = (format: MeshIndexFormat): IndexElementTypes => (format === 'uint32' ? IndexElementTypes.UnsignedInt : IndexElementTypes.UnsignedShort); + interface GeometryCacheEntry { readonly geometry: Geometry; readonly vertexBuffer: WebGl2RenderBuffer; @@ -70,6 +75,8 @@ interface GeometryCacheEntry { readonly vaos: Map>; readonly disposeListener: () => void; indexCount: number; + /** Index width the buffer currently holds; every VAO cached here draws with it. */ + indexFormat: MeshIndexFormat; // The geometry version the buffers currently hold. Re-packed on mismatch, so // dynamic/stream geometry reaches the GPU via Geometry.invalidate(). The // buffer objects themselves are reused, which keeps the cached VAOs valid. @@ -134,6 +141,14 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements private _float32View: Float32Array = new Float32Array(this._vertexData); private _uint32View: Uint32Array = new Uint32Array(this._vertexData); private _indexData: Uint16Array = new Uint16Array(initialIndexCapacity); + /** + * 32-bit staging, allocated only once a mesh actually needs it. Kept separate + * from the 16-bit array rather than replacing it: the common mesh is far below + * the 16-bit ceiling, and promoting the shared scratch on first sight of a wide + * mesh would double the upload bytes of every mesh after it for the rest of the + * session. + */ + private _indexData32: Uint32Array = new Uint32Array(0); private _nodeIndexData: Uint32Array = new Uint32Array(initialNodeIndexCapacity); // Initial (empty) backing data for the shared divisor-1 instance buffer; a // draw uploads the packed prefix of the RenderBatch's own storage directly. @@ -463,18 +478,23 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements this._setBlendMode(draw.blendMode, backend); this._bindInstancedShaderState(draw.shader, draw.texture, draw.material, backend, nodeIndex); + const indexFormat = draw.mesh.indexFormat; + this._ensureVertexCapacity(draw.mesh.vertexCount); - this._ensureIndexCapacity(draw.mesh.indexCount); + this._ensureIndexCapacity(draw.mesh.indexCount, indexFormat); this._ensureNodeIndexCapacity(1); + const indexScratch = this._indexScratch(indexFormat); + this._packVertices(draw.mesh, 0); - this._packIndices(draw.mesh, 0); + this._packIndices(draw.mesh, 0, indexScratch); this._nodeIndexData[0] = nodeIndex >>> 0; + connection.dynamicVao.setIndexType(glIndexType(indexFormat)); backend.bindVertexArrayObject(connection.dynamicVao); connection.dynamicVertexBuffer.upload(this._float32View, 0, draw.mesh.vertexCount * vertexStrideWords); - connection.dynamicIndexBuffer.upload(this._indexData, 0, draw.mesh.indexCount); + connection.dynamicIndexBuffer.upload(indexScratch, 0, draw.mesh.indexCount); connection.dynamicNodeIndexBuffer.upload(this._nodeIndexData, 0, 1); connection.dynamicVao.drawInstanced(draw.mesh.indexCount, 0, 1, RenderingPrimitives.Triangles); @@ -580,15 +600,21 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements this._bindCustomUniforms(shader, draw.material, backend); } + const indexFormat = mesh.indexFormat; + this._ensureVertexCapacity(mesh.vertexCount); - this._ensureIndexCapacity(mesh.indexCount); + this._ensureIndexCapacity(mesh.indexCount, indexFormat); + + const indexScratch = this._indexScratch(indexFormat); + this._packVertices(mesh, 0); - this._packIndices(mesh, 0); + this._packIndices(mesh, 0, indexScratch); shader.sync(); + connection.dynamicVao.setIndexType(glIndexType(indexFormat)); backend.bindVertexArrayObject(connection.dynamicVao); connection.dynamicVertexBuffer.upload(this._float32View, 0, mesh.vertexCount * vertexStrideWords); - connection.dynamicIndexBuffer.upload(this._indexData, 0, mesh.indexCount); + connection.dynamicIndexBuffer.upload(indexScratch, 0, mesh.indexCount); this._bindBaseTextureSampler(backend, draw.material); connection.dynamicVao.draw(mesh.indexCount, 0, RenderingPrimitives.Triangles); this._unbindBaseTextureSampler(backend, draw.material); @@ -712,7 +738,7 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements const shader = this._defaultShader; vao - .addIndex(geometry.indexBuffer) + .addIndex(geometry.indexBuffer, glIndexType(geometry.indexFormat)) .addAttribute(geometry.vertexBuffer, shader.getAttribute('a_position'), gl.FLOAT, false, vertexStrideBytes, 0) .addAttribute(geometry.vertexBuffer, shader.getAttribute('a_texcoord'), gl.FLOAT, false, vertexStrideBytes, 8) .addAttribute(geometry.vertexBuffer, shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, vertexStrideBytes, 16) @@ -882,7 +908,8 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements this._packVertices(mesh, 0, floatView, uintView); - const indexData = new Uint16Array(indexCount); + const indexFormat = mesh.indexFormat; + const indexData = createIndexArray(indexFormat, indexCount); this._packIndices(mesh, 0, indexData); @@ -930,6 +957,7 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements vaos: new Map(), disposeListener, indexCount, + indexFormat, version: geometry.version, }; @@ -953,13 +981,26 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements this._packVertices(mesh, 0, floatView, uintView); - const indexData = new Uint16Array(indexCount); + const indexData = createIndexArray(mesh.indexFormat, indexCount); this._packIndices(mesh, 0, indexData); entry.vertexBuffer.upload(floatView); entry.indexBuffer.upload(indexData); entry.indexCount = indexCount; + // A re-pack may cross the 16-bit ceiling in either direction, and every VAO + // cached against this entry draws with the type set at its creation - so a + // changed width has to be pushed onto all of them, not just recorded here. + if (entry.indexFormat !== mesh.indexFormat) { + entry.indexFormat = mesh.indexFormat; + + for (const perLayout of entry.vaos.values()) { + for (const vao of perLayout.values()) { + vao.setIndexType(glIndexType(mesh.indexFormat)); + } + } + } + entry.version = entry.geometry.version; } @@ -1000,7 +1041,7 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements // declared-but-unread inputs at link time, so a custom batch shader that // ignores texcoords or vertex colors simply has no such attribute to bind. // Skipping those keeps the interleaved layout's offsets untouched. - const vao = new WebGl2VertexArrayObject().addIndex(entry.indexBuffer); + const vao = new WebGl2VertexArrayObject().addIndex(entry.indexBuffer, glIndexType(entry.indexFormat)); const geometryAttributes = [ { name: 'a_position', type: gl.FLOAT, normalized: false, offset: 0 }, { name: 'a_texcoord', type: gl.FLOAT, normalized: false, offset: 8 }, @@ -1074,7 +1115,7 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements } } - private _packIndices(mesh: Mesh, indexStart: number, target: Uint16Array = this._indexData): void { + private _packIndices(mesh: Mesh, indexStart: number, target: MeshIndexArray): void { const indexCount = mesh.indexCount; if (mesh.indices !== null) { @@ -1101,16 +1142,24 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements this._uint32View = new Uint32Array(this._vertexData); } - private _ensureIndexCapacity(indexCount: number): void { - if (indexCount <= this._indexCapacity) { - return; + private _ensureIndexCapacity(indexCount: number, format: MeshIndexFormat): void { + if (indexCount > this._indexCapacity) { + while (this._indexCapacity < indexCount) { + this._indexCapacity *= 2; + } + + this._indexData = new Uint16Array(this._indexCapacity); + this._indexData32 = this._indexData32.length === 0 ? this._indexData32 : new Uint32Array(this._indexCapacity); } - while (this._indexCapacity < indexCount) { - this._indexCapacity *= 2; + if (format === 'uint32' && this._indexData32.length < this._indexCapacity) { + this._indexData32 = new Uint32Array(this._indexCapacity); } + } - this._indexData = new Uint16Array(this._indexCapacity); + /** The staging array a draw of `format` packs into. */ + private _indexScratch(format: MeshIndexFormat): MeshIndexArray { + return format === 'uint32' ? this._indexData32 : this._indexData; } private _ensureNodeIndexCapacity(instanceCount: number): void { @@ -1197,14 +1246,14 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements }, draw: (vao: WebGl2VertexArrayObject, size: number, start: number, type: number): void => { if (vao.indexBuffer) { - gl.drawElements(type, size, gl.UNSIGNED_SHORT, start); + gl.drawElements(type, size, vao.indexType, start); } else { gl.drawArrays(type, start, size); } }, drawInstanced: (vao: WebGl2VertexArrayObject, count: number, start: number, instanceCount: number, type: number): void => { if (vao.indexBuffer) { - gl.drawElementsInstanced(type, count, gl.UNSIGNED_SHORT, start, instanceCount); + gl.drawElementsInstanced(type, count, vao.indexType, start, instanceCount); } else { gl.drawArraysInstanced(type, start, count, instanceCount); } diff --git a/src/rendering/webgl2/WebGl2RetainedGroupResources.ts b/src/rendering/webgl2/WebGl2RetainedGroupResources.ts index 33faff093..b5fb4b68b 100644 --- a/src/rendering/webgl2/WebGl2RetainedGroupResources.ts +++ b/src/rendering/webgl2/WebGl2RetainedGroupResources.ts @@ -1,4 +1,5 @@ import type { GpuResourceAccountant } from '#rendering/GpuResourceAccountant'; +import type { MeshIndexFormat } from '#rendering/mesh/meshIndices'; import type { RetainedGroupBundle } from '#rendering/plan/RetainedInstructionSet'; import { createTransformTextureLayout, @@ -67,6 +68,8 @@ export interface WebGl2RetainedGeometryRef { readonly vertexBuffer: WebGl2RenderBuffer; readonly indexBuffer: WebGl2RenderBuffer; readonly indexCount: number; + /** Width `indexBuffer` holds, so replay draws it with the type it was packed at. */ + readonly indexFormat: MeshIndexFormat; } /** diff --git a/src/rendering/webgl2/WebGl2VertexArrayObject.ts b/src/rendering/webgl2/WebGl2VertexArrayObject.ts index 7b9b7bcfb..80f1d28b1 100644 --- a/src/rendering/webgl2/WebGl2VertexArrayObject.ts +++ b/src/rendering/webgl2/WebGl2VertexArrayObject.ts @@ -125,6 +125,21 @@ export class WebGl2VertexArrayObject { return this; } + /** + * Retarget the element type {@link draw} reports for the already-bound index + * buffer. + * + * Deliberately does NOT bump {@link version}: the type is read at draw time, + * not baked into the VAO's attribute state, so treating it as a layout change + * would make a shared dynamic VAO re-specify every attribute pointer on every + * draw whose index width differs from the last one. + */ + public setIndexType(type: IndexElementTypes): this { + this._indexType = type; + + return this; + } + public clear(): this { this._attributes.length = 0; this._indexBuffer = null; diff --git a/src/rendering/webgl2/webgl2CompressedFormat.ts b/src/rendering/webgl2/webgl2CompressedFormat.ts new file mode 100644 index 000000000..25cab8182 --- /dev/null +++ b/src/rendering/webgl2/webgl2CompressedFormat.ts @@ -0,0 +1,106 @@ +import { CompressedTextureFormat, orderCompressedFormats } from '#rendering/texture/CompressedTextureFormat'; + +/** + * Internal formats of the compressed-texture extensions, as constants. + * + * They are read from the extension objects at runtime in principle, but every + * one of these is a fixed enum value in its registry entry and several of the + * extension objects are typed as `{}` in the DOM lib, so a literal table is both + * shorter and better typed than probing each object for a property that is + * declared nowhere. + */ +const enum Gl { + CompressedRgbaS3tcDxt1 = 0x83f1, + CompressedRgbaS3tcDxt3 = 0x83f2, + CompressedRgbaS3tcDxt5 = 0x83f3, + CompressedRedRgtc1 = 0x8dbb, + CompressedRedGreenRgtc2 = 0x8dbd, + CompressedRgbBptcUnsignedFloat = 0x8e8f, + CompressedRgbaBptcUnorm = 0x8e8c, + CompressedR11Eac = 0x9270, + CompressedRg11Eac = 0x9272, + CompressedRgb8Etc2 = 0x9274, + CompressedRgb8PunchthroughAlpha1Etc2 = 0x9276, + CompressedRgba8Etc2Eac = 0x9278, + CompressedRgbaAstc4x4 = 0x93b0, + CompressedRgbaAstc5x5 = 0x93b2, + CompressedRgbaAstc6x6 = 0x93b4, + CompressedRgbaAstc8x8 = 0x93b7, +} + +/** Extension name each format family needs, and the internal format per member. */ +const families: ReadonlyArray<{ readonly extension: string; readonly formats: Readonly>> }> = [ + { + extension: 'WEBGL_compressed_texture_s3tc', + formats: { + [CompressedTextureFormat.Bc1RgbaUnorm]: Gl.CompressedRgbaS3tcDxt1, + [CompressedTextureFormat.Bc2RgbaUnorm]: Gl.CompressedRgbaS3tcDxt3, + [CompressedTextureFormat.Bc3RgbaUnorm]: Gl.CompressedRgbaS3tcDxt5, + }, + }, + { + extension: 'EXT_texture_compression_rgtc', + formats: { + [CompressedTextureFormat.Bc4RUnorm]: Gl.CompressedRedRgtc1, + [CompressedTextureFormat.Bc5RgUnorm]: Gl.CompressedRedGreenRgtc2, + }, + }, + { + extension: 'EXT_texture_compression_bptc', + formats: { + [CompressedTextureFormat.Bc6hRgbUfloat]: Gl.CompressedRgbBptcUnsignedFloat, + [CompressedTextureFormat.Bc7RgbaUnorm]: Gl.CompressedRgbaBptcUnorm, + }, + }, + { + extension: 'WEBGL_compressed_texture_etc', + formats: { + [CompressedTextureFormat.Etc2Rgb8Unorm]: Gl.CompressedRgb8Etc2, + [CompressedTextureFormat.Etc2Rgb8A1Unorm]: Gl.CompressedRgb8PunchthroughAlpha1Etc2, + [CompressedTextureFormat.Etc2Rgba8Unorm]: Gl.CompressedRgba8Etc2Eac, + [CompressedTextureFormat.EacR11Unorm]: Gl.CompressedR11Eac, + [CompressedTextureFormat.EacRg11Unorm]: Gl.CompressedRg11Eac, + }, + }, + { + extension: 'WEBGL_compressed_texture_astc', + formats: { + [CompressedTextureFormat.Astc4x4Unorm]: Gl.CompressedRgbaAstc4x4, + [CompressedTextureFormat.Astc5x5Unorm]: Gl.CompressedRgbaAstc5x5, + [CompressedTextureFormat.Astc6x6Unorm]: Gl.CompressedRgbaAstc6x6, + [CompressedTextureFormat.Astc8x8Unorm]: Gl.CompressedRgbaAstc8x8, + }, + }, +]; + +/** The compressed formats one context implements, and the internal format each uploads as. */ +export interface Webgl2CompressedFormatSupport { + readonly formats: readonly CompressedTextureFormat[]; + readonly internalFormats: ReadonlyMap; +} + +/** + * Probe `gl` for the compressed-texture extensions and build its format table. + * + * Run once per context: `getExtension` is a comparatively expensive call and + * enabling an extension is idempotent, so there is nothing to gain from asking + * again per upload. ETC2/EAC is deliberately probed like the rest - WebGL2 + * exposes it through `WEBGL_compressed_texture_etc` rather than as a core + * format, so assuming its presence from the context version alone would claim + * support on every desktop browser that lacks it. + */ +export const probeWebgl2CompressedFormats = (gl: WebGL2RenderingContext): Webgl2CompressedFormatSupport => { + const internalFormats = new Map(); + + for (const { extension, formats } of families) { + if (gl.getExtension(extension) === null) { + continue; + } + + for (const [format, internalFormat] of Object.entries(formats)) { + internalFormats.set(format as CompressedTextureFormat, internalFormat); + } + } + + return { formats: orderCompressedFormats(internalFormats.keys()), internalFormats }; +}; diff --git a/src/rendering/webgpu/WebGpuBackend.ts b/src/rendering/webgpu/WebGpuBackend.ts index e23d95542..69dc7cb0c 100644 --- a/src/rendering/webgpu/WebGpuBackend.ts +++ b/src/rendering/webgpu/WebGpuBackend.ts @@ -15,6 +15,8 @@ import type { Drawable } from '#rendering/Drawable'; import type { Geometry } from '#rendering/geometry/Geometry'; import { dataTextureBytesPerPixel, estimateTextureBytes, GpuResourceAccountant } from '#rendering/GpuResourceAccountant'; import type { Mesh } from '#rendering/mesh/Mesh'; +import { assertDrawsAllAttachments, assertSingleAttachmentCompose } from '#rendering/multiAttachmentGuard'; +import { isMultiAttachmentTarget, MultiRenderTarget } from '#rendering/MultiRenderTarget'; import type { PersistentSlotBundle } from '#rendering/plan/PersistentSlotDraw'; import { type DrawCommand, drawCommandUsesSharedTransform, RenderEntryKind } from '#rendering/plan/RenderCommand'; import type { RenderRootSource } from '#rendering/plan/RenderRootSource'; @@ -37,6 +39,8 @@ import type { RenderStats } from '#rendering/RenderStats'; import { createRenderStats, resetRenderStats } from '#rendering/RenderStats'; import { RenderTarget } from '#rendering/RenderTarget'; import { RenderTexturePool } from '#rendering/RenderTexturePool'; +import { compressedPayloadOf } from '#rendering/texture/compressedPayload'; +import { compressedBlockLayout, compressedBlocksAcross, compressedBlocksDown, type CompressedTextureFormat } from '#rendering/texture/CompressedTextureFormat'; import { DataTexture, type DataTextureFormat } from '#rendering/texture/DataTexture'; import { RenderTexture } from '#rendering/texture/RenderTexture'; import { Texture } from '#rendering/texture/Texture'; @@ -47,6 +51,7 @@ import { createCanvas } from '#rendering/utils'; import type { View } from '#rendering/View'; import { WebGpuBackdropBlendCompositor } from './WebGpuBackdropBlendCompositor'; +import { readWebgpuCompressedFormats, type WebgpuCompressedFormatSupport, webgpuCompressedTextureFeatures } from './webgpuCompressedFormat'; import { WebGpuMaskCompositor } from './WebGpuMaskCompositor'; import { WebGpuMeshRenderer } from './WebGpuMeshRenderer'; import { WebGpuPassCoordinator } from './WebGpuPassCoordinator'; @@ -80,6 +85,13 @@ interface ManagedWebGpuTextureState { width: number; height: number; mipLevelCount: number; + /** + * The GPU format the texture object was created with. A texture handle can + * change format across its life - an empty loader handle becomes either a + * managed RGBA8 upload or a compressed payload - and a format is fixed at + * creation, so this is what decides whether the object has to be rebuilt. + */ + format: GPUTextureFormat; hasContent: boolean; /** GPU bytes currently booked for this texture's storage with the resource accountant. */ accountedBytes: number; @@ -226,6 +238,12 @@ export class WebGpuBackend implements RenderBackend { private _mipmapSampler: GPUSampler | null = null; private _context: GPUCanvasContext | null = null; private _device: GPUDevice | null = null; + /** + * Compressed-format table of the granted device. Empty until the device + * exists, so a format query made before initialization reports nothing + * supported rather than claiming a family the device may never carry. + */ + private _compressedFormats: WebgpuCompressedFormatSupport = { formats: [], gpuFormats: new Map() }; private _format: GPUTextureFormat | null = null; // `copyExternalImageToTexture` from a source: `null` while unknown // (never probed yet), `true`/`false` once the one-off probe below resolves. @@ -247,6 +265,18 @@ export class WebGpuBackend implements RenderBackend { loadOp: 'load', storeOp: 'store', }; + /** + * Extra reusable attachment records for a MultiRenderTarget, one per slot + * beyond the first. Pooled for the same reason the first one is: a pass is + * opened many times per frame and the records do not outlive beginRenderPass. + */ + private readonly _extraColorAttachments: GPURenderPassColorAttachment[] = []; + /** Reused list backing `renderTargetFormats`; refilled in place on every read. */ + private readonly _formatScratch: GPUTextureFormat[] = ['rgba8unorm']; + /** Load op slot 0 resolved for the pass being opened; the other slots follow it. */ + private _loadOpForPass: GPULoadOp = 'load'; + /** Whether the bound target writes more than one colour attachment - see `draw`. */ + private _multiAttachmentTarget = false; /** Reused one-element command-buffer list for `submit`. */ private readonly _submitBatch: GPUCommandBuffer[] = [undefined as unknown as GPUCommandBuffer]; private _renderer: Renderer | null = null; @@ -358,6 +388,17 @@ export class WebGpuBackend implements RenderBackend { return limits?.maxTextureDimension2D ?? WEBGPU_DEFAULT_MAX_TEXTURE_DIMENSION_2D; } + public get supportedTextureFormats(): readonly CompressedTextureFormat[] { + return this._compressedFormats.formats; + } + + public get maxColorAttachments(): number { + const limits = (this._device as { limits?: GPUSupportedLimits } | null)?.limits; + const reported = limits?.maxColorAttachments; + + return typeof reported === 'number' && reported > 0 ? reported : 1; + } + public get device(): GPUDevice { if (this._device === null) { throw new Error('WebGPU device is not initialized yet.'); @@ -394,9 +435,53 @@ export class WebGpuBackend implements RenderBackend { return this._getGpuTextureFormat(this._renderTarget); } + if (this._renderTarget instanceof MultiRenderTarget) { + return this._getGpuTextureFormat(this._renderTarget.attachment(0)); + } + return managedTextureFormat; } + /** + * Colour format of every attachment of the bound target, in slot order. + * + * A pipeline must declare one target per attachment of the pass it runs in, so a + * renderer that can draw into a multi-attachment target keys its pipelines on + * all of these rather than on renderTargetFormat alone. Single-target renderers + * keep reading the singular getter, which stays the first slot. + * @internal + */ + public get renderTargetFormats(): readonly GPUTextureFormat[] { + const target = this._renderTarget; + // Filled in place rather than mapped: this is read once per custom-material + // draw, and a fresh array there would be per-draw garbage. + const formats = this._formatScratch; + + if (target instanceof MultiRenderTarget) { + const attachments = target.attachments; + + formats.length = attachments.length; + + for (let index = 0; index < attachments.length; index++) { + formats[index] = this._getGpuTextureFormat(attachments[index]!); + } + + return formats; + } + + formats.length = 1; + formats[0] = this.renderTargetFormat; + + return formats; + } + + /** Colour attachments the bound target contributes to a pass. */ + public get colorAttachmentCount(): number { + const target = this._renderTarget; + + return target instanceof MultiRenderTarget ? target.attachments.length : 1; + } + /** * Whether the root canvas composites without an alpha channel. Only then may a * root target be treated as a fully covered backdrop: under @@ -689,6 +774,12 @@ export class WebGpuBackend implements RenderBackend { return this; } + // Only consulted while a multi-attachment target is bound, so an ordinary + // frame pays one boolean read per drawable. + if (this._multiAttachmentTarget) { + assertDrawsAllAttachments(drawable, (this._renderTarget as MultiRenderTarget).attachments.length, RenderBackendType.WebGpu); + } + const renderer = this.rendererRegistry.resolve(drawable); // Defensive: a draw the recorder cannot capture inside an active @@ -779,8 +870,19 @@ export class WebGpuBackend implements RenderBackend { } this._renderTarget = nextRenderTarget; + this._multiAttachmentTarget = isMultiAttachmentTarget(nextRenderTarget); this._stats.renderTargetChanges++; + if (this._multiAttachmentTarget && (nextRenderTarget as MultiRenderTarget).attachments.length > this.maxColorAttachments) { + throw new RenderError({ + code: 'unsupported-format', + backendType: RenderBackendType.WebGpu, + message: + `This device accepts ${this.maxColorAttachments} colour attachment(s), but the target declares ` + + `${(nextRenderTarget as MultiRenderTarget).attachments.length}. Check backend.maxColorAttachments.`, + }); + } + if (nextRenderTarget !== this._rootRenderTarget) { this._subscribeRenderTarget(nextRenderTarget); } @@ -820,6 +922,10 @@ export class WebGpuBackend implements RenderBackend { height: number, blendMode: BlendModes, ): this { + if (this._multiAttachmentTarget) { + assertSingleAttachmentCompose('Alpha-mask compositing', (this._renderTarget as MultiRenderTarget).attachments.length, RenderBackendType.WebGpu); + } + if (width <= 0 || height <= 0) { return this; } @@ -846,6 +952,10 @@ export class WebGpuBackend implements RenderBackend { } public composeWithBackdropBlend(source: RenderTexture, x: number, y: number, width: number, height: number, mode: BlendModes): this { + if (this._multiAttachmentTarget) { + assertSingleAttachmentCompose('Backdrop-blend compositing', (this._renderTarget as MultiRenderTarget).attachments.length, RenderBackendType.WebGpu); + } + if (width <= 0 || height <= 0) { return this; } @@ -1102,6 +1212,7 @@ export class WebGpuBackend implements RenderBackend { this._context?.unconfigure(); this._context = null; this._device = null; + this._compressedFormats = { formats: [], gpuFormats: new Map() }; this._format = null; this._initializePromise = null; this._clearRequested = false; @@ -1136,34 +1247,69 @@ export class WebGpuBackend implements RenderBackend { * passes (501 on `filter/color 100`), and two fresh records per pass was one * of the larger remaining per-pass costs. */ - public createColorAttachment(): GPURenderPassColorAttachment { + /** + * Resolve the pass attachment for colour slot `index` of the bound target. + * + * The load op is resolved once, on slot 0: it answers whether this target has + * already been drawn into this frame, which is a property of the target, and the + * attachments of one multi-attachment target are always written together. + * Resolving per slot would consume the pending clear request on the first slot + * and leave the rest loading undefined contents. + */ + public createColorAttachment(index = 0): GPURenderPassColorAttachment { const renderTarget = this._renderTarget; + const multi = renderTarget instanceof MultiRenderTarget ? renderTarget : null; let view: GPUTextureView; - if (renderTarget === this._rootRenderTarget) { + if (multi !== null) { + view = this._syncTexture(multi.attachment(index)).view; + } else if (renderTarget === this._rootRenderTarget) { view = this.context.getCurrentTexture().createView(); } else if (renderTarget instanceof RenderTexture) { // Sync first so a resized RenderTexture resets its content flag before the // coordinator resolves the load op below. view = this._syncTexture(renderTarget).view; } else { - throw new Error('WebGPU currently supports only root targets and RenderTexture targets.'); + throw new Error('WebGPU currently supports only root targets, RenderTexture and MultiRenderTarget targets.'); } - const loadOp = this._passCoordinator.resolveLoad(renderTarget, this._clearRequested); + if (index === 0) { + this._loadOpForPass = this._passCoordinator.resolveLoad(renderTarget, this._clearRequested); + this._clearRequested = false; - this._clearRequested = false; + const clearValue = this._clearValue; - const attachment = this._colorAttachment; - const clearValue = this._clearValue; + clearValue.r = this._clearColor.r / 255; + clearValue.g = this._clearColor.g / 255; + clearValue.b = this._clearColor.b / 255; + clearValue.a = this._clearColor.a; + } - clearValue.r = this._clearColor.r / 255; - clearValue.g = this._clearColor.g / 255; - clearValue.b = this._clearColor.b / 255; - clearValue.a = this._clearColor.a; + const attachment = index === 0 ? this._colorAttachment : this._extraAttachment(index); attachment.view = view; - attachment.loadOp = loadOp; + attachment.loadOp = this._loadOpForPass; + + return attachment; + } + + /** Pooled attachment record for colour slot `index`, grown on demand. */ + private _extraAttachment(index: number): GPURenderPassColorAttachment { + const slot = index - 1; + const existing = this._extraColorAttachments[slot]; + + if (existing !== undefined) { + return existing; + } + + const attachment: GPURenderPassColorAttachment = { + view: undefined as unknown as GPUTextureView, + clearValue: this._clearValue, + loadOp: 'load', + storeOp: 'store', + }; + + this._extraColorAttachments[slot] = attachment; return attachment; } @@ -1932,6 +2078,13 @@ export class WebGpuBackend implements RenderBackend { // that way (float RenderTextures default to nearest, so this is a bonus). const floatFeatures = (['float32-filterable', 'float32-blendable'] as const).filter(feature => adapter.features?.has(feature) ?? false); + // Compressed-format families are optional features, and a device only + // carries what the request asked for - so an adapter that supports BC + // still yields a device that rejects a BC texture unless it is requested + // here. Filtering against the adapter first keeps the request satisfiable: + // asking for a family the adapter lacks fails the whole `requestDevice`. + const compressedFeatures = webgpuCompressedTextureFeatures.filter(feature => adapter.features?.has(feature) ?? false); + // The sprite batcher sizes its multi-texture bind-group layout from the // GRANTED device limits (resolveSpriteBatchTextureSlots): request up to // the 32-slot ceiling when the adapter offers more than the spec base of @@ -1952,8 +2105,8 @@ export class WebGpuBackend implements RenderBackend { const descriptor: GPUDeviceDescriptor = {}; - if (floatFeatures.length > 0) { - descriptor.requiredFeatures = floatFeatures; + if (floatFeatures.length > 0 || compressedFeatures.length > 0) { + descriptor.requiredFeatures = [...floatFeatures, ...compressedFeatures]; } if (Object.keys(requiredLimits).length > 0) { @@ -2014,6 +2167,7 @@ export class WebGpuBackend implements RenderBackend { } this._device = device; + this._compressedFormats = readWebgpuCompressedFormats(device); // Surface uncaptured GPU errors (validation / OOM / internal) through // onRenderError. Re-installed automatically after device-loss recovery @@ -2241,6 +2395,7 @@ export class WebGpuBackend implements RenderBackend { this._context?.unconfigure(); this._context = null; this._device = null; + this._compressedFormats = { formats: [], gpuFormats: new Map() }; this._format = null; this._initializePromise = null; this._hasPresentedFrame = false; @@ -2383,13 +2538,14 @@ export class WebGpuBackend implements RenderBackend { let state = this._textureStates.get(texture); if (!state) { + const format = this._getGpuTextureFormat(texture); const gpuTexture = this.device.createTexture({ label: 'backend:texture', size: { width: Math.max(texture.width, 1), height: Math.max(texture.height, 1), }, - format: this._getGpuTextureFormat(texture), + format, mipLevelCount: this._getMipLevelCount(texture), usage: this._getTextureUsage(texture), }); @@ -2410,6 +2566,7 @@ export class WebGpuBackend implements RenderBackend { width: texture.width, height: texture.height, mipLevelCount, + format, hasContent: false, accountedBytes: 0, partialUploadScratch: null, @@ -2605,11 +2762,17 @@ export class WebGpuBackend implements RenderBackend { private _syncTexture(texture: Texture | RenderTexture): ManagedWebGpuTextureState { assertLiveTexture(texture); - if (!(texture instanceof RenderTexture) && !(texture instanceof DataTexture) && (texture.source === null || texture.width === 0 || texture.height === 0)) { + if ( + !(texture instanceof RenderTexture) && + !(texture instanceof DataTexture) && + texture.compressed === null && + (texture.source === null || texture.width === 0 || texture.height === 0) + ) { throw new Error('WebGPU sprite rendering requires a texture with a valid source and non-zero dimensions.'); } const state = this._getTextureState(texture); + const compressedPayload = compressedPayloadOf(texture); const textureVersion = texture instanceof RenderTexture ? texture.textureVersion : texture.version; const mipLevelCount = this._getMipLevelCount(texture); const nonFilterable = this._isNonFilterable(texture); @@ -2621,7 +2784,9 @@ export class WebGpuBackend implements RenderBackend { } if (state.version !== textureVersion) { - if (state.width !== texture.width || state.height !== texture.height || state.mipLevelCount !== mipLevelCount) { + const gpuFormat = this._getGpuTextureFormat(texture); + + if (state.width !== texture.width || state.height !== texture.height || state.mipLevelCount !== mipLevelCount || state.format !== gpuFormat) { state.texture.destroy(); const resizedTexture = this.device.createTexture({ @@ -2630,7 +2795,7 @@ export class WebGpuBackend implements RenderBackend { width: texture.width, height: texture.height, }, - format: this._getGpuTextureFormat(texture), + format: gpuFormat, mipLevelCount, usage: this._getTextureUsage(texture), }); @@ -2640,6 +2805,7 @@ export class WebGpuBackend implements RenderBackend { state.width = texture.width; state.height = texture.height; state.mipLevelCount = mipLevelCount; + state.format = gpuFormat; state.hasContent = false; // Free the previous storage before booking the new size (no transient spike). state.accountedBytes = this._accountant.reallocate(state.accountedBytes, this._estimateTextureBytes(texture, mipLevelCount)); @@ -2706,6 +2872,28 @@ export class WebGpuBackend implements RenderBackend { this._accountant.recordTextureUpload(region.width * region.height * bytesPerPixel); } + state.hasContent = true; + } else if (compressedPayload !== null) { + const { format: compressedFormat, levels } = compressedPayload; + const { blockWidth, blockHeight, bytesPerBlock } = compressedBlockLayout(compressedFormat); + + for (const [mipLevel, level] of levels.entries()) { + const blocksAcross = compressedBlocksAcross(compressedFormat, level.width); + const blocksDown = compressedBlocksDown(compressedFormat, level.height); + + // `bytesPerRow` counts BLOCK rows, not texel rows, and the write extent + // is padded up to whole blocks - a 5x5 ASTC 4x4 level is a 2x2 block + // grid. Passing the texel width here would under-run the driver's read + // by the block size and corrupt every level. + this.device.queue.writeTexture( + { texture: state.texture, mipLevel }, + level.data, + { bytesPerRow: blocksAcross * bytesPerBlock, rowsPerImage: blocksDown }, + { width: blocksAcross * blockWidth, height: blocksDown * blockHeight }, + ); + this._accountant.recordTextureUpload(level.data.byteLength); + } + state.hasContent = true; } else if (!(texture instanceof RenderTexture)) { const source = texture.source!; @@ -2952,10 +3140,31 @@ export class WebGpuBackend implements RenderBackend { if (texture instanceof RenderTexture) { return webgpuColorTextureFormat(texture.format); } + const compressed = compressedPayloadOf(texture); + + if (compressed !== null) { + const gpuFormat = this._compressedFormats.gpuFormats.get(compressed.format); + if (gpuFormat === undefined) { + throw new RenderError({ + code: 'unsupported-format', + backendType: RenderBackendType.WebGpu, + message: `This device cannot sample the compressed texture format "${compressed.format}". Declare an asset variant this device supports, or check backend.supportedTextureFormats before constructing the texture.`, + }); + } + return gpuFormat; + } return managedTextureFormat; } private _getTextureUsage(texture: Texture | RenderTexture): number { + // RENDER_ATTACHMENT exists purely so `_generateMipmaps` can render into the + // smaller levels. A compressed format is not renderable at all, so asking + // for it would fail texture creation outright - and there is nothing to + // generate, because the chain arrives complete. + if (compressedPayloadOf(texture) !== null) { + return GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING; + } + const mipmapUsage = this._getMipLevelCount(texture) > 1 ? GPUTextureUsage.RENDER_ATTACHMENT : 0; if (texture instanceof RenderTexture) { @@ -3008,6 +3217,14 @@ export class WebGpuBackend implements RenderBackend { return dataTextureBytesPerPixel(format); } + const compressed = compressedPayloadOf(texture); + + if (compressed !== null) { + const { blockWidth, blockHeight, bytesPerBlock } = compressedBlockLayout(compressed.format); + + return bytesPerBlock / (blockWidth * blockHeight); + } + return MANAGED_TEXTURE_BYTES_PER_PIXEL; } @@ -3017,6 +3234,15 @@ export class WebGpuBackend implements RenderBackend { } private _getMipLevelCount(texture: Texture | RenderTexture): number { + // A compressed payload carries whatever chain the container shipped; the GPU + // cannot derive one from compressed blocks, so `generateMipMap` says nothing + // about it and the level count comes from the levels themselves. + const compressed = compressedPayloadOf(texture); + + if (compressed !== null) { + return compressed.levels.length; + } + if (!texture.generateMipMap) { return 1; } diff --git a/src/rendering/webgpu/WebGpuMeshRenderer.ts b/src/rendering/webgpu/WebGpuMeshRenderer.ts index 0fc600dd9..6fdbd0fa9 100644 --- a/src/rendering/webgpu/WebGpuMeshRenderer.ts +++ b/src/rendering/webgpu/WebGpuMeshRenderer.ts @@ -6,6 +6,8 @@ import type { Drawable } from '#rendering/Drawable'; import type { Geometry } from '#rendering/geometry/Geometry'; import type { Material } from '#rendering/material/Material'; import type { Mesh } from '#rendering/mesh/Mesh'; +import type { MeshIndexArray, MeshIndexFormat } from '#rendering/mesh/meshIndices'; +import { createIndexArray, meshIndexBytes } from '#rendering/mesh/meshIndices'; import type { DrawCommand } from '#rendering/plan/RenderCommand'; import type { InstanceDataView } from '#rendering/RenderBatch'; import type { RenderTexture } from '#rendering/texture/RenderTexture'; @@ -58,12 +60,17 @@ export const instancedMeshShaderSource: string = instancedMeshShaderSourceModule const vertexStrideBytes = 20; const wordsPerVertex = vertexStrideBytes / 4; /** - * Byte size of `indexCount` uint16 indices, rounded up to 4. `GPUQueue.writeBuffer` - * rejects byte counts and offsets that are not a multiple of 4, so index sub-ranges - * within the shared buffer are laid out on 4-byte boundaries - which also satisfies - * `setIndexBuffer`'s weaker 2-byte offset requirement. + * Byte size of `indexCount` indices of `format`, rounded up to 4. + * + * `GPUQueue.writeBuffer` rejects byte counts and offsets that are not a multiple + * of 4, so index sub-ranges within the shared buffer are laid out on 4-byte + * boundaries. That also satisfies `setIndexBuffer`'s per-format offset + * requirement for BOTH widths at once, which is what lets 16- and 32-bit meshes + * share one buffer in one flush - the alternative, packing each width tightly, + * would put a uint32 block on a 2-byte boundary the moment an odd uint16 block + * preceded it. */ -const alignIndexBytes = (indexCount: number): number => (indexCount * Uint16Array.BYTES_PER_ELEMENT + 3) & ~3; +const alignIndexBytes = (indexCount: number, format: MeshIndexFormat): number => (indexCount * meshIndexBytes(format) + 3) & ~3; const tintByteLength = 32; // vec4 tint + vec4 flags (only flags.x used) const transformUniformByteLength = 128; // mat3x3 projection (48B) + mat3x3 group (48B) + vec4 flags (16B) + vec4 snap viewport (16B) @@ -86,6 +93,7 @@ interface MeshDrawCall { vertexCount: number; indexByteOffset: number; indexCount: number; + indexFormat: MeshIndexFormat; customDrawIndex: number; // index within the per-shader custom queue, -1 for default } @@ -137,6 +145,8 @@ interface GeometryCacheEntry { vertexBuffer: GPUBuffer; indexBuffer: GPUBuffer; indexCount: number; + /** Width `indexBuffer` holds; every draw and every replay of it binds this format. */ + indexFormat: MeshIndexFormat; readonly disposeListener: () => void; // The geometry version currently resident in the buffers; re-uploaded on // mismatch so dynamic/stream geometry reaches the GPU via Geometry.invalidate(). @@ -170,7 +180,10 @@ interface CustomShaderResources { vertexData: ArrayBuffer; vertexFloatView: Float32Array; vertexUintView: Uint32Array; - indexData: Uint16Array; + /** Index staging, viewed at both widths so one buffer carries a mixed-width flush. */ + indexData: ArrayBuffer; + indexU16: Uint16Array; + indexU32: Uint32Array; // Mesh-uniform UBO (proj/trans/tint), one slot per draw, dynamic offset. meshUniformBuffer: GPUBuffer | null; meshUniformBufferCapacity: number; @@ -188,7 +201,8 @@ interface CustomShaderResources { // Per-frame state, reset in flush(). drawCount: number; totalVertices: number; - totalIndices: number; + /** Sum of this frame's per-draw index blocks, each already 4-byte aligned. */ + totalIndexBytes: number; } const meshUniformAlignment = 256; @@ -352,7 +366,9 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements private readonly _customIndexCursors = new Map(); private _float32View: Float32Array = new Float32Array(this._vertexData); private _uint32View: Uint32Array = new Uint32Array(this._vertexData); - private _packedIndexData: Uint16Array = new Uint16Array(0); + private _indexStaging: ArrayBuffer = new ArrayBuffer(0); + private _indexStagingU16: Uint16Array = new Uint16Array(this._indexStaging); + private _indexStagingU32: Uint32Array = new Uint32Array(this._indexStaging); private _drawCallCount = 0; public render(mesh: Mesh): void { @@ -388,6 +404,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements // it so the default path uses the right value. const premultiplySample = backend.shouldPremultiplyTextureSample(meshTexture); const indexCount = mesh.indexCount; + const indexFormat = mesh.indexFormat; let customDrawIndex = -1; @@ -396,7 +413,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements customDrawIndex = resources.drawCount; resources.drawCount++; resources.totalVertices += vertexCount; - resources.totalIndices += indexCount; + resources.totalIndexBytes += alignIndexBytes(indexCount, indexFormat); } // Plan offsets within the shared (default) or per-shader (custom) buffers; @@ -413,6 +430,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements vertexCount, indexByteOffset: 0, indexCount, + indexFormat, customDrawIndex, }; @@ -555,7 +573,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements pass.setVertexBuffer(2, this._instancedAttributeArena.buffer, attributeByteOffset); } - pass.setIndexBuffer(staticGeometry.indexBuffer, 'uint16'); + pass.setIndexBuffer(staticGeometry.indexBuffer, staticGeometry.indexFormat); pass.drawIndexed(staticGeometry.indexCount, count); this._ownDrawsPass = active; @@ -660,7 +678,9 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements // the CPU staging arrays stay flush-local and only the GPU buffers carry the // whole pass. let defaultVertices = 0; - let defaultIndices = 0; + // Byte cursor, not an element count: two draws in one flush may carry + // different index widths, so element arithmetic cannot express the layout. + let defaultIndexBytes = 0; // Reused, and cleared rather than rebuilt: a frame with no custom-material // mesh never touches them, and rebuilding two Maps per flush is pure churn // in a scene that flushes often. @@ -677,16 +697,16 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements if (dc.customShader === null) { dc.vertexByteOffset = defaultVertices * vertexStrideBytes; - dc.indexByteOffset = defaultIndices * Uint16Array.BYTES_PER_ELEMENT; + dc.indexByteOffset = defaultIndexBytes; defaultVertices += dc.vertexCount; - defaultIndices += dc.indexCount; + defaultIndexBytes += alignIndexBytes(dc.indexCount, dc.indexFormat); } else { const vCursor = customVertexCursors.get(dc.customShader) ?? 0; const iCursor = customIndexCursors.get(dc.customShader) ?? 0; dc.vertexByteOffset = vCursor * vertexStrideBytes; - dc.indexByteOffset = iCursor * Uint16Array.BYTES_PER_ELEMENT; + dc.indexByteOffset = iCursor; customVertexCursors.set(dc.customShader, vCursor + dc.vertexCount); - customIndexCursors.set(dc.customShader, iCursor + dc.indexCount); + customIndexCursors.set(dc.customShader, iCursor + alignIndexBytes(dc.indexCount, dc.indexFormat)); } } @@ -695,7 +715,6 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements const customDraws = this._totalCustomDraws(); const defaultDrawCalls = this._drawCallCount - customDraws; const defaultVertexBytes = defaultVertices * vertexStrideBytes; - const defaultIndexBytes = alignIndexBytes(defaultIndices); // Upper bounds: not every draw call becomes an instanced batch, and each // instanced batch takes one uniform slot plus one node index per instance. const nodeIndexBytes = this._drawCallCount * Uint32Array.BYTES_PER_ELEMENT; @@ -746,7 +765,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements // Phase 2: ensure capacities for the pass totals (default path). The staging // arrays are sized to this flush; the GPU buffers to the pre-split targets. this._ensureVertexCapacity(defaultVertices, targetVertexBytes); - this._ensureIndexCapacity(defaultIndices, targetIndexBytes); + this._ensureIndexCapacity(defaultIndexBytes, targetIndexBytes); this._ensureUniformCapacity(targetUniformSlots); this._ensureInstancedUniformCapacity(targetInstancedUniformSlots); // Every instanced batch in this pass gets a distinct node-index sub-range; @@ -776,14 +795,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements // Default path: CPU-bake transform into vertex positions. this._writeMeshVertices(backend, dc.mesh, dc.vertexByteOffset / vertexStrideBytes, /* bake */ true); - if (dc.mesh.indices !== null) { - this._packedIndexData.set(dc.mesh.indices, dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT); - } else { - const start = dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT; - for (let j = 0; j < dc.indexCount; j++) { - this._packedIndexData[start + j] = j; - } - } + this._packIndices(dc, dc.indexFormat === 'uint32' ? this._indexStagingU32 : this._indexStagingU16); // Pack tint+flags for default path. Color RGB channels are 0..255; the // shader multiplies the sampled texel by this tint, so normalize to @@ -818,7 +830,6 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements // Pack vertices/indices in local space (no CPU bake). let vWritten = 0; - let iWritten = 0; let drawCursor = 0; for (let i = 0; i < this._drawCallCount; i++) { @@ -828,30 +839,17 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements this._writeMeshVerticesIntoBuffer(dc.mesh, vWritten, resources.vertexFloatView, resources.vertexUintView); - if (dc.mesh.indices !== null) { - resources.indexData.set(dc.mesh.indices, iWritten); - } else { - for (let j = 0; j < dc.indexCount; j++) { - resources.indexData[iWritten + j] = j; - } - } + this._packIndices(dc, dc.indexFormat === 'uint32' ? resources.indexU32 : resources.indexU16); // Write mesh-uniform slot (proj/trans/tint) with dynamic offset. this._writeCustomMeshUniform(material, resources, drawCursor, dc.mesh, backend); vWritten += dc.vertexCount; - iWritten += dc.indexCount; drawCursor++; } device.queue.writeBuffer(resources.vertexBuffer!, 0, resources.vertexData, 0, resources.totalVertices * vertexStrideBytes); - device.queue.writeBuffer( - resources.indexBuffer!, - 0, - resources.indexData.buffer, - resources.indexData.byteOffset, - (resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT + 3) & ~3, - ); + device.queue.writeBuffer(resources.indexBuffer!, 0, resources.indexData, 0, resources.totalIndexBytes); // Refresh the user uniform UBO from the material - uploaded only when the // uniform values actually changed since the last frame. @@ -862,7 +860,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements // flush's sub-range within the pass. if (defaultVertices > 0) { device.queue.writeBuffer(this._vertexBuffer!, vertexBase, this._vertexData, 0, defaultVertexBytes); - device.queue.writeBuffer(this._indexBuffer!, indexBase, this._packedIndexData.buffer, this._packedIndexData.byteOffset, defaultIndexBytes); + device.queue.writeBuffer(this._indexBuffer!, indexBase, this._indexStaging, 0, defaultIndexBytes); } if (defaultUniformData !== null) { device.queue.writeBuffer(this._uniformBuffer!, uniformSlotBase * this._uniformAlignment, defaultUniformData, 0, defaultUniformBytes); @@ -950,7 +948,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements pass.setVertexBuffer(0, staticGeometry.vertexBuffer); pass.setVertexBuffer(1, instanceNodeIndexBuffer, nodeIndexByteOffset); - pass.setIndexBuffer(staticGeometry.indexBuffer, 'uint16'); + pass.setIndexBuffer(staticGeometry.indexBuffer, staticGeometry.indexFormat); pass.drawIndexed(staticGeometry.indexCount, batchLength); backend.stats.batches++; @@ -1012,7 +1010,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements } pass.setVertexBuffer(0, this._vertexBuffer, vertexBase + dc.vertexByteOffset); - pass.setIndexBuffer(this._indexBuffer!, 'uint16', indexBase + dc.indexByteOffset); + pass.setIndexBuffer(this._indexBuffer!, dc.indexFormat, indexBase + dc.indexByteOffset); pass.drawIndexed(dc.indexCount); defaultDrawCursor++; @@ -1035,7 +1033,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements pass.pushDebugGroup('MeshMaterial (custom)'); if (needsPipeline) { - pass.setPipeline(this._getOrCreateCustomPipeline(resources, dc.blendMode, renderTargetFormat, stencil)); + pass.setPipeline(this._getOrCreateCustomPipeline(resources, dc.blendMode, backend.renderTargetFormats, stencil)); lastShader = dc.customShader; lastBlendMode = dc.blendMode; lastFormat = renderTargetFormat; @@ -1062,7 +1060,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements } pass.setVertexBuffer(0, resources.vertexBuffer, dc.vertexByteOffset); - pass.setIndexBuffer(resources.indexBuffer!, 'uint16', dc.indexByteOffset); + pass.setIndexBuffer(resources.indexBuffer!, dc.indexFormat, dc.indexByteOffset); pass.drawIndexed(dc.indexCount); pass.popDebugGroup(); @@ -1887,7 +1885,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements pass.setBindGroup(1, textureBindGroup); pass.setVertexBuffer(0, geometry.vertexBuffer); pass.setVertexBuffer(1, bundle.instanceBuffer, payload.byteOffset); - pass.setIndexBuffer(geometry.indexBuffer, 'uint16'); + pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat); pass.drawIndexed(geometry.indexCount, payload.instanceCount); state.drawsInPass = active; @@ -2067,6 +2065,28 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements return descriptor; } + /** + * Write one draw call's index block into `target` at its planned byte offset. + * + * `target` must be the view matching `dc.indexFormat`: the offset is a byte + * offset into the shared staging buffer, and dividing it by the wrong element + * size would land the block on top of a neighbour. + */ + private _packIndices(dc: MeshDrawCall, target: Uint16Array | Uint32Array): void { + const start = dc.indexByteOffset / target.BYTES_PER_ELEMENT; + const indices = dc.mesh.indices; + + if (indices !== null) { + target.set(indices, start); + + return; + } + + for (let j = 0; j < dc.indexCount; j++) { + target[start + j] = j; + } + } + private _getOrCreateGeometryEntry(mesh: Mesh): GeometryCacheEntry { const geometry = mesh.geometry; @@ -2119,6 +2139,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements vertexBuffer, indexBuffer, indexCount: mesh.indexCount, + indexFormat: packed.indexFormat, disposeListener, version: geometry.version, }; @@ -2131,14 +2152,18 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements // Pack a mesh into fresh CPU-side vertex/index arrays in the shared layout. // One extra index element is allocated when indexCount is odd so the GPU // buffer and writeBuffer byte count round up to 4 without a buffer overread. - private _packGeometry(mesh: Mesh): { vertexData: ArrayBuffer; indexData: Uint16Array; alignedIndexByteLen: number } { + private _packGeometry(mesh: Mesh): { vertexData: ArrayBuffer; indexData: MeshIndexArray; indexFormat: MeshIndexFormat; alignedIndexByteLen: number } { const vertexData = new ArrayBuffer(mesh.vertexCount * vertexStrideBytes); const vertexFloatView = new Float32Array(vertexData); const vertexUintView = new Uint32Array(vertexData); this._writeMeshVerticesIntoBuffer(mesh, 0, vertexFloatView, vertexUintView); - const indexData = new Uint16Array(mesh.indexCount + (mesh.indexCount & 1)); + const indexFormat = mesh.indexFormat; + const alignedIndexByteLen = alignIndexBytes(mesh.indexCount, indexFormat); + // Sized from the ALIGNED byte length, so `writeBuffer`'s 4-byte multiple is + // covered by real backing rather than by an overread past the array. + const indexData = createIndexArray(indexFormat, alignedIndexByteLen / meshIndexBytes(indexFormat)); if (mesh.indices !== null) { indexData.set(mesh.indices, 0); @@ -2148,11 +2173,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements } } - return { - vertexData, - indexData, - alignedIndexByteLen: (mesh.indexCount * Uint16Array.BYTES_PER_ELEMENT + 3) & ~3, - }; + return { vertexData, indexData, indexFormat, alignedIndexByteLen }; } /** @@ -2187,6 +2208,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements device.queue.writeBuffer(entry.indexBuffer, 0, packed.indexData.buffer, packed.indexData.byteOffset, packed.alignedIndexByteLen); entry.indexCount = mesh.indexCount; + entry.indexFormat = packed.indexFormat; entry.version = entry.geometry.version; } @@ -2217,17 +2239,15 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements } } - /** Staging/GPU split as in {@link _ensureVertexCapacity}. */ - private _ensureIndexCapacity(indexCount: number, requiredBytes = alignIndexBytes(indexCount)): void { - // GPUQueue.writeBuffer requires the byte count to be a multiple of 4. - // Round up: odd Uint16 counts (e.g. a 3-index triangle) would otherwise - // produce 6-byte writes which the WebGPU validation layer rejects. - const stagingBytes = alignIndexBytes(indexCount); - - if (this._packedIndexData.length * Uint16Array.BYTES_PER_ELEMENT < stagingBytes) { - this._packedIndexData = new Uint16Array( - Math.max(stagingBytes / Uint16Array.BYTES_PER_ELEMENT, this._packedIndexData.length === 0 ? 2 : this._packedIndexData.length * 2), - ); + /** + * Staging/GPU split as in {@link _ensureVertexCapacity}, both sized in BYTES - + * a flush may mix index widths, so an element count no longer describes it. + */ + private _ensureIndexCapacity(stagingBytes: number, requiredBytes = stagingBytes): void { + if (this._indexStaging.byteLength < stagingBytes) { + this._indexStaging = new ArrayBuffer(Math.max(stagingBytes, this._indexStaging.byteLength === 0 ? 4 : this._indexStaging.byteLength * 2)); + this._indexStagingU16 = new Uint16Array(this._indexStaging); + this._indexStagingU32 = new Uint32Array(this._indexStaging); } if (requiredBytes > this._indexBufferCapacity) { @@ -2296,7 +2316,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements for (const resources of this._customShaders.values()) { resources.drawCount = 0; resources.totalVertices = 0; - resources.totalIndices = 0; + resources.totalIndexBytes = 0; } } @@ -2351,8 +2371,9 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements }); const initialVertexCount = 64; - const initialIndexCount = 192; + const initialIndexBytes = 192 * Uint16Array.BYTES_PER_ELEMENT; const vertexData = new ArrayBuffer(initialVertexCount * vertexStrideBytes); + const indexData = new ArrayBuffer(initialIndexBytes); resources = { shaderModule, @@ -2370,7 +2391,9 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements vertexData, vertexFloatView: new Float32Array(vertexData), vertexUintView: new Uint32Array(vertexData), - indexData: new Uint16Array(initialIndexCount), + indexData, + indexU16: new Uint16Array(indexData), + indexU32: new Uint32Array(indexData), meshUniformBuffer: null, meshUniformBufferCapacity: 0, meshUniformBindGroup: null, @@ -2380,7 +2403,7 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements meshTextureBindGroups: new WeakMap(), drawCount: 0, totalVertices: 0, - totalIndices: 0, + totalIndexBytes: 0, }; this._customShaders.set(material, resources); @@ -2418,10 +2441,12 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements }); } - // Index buffer - capacity must be 4-byte aligned for GPUQueue.writeBuffer. - const indexBytes = (resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT + 3) & ~3; - if (resources.indexData.length * Uint16Array.BYTES_PER_ELEMENT < indexBytes) { - resources.indexData = new Uint16Array(Math.max(indexBytes / Uint16Array.BYTES_PER_ELEMENT, resources.indexData.length * 2)); + // Index buffer - every per-draw block is already 4-byte aligned, so the total is too. + const indexBytes = resources.totalIndexBytes; + if (resources.indexData.byteLength < indexBytes) { + resources.indexData = new ArrayBuffer(Math.max(indexBytes, resources.indexData.byteLength * 2 || 4)); + resources.indexU16 = new Uint16Array(resources.indexData); + resources.indexU32 = new Uint32Array(resources.indexData); } if (indexBytes > resources.indexBufferCapacity) { resources.indexBuffer?.destroy(); @@ -2505,12 +2530,22 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements this._device!.queue.writeBuffer(resources.meshUniformBuffer!, drawCursor * slotBytes, data); } - private _getOrCreateCustomPipeline(resources: CustomShaderResources, blendMode: BlendModes, format: GPUTextureFormat, stencil: boolean): GPURenderPipeline { + private _getOrCreateCustomPipeline( + resources: CustomShaderResources, + blendMode: BlendModes, + formats: readonly GPUTextureFormat[], + stencil: boolean, + ): GPURenderPipeline { // The stencil dimension keeps the clip and no-clip variants distinct, // mirroring the default and static-batch caches: a stencil pipeline carries // depth/stencil state and is only valid in a pass with the matching // attachment, so the two are never interchangeable. - const cacheKey = `${blendMode}:${format}:${stencil ? 's' : 'n'}`; + // + // The whole format LIST is part of the key, not just the first format: a + // pipeline must declare one target per attachment of the pass it runs in, so + // the same material in a one-attachment and a two-attachment pass needs two + // pipelines. + const cacheKey = `${blendMode}:${formats.join(',')}:${stencil ? 's' : 'n'}`; let pipeline = resources.pipelines.get(cacheKey); if (pipeline === undefined) { @@ -2535,13 +2570,11 @@ export class WebGpuMeshRenderer extends AbstractWebGpuRenderer implements fragment: { module: resources.shaderModule, entryPoint: 'fragmentMain', - targets: [ - { - format, - blend: getWebGpuBlendState(blendMode), - writeMask: GPUColorWrite.ALL, - }, - ], + targets: formats.map(format => ({ + format, + blend: getWebGpuBlendState(blendMode), + writeMask: GPUColorWrite.ALL, + })), }, primitive: { topology: 'triangle-list', diff --git a/src/rendering/webgpu/WebGpuPassCoordinator.ts b/src/rendering/webgpu/WebGpuPassCoordinator.ts index c56decaa7..78bf2c393 100644 --- a/src/rendering/webgpu/WebGpuPassCoordinator.ts +++ b/src/rendering/webgpu/WebGpuPassCoordinator.ts @@ -90,7 +90,9 @@ export interface WebGpuPassBackend { flush(): unknown; pushScissorRect(bounds: Rectangle): unknown; popScissorRect(): unknown; - createColorAttachment(): GPURenderPassColorAttachment; + createColorAttachment(index?: number): GPURenderPassColorAttachment; + /** Colour attachments the bound target contributes to a pass; `1` for every ordinary target. */ + readonly colorAttachmentCount: number; getScissorRect(): ScissorRect | null; submit(commandBuffer: GPUCommandBuffer): void; /** Whether `target` already holds rendered content this frame. */ @@ -222,7 +224,16 @@ export class WebGpuPassCoordinator implements RenderPassCoordinator { // identity to decide whether their draws are in the open pass. const descriptor = this._passDescriptor; - this._colorAttachments[0] = backend.createColorAttachment(); + const attachmentCount = backend.colorAttachmentCount; + + // Length is set before the slots are filled: the array is reused across + // passes, so a shorter pass after a multi-attachment one would otherwise + // leave the previous pass's extra views in place and bind them again. + this._colorAttachments.length = attachmentCount; + + for (let index = 0; index < attachmentCount; index++) { + this._colorAttachments[index] = backend.createColorAttachment(index); + } descriptor.depthStencilAttachment = stencilEnabled ? this._createStencilAttachment(backend.renderTarget) : undefined; const encoder = backend.device.createCommandEncoder(commandEncoderDescriptor); diff --git a/src/rendering/webgpu/WebGpuRetainedGroupResources.ts b/src/rendering/webgpu/WebGpuRetainedGroupResources.ts index 01eecf7e6..b5c29a811 100644 --- a/src/rendering/webgpu/WebGpuRetainedGroupResources.ts +++ b/src/rendering/webgpu/WebGpuRetainedGroupResources.ts @@ -1,6 +1,7 @@ /// import type { GpuResourceAccountant } from '#rendering/GpuResourceAccountant'; +import type { MeshIndexFormat } from '#rendering/mesh/meshIndices'; import type { RetainedBatchInstruction, RetainedGroupBundle, RetainedInstructionSet } from '#rendering/plan/RetainedInstructionSet'; import type { Renderer } from '#rendering/Renderer'; import type { RenderTexture } from '#rendering/texture/RenderTexture'; @@ -30,6 +31,8 @@ export interface WebGpuRetainedGeometryRef { readonly vertexBuffer: GPUBuffer; readonly indexBuffer: GPUBuffer; readonly indexCount: number; + /** Width `indexBuffer` holds, so replay binds it with the format it was packed at. */ + readonly indexFormat: MeshIndexFormat; } /** diff --git a/src/rendering/webgpu/webgpuCompressedFormat.ts b/src/rendering/webgpu/webgpuCompressedFormat.ts new file mode 100644 index 000000000..db5345c1d --- /dev/null +++ b/src/rendering/webgpu/webgpuCompressedFormat.ts @@ -0,0 +1,76 @@ +import { CompressedTextureFormat, orderCompressedFormats } from '#rendering/texture/CompressedTextureFormat'; + +/** + * WebGPU groups compressed formats into three optional features, and a device + * only carries a feature that was requested at `requestDevice`. Each family maps + * its members onto the `GPUTextureFormat` strings the device accepts. + */ +const families: ReadonlyArray<{ readonly feature: GPUFeatureName; readonly formats: Readonly>> }> = [ + { + feature: 'texture-compression-bc', + formats: { + [CompressedTextureFormat.Bc1RgbaUnorm]: 'bc1-rgba-unorm', + [CompressedTextureFormat.Bc2RgbaUnorm]: 'bc2-rgba-unorm', + [CompressedTextureFormat.Bc3RgbaUnorm]: 'bc3-rgba-unorm', + [CompressedTextureFormat.Bc4RUnorm]: 'bc4-r-unorm', + [CompressedTextureFormat.Bc5RgUnorm]: 'bc5-rg-unorm', + [CompressedTextureFormat.Bc6hRgbUfloat]: 'bc6h-rgb-ufloat', + [CompressedTextureFormat.Bc7RgbaUnorm]: 'bc7-rgba-unorm', + }, + }, + { + feature: 'texture-compression-etc2', + formats: { + [CompressedTextureFormat.Etc2Rgb8Unorm]: 'etc2-rgb8unorm', + [CompressedTextureFormat.Etc2Rgb8A1Unorm]: 'etc2-rgb8a1unorm', + [CompressedTextureFormat.Etc2Rgba8Unorm]: 'etc2-rgba8unorm', + [CompressedTextureFormat.EacR11Unorm]: 'eac-r11unorm', + [CompressedTextureFormat.EacRg11Unorm]: 'eac-rg11unorm', + }, + }, + { + feature: 'texture-compression-astc', + formats: { + [CompressedTextureFormat.Astc4x4Unorm]: 'astc-4x4-unorm', + [CompressedTextureFormat.Astc5x5Unorm]: 'astc-5x5-unorm', + [CompressedTextureFormat.Astc6x6Unorm]: 'astc-6x6-unorm', + [CompressedTextureFormat.Astc8x8Unorm]: 'astc-8x8-unorm', + }, + }, +]; + +/** The three optional features that carry compressed formats, for `requestDevice`. */ +export const webgpuCompressedTextureFeatures: readonly GPUFeatureName[] = Object.freeze(families.map(({ feature }) => feature)); + +/** The compressed formats one device implements, and the `GPUTextureFormat` each maps to. */ +export interface WebgpuCompressedFormatSupport { + readonly formats: readonly CompressedTextureFormat[]; + readonly gpuFormats: ReadonlyMap; +} + +/** + * Read `device.features` and build its compressed-format table. + * + * Reads the granted device rather than the adapter: an adapter may advertise a + * family the device was never asked for, and a texture created in a format the + * device does not carry is a validation error, not a soft fallback. + */ +export const readWebgpuCompressedFormats = (device: GPUDevice): WebgpuCompressedFormatSupport => { + const gpuFormats = new Map(); + // Optional-chained like the adapter reads in the backend: a stand-in device + // (a probe, a test double) carries no feature set, and the honest answer for + // one is "no compressed formats" rather than a throw during initialization. + const features = (device as { features?: GPUSupportedFeatures }).features; + + for (const { feature, formats } of families) { + if (features?.has(feature) !== true) { + continue; + } + + for (const [format, gpuFormat] of Object.entries(formats)) { + gpuFormats.set(format as CompressedTextureFormat, gpuFormat); + } + } + + return { formats: orderCompressedFormats(gpuFormats.keys()), gpuFormats }; +}; diff --git a/test/assets/asset-variants.test.ts b/test/assets/asset-variants.test.ts new file mode 100644 index 000000000..7ba9c390d --- /dev/null +++ b/test/assets/asset-variants.test.ts @@ -0,0 +1,189 @@ +/** + * Per-device variant selection. + * + * The selection rules are asserted directly on {@link AssetVariantSet}, and the + * two places the loader consults it are asserted through the loader: identity has + * to be keyed on the chosen file, and the asset type has to be inferred from it - + * a rule that swaps a `.png` for a `.ktx2` would otherwise hand container bytes + * to the image decoder. + */ + +import { describe, expect, test } from 'vitest'; + +import { Asset } from '#assets/Asset'; +import { AssetVariantSet } from '#assets/AssetVariants'; +import { coreAssetTypes } from '#assets/coreAssetTypes'; +import { Loader } from '#assets/Loader'; +import { materializeAssetTypes } from '#extensions/materialize'; +import { CompressedTextureFormat } from '#rendering/texture/CompressedTextureFormat'; + +const createCoreLoader = (): Loader => { + const loader = new Loader(); + + materializeAssetTypes(loader, coreAssetTypes); + + return loader; +}; + +const terrainVariants = [ + { source: 'terrain.bc7.ktx2', textureFormat: CompressedTextureFormat.Bc7RgbaUnorm }, + { source: 'terrain.astc.ktx2', textureFormat: CompressedTextureFormat.Astc4x4Unorm }, + { source: 'terrain@2x.png', resolution: 2 }, + { source: 'terrain.png' }, +]; + +describe('AssetVariantSet', () => { + test('a source with no rule resolves to itself', () => { + expect(new AssetVariantSet().resolve('hero.png')).toBe('hero.png'); + }); + + test('falls back to the unconditional candidate on the conservative default profile', () => { + const set = new AssetVariantSet().define('terrain.png', terrainVariants); + + expect(set.resolve('terrain.png')).toBe('terrain.png'); + }); + + test('picks the candidate whose format the profile lists', () => { + const set = new AssetVariantSet().define('terrain.png', terrainVariants); + + set.profile = { textureFormats: [CompressedTextureFormat.Astc4x4Unorm], resolution: 1 }; + + expect(set.resolve('terrain.png')).toBe('terrain.astc.ktx2'); + }); + + test('the profile order decides between two supported formats', () => { + const set = new AssetVariantSet().define('terrain.png', terrainVariants); + + set.profile = { textureFormats: [CompressedTextureFormat.Astc4x4Unorm, CompressedTextureFormat.Bc7RgbaUnorm], resolution: 1 }; + expect(set.resolve('terrain.png')).toBe('terrain.astc.ktx2'); + + set.profile = { textureFormats: [CompressedTextureFormat.Bc7RgbaUnorm, CompressedTextureFormat.Astc4x4Unorm], resolution: 1 }; + expect(set.resolve('terrain.png')).toBe('terrain.bc7.ktx2'); + }); + + test('a density candidate is eligible only up to the profile resolution', () => { + const set = new AssetVariantSet().define('terrain.png', terrainVariants); + + set.profile = { textureFormats: [], resolution: 1 }; + expect(set.resolve('terrain.png')).toBe('terrain.png'); + + set.profile = { textureFormats: [], resolution: 2 }; + expect(set.resolve('terrain.png')).toBe('terrain@2x.png'); + + set.profile = { textureFormats: [], resolution: 3 }; + expect(set.resolve('terrain.png')).toBe('terrain@2x.png'); + }); + + test('a supported format outranks a higher density', () => { + const set = new AssetVariantSet().define('terrain.png', terrainVariants); + + set.profile = { textureFormats: [CompressedTextureFormat.Bc7RgbaUnorm], resolution: 2 }; + + expect(set.resolve('terrain.png')).toBe('terrain.bc7.ktx2'); + }); + + test('the highest eligible density wins within one format rank', () => { + const set = new AssetVariantSet().define('ui.png', [{ source: 'ui.png' }, { source: 'ui@2x.png', resolution: 2 }, { source: 'ui@3x.png', resolution: 3 }]); + + set.profile = { textureFormats: [], resolution: 3 }; + + expect(set.resolve('ui.png')).toBe('ui@3x.png'); + }); + + test('a rule whose candidates are all ineligible falls back to the logical source', () => { + const set = new AssetVariantSet().define('terrain.png', [ + { source: 'terrain.bc7.ktx2', textureFormat: CompressedTextureFormat.Bc7RgbaUnorm }, + { source: 'terrain@2x.png', resolution: 2 }, + ]); + + set.profile = { textureFormats: [CompressedTextureFormat.Etc2Rgba8Unorm], resolution: 1 }; + + expect(set.resolve('terrain.png')).toBe('terrain.png'); + }); + + test('resolving a chosen candidate again is a no-op', () => { + // The loader consults the set twice per request - once for the type, once for + // identity - so a second pass must not move the answer. + const set = new AssetVariantSet().define('terrain.png', terrainVariants); + + set.profile = { textureFormats: [CompressedTextureFormat.Bc7RgbaUnorm], resolution: 1 }; + + const first = set.resolve('terrain.png'); + + expect(set.resolve(first)).toBe(first); + }); + + test('define replaces a rule, undefine and clear remove them', () => { + const set = new AssetVariantSet().define('a.png', [{ source: 'one.png' }]); + + set.define('a.png', [{ source: 'two.png' }]); + expect(set.resolve('a.png')).toBe('two.png'); + expect(set.candidates('a.png')).toEqual([{ source: 'two.png' }]); + + set.undefine('a.png'); + expect(set.resolve('a.png')).toBe('a.png'); + expect(set.candidates('a.png')).toBeUndefined(); + + set.define('b.png', [{ source: 'three.png' }]).clear(); + expect(set.resolve('b.png')).toBe('b.png'); + }); +}); + +describe('Loader variant wiring', () => { + test('identity is keyed on the chosen variant, not the logical source', () => { + const loader = createCoreLoader(); + + loader.variants.define('terrain.png', terrainVariants); + + const logical = loader.identify(Asset.type('texture', 'terrain.png')); + + loader.variants.profile = { textureFormats: [CompressedTextureFormat.Bc7RgbaUnorm], resolution: 1 }; + + const chosen = loader.identify(Asset.type('texture', 'terrain.png')); + + expect(chosen.locator).not.toBe(logical.locator); + expect(chosen.locator).toContain('terrain.bc7.ktx2'); + expect(chosen.sourceKey).not.toBe(logical.sourceKey); + expect(chosen.resourceKey).not.toBe(logical.resourceKey); + }); + + test('two profiles never share one cache entry for one logical source', () => { + const loader = createCoreLoader(); + + loader.variants.define('terrain.png', terrainVariants); + loader.variants.profile = { textureFormats: [CompressedTextureFormat.Bc7RgbaUnorm], resolution: 1 }; + + const bc7 = loader.identify(Asset.type('texture', 'terrain.png')); + + loader.variants.profile = { textureFormats: [CompressedTextureFormat.Astc4x4Unorm], resolution: 1 }; + + expect(loader.identify(Asset.type('texture', 'terrain.png')).sourceKey).not.toBe(bc7.sourceKey); + }); + + test('a bare path infers its type from the chosen variant', () => { + const loader = createCoreLoader(); + + // `.data` is claimed by no installed type, so the error proves which of the + // two names the type lookup ran against. + loader.variants.define('terrain.png', [{ source: 'terrain.data' }]); + + expect(() => loader.peek('terrain.png' as never)).toThrow(/terrain\.data.*selected as a variant of.*terrain\.png/s); + }); + + test('an unresolved bare path still reports its own name', () => { + const loader = createCoreLoader(); + + expect(() => loader.peek('terrain.data' as never)).toThrow(/no installed asset type claims any extension of "terrain\.data"/); + }); + + test('a ktx2 variant of a png resolves to the texture type', () => { + const loader = createCoreLoader(); + + loader.variants.define('terrain.png', terrainVariants); + loader.variants.profile = { textureFormats: [CompressedTextureFormat.Bc7RgbaUnorm], resolution: 1 }; + + // No throw: `ktx2` is claimed by the `texture` type, so the swap keeps the + // caller-visible shape of the asset. + expect(loader.peek('terrain.png' as never)).toBeUndefined(); + }); +}); diff --git a/test/assets/ktx2.test.ts b/test/assets/ktx2.test.ts new file mode 100644 index 000000000..4440f05c1 --- /dev/null +++ b/test/assets/ktx2.test.ts @@ -0,0 +1,216 @@ +/** + * KTX2 container parsing. + * + * The containers are synthesized here rather than committed as fixtures: the + * interesting cases are header shapes (a rejected supercompression scheme, a + * cube map, a truncated level index), and a real file cannot be edited into those + * shapes without a toolchain. Every level's byte length is derived from the + * engine's own block table, so a wrong table would fail these rather than hide in + * them. + */ + +import { describe, expect, test } from 'vitest'; + +import { isKtx2, parseKtx2 } from '#assets/factories/ktx2'; +import { compressedLevelByteLength, CompressedTextureFormat } from '#rendering/texture/CompressedTextureFormat'; + +const HEADER_BYTES = 80; +const LEVEL_ENTRY_BYTES = 24; +const IDENTIFIER = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]; + +interface Ktx2Spec { + readonly vkFormat: number; + readonly width: number; + readonly height: number; + /** Byte length of each level, mip 0 first. */ + readonly levelLengths: readonly number[]; + readonly supercompressionScheme?: number; + readonly pixelDepth?: number; + readonly layerCount?: number; + readonly faceCount?: number; + /** Overrides the level count written into the header, without changing the index. */ + readonly declaredLevelCount?: number; + /** Fills every level with this byte, so a mis-sliced level is visible. */ + readonly fillFrom?: number; +} + +const buildKtx2 = ({ + vkFormat, + width, + height, + levelLengths, + supercompressionScheme = 0, + pixelDepth = 0, + layerCount = 0, + faceCount = 1, + declaredLevelCount, + fillFrom = 1, +}: Ktx2Spec): ArrayBuffer => { + const dataBytes = levelLengths.reduce((total, length) => total + length, 0); + const indexBytes = levelLengths.length * LEVEL_ENTRY_BYTES; + const buffer = new ArrayBuffer(HEADER_BYTES + indexBytes + dataBytes); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + + bytes.set(IDENTIFIER, 0); + view.setUint32(12, vkFormat, true); + view.setUint32(16, 1, true); + view.setUint32(20, width, true); + view.setUint32(24, height, true); + view.setUint32(28, pixelDepth, true); + view.setUint32(32, layerCount, true); + view.setUint32(36, faceCount, true); + view.setUint32(40, declaredLevelCount ?? levelLengths.length, true); + view.setUint32(44, supercompressionScheme, true); + + // KTX2 stores the image data smallest level first, so the offsets are laid out + // in reverse mip order while the index entries stay in mip order. + let offset = HEADER_BYTES + indexBytes + dataBytes; + + for (let index = levelLengths.length - 1; index >= 0; index--) { + const length = levelLengths[index]!; + + offset -= length; + view.setUint32(HEADER_BYTES + index * LEVEL_ENTRY_BYTES, offset, true); + view.setUint32(HEADER_BYTES + index * LEVEL_ENTRY_BYTES + 8, length, true); + view.setUint32(HEADER_BYTES + index * LEVEL_ENTRY_BYTES + 16, length, true); + bytes.fill(fillFrom + index, offset, offset + length); + } + + return buffer; +}; + +const levelLengthsFor = (format: CompressedTextureFormat, width: number, height: number, count: number): number[] => + Array.from({ length: count }, (_unused, index) => compressedLevelByteLength(format, Math.max(width >> index, 1), Math.max(height >> index, 1))); + +describe('isKtx2', () => { + test('recognizes the identifier', () => { + expect(isKtx2(new Uint8Array(IDENTIFIER))).toBe(true); + }); + + test('rejects image bytes and anything shorter than the identifier', () => { + expect(isKtx2(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))).toBe(false); + expect(isKtx2(new Uint8Array(IDENTIFIER.slice(0, 6)))).toBe(false); + }); +}); + +describe('parseKtx2', () => { + test('reads a single-level BC7 payload', () => { + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const payload = parseKtx2(buildKtx2({ vkFormat: 145, width: 16, height: 8, levelLengths: levelLengthsFor(format, 16, 8, 1) }), 'hero.ktx2'); + + expect(payload).toMatchObject({ kind: 'compressed', format }); + expect(payload.kind === 'compressed' && payload.levels).toHaveLength(1); + expect(payload.kind === 'compressed' && payload.levels[0]).toMatchObject({ width: 16, height: 8 }); + }); + + test('reads a mip chain in mip order, not in storage order', () => { + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const payload = parseKtx2(buildKtx2({ vkFormat: 145, width: 16, height: 16, levelLengths: levelLengthsFor(format, 16, 16, 3) }), 'hero.ktx2'); + + expect(payload.kind).toBe('compressed'); + + if (payload.kind !== 'compressed') return; + + expect(payload.levels.map(({ width, height }) => [width, height])).toEqual([ + [16, 16], + [8, 8], + [4, 4], + ]); + // Level n was filled with the byte `1 + n`, so the slices are in mip order + // even though the file stores them the other way round. + expect(payload.levels.map(({ data }) => data[0])).toEqual([1, 2, 3]); + }); + + test('maps the sRGB and UNORM variants of one block format to the same engine format', () => { + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const lengths = levelLengthsFor(format, 8, 8, 1); + + for (const vkFormat of [145, 146]) { + expect(parseKtx2(buildKtx2({ vkFormat, width: 8, height: 8, levelLengths: lengths }), 'hero.ktx2')).toMatchObject({ format }); + } + }); + + test('reads ETC2 and ASTC payloads', () => { + const etc2 = CompressedTextureFormat.Etc2Rgba8Unorm; + const astc = CompressedTextureFormat.Astc6x6Unorm; + + expect(parseKtx2(buildKtx2({ vkFormat: 151, width: 8, height: 8, levelLengths: levelLengthsFor(etc2, 8, 8, 1) }), 'a.ktx2')).toMatchObject({ + format: etc2, + }); + expect(parseKtx2(buildKtx2({ vkFormat: 165, width: 12, height: 12, levelLengths: levelLengthsFor(astc, 12, 12, 1) }), 'b.ktx2')).toMatchObject({ + format: astc, + }); + }); + + test('reads an uncompressed RGBA8 payload as pixels', () => { + const payload = parseKtx2(buildKtx2({ vkFormat: 37, width: 4, height: 2, levelLengths: [4 * 2 * 4] }), 'hero.ktx2'); + + expect(payload).toMatchObject({ kind: 'rgba8', width: 4, height: 2 }); + expect(payload.kind === 'rgba8' && payload.data.byteLength).toBe(32); + }); + + test('treats a declared level count of zero as the one level present', () => { + const format = CompressedTextureFormat.Bc1RgbaUnorm; + const payload = parseKtx2( + buildKtx2({ vkFormat: 133, width: 8, height: 8, levelLengths: levelLengthsFor(format, 8, 8, 1), declaredLevelCount: 0 }), + 'hero.ktx2', + ); + + expect(payload.kind === 'compressed' && payload.levels).toHaveLength(1); + }); + + test.each([ + [1, /BasisLZ/], + [2, /Zstandard/], + [3, /ZLIB/], + [9, /scheme 9/], + ])('rejects supercompression scheme %i', (scheme, expected) => { + const format = CompressedTextureFormat.Bc7RgbaUnorm; + + expect(() => + parseKtx2(buildKtx2({ vkFormat: 145, width: 8, height: 8, levelLengths: levelLengthsFor(format, 8, 8, 1), supercompressionScheme: scheme }), 'hero.ktx2'), + ).toThrow(expected); + }); + + test('rejects a payload that is not a KTX2 file', () => { + expect(() => parseKtx2(new Uint8Array(HEADER_BYTES).buffer, 'hero.ktx2')).toThrow(/does not start with the KTX2 identifier/); + }); + + test('rejects a file too short to hold a header', () => { + expect(() => parseKtx2(new Uint8Array(16).buffer, 'hero.ktx2')).toThrow(/too short to hold a header/); + }); + + test('rejects a cube map, an array texture and a 3D texture', () => { + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const lengths = levelLengthsFor(format, 8, 8, 1); + + for (const override of [{ faceCount: 6 }, { layerCount: 4 }, { pixelDepth: 4 }]) { + expect(() => parseKtx2(buildKtx2({ vkFormat: 145, width: 8, height: 8, levelLengths: lengths, ...override }), 'hero.ktx2')).toThrow( + /only 2D single-layer textures/, + ); + } + }); + + test('rejects a vkFormat outside the supported set', () => { + expect(() => parseKtx2(buildKtx2({ vkFormat: 999, width: 8, height: 8, levelLengths: [64] }), 'hero.ktx2')).toThrow(/vkFormat 999/); + }); + + test('rejects a level whose declared length does not match its extent', () => { + // A BC7 8x8 level is 64 bytes; the file claims 32. + expect(() => parseKtx2(buildKtx2({ vkFormat: 145, width: 8, height: 8, levelLengths: [32] }), 'hero.ktx2')).toThrow( + /declares 32 bytes but its extent needs exactly 64/, + ); + }); + + test('rejects a level index the file is too short to hold', () => { + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const full = buildKtx2({ vkFormat: 145, width: 8, height: 8, levelLengths: levelLengthsFor(format, 8, 8, 1) }); + + expect(() => parseKtx2(full.slice(0, HEADER_BYTES + 8), 'hero.ktx2')).toThrow(/too short to hold their index/); + }); + + test('names the file in every message', () => { + expect(() => parseKtx2(new Uint8Array(16).buffer, 'levels/terrain.ktx2')).toThrow(/levels\/terrain\.ktx2/); + }); +}); diff --git a/test/audio/spatial-model.test.ts b/test/audio/spatial-model.test.ts new file mode 100644 index 000000000..38477cad4 --- /dev/null +++ b/test/audio/spatial-model.test.ts @@ -0,0 +1,617 @@ +/** + * The parts of the spatial model this engine did not have: the third axis, + * occlusion, and the send/zone layer. + * + * Everything else the original finding listed - distance models, cones, HRTF, + * Doppler, a virtual listener - already existed, so these cells cover only what + * was added, plus one re-pin that a voice nobody positions still builds nothing. + */ + +import type { MockInstance } from 'vitest'; + +import { getAudioContext } from '#audio/audio-context'; +import { AudioBus } from '#audio/AudioBus'; +import { AudioManager } from '#audio/AudioManager'; +import { AudioZone } from '#audio/AudioZone'; +import { Sound } from '#audio/Sound'; +import { Rectangle } from '#math/Rectangle'; + +const createAudioBufferStub = (): AudioBuffer => ({ duration: 2 }) as AudioBuffer; + +/** + * Run the per-frame spatial update a voice would get from `AudioManager`. + * + * `_tickSpatial` is internal to the voice implementations rather than part of the + * public `Voice` surface, and these cells drive it directly to read back exactly + * what one frame writes. + */ +const tickSpatial = (voice: object): void => { + (voice as { _tickSpatial(): void })._tickSpatial(); +}; + +interface MockParam { + setValueAtTime: MockInstance; + setTargetAtTime: MockInstance; + cancelScheduledValues: MockInstance; + value: number; +} + +const makeParam = (value = 0): MockParam => ({ + setValueAtTime: vi.fn(), + setTargetAtTime: vi.fn(), + cancelScheduledValues: vi.fn(), + value, +}); + +interface MockPanner { + connect: MockInstance; + disconnect: MockInstance; + panningModel: PanningModelType; + distanceModel: DistanceModelType; + maxDistance: number; + refDistance: number; + rolloffFactor: number; + coneInnerAngle: number; + coneOuterAngle: number; + coneOuterGain: number; + positionX: MockParam; + positionY: MockParam; + positionZ: MockParam; + orientationX: MockParam; + orientationY: MockParam; + orientationZ: MockParam; +} + +const spyPanners = (): { panners: MockPanner[]; restore: () => void } => { + const ctx = getAudioContext() as AudioContext & { createPanner: () => PannerNode }; + const panners: MockPanner[] = []; + const spy = vi.spyOn(ctx, 'createPanner').mockImplementation(() => { + const panner: MockPanner = { + connect: vi.fn(), + disconnect: vi.fn(), + panningModel: 'equalpower', + distanceModel: 'linear', + maxDistance: 10000, + refDistance: 1, + rolloffFactor: 1, + coneInnerAngle: 360, + coneOuterAngle: 360, + coneOuterGain: 0, + positionX: makeParam(), + positionY: makeParam(), + positionZ: makeParam(), + orientationX: makeParam(), + orientationY: makeParam(), + orientationZ: makeParam(), + }; + + panners.push(panner); + + return panner as unknown as PannerNode; + }); + + return { panners, restore: () => spy.mockRestore() }; +}; + +interface MockFilter { + type: BiquadFilterType; + frequency: MockParam; + Q: MockParam; + connect: MockInstance; + disconnect: MockInstance; +} + +const spyFilters = (): { filters: MockFilter[]; restore: () => void } => { + const ctx = getAudioContext() as AudioContext & { createBiquadFilter: () => BiquadFilterNode }; + const filters: MockFilter[] = []; + const spy = vi.spyOn(ctx, 'createBiquadFilter').mockImplementation(() => { + const filter: MockFilter = { + type: 'lowpass', + frequency: makeParam(350), + Q: makeParam(1), + connect: vi.fn(), + disconnect: vi.fn(), + }; + + filters.push(filter); + + return filter as unknown as BiquadFilterNode; + }); + + return { filters, restore: () => spy.mockRestore() }; +}; + +/** The last value a param was asked to ramp to, or `undefined` if it never was. */ +const lastTarget = (param: MockParam): number | undefined => param.setTargetAtTime.mock.calls.at(-1)?.[0] as number | undefined; + +/** + * The last value a param was asked to hold, whichever call made it. + * + * The smoothing layer snaps the first write and ramps later ones, so neither + * spy alone tells the whole story - `invocationCallOrder` decides which came + * last. + */ +const lastWritten = (param: MockParam): number | undefined => { + const calls = [...param.setValueAtTime.mock.calls.keys()] + .map(index => ({ value: param.setValueAtTime.mock.calls[index]![0] as number, order: param.setValueAtTime.mock.invocationCallOrder[index]! })) + .concat( + [...param.setTargetAtTime.mock.calls.keys()].map(index => ({ + value: param.setTargetAtTime.mock.calls[index]![0] as number, + order: param.setTargetAtTime.mock.invocationCallOrder[index]!, + })), + ) + .sort((a, b) => a.order - b.order); + + return calls.at(-1)?.value; +}; + +describe('elevation - the third axis', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('a voice with no position still builds no panner', () => { + const panners = spyPanners(); + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + + manager.play(sound); + + expect(panners.panners).toHaveLength(0); + + panners.restore(); + sound.destroy(); + }); + + test('elevation reaches the panner Z param', () => { + const panners = spyPanners(); + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound, { position: { x: 0, y: 0 }, elevation: 30 }); + + expect(voice.elevation).toBe(30); + expect(lastWritten(panners.panners[0]!.positionZ)).toBe(30); + + panners.restore(); + sound.destroy(); + }); + + test('a three-component position writes elevation, and a two-component one leaves it alone', () => { + const panners = spyPanners(); + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound); + + voice.position = { x: 4, y: 5, z: 12 }; + expect(voice.elevation).toBe(12); + + // Deliberate: `follow()` and a plain `{ x, y }` write cannot silently drop a + // source back onto the plane. + voice.position = { x: 6, y: 7 }; + expect(voice.elevation).toBe(12); + + panners.restore(); + sound.destroy(); + }); + + test('setting elevation alone is enough to spatialize a voice', () => { + const panners = spyPanners(); + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound); + + voice.elevation = 8; + + expect(panners.panners).toHaveLength(1); + + panners.restore(); + sound.destroy(); + }); + + test('listener elevation shifts the relative Z a voice writes', () => { + const panners = spyPanners(); + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound, { position: { x: 0, y: 0 }, elevation: 30 }); + + manager.listener.elevation = 10; + tickSpatial(voice); + + expect(lastWritten(panners.panners[0]!.positionZ)).toBe(20); + + panners.restore(); + sound.destroy(); + }); + + test('vertical motion produces a Doppler shift', () => { + const panners = spyPanners(); + const manager = new AudioManager(); + + manager.spatial.dopplerFactor = 1; + manager.spatial.speedOfSound = 1000; + + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound, { position: { x: 0, y: 0 }, elevation: 100 }); + const ratios: number[] = []; + + // `_applyDopplerRate` is the documented hook a voice type overrides to reach + // its own rate param, so it is also the honest place to read the ratio. + (voice as unknown as { _applyDopplerRate(ratio: number): void })._applyDopplerRate = (ratio: number): void => { + ratios.push(ratio); + }; + + // Receding straight upward from a listener on the plane. A planar projection + // could never produce this: every component of the motion is out of plane. + voice.elevationVelocity = 200; + tickSpatial(voice); + + expect(ratios.at(-1)).toBeCloseTo(0.8, 5); + + voice.elevationVelocity = -200; + tickSpatial(voice); + + expect(ratios.at(-1)).toBeCloseTo(1.2, 5); + + panners.restore(); + sound.destroy(); + }); +}); + +describe('occlusion', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('a voice that is never occluded builds no filter', () => { + const filters = spyFilters(); + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound, { position: { x: 0, y: 0 } }); + + expect(voice.occlusion).toBe(0); + expect(filters.filters).toHaveLength(0); + + filters.restore(); + sound.destroy(); + }); + + test('occluding a voice lowers its cutoff and its gain', () => { + const filters = spyFilters(); + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound, { position: { x: 0, y: 0 } }); + + voice.occlusion = 1; + + const filter = filters.filters[0]; + + expect(filter).toBeDefined(); + expect(filter!.type).toBe('lowpass'); + expect(lastTarget(filter!.frequency)).toBeCloseTo(manager.spatial.occlusionCutoff, 5); + + voice.occlusion = 0.5; + + // Logarithmic: the halfway point is the geometric mean of the endpoints, not + // the arithmetic one - a linear sweep would spend half the range inaudible. + const open = getAudioContext().sampleRate / 2; + + expect(lastTarget(filter!.frequency)).toBeCloseTo(Math.sqrt(open * manager.spatial.occlusionCutoff), 3); + + filters.restore(); + sound.destroy(); + }); + + test('occlusion is clamped and idempotent', () => { + const filters = spyFilters(); + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound, { position: { x: 0, y: 0 } }); + + voice.occlusion = 5; + expect(voice.occlusion).toBe(1); + + voice.occlusion = -3; + expect(voice.occlusion).toBe(0); + + // Returning to clear does not tear the stage down again - rebuilding the + // chain on every threshold crossing would be audible. + expect(filters.filters).toHaveLength(1); + + filters.restore(); + sound.destroy(); + }); + + test('PlayOptions.occlusion seeds it', () => { + const filters = spyFilters(); + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound, { position: { x: 0, y: 0 }, occlusion: 0.75 }); + + expect(voice.occlusion).toBe(0.75); + expect(filters.filters).toHaveLength(1); + + filters.restore(); + sound.destroy(); + }); +}); + +describe('AudioSend', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('a send carries its own level and shows up on the voice', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + const voice = manager.play(sound); + const send = voice.addSend(reverb, 0.4); + + expect(send.bus).toBe(reverb); + expect(send.level).toBe(0.4); + expect(voice.sends).toEqual([send]); + + send.level = 0.9; + expect(send.level).toBe(0.9); + + reverb.destroy(); + sound.destroy(); + }); + + test('a negative level is clamped to silence', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + const send = manager.play(sound).addSend(reverb, -1); + + expect(send.level).toBe(0); + + reverb.destroy(); + sound.destroy(); + }); + + test('removeSend destroys it and drops it from the list', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + const voice = manager.play(sound); + const send = voice.addSend(reverb); + + voice.removeSend(send); + + expect(send.destroyed).toBe(true); + expect(voice.sends).toHaveLength(0); + + // Idempotent, and a send from nowhere is ignored. + voice.removeSend(send); + + reverb.destroy(); + sound.destroy(); + }); + + test('the voice tears its sends down when it ends', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + const voice = manager.play(sound); + const send = voice.addSend(reverb); + + voice.stop(); + + expect(send.destroyed).toBe(true); + expect(voice.sends).toHaveLength(0); + + reverb.destroy(); + sound.destroy(); + }); + + test('PlayOptions.sends opens them at play time', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + const echo = new AudioBus('echo'); + const voice = manager.play(sound, { sends: [{ bus: reverb, level: 0.3 }, { bus: echo }] }); + + expect(voice.sends.map(({ bus, level }) => [bus.name, level])).toEqual([ + ['reverb', 0.3], + ['echo', 1], + ]); + + reverb.destroy(); + echo.destroy(); + sound.destroy(); + }); +}); + +describe('AudioZone geometry', () => { + test('a rectangle zone is full weight inside and zero past its falloff', () => { + const bus = new AudioBus('reverb'); + const zone = new AudioZone({ shape: new Rectangle(0, 0, 100, 100), bus, falloff: 50 }); + + expect(zone.weightAt(50, 50)).toBe(1); + expect(zone.weightAt(0, 0)).toBe(1); + expect(zone.weightAt(125, 50)).toBeCloseTo(0.5, 5); + expect(zone.weightAt(150, 50)).toBe(0); + expect(zone.weightAt(400, 400)).toBe(0); + + bus.destroy(); + }); + + test('a zero falloff is a hard edge', () => { + const bus = new AudioBus('reverb'); + const zone = new AudioZone({ shape: new Rectangle(0, 0, 100, 100), bus }); + + expect(zone.weightAt(100, 100)).toBe(1); + expect(zone.weightAt(101, 50)).toBe(0); + + bus.destroy(); + }); + + test('a circle zone measures distance to its rim', () => { + const bus = new AudioBus('reverb'); + const zone = new AudioZone({ shape: { x: 0, y: 0, radius: 100 }, bus, falloff: 100 }); + + expect(zone.weightAt(0, 0)).toBe(1); + expect(zone.weightAt(100, 0)).toBe(1); + expect(zone.weightAt(150, 0)).toBeCloseTo(0.5, 5); + expect(zone.weightAt(200, 0)).toBe(0); + + bus.destroy(); + }); + + test('height bounds the zone vertically', () => { + const bus = new AudioBus('reverb'); + const columnar = new AudioZone({ shape: new Rectangle(0, 0, 100, 100), bus }); + const banded = new AudioZone({ shape: new Rectangle(0, 0, 100, 100), bus, height: 20 }); + + expect(columnar.weightAt(50, 50, 10_000)).toBe(1); + expect(banded.weightAt(50, 50, 10)).toBe(1); + expect(banded.weightAt(50, 50, 25)).toBe(0); + + bus.destroy(); + }); +}); + +describe('SpatialZones', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('does nothing at all while no zone is registered', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const voice = manager.play(sound, { position: { x: 0, y: 0 } }); + + expect(manager.zones.active).toBe(false); + manager.preUpdate(0.016 as never); + + expect(voice.sends).toHaveLength(0); + + sound.destroy(); + }); + + test('a listener inside a zone opens a send on every voice, and leaving closes it', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + const zone = new AudioZone({ shape: new Rectangle(0, 0, 100, 100), bus: reverb, send: 0.6 }); + + manager.zones.add(zone); + expect(manager.zones.active).toBe(true); + + const voice = manager.play(sound, { position: { x: 10, y: 10 } }); + + manager.listener.position.set(50, 50); + manager.preUpdate(0.016 as never); + + expect(voice.sends).toHaveLength(1); + expect(voice.sends[0]!.bus).toBe(reverb); + expect(voice.sends[0]!.level).toBeCloseTo(0.6, 5); + + const send = voice.sends[0]!; + + manager.listener.position.set(400, 400); + manager.preUpdate(0.016 as never); + + expect(voice.sends).toHaveLength(0); + expect(send.destroyed).toBe(true); + + reverb.destroy(); + sound.destroy(); + }); + + test('the send level follows the falloff ramp instead of switching', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + + manager.zones.add(new AudioZone({ shape: new Rectangle(0, 0, 100, 100), bus: reverb, send: 1, falloff: 100 })); + + const voice = manager.play(sound, { position: { x: 0, y: 0 } }); + + manager.listener.position.set(150, 50); + manager.preUpdate(0.016 as never); + + expect(voice.sends[0]!.level).toBeCloseTo(0.5, 5); + + // Same send object, new level - a boundary crossing is a crossfade, not a + // teardown and rebuild. + const send = voice.sends[0]!; + + manager.listener.position.set(125, 50); + manager.preUpdate(0.016 as never); + + expect(voice.sends[0]).toBe(send); + expect(send.level).toBeCloseTo(0.75, 5); + + reverb.destroy(); + sound.destroy(); + }); + + test('two overlapping zones each contribute their own send', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + const echo = new AudioBus('echo'); + + manager.zones + .add(new AudioZone({ shape: new Rectangle(0, 0, 100, 100), bus: reverb, send: 0.5 })) + .add(new AudioZone({ shape: { x: 60, y: 60, radius: 80 }, bus: echo, send: 0.25 })); + + const voice = manager.play(sound, { position: { x: 0, y: 0 } }); + + manager.listener.position.set(70, 70); + manager.preUpdate(0.016 as never); + + expect(voice.sends.map(({ bus }) => bus.name)).toEqual(['reverb', 'echo']); + + reverb.destroy(); + echo.destroy(); + sound.destroy(); + }); + + test('removing a zone closes the sends it held', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + const zone = new AudioZone({ shape: new Rectangle(0, 0, 100, 100), bus: reverb }); + + manager.zones.add(zone); + + const voice = manager.play(sound, { position: { x: 0, y: 0 } }); + + manager.listener.position.set(50, 50); + manager.preUpdate(0.016 as never); + expect(voice.sends).toHaveLength(1); + + const send = voice.sends[0]!; + + manager.zones.remove(zone); + + expect(manager.zones.active).toBe(false); + expect(send.destroyed).toBe(true); + expect(voice.sends).toHaveLength(0); + + reverb.destroy(); + sound.destroy(); + }); + + test('an ended voice gets no new sends and is dropped from the bookkeeping', () => { + const manager = new AudioManager(); + const sound = new Sound(createAudioBufferStub()); + const reverb = new AudioBus('reverb'); + + manager.zones.add(new AudioZone({ shape: new Rectangle(0, 0, 100, 100), bus: reverb })); + + const voice = manager.play(sound, { position: { x: 0, y: 0 } }); + + manager.listener.position.set(50, 50); + voice.stop(); + manager.preUpdate(0.016 as never); + + expect(voice.sends).toHaveLength(0); + + reverb.destroy(); + sound.destroy(); + }); +}); diff --git a/test/core/__snapshots__/root-index-snapshot.test.ts.snap b/test/core/__snapshots__/root-index-snapshot.test.ts.snap index fc2a45984..88a360aa2 100644 --- a/test/core/__snapshots__/root-index-snapshot.test.ts.snap +++ b/test/core/__snapshots__/root-index-snapshot.test.ts.snap @@ -18,6 +18,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "AssetNetworkError", "AssetRef", "AssetType", + "AssetVariantSet", "Assets", "AudioBus", "AudioEffect", @@ -25,8 +26,10 @@ exports[`root index export surface snapshot > sorted runtime export names match "AudioInput", "AudioListener", "AudioManager", + "AudioSend", "AudioStream", "AudioUnsupportedError", + "AudioZone", "AxisAction", "BinaryAsset", "BinaryAssetType", @@ -62,6 +65,8 @@ exports[`root index export surface snapshot > sorted runtime export names match "CollisionType", "Color", "ColorMatrixFilter", + "CompressedTexture", + "CompressedTextureFormat", "ConcurrentSceneNavigationError", "Connectivity", "ConnectivityPolicyResolver", @@ -136,6 +141,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "Mesh", "MeshBuilder", "MeshMaterial", + "MultiRenderTarget", "MusicAssetType", "NetworkFirstPolicy", "NetworkOnlyPolicy", @@ -204,6 +210,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "Sound", "SoundAssetType", "SoundPoolStrategy", + "SpatialZones", "Sprite", "SpriteFlags", "SpriteMaterial", @@ -259,6 +266,11 @@ exports[`root index export surface snapshot > sorted runtime export names match "cacheMiss", "cacheNamespacePrefix", "clamp", + "compressedBlockLayout", + "compressedBlocksAcross", + "compressedBlocksDown", + "compressedFormatPreference", + "compressedLevelByteLength", "coreAssetTypes", "createArcadeStickGamepadMapping", "createJoyConLeftGamepadMapping", @@ -285,6 +297,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "inputToken", "isAdvancedBlendMode", "isAudioContextReady", + "isCompressedTextureFormat", "isDomCanvas", "isPowerOfTwo", "jsonSourceCodec", @@ -293,8 +306,12 @@ exports[`root index export surface snapshot > sorted runtime export names match "lerp", "logger", "maxPointers", + "maxUint16VertexCount", + "meshIndexBytes", + "meshIndexFormatFor", "musicType", "onAudioContextReady", + "orderCompressedFormats", "pointerSlotSize", "registerAudioWorkletProcessor", "registerSerializer", diff --git a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap index cd2b0dd84..3896c8f94 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -55,6 +55,9 @@ exports[`root index type-level export inventory > all exported symbols with kind "AssetStatus: interface", "AssetType: class", "AssetTypeName: type alias", + "AssetVariant: interface", + "AssetVariantProfile: interface", + "AssetVariantSet: class", "Assets: type alias", "AtLeastOne: type alias", "AtlasMode: type alias", @@ -69,9 +72,14 @@ exports[`root index type-level export inventory > all exported symbols with kind "AudioListener: class", "AudioListenerTarget: type alias", "AudioManager: class", + "AudioSend: class", "AudioSpriteClip: interface", "AudioStream: class", "AudioUnsupportedError: class", + "AudioZone: class", + "AudioZoneCircle: interface", + "AudioZoneOptions: interface", + "AudioZoneShape: type alias", "AutoBackendConfig: interface", "AxisAction: class", "AxisBinding: type alias", @@ -153,6 +161,12 @@ exports[`root index type-level export inventory > all exported symbols with kind "ColorMatrixEntries: type alias", "ColorMatrixFilter: class", "ColorTextureFormat: type alias", + "CompressedBlockLayout: interface", + "CompressedTexture: class", + "CompressedTextureFormat: enum", + "CompressedTextureLevel: interface", + "CompressedTextureOptions: interface", + "CompressedTexturePayload: interface", "ConcurrentSceneNavigationError: class", "Connectivity: class", "ConnectivityPolicyResolver: class", @@ -329,9 +343,13 @@ exports[`root index type-level export inventory > all exported symbols with kind "Mesh: class", "MeshBuilder: variable", "MeshGeometryData: interface", + "MeshIndexArray: type alias", + "MeshIndexFormat: type alias", "MeshMaterial: class", "MeshOptions: interface", "Milliseconds: type alias", + "MultiRenderTarget: class", + "MultiRenderTargetOptions: interface", "MusicAssetOptions: interface", "MusicAssetType: class", "Mutable: type alias", @@ -509,7 +527,9 @@ exports[`root index type-level export inventory > all exported symbols with kind "SoundSpriteSheet: type alias", "SourceCodecContext: interface", "SourceKey: type alias", + "SpatialPoint: interface", "SpatialSmoothingSettings: interface", + "SpatialZones: class", "Spatializable: interface", "Sprite: class", "SpriteFlags: enum", @@ -633,6 +653,11 @@ exports[`root index type-level export inventory > all exported symbols with kind "cacheMiss: variable", "cacheNamespacePrefix: variable", "clamp: variable", + "compressedBlockLayout: variable", + "compressedBlocksAcross: variable", + "compressedBlocksDown: variable", + "compressedFormatPreference: variable", + "compressedLevelByteLength: variable", "coreAssetTypes: variable", "createArcadeStickGamepadMapping: variable", "createJoyConLeftGamepadMapping: variable", @@ -659,6 +684,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "inputToken: variable", "isAdvancedBlendMode: variable", "isAudioContextReady: variable", + "isCompressedTextureFormat: variable", "isDomCanvas: variable", "isPowerOfTwo: variable", "jsonSourceCodec: variable", @@ -667,8 +693,12 @@ exports[`root index type-level export inventory > all exported symbols with kind "lerp: variable", "logger: variable", "maxPointers: variable", + "maxUint16VertexCount: variable", + "meshIndexBytes: variable", + "meshIndexFormatFor: variable", "musicType: variable", "onAudioContextReady: variable", + "orderCompressedFormats: variable", "pointerSlotSize: variable", "registerAudioWorkletProcessor: variable", "registerSerializer: variable", diff --git a/test/core/application-lifecycle.test.ts b/test/core/application-lifecycle.test.ts index ef1b7ca37..dbcda9079 100644 --- a/test/core/application-lifecycle.test.ts +++ b/test/core/application-lifecycle.test.ts @@ -96,7 +96,7 @@ interface LifecycleHarness { readonly ManualCanvasSizing: typeof import('#core/sizing/ManualCanvasSizing').ManualCanvasSizing; readonly ResponsiveCanvasSizing: typeof import('#core/sizing/ResponsiveCanvasSizing').ResponsiveCanvasSizing; readonly Texture: typeof import('#rendering/texture/Texture').Texture; - readonly loader: { destroy: MockInstance }; + readonly loader: { destroy: MockInstance; variants: { profile: unknown } }; readonly webglManager: { initialize: MockInstance; flush: MockInstance; @@ -200,6 +200,9 @@ const loadHarness = async (options: LifecycleHarnessOptions = {}): Promise ({ + data: new Uint8Array(compressedLevelByteLength(format, width, height)), + width, + height, +}); + +describe('compressed texture formats', () => { + test('block geometry matches the hardware layout of each family', () => { + expect(compressedBlockLayout(CompressedTextureFormat.Bc1RgbaUnorm)).toEqual({ blockWidth: 4, blockHeight: 4, bytesPerBlock: 8 }); + expect(compressedBlockLayout(CompressedTextureFormat.Bc7RgbaUnorm)).toEqual({ blockWidth: 4, blockHeight: 4, bytesPerBlock: 16 }); + expect(compressedBlockLayout(CompressedTextureFormat.Astc8x8Unorm)).toEqual({ blockWidth: 8, blockHeight: 8, bytesPerBlock: 16 }); + }); + + test('a level is padded out to whole blocks', () => { + // 5x5 in 4x4 blocks is a 2x2 block grid - four blocks, not one and a half. + expect(compressedBlocksAcross(CompressedTextureFormat.Bc7RgbaUnorm, 5)).toBe(2); + expect(compressedBlocksDown(CompressedTextureFormat.Bc7RgbaUnorm, 5)).toBe(2); + expect(compressedLevelByteLength(CompressedTextureFormat.Bc7RgbaUnorm, 5, 5)).toBe(64); + expect(compressedLevelByteLength(CompressedTextureFormat.Bc1RgbaUnorm, 8, 8)).toBe(32); + expect(compressedLevelByteLength(CompressedTextureFormat.Astc8x8Unorm, 16, 8)).toBe(32); + }); + + test('a level below one block still costs one block', () => { + expect(compressedLevelByteLength(CompressedTextureFormat.Bc3RgbaUnorm, 1, 1)).toBe(16); + }); + + test('isCompressedTextureFormat only accepts formats with a layout', () => { + expect(isCompressedTextureFormat(CompressedTextureFormat.Bc5RgUnorm)).toBe(true); + expect(isCompressedTextureFormat('pvrtc-4bpp')).toBe(false); + }); + + test('orderCompressedFormats puts a device set on the engine preference order', () => { + const ordered = orderCompressedFormats([CompressedTextureFormat.Bc1RgbaUnorm, CompressedTextureFormat.Bc7RgbaUnorm, CompressedTextureFormat.Bc3RgbaUnorm]); + + expect(ordered).toEqual([CompressedTextureFormat.Bc7RgbaUnorm, CompressedTextureFormat.Bc3RgbaUnorm, CompressedTextureFormat.Bc1RgbaUnorm]); + }); + + test('the preference order covers every format exactly once', () => { + const formats = Object.values(CompressedTextureFormat); + + expect([...compressedFormatPreference].sort()).toEqual([...formats].sort()); + }); +}); + +describe('CompressedTexture', () => { + test('is a Texture sized from its base level', () => { + const texture = new CompressedTexture({ + format: CompressedTextureFormat.Bc7RgbaUnorm, + levels: [level(CompressedTextureFormat.Bc7RgbaUnorm, 16, 8)], + }); + + expect(texture).toBeInstanceOf(Texture); + expect(texture.width).toBe(16); + expect(texture.height).toBe(8); + expect(texture.source).toBeNull(); + expect(texture.compressed?.format).toBe(CompressedTextureFormat.Bc7RgbaUnorm); + }); + + test('forces the upload state a compressed payload cannot honour', () => { + const texture = new CompressedTexture({ + format: CompressedTextureFormat.Etc2Rgba8Unorm, + levels: [level(CompressedTextureFormat.Etc2Rgba8Unorm, 8, 8)], + }); + + expect(texture.premultiplyAlpha).toBe(false); + expect(texture.generateMipMap).toBe(false); + }); + + test('keeps the sampler state it was given', () => { + const texture = new CompressedTexture({ + format: CompressedTextureFormat.Bc1RgbaUnorm, + levels: [level(CompressedTextureFormat.Bc1RgbaUnorm, 4, 4)], + samplerOptions: { scaleMode: ScaleModes.Nearest, wrapMode: WrapModes.Repeat }, + }); + + expect(texture.scaleMode).toBe(ScaleModes.Nearest); + expect(texture.wrapMode).toBe(WrapModes.Repeat); + }); + + test('carries the full mip chain the payload declares', () => { + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const texture = new CompressedTexture({ format, levels: [level(format, 16, 16), level(format, 8, 8), level(format, 4, 4)] }); + + expect(texture.compressed?.levels).toHaveLength(3); + }); + + test('rejects an empty level list', () => { + expect(() => new CompressedTexture({ format: CompressedTextureFormat.Bc7RgbaUnorm, levels: [] })).toThrow(/at least one mip level/); + }); + + test('rejects a level whose byte length does not match its extent', () => { + expect( + () => + new CompressedTexture({ + format: CompressedTextureFormat.Bc7RgbaUnorm, + levels: [{ data: new Uint8Array(8), width: 4, height: 4 }], + }), + ).toThrow(/occupies 16 bytes, but carries 8/); + }); + + test('rejects a base level that is not a whole number of blocks', () => { + // WebGPU refuses to create such a texture at all, so accepting it on WebGL2 + // would make the same file work on one backend and fail on the other. + expect( + () => + new CompressedTexture({ + format: CompressedTextureFormat.Astc8x8Unorm, + levels: [level(CompressedTextureFormat.Astc8x8Unorm, 12, 8)], + }), + ).toThrow(/must be a multiple of that on both axes/); + }); +}); + +describe('Texture payload exclusivity', () => { + const format = CompressedTextureFormat.Bc3RgbaUnorm; + + test('setCompressed clears a pixel source and bumps the version', () => { + const texture = new Texture(null); + const before = texture.version; + + texture.setCompressed({ format, levels: [level(format, 8, 8)] }); + + expect(texture.compressed).not.toBeNull(); + expect(texture.source).toBeNull(); + expect(texture.version).toBeGreaterThan(before); + expect(texture.width).toBe(8); + }); + + test('setSource clears a compressed payload', () => { + const texture = new Texture(null); + + texture.setCompressed({ format, levels: [level(format, 8, 8)] }); + texture.setSource(Texture.missing.source); + + expect(texture.compressed).toBeNull(); + }); + + test('setCompressed(null) drops the payload and bumps the version', () => { + const texture = new Texture(null); + + texture.setCompressed({ format, levels: [level(format, 8, 8)] }); + + const before = texture.version; + + texture.setCompressed(null); + + expect(texture.compressed).toBeNull(); + expect(texture.version).toBeGreaterThan(before); + }); + + test('setCompressed(null) on a texture without one changes nothing', () => { + const texture = new Texture(null); + const before = texture.version; + + texture.setCompressed(null); + + expect(texture.version).toBe(before); + }); + + test('compressedPayloadOf reads a texture without the property as uncompressed', () => { + expect(compressedPayloadOf(new Texture(null))).toBeNull(); + expect(compressedPayloadOf({})).toBeNull(); + }); +}); diff --git a/test/rendering/filters/lut-filter.test.ts b/test/rendering/filters/lut-filter.test.ts index e97997f7c..eabcce29e 100644 --- a/test/rendering/filters/lut-filter.test.ts +++ b/test/rendering/filters/lut-filter.test.ts @@ -342,6 +342,7 @@ const makeWebGpuBackend = (device: GPUDevice): RenderBackend & WebGpuBackend => }, getTextureBinding: vi.fn(() => ({ view: {} as GPUTextureView, sampler: {} as GPUSampler })), getTextureFormat: vi.fn(() => 'rgba8unorm' as GPUTextureFormat), + colorAttachmentCount: 1, createColorAttachment: vi.fn( () => ({ diff --git a/test/rendering/filters/shader-filter-webgpu.test.ts b/test/rendering/filters/shader-filter-webgpu.test.ts index 82ab77c0f..28e7bdb79 100644 --- a/test/rendering/filters/shader-filter-webgpu.test.ts +++ b/test/rendering/filters/shader-filter-webgpu.test.ts @@ -151,6 +151,7 @@ interface MockWebGpuBackendExtras { device: GPUDevice; renderTargetFormat: GPUTextureFormat; createColorAttachment: MockInstance; + colorAttachmentCount: number; submit: MockInstance; stats: ReturnType; } @@ -258,6 +259,9 @@ const makeWebGpuBackend = (env: MockWebGpuEnv): RenderBackend & WebGpuBackend & getTextureBinding, getTextureFormat, createColorAttachment, + // The coordinator sizes its attachment list from this; a single-attachment + // target is what every filter path renders into. + colorAttachmentCount: 1, submit, } as unknown as RenderBackend & WebGpuBackend & MockWebGpuBackendExtras; diff --git a/test/rendering/mesh-index-format.test.ts b/test/rendering/mesh-index-format.test.ts new file mode 100644 index 000000000..21d7b756b --- /dev/null +++ b/test/rendering/mesh-index-format.test.ts @@ -0,0 +1,310 @@ +/** + * The dual 16/32-bit mesh index path. + * + * A mesh used to be forced through 16-bit indices: `Geometry.indices` already + * accepted a `Uint32Array` but `Mesh` narrowed it back, and a non-indexed mesh + * was rejected outright past 65 536 vertices. That capped a single mesh at + * roughly 21 800 triangles, which is fine for a hand-authored leaf and not fine + * for generated or merged tile, trail, terrain or SVG geometry. + * + * These cells cover all seven paths a mesh's indices can reach the GPU through - + * public types, explicit `Uint32` geometry, a large non-indexed mesh, immediate + * `drawGeometry`, `drawBatch`, the static geometry cache (including a re-pack + * after `Geometry.invalidate()`), and retained replay - and read back what each + * backend was actually told: `UNSIGNED_INT` on WebGL2, `'uint32'` at a 4-aligned + * offset on WebGPU. + */ + +import { afterEach, describe, expect, test } from 'vitest'; + +import { Color } from '#core/Color'; +import { Matrix } from '#math/Matrix'; +import { Geometry } from '#rendering/geometry/Geometry'; +import { Mesh } from '#rendering/mesh/Mesh'; +import { maxUint16VertexCount, meshIndexBytes, meshIndexFormatFor } from '#rendering/mesh/meshIndices'; +import { RenderBatch } from '#rendering/RenderBatch'; +import { RenderingContext } from '#rendering/RenderingContext'; +import { RetainedContainer } from '#rendering/RetainedContainer'; + +import { createWebGl2Harness, type WebGl2Harness } from '../perf/rendering/harness'; +import { createCanvasTexture, createMockBackend, createMockWebGpuEnvironment } from './webgpuMockEnvironment'; + +const GL_UNSIGNED_SHORT = 0x1403; +const GL_UNSIGNED_INT = 0x1405; + +/** Interleaved position + texcoord + color, the layout the mesh renderers read. */ +const vertexStride = 20; + +interface Ctor { + readonly triangles: number; + readonly indices?: Uint16Array | Uint32Array | null; + readonly usage?: 'static' | 'dynamic'; +} + +const buildGeometry = ({ triangles, indices = null, usage = 'static' }: Ctor): Geometry => { + const vertexCount = indices === null ? triangles * 3 : Math.max(...indices) + 1; + const buffer = new ArrayBuffer(vertexCount * vertexStride); + const view = new DataView(buffer); + + for (let i = 0; i < vertexCount; i++) { + const base = i * vertexStride; + + view.setFloat32(base, (i % 64) * 4, true); + view.setFloat32(base + 4, Math.floor(i / 64) * 4, true); + view.setFloat32(base + 8, 0, true); + view.setFloat32(base + 12, 0, true); + view.setUint32(base + 16, 0xffffffff, true); + } + + return new Geometry({ + attributes: [ + { name: 'a_position', size: 2, type: 'f32', normalized: false, offset: 0 }, + { name: 'a_texcoord', size: 2, type: 'f32', normalized: false, offset: 8 }, + { name: 'a_color', size: 4, type: 'u8', normalized: true, offset: 16 }, + ], + vertexData: buffer, + stride: vertexStride, + indices, + usage, + }); +}; + +interface RecordedElementDraw { + readonly kind: 'draw' | 'instanced'; + readonly count: number; + readonly indexType: number; +} + +interface GlSpyHarness extends WebGl2Harness { + readonly draws: RecordedElementDraw[]; +} + +const createGlSpyHarness = (): GlSpyHarness => { + const harness = createWebGl2Harness({ width: 256, height: 256 }); + const draws: RecordedElementDraw[] = []; + // The fake context is a Proxy with no `set` trap, so these land on its target + // and every backend draw goes through the spies. + const mutable = harness.context as unknown as Record; + + mutable['drawElements'] = (_mode: number, count: number, indexType: number): void => { + draws.push({ kind: 'draw', count, indexType }); + }; + mutable['drawElementsInstanced'] = (_mode: number, count: number, indexType: number): void => { + draws.push({ kind: 'instanced', count, indexType }); + }; + + return { ...harness, draws }; +}; + +describe('mesh index width - public contract', () => { + test('Mesh accepts and reports both index kinds', () => { + const narrow = new Mesh({ vertices: new Float32Array(12), indices: new Uint16Array([0, 1, 2]) }); + const wide = new Mesh({ vertices: new Float32Array(12), indices: new Uint32Array([0, 1, 2]) }); + + expect(narrow.indices).toBeInstanceOf(Uint16Array); + expect(narrow.indexFormat).toBe('uint16'); + expect(wide.indices).toBeInstanceOf(Uint32Array); + expect(wide.indexFormat).toBe('uint32'); + }); + + test('an explicit Uint32 geometry is not narrowed back to 16 bits', () => { + // The declared width is the contract. Narrowing a stream that happens to fit + // would let the same geometry change index width when its content changes. + const geometry = buildGeometry({ triangles: 1, indices: new Uint32Array([0, 1, 2]) }); + const mesh = new Mesh({ geometry }); + + expect(mesh.indices).toBeInstanceOf(Uint32Array); + expect(mesh.indexFormat).toBe('uint32'); + }); + + test('meshIndexFormatFor follows the declaration for indexed and the count for non-indexed meshes', () => { + expect(meshIndexFormatFor(new Uint16Array([0]), 3)).toBe('uint16'); + expect(meshIndexFormatFor(new Uint32Array([0]), 3)).toBe('uint32'); + expect(meshIndexFormatFor(null, maxUint16VertexCount)).toBe('uint16'); + expect(meshIndexFormatFor(null, maxUint16VertexCount + 1)).toBe('uint32'); + expect(meshIndexBytes('uint16')).toBe(2); + expect(meshIndexBytes('uint32')).toBe(4); + }); + + test('index bounds are still validated at the wider width', () => { + expect(() => new Mesh({ vertices: new Float32Array(12), indices: new Uint32Array([0, 1, 9]) })).toThrow(/out of range/); + }); +}); + +describe('WebGL2 mesh index width', () => { + let harness: GlSpyHarness | null = null; + + afterEach(() => { + harness?.destroy(); + harness = null; + }); + + test('a 16-bit mesh still draws with UNSIGNED_SHORT', () => { + harness = createGlSpyHarness(); + + const context = new RenderingContext(harness.backend); + + context.drawGeometry(buildGeometry({ triangles: 2, indices: new Uint16Array([0, 1, 2, 3, 4, 5]) }), new Matrix()); + harness.backend.flush(); + + expect(harness.draws).toHaveLength(1); + expect(harness.draws[0]?.indexType).toBe(GL_UNSIGNED_SHORT); + }); + + test('drawGeometry with a 32-bit stream draws with UNSIGNED_INT', () => { + harness = createGlSpyHarness(); + + const context = new RenderingContext(harness.backend); + + context.drawGeometry(buildGeometry({ triangles: 2, indices: new Uint32Array([0, 1, 2, 3, 4, 5]) }), new Matrix()); + harness.backend.flush(); + + expect(harness.draws).toHaveLength(1); + expect(harness.draws[0]).toMatchObject({ count: 6, indexType: GL_UNSIGNED_INT }); + }); + + test('mixing widths in one frame draws each with its own element type', () => { + harness = createGlSpyHarness(); + + const context = new RenderingContext(harness.backend); + + context.drawGeometry(buildGeometry({ triangles: 1, indices: new Uint16Array([0, 1, 2]) }), new Matrix()); + context.drawGeometry(buildGeometry({ triangles: 1, indices: new Uint32Array([0, 1, 2]) }), new Matrix()); + context.drawGeometry(buildGeometry({ triangles: 1, indices: new Uint16Array([0, 1, 2]) }), new Matrix()); + harness.backend.flush(); + + expect(harness.draws.map(({ indexType }) => indexType)).toEqual([GL_UNSIGNED_SHORT, GL_UNSIGNED_INT, GL_UNSIGNED_SHORT]); + }); + + test('drawBatch draws its shared 32-bit geometry with UNSIGNED_INT', () => { + harness = createGlSpyHarness(); + + const context = new RenderingContext(harness.backend); + const batch = new RenderBatch(buildGeometry({ triangles: 2, indices: new Uint32Array([0, 1, 2, 3, 4, 5]) })); + + batch.add(new Matrix(), Color.white); + batch.add(new Matrix(), Color.white); + context.drawBatch(batch); + harness.backend.flush(); + + expect(harness.draws).toHaveLength(1); + expect(harness.draws[0]).toMatchObject({ kind: 'instanced', count: 6, indexType: GL_UNSIGNED_INT }); + }); + + test('a scene mesh on cached static geometry keeps the width across a re-pack', () => { + harness = createGlSpyHarness(); + + const context = new RenderingContext(harness.backend); + const geometry = buildGeometry({ triangles: 2, indices: new Uint32Array([0, 1, 2, 3, 4, 5]) }); + const mesh = new Mesh({ geometry, texture: null }); + + context.render(mesh); + harness.backend.flush(); + expect(harness.draws.at(-1)?.indexType).toBe(GL_UNSIGNED_INT); + + // The cached entry is re-packed on a version bump, and every VAO already + // built against it has to keep drawing at the packed width. + harness.draws.length = 0; + geometry.invalidate(); + context.render(mesh); + harness.backend.flush(); + + expect(harness.draws.at(-1)?.indexType).toBe(GL_UNSIGNED_INT); + }); + + test('retained replay draws the recorded geometry at its recorded width', () => { + harness = createGlSpyHarness(); + + const context = new RenderingContext(harness.backend); + const group = new RetainedContainer(); + + group.addChild(new Mesh({ geometry: buildGeometry({ triangles: 2, indices: new Uint32Array([0, 1, 2, 3, 4, 5]) }), texture: null })); + + // First frames record the fragment; a later frame replays it. + for (let frame = 0; frame < 4; frame++) { + context.render(group); + harness.backend.flush(); + } + + harness.draws.length = 0; + context.render(group); + harness.backend.flush(); + + expect(harness.draws.length).toBeGreaterThan(0); + + for (const draw of harness.draws) { + expect(draw.indexType).toBe(GL_UNSIGNED_INT); + } + }); + + test('a large non-indexed mesh reaches the GPU instead of throwing', () => { + harness = createGlSpyHarness(); + + const context = new RenderingContext(harness.backend); + // A non-indexed triangle list needs a multiple of 3; 65 538 is the first one + // past what a 16-bit index can address. + const vertexCount = maxUint16VertexCount + 2; + const geometry = buildGeometry({ triangles: vertexCount / 3 }); + + expect(new Mesh({ geometry }).indexFormat).toBe('uint32'); + + context.drawGeometry(geometry, new Matrix()); + harness.backend.flush(); + + expect(harness.draws).toHaveLength(1); + expect(harness.draws[0]).toMatchObject({ count: vertexCount, indexType: GL_UNSIGNED_INT }); + }); +}); + +describe('WebGPU mesh index width', () => { + test('a 32-bit mesh binds uint32 at a 4-aligned offset, and widths may mix in one flush', async () => { + const environment = createMockWebGpuEnvironment(); + + try { + const backend = await createMockBackend(environment); + const context = new RenderingContext(backend); + const texture = createCanvasTexture(); + + // Three indices is an odd uint16 block: without the shared 4-byte + // alignment the uint32 draw behind it would land on a 2-byte boundary, + // which `setIndexBuffer` rejects for that format. + context.drawGeometry(buildGeometry({ triangles: 1, indices: new Uint16Array([0, 1, 2]) }), new Matrix()); + context.drawGeometry(buildGeometry({ triangles: 1, indices: new Uint32Array([0, 1, 2]) }), new Matrix()); + backend.flush(); + + const bindings = environment.indexBufferBindings(); + + expect(bindings.map(({ format }) => format)).toEqual(['uint16', 'uint32']); + + for (const { format, offset } of bindings) { + expect(offset % meshIndexBytes(format as 'uint16' | 'uint32')).toBe(0); + } + + texture.destroy(); + backend.destroy(); + } finally { + environment.restore(); + } + }); + + test('drawBatch binds the cached static geometry at its own width', async () => { + const environment = createMockWebGpuEnvironment(); + + try { + const backend = await createMockBackend(environment); + const context = new RenderingContext(backend); + const batch = new RenderBatch(buildGeometry({ triangles: 2, indices: new Uint32Array([0, 1, 2, 3, 4, 5]) })); + + batch.add(new Matrix(), Color.white); + batch.add(new Matrix(), Color.white); + context.drawBatch(batch); + backend.flush(); + + expect(environment.indexBufferBindings().map(({ format }) => format)).toEqual(['uint32']); + + backend.destroy(); + } finally { + environment.restore(); + } + }); +}); diff --git a/test/rendering/mesh.test.ts b/test/rendering/mesh.test.ts index 5fdb1aa11..96bb47cff 100644 --- a/test/rendering/mesh.test.ts +++ b/test/rendering/mesh.test.ts @@ -159,18 +159,23 @@ describe('Mesh', () => { ).toThrow(/multiple of 3/); }); - test('rejects non-indexed meshes beyond the 16-bit implicit-index range', () => { - const largestTriangleListVertexCount = 0xffff; - const overflowingTriangleListVertexCount = 0x10002; - const overflowingGeometry = new Geometry({ + test('a non-indexed mesh past the 16-bit range widens its implicit indices instead of failing', () => { + // The former guard rejected these outright. The limit is gone: the implicit + // indices simply widen, because they are synthesized and nothing else about + // the mesh has to change. + // Both multiples of 3, as a non-indexed triangle list must be: 65 535 is the + // largest such count a 16-bit index reaches, 65 538 the first past it. + const largestUint16VertexCount = 0xffff; + const wideVertexCount = 0x10002; + const wideGeometry = new Geometry({ attributes: [{ name: 'a_position', size: 2, type: 'f32', normalized: false, offset: 0 }], - vertexData: new Float32Array(overflowingTriangleListVertexCount * 2), + vertexData: new Float32Array(wideVertexCount * 2), stride: 8, }); - expect(() => new Mesh({ vertices: new Float32Array(largestTriangleListVertexCount * 2) })).not.toThrow(); - expect(() => new Mesh({ vertices: new Float32Array(overflowingTriangleListVertexCount * 2) })).toThrow(/16-bit implicit-index limit of 65536 vertices/); - expect(() => new Mesh({ geometry: overflowingGeometry })).toThrow(/16-bit implicit-index limit of 65536 vertices/); + expect(new Mesh({ vertices: new Float32Array(largestUint16VertexCount * 2) }).indexFormat).toBe('uint16'); + expect(new Mesh({ vertices: new Float32Array(wideVertexCount * 2) }).indexFormat).toBe('uint32'); + expect(new Mesh({ geometry: wideGeometry }).indexFormat).toBe('uint32'); }); test('texture setter swaps the bound texture', () => { diff --git a/test/rendering/multi-render-target.test.ts b/test/rendering/multi-render-target.test.ts new file mode 100644 index 000000000..f68c0608d --- /dev/null +++ b/test/rendering/multi-render-target.test.ts @@ -0,0 +1,338 @@ +/** + * Multiple colour attachments in one pass. + * + * A render target could only ever carry 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. + * + * These cells cover the target itself, the two backend paths that realize it + * (WebGL2 `drawBuffers`, a WebGPU pass descriptor and pipeline sized to the + * attachment count), the refusals that keep the two backends telling the same + * story, and the concrete consumer the work package required before any of this + * was allowed to exist: a mesh material whose fragment shader declares one output + * per attachment. + */ + +import { afterEach, describe, expect, test } from 'vitest'; + +import { Geometry } from '#rendering/geometry/Geometry'; +import { MeshMaterial } from '#rendering/material/MeshMaterial'; +import { ShaderSource } from '#rendering/material/ShaderSource'; +import { Mesh } from '#rendering/mesh/Mesh'; +import { MultiRenderTarget } from '#rendering/MultiRenderTarget'; +import { RenderError } from '#rendering/RenderError'; +import { RenderingContext } from '#rendering/RenderingContext'; +import { Sprite } from '#rendering/sprite/Sprite'; +import { RenderTexture } from '#rendering/texture/RenderTexture'; +import type { ColorTextureFormat } from '#rendering/types'; +import { TextureFormat } from '#rendering/types'; + +import { createWebGl2Harness } from '../perf/rendering/harness'; +import { createCanvasTexture, createMockBackend, createMockWebGpuEnvironment } from './webgpuMockEnvironment'; + +/** A fragment shader with one output per attachment - what a multi-attachment pass requires. */ +const twoOutputMaterial = (): MeshMaterial => + new MeshMaterial({ + shader: new ShaderSource({ + glsl: { + vertex: `#version 300 es +in vec2 a_position; +void main() { gl_Position = vec4(a_position, 0.0, 1.0); }`, + fragment: `#version 300 es +precision mediump float; +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outId; +void main() { outColor = vec4(1.0); outId = vec4(0.5); }`, + }, + wgsl: ` +struct FragmentOut { + @location(0) color: vec4, + @location(1) id: vec4, +}; + +@fragment +fn fragmentMain(input: VertexOutput) -> FragmentOut { + var out: FragmentOut; + out.color = vec4(1.0); + out.id = vec4(0.5); + return out; +} +`.trim(), + }), + }); + +const triangleGeometry = (): Geometry => { + const stride = 20; + const buffer = new ArrayBuffer(3 * stride); + const view = new DataView(buffer); + + for (const [index, [x, y]] of ([[0, 0] as const, [32, 0] as const, [0, 32] as const] as const).entries()) { + const base = index * stride; + + view.setFloat32(base, x, true); + view.setFloat32(base + 4, y, true); + view.setUint32(base + 16, 0xffffffff, true); + } + + return new Geometry({ + attributes: [ + { name: 'a_position', size: 2, type: 'f32', normalized: false, offset: 0 }, + { name: 'a_texcoord', size: 2, type: 'f32', normalized: false, offset: 8 }, + { name: 'a_color', size: 4, type: 'u8', normalized: true, offset: 16 }, + ], + vertexData: buffer, + stride, + usage: 'static', + }); +}; + +interface RecordedAttachment { + readonly slot: number; + readonly handle: unknown; +} + +interface GlHarness { + readonly backend: import('#rendering/webgl2/WebGl2Backend').WebGl2Backend; + readonly attachments: RecordedAttachment[]; + readonly drawBufferLists: number[][]; + /** The context's own `COLOR_ATTACHMENT0`, so slot arithmetic is read back in its terms. */ + readonly colorAttachment0: number; + destroy(): void; +} + +const createGlHarness = (): GlHarness => { + const harness = createWebGl2Harness({ width: 128, height: 128 }); + const attachments: RecordedAttachment[] = []; + const drawBufferLists: number[][] = []; + // The fake context is a Proxy with no `set` trap, so these land on its target + // and every backend call goes through the spies. + const mutable = harness.context as unknown as Record; + const colorAttachment0 = harness.context.COLOR_ATTACHMENT0; + + mutable['framebufferTexture2D'] = (_target: number, attachment: number, _texTarget: number, handle: unknown): void => { + attachments.push({ slot: attachment - colorAttachment0, handle }); + }; + mutable['drawBuffers'] = (buffers: number[]): void => { + drawBufferLists.push([...buffers]); + }; + + return { + backend: harness.backend, + attachments, + drawBufferLists, + colorAttachment0, + destroy: (): void => { + harness.destroy(); + }, + }; +}; + +describe('MultiRenderTarget', () => { + test('owns one RenderTexture per declared format', () => { + const target = new MultiRenderTarget(64, 32, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba8] }); + + expect(target.attachments).toHaveLength(2); + expect(target.attachment(0)).toBeInstanceOf(RenderTexture); + expect(target.attachment(0).width).toBe(64); + expect(target.attachment(1).height).toBe(32); + expect(target.formats).toEqual([TextureFormat.Rgba8, TextureFormat.Rgba8]); + + target.destroy(); + }); + + test('resizing carries every attachment with it', () => { + // An attachment left at the old size makes the whole framebuffer incomplete, + // and the symptom would surface on the next unrelated draw into it. + const target = new MultiRenderTarget(64, 64, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba8] }); + + target.resize(128, 96); + + for (const attachment of target.attachments) { + expect(attachment.width).toBe(128); + expect(attachment.height).toBe(96); + } + + target.destroy(); + }); + + test('destroying it destroys the attachments it owns', () => { + const target = new MultiRenderTarget(16, 16, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba8] }); + const [first, second] = target.attachments; + + target.destroy(); + + expect(first!.destroyed).toBe(true); + expect(second!.destroyed).toBe(true); + }); + + test('attachments may carry different formats', () => { + const target = new MultiRenderTarget(8, 8, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba16F] }); + + expect(target.formats).toEqual([TextureFormat.Rgba8, TextureFormat.Rgba16F]); + + target.destroy(); + }); +}); + +describe('WebGL2 multiple colour attachments', () => { + let harness: GlHarness | null = null; + + afterEach(() => { + harness?.destroy(); + harness = null; + }); + + test('reports the attachment capacity of the context', () => { + harness = createGlHarness(); + + expect(harness.backend.maxColorAttachments).toBeGreaterThanOrEqual(1); + }); + + test('attaches one texture per slot and declares them as draw buffers', () => { + harness = createGlHarness(); + + const context = new RenderingContext(harness.backend); + const target = new MultiRenderTarget(64, 64, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba8] }); + const mesh = new Mesh({ geometry: triangleGeometry(), material: twoOutputMaterial(), texture: null }); + + context.renderTo(mesh, { target }); + harness.backend.flush(); + + expect(harness.attachments.map(({ slot }) => slot)).toEqual([0, 1]); + expect(harness.attachments[0]?.handle).not.toBe(harness.attachments[1]?.handle); + // Without this GL would only ever write slot 0, which is its default + // draw-buffer list. + expect(harness.drawBufferLists).toEqual([[harness.colorAttachment0, harness.colorAttachment0 + 1]]); + + target.destroy(); + }); + + test('a single-attachment RenderTexture still needs no drawBuffers call', () => { + harness = createGlHarness(); + + const context = new RenderingContext(harness.backend); + const target = new RenderTexture(64, 64); + const mesh = new Mesh({ geometry: triangleGeometry(), texture: null }); + + context.renderTo(mesh, { target }); + harness.backend.flush(); + + expect(harness.attachments.map(({ slot }) => slot)).toEqual([0]); + expect(harness.drawBufferLists).toEqual([]); + + target.destroy(); + }); + + test('refuses more attachments than the context accepts', () => { + harness = createGlHarness(); + + const context = new RenderingContext(harness.backend); + const formats: ColorTextureFormat[] = Array.from({ length: harness.backend.maxColorAttachments + 1 }, () => TextureFormat.Rgba8); + const target = new MultiRenderTarget(16, 16, { formats }); + const mesh = new Mesh({ geometry: triangleGeometry(), material: twoOutputMaterial(), texture: null }); + + expect(() => { + context.renderTo(mesh, { target }); + }).toThrow(/colour attachment/); + + target.destroy(); + }); + + test('refuses a drawable that cannot write every attachment', () => { + harness = createGlHarness(); + + const context = new RenderingContext(harness.backend); + const target = new MultiRenderTarget(64, 64, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba8] }); + const sprite = new Sprite(new RenderTexture(8, 8)); + + // WebGL2 would happily write slot 0 and leave the rest cleared; WebGPU cannot + // build the pipeline at all. A refusal on both beats two behaviours. + expect(() => { + context.renderTo(sprite, { target }); + }).toThrow(RenderError); + + target.destroy(); + }); + + test('refuses alpha-mask compositing into a multi-attachment target', () => { + harness = createGlHarness(); + + const target = new MultiRenderTarget(64, 64, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba8] }); + const content = new RenderTexture(16, 16); + const mask = new RenderTexture(16, 16); + + harness.backend.setRenderTarget(target); + + expect(() => harness!.backend.composeWithAlphaMask(content, mask, 0, 0, 16, 16, 0 as never)).toThrow(/single-output shader/); + + harness.backend.setRenderTarget(null); + target.destroy(); + }); +}); + +describe('WebGPU multiple colour attachments', () => { + test('sizes the render pass and the pipeline to the attachment count', async () => { + const environment = createMockWebGpuEnvironment(); + + try { + const backend = await createMockBackend(environment); + + expect(backend.maxColorAttachments).toBeGreaterThanOrEqual(2); + + const context = new RenderingContext(backend); + const target = new MultiRenderTarget(64, 64, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba8] }); + const mesh = new Mesh({ geometry: triangleGeometry(), material: twoOutputMaterial(), texture: createCanvasTexture() }); + + context.renderTo(mesh, { target }); + backend.flush(); + + expect(environment.renderPassAttachmentCounts()).toContain(2); + // A pipeline must declare one target per attachment of the pass it runs in, + // so the two-output material gets its own pipeline rather than reusing the + // single-target one. + expect(environment.pipelineTargetCounts()).toContain(2); + + target.destroy(); + backend.destroy(); + } finally { + environment.restore(); + } + }); + + test('an ordinary single-attachment pass is unchanged', async () => { + const environment = createMockWebGpuEnvironment(); + + try { + const backend = await createMockBackend(environment); + const context = new RenderingContext(backend); + const target = new RenderTexture(64, 64); + + context.renderTo(new Sprite(createCanvasTexture()), { target }); + backend.flush(); + + expect(environment.renderPassAttachmentCounts().every(count => count === 1)).toBe(true); + + target.destroy(); + backend.destroy(); + } finally { + environment.restore(); + } + }); + + test('refuses more attachments than the device accepts', async () => { + const environment = createMockWebGpuEnvironment(); + + try { + const backend = await createMockBackend(environment); + const formats: ColorTextureFormat[] = Array.from({ length: backend.maxColorAttachments + 1 }, () => TextureFormat.Rgba8); + const target = new MultiRenderTarget(16, 16, { formats }); + + expect(() => backend.setRenderTarget(target)).toThrow(/colour attachment/); + + target.destroy(); + backend.destroy(); + } finally { + environment.restore(); + } + }); +}); diff --git a/test/rendering/pass/web-gpu-pass-coordinator.test.ts b/test/rendering/pass/web-gpu-pass-coordinator.test.ts index 6afc115c5..ab3bde50a 100644 --- a/test/rendering/pass/web-gpu-pass-coordinator.test.ts +++ b/test/rendering/pass/web-gpu-pass-coordinator.test.ts @@ -77,6 +77,7 @@ const createMockBackend = ( pushScissorRect, popScissorRect, createColorAttachment, + colorAttachmentCount: 1, getScissorRect, submit, _targetHasContent: targetHasContent, diff --git a/test/rendering/webgl2-compressed-texture.test.ts b/test/rendering/webgl2-compressed-texture.test.ts new file mode 100644 index 000000000..2be72556d --- /dev/null +++ b/test/rendering/webgl2-compressed-texture.test.ts @@ -0,0 +1,234 @@ +/** + * WebGL2 compressed-texture upload. + * + * Drives the REAL `WebGl2Backend` against the recording fake context and reads + * back what it handed `compressedTexImage2D`: one call per mip level, in mip + * order, with the internal format of the extension that carries the family. The + * fake context reports no extensions by default, which is also the device shape + * the refusal path has to produce - a loud `RenderError` rather than an upload of + * bytes the driver would misread. + */ + +import { afterEach, describe, expect, test } from 'vitest'; + +import type { Application } from '#core/Application'; +import { RenderError } from '#rendering/RenderError'; +import { CompressedTexture } from '#rendering/texture/CompressedTexture'; +import { compressedLevelByteLength, CompressedTextureFormat } from '#rendering/texture/CompressedTextureFormat'; +import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; + +import { createFakeCanvas, createFakeWebGl2Context, GlRecorder, installFakeWebGl2Globals } from '../perf/rendering/fakeWebGl2'; + +interface RecordedCompressedUpload { + readonly level: number; + readonly internalFormat: number; + readonly width: number; + readonly height: number; + readonly byteLength: number; +} + +interface CompressedHarness { + readonly backend: WebGl2Backend; + readonly uploads: RecordedCompressedUpload[]; + destroy(): void; +} + +/** Internal formats of the extensions the engine probes, for asserting the mapping. */ +const GL_COMPRESSED_RGBA_BPTC_UNORM = 0x8e8c; +const GL_COMPRESSED_RGBA8_ETC2_EAC = 0x9278; +const GL_COMPRESSED_RGBA_S3TC_DXT5 = 0x83f3; + +const createHarness = (extensions: readonly string[]): CompressedHarness => { + installFakeWebGl2Globals(); + + const context = createFakeWebGl2Context(new GlRecorder()); + const uploads: RecordedCompressedUpload[] = []; + const supported = new Set(extensions); + // The fake context is a Proxy with no `set` trap, so these land on its target + // and every backend call goes through the spies. Installed before the backend + // exists: it probes the extensions once, in its constructor. + const mutable = context as unknown as Record; + + mutable['getExtension'] = (name: string): object | null => (supported.has(name) ? {} : null); + mutable['compressedTexImage2D'] = ( + _target: number, + level: number, + internalFormat: number, + width: number, + height: number, + _border: number, + data: ArrayBufferView, + ): void => { + uploads.push({ level, internalFormat, width, height, byteLength: data.byteLength }); + }; + + const app = { + canvas: createFakeCanvas(64, 64, context), + options: { canvas: { width: 64, height: 64 }, rendering: { debug: false } }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + uploads.length = 0; + + return { + backend, + uploads, + destroy(): void { + backend.destroy(); + }, + }; +}; + +const chain = (format: CompressedTextureFormat, width: number, height: number, count: number) => + Array.from({ length: count }, (_unused, index) => { + const levelWidth = Math.max(width >> index, 1); + const levelHeight = Math.max(height >> index, 1); + + return { data: new Uint8Array(compressedLevelByteLength(format, levelWidth, levelHeight)), width: levelWidth, height: levelHeight }; + }); + +describe('WebGl2Backend.supportedTextureFormats', () => { + let harness: CompressedHarness | null = null; + + afterEach(() => { + harness?.destroy(); + harness = null; + }); + + test('is empty on a context that exposes no compressed-texture extension', () => { + harness = createHarness([]); + + expect(harness.backend.supportedTextureFormats).toEqual([]); + }); + + test('reports one family per enabled extension, in the engine preference order', () => { + harness = createHarness(['WEBGL_compressed_texture_s3tc', 'EXT_texture_compression_bptc']); + + expect(harness.backend.supportedTextureFormats).toEqual([ + CompressedTextureFormat.Bc7RgbaUnorm, + CompressedTextureFormat.Bc3RgbaUnorm, + CompressedTextureFormat.Bc2RgbaUnorm, + CompressedTextureFormat.Bc1RgbaUnorm, + CompressedTextureFormat.Bc6hRgbUfloat, + ]); + }); + + test('probes ETC2 rather than assuming it from the context version', () => { + harness = createHarness(['WEBGL_compressed_texture_etc']); + + expect(harness.backend.supportedTextureFormats).toContain(CompressedTextureFormat.Etc2Rgba8Unorm); + + harness.destroy(); + harness = createHarness([]); + + expect(harness.backend.supportedTextureFormats).not.toContain(CompressedTextureFormat.Etc2Rgba8Unorm); + }); +}); + +describe('WebGl2Backend compressed upload', () => { + let harness: CompressedHarness | null = null; + + afterEach(() => { + harness?.destroy(); + harness = null; + }); + + test('uploads one level with the internal format of its extension', () => { + harness = createHarness(['EXT_texture_compression_bptc']); + + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const texture = new CompressedTexture({ format, levels: chain(format, 16, 8, 1) }); + + harness.backend.bindTexture(texture, 0); + + expect(harness.uploads).toEqual([{ level: 0, internalFormat: GL_COMPRESSED_RGBA_BPTC_UNORM, width: 16, height: 8, byteLength: 128 }]); + + texture.destroy(); + }); + + test('uploads a mip chain as one call per level, in mip order', () => { + harness = createHarness(['EXT_texture_compression_bptc']); + + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const texture = new CompressedTexture({ format, levels: chain(format, 16, 16, 3) }); + + harness.backend.bindTexture(texture, 0); + + expect(harness.uploads.map(({ level, width, height }) => [level, width, height])).toEqual([ + [0, 16, 16], + [1, 8, 8], + [2, 4, 4], + ]); + + texture.destroy(); + }); + + test('maps each family onto its own internal format', () => { + harness = createHarness(['WEBGL_compressed_texture_s3tc', 'WEBGL_compressed_texture_etc']); + + const bc3 = new CompressedTexture({ + format: CompressedTextureFormat.Bc3RgbaUnorm, + levels: chain(CompressedTextureFormat.Bc3RgbaUnorm, 8, 8, 1), + }); + const etc2 = new CompressedTexture({ + format: CompressedTextureFormat.Etc2Rgba8Unorm, + levels: chain(CompressedTextureFormat.Etc2Rgba8Unorm, 8, 8, 1), + }); + + harness.backend.bindTexture(bc3, 0); + harness.backend.bindTexture(etc2, 1); + + expect(harness.uploads.map(({ internalFormat }) => internalFormat)).toEqual([GL_COMPRESSED_RGBA_S3TC_DXT5, GL_COMPRESSED_RGBA8_ETC2_EAC]); + + bc3.destroy(); + etc2.destroy(); + }); + + test('does not re-upload an unchanged texture on a second bind', () => { + harness = createHarness(['EXT_texture_compression_bptc']); + + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const texture = new CompressedTexture({ format, levels: chain(format, 8, 8, 1) }); + + harness.backend.bindTexture(texture, 0); + harness.backend.bindTexture(texture, 1); + + expect(harness.uploads).toHaveLength(1); + + texture.destroy(); + }); + + test('refuses a format the context does not implement instead of uploading it', () => { + harness = createHarness(['WEBGL_compressed_texture_etc']); + + const format = CompressedTextureFormat.Bc7RgbaUnorm; + const texture = new CompressedTexture({ format, levels: chain(format, 8, 8, 1) }); + const bind = (): void => { + harness?.backend.bindTexture(texture, 0); + }; + + expect(bind).toThrow(RenderError); + expect(bind).toThrow(/cannot sample the compressed texture format "bc7-rgba-unorm"/); + expect(harness.uploads).toEqual([]); + + texture.destroy(); + }); + + test('carries the machine-readable failure class', () => { + harness = createHarness([]); + + const format = CompressedTextureFormat.Bc1RgbaUnorm; + const texture = new CompressedTexture({ format, levels: chain(format, 8, 8, 1) }); + + try { + harness.backend.bindTexture(texture, 0); + expect.unreachable('binding an unsupported compressed format must throw'); + } catch (error) { + expect(error).toBeInstanceOf(RenderError); + expect((error as RenderError).code).toBe('unsupported-format'); + } + + texture.destroy(); + }); +}); diff --git a/test/rendering/webgpuMockEnvironment.ts b/test/rendering/webgpuMockEnvironment.ts index a2bc85308..84b53d9ba 100644 --- a/test/rendering/webgpuMockEnvironment.ts +++ b/test/rendering/webgpuMockEnvironment.ts @@ -35,6 +35,12 @@ export interface MockWebGpuEnvironment { /** Number of render pipelines synchronously created (async prewarm excluded). */ syncPipelineCount(): number; drawIndexedCount(): number; + /** Format and byte offset of every `setIndexBuffer` call, in call order. */ + indexBufferBindings(): ReadonlyArray<{ readonly format: string; readonly offset: number }>; + /** Colour-attachment count of every `beginRenderPass` descriptor, in call order. */ + renderPassAttachmentCounts(): readonly number[]; + /** Fragment-target count of every synchronously created render pipeline, in call order. */ + pipelineTargetCounts(): readonly number[]; restore(): void; } @@ -56,12 +62,17 @@ export const createMockWebGpuEnvironment = (): MockWebGpuEnvironment => { const writeBufferLabels: string[] = []; const createBufferLabels: string[] = []; const writeTextureData: ArrayBufferView[] = []; + const indexBufferBindings: Array<{ format: string; offset: number }> = []; + const renderPassAttachmentCounts: number[] = []; + const pipelineTargetCounts: number[] = []; const pass = { setPipeline: (): void => {}, setBindGroup: (): void => {}, setVertexBuffer: (): void => {}, - setIndexBuffer: (): void => {}, + setIndexBuffer: (_buffer: unknown, format: string, offset = 0): void => { + indexBufferBindings.push({ format, offset }); + }, setScissorRect: (): void => {}, pushDebugGroup: (): void => {}, popDebugGroup: (): void => {}, @@ -72,7 +83,11 @@ export const createMockWebGpuEnvironment = (): MockWebGpuEnvironment => { end: (): void => {}, }; const encoder = { - beginRenderPass: () => pass, + beginRenderPass: (descriptor: GPURenderPassDescriptor) => { + renderPassAttachmentCounts.push([...descriptor.colorAttachments].length); + + return pass; + }, finish: () => ({ label: 'command-buffer' }) as unknown as GPUCommandBuffer, }; const queue = { @@ -86,6 +101,9 @@ export const createMockWebGpuEnvironment = (): MockWebGpuEnvironment => { }, }; const device = { + // The spec's default. The backend reads it to bound a MultiRenderTarget's + // attachment count, and falls back to 1 when a device reports nothing. + limits: { maxColorAttachments: 8 }, createShaderModule: () => ({}) as GPUShaderModule, createBindGroupLayout: () => ({}) as GPUBindGroupLayout, createPipelineLayout: () => ({}) as GPUPipelineLayout, @@ -95,8 +113,9 @@ export const createMockWebGpuEnvironment = (): MockWebGpuEnvironment => { return {} as GPUBindGroup; }, - createRenderPipeline: (): GPURenderPipeline => { + createRenderPipeline: (descriptor: GPURenderPipelineDescriptor): GPURenderPipeline => { syncPipelineCount++; + pipelineTargetCounts.push(descriptor.fragment?.targets.length ?? 0); return {} as GPURenderPipeline; }, @@ -161,6 +180,9 @@ export const createMockWebGpuEnvironment = (): MockWebGpuEnvironment => { writeTextureData: () => writeTextureData, syncPipelineCount: () => syncPipelineCount, drawIndexedCount: () => drawIndexedCount, + indexBufferBindings: () => indexBufferBindings, + renderPassAttachmentCounts: () => renderPassAttachmentCounts, + pipelineTargetCounts: () => pipelineTargetCounts, restore: (): void => { if (previousGpu) { Object.defineProperty(navigator, 'gpu', previousGpu); diff --git a/test/support/render-backend-double.ts b/test/support/render-backend-double.ts index bb010bee6..30bbfbd63 100644 --- a/test/support/render-backend-double.ts +++ b/test/support/render-backend-double.ts @@ -6,6 +6,7 @@ import { RendererRegistry } from '#rendering/RendererRegistry'; import type { RenderError } from '#rendering/RenderError'; import { createRenderStats, type RenderStats } from '#rendering/RenderStats'; import { RenderTarget } from '#rendering/RenderTarget'; +import type { CompressedTextureFormat } from '#rendering/texture/CompressedTextureFormat'; import { RenderTexture } from '#rendering/texture/RenderTexture'; export interface RenderBackendDoubleOptions { @@ -16,6 +17,10 @@ export interface RenderBackendDoubleOptions { readonly backendType?: RenderBackendType; readonly rootResolution?: number; readonly maxTextureSize?: number; + /** Compressed formats the double claims to sample. Empty when omitted - the conservative answer. */ + readonly supportedTextureFormats?: readonly CompressedTextureFormat[]; + /** Colour attachments the double accepts in one pass. `1` when omitted. */ + readonly maxColorAttachments?: number; } /** @@ -55,6 +60,8 @@ export const createRenderBackendDouble = (options: RenderBackendDoubleOptions = clearColor: new Color(0, 0, 0, 0), rootResolution: options.rootResolution ?? 1, maxTextureSize: options.maxTextureSize ?? 4096, + supportedTextureFormats: options.supportedTextureFormats ?? [], + maxColorAttachments: options.maxColorAttachments ?? 1, onRenderError: new Signal<[RenderError]>(), async initialize() {