diff --git a/site/src/content/api/compressed-texture-format.json b/site/src/content/api/compressed-texture-format.json index b22a706df..c5b7fbc56 100644 --- a/site/src/content/api/compressed-texture-format.json +++ b/site/src/content/api/compressed-texture-format.json @@ -6,7 +6,7 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 16, + "memberCount": 29, "counts": { "constructors": 0, "methods": 0, @@ -30,6 +30,84 @@ "id": "members", "title": "Members", "members": [ + { + "name": "Astc10x10Unorm", + "signature": "Astc10x10Unorm", + "signatureTokens": [ + { + "text": "Astc10x10Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Astc10x5Unorm", + "signature": "Astc10x5Unorm", + "signatureTokens": [ + { + "text": "Astc10x5Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Astc10x6Unorm", + "signature": "Astc10x6Unorm", + "signatureTokens": [ + { + "text": "Astc10x6Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Astc10x8Unorm", + "signature": "Astc10x8Unorm", + "signatureTokens": [ + { + "text": "Astc10x8Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Astc12x10Unorm", + "signature": "Astc12x10Unorm", + "signatureTokens": [ + { + "text": "Astc12x10Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Astc12x12Unorm", + "signature": "Astc12x12Unorm", + "signatureTokens": [ + { + "text": "Astc12x12Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, { "name": "Astc4x4Unorm", "signature": "Astc4x4Unorm", @@ -43,6 +121,19 @@ "returnType": null, "description": "" }, + { + "name": "Astc5x4Unorm", + "signature": "Astc5x4Unorm", + "signatureTokens": [ + { + "text": "Astc5x4Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, { "name": "Astc5x5Unorm", "signature": "Astc5x5Unorm", @@ -56,6 +147,19 @@ "returnType": null, "description": "" }, + { + "name": "Astc6x5Unorm", + "signature": "Astc6x5Unorm", + "signatureTokens": [ + { + "text": "Astc6x5Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, { "name": "Astc6x6Unorm", "signature": "Astc6x6Unorm", @@ -69,6 +173,32 @@ "returnType": null, "description": "" }, + { + "name": "Astc8x5Unorm", + "signature": "Astc8x5Unorm", + "signatureTokens": [ + { + "text": "Astc8x5Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "Astc8x6Unorm", + "signature": "Astc8x6Unorm", + "signatureTokens": [ + { + "text": "Astc8x6Unorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, { "name": "Astc8x8Unorm", "signature": "Astc8x8Unorm", @@ -121,6 +251,19 @@ "returnType": null, "description": "" }, + { + "name": "Bc4RSnorm", + "signature": "Bc4RSnorm", + "signatureTokens": [ + { + "text": "Bc4RSnorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, { "name": "Bc4RUnorm", "signature": "Bc4RUnorm", @@ -134,6 +277,19 @@ "returnType": null, "description": "" }, + { + "name": "Bc5RgSnorm", + "signature": "Bc5RgSnorm", + "signatureTokens": [ + { + "text": "Bc5RgSnorm", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, { "name": "Bc5RgUnorm", "signature": "Bc5RgUnorm", @@ -147,6 +303,19 @@ "returnType": null, "description": "" }, + { + "name": "Bc6hRgbFloat", + "signature": "Bc6hRgbFloat", + "signatureTokens": [ + { + "text": "Bc6hRgbFloat", + "kind": "name" + } + ], + "params": [], + "returnType": null, + "description": "" + }, { "name": "Bc6hRgbUfloat", "signature": "Bc6hRgbUfloat", diff --git a/site/src/content/api/texture-asset-type.json b/site/src/content/api/texture-asset-type.json index 6d5608637..7cab8d67b 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, 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.", + "description": "GPU-ready Textures decoded from PNG, JPG, WebP, AVIF, GIF, BMP and ICO 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. An `.ico` file may hold several resolutions of one image. Which of them the decode yields is the browser's choice and is not specified anywhere, so an icon whose exact size matters should be shipped as the single image it is meant to be rather than as a multi-resolution container.", "symbol": "TextureAssetType", "kind": "class", "subsystem": "assets", @@ -19,8 +19,9 @@ "title": "Import", "members": [], "paragraphs": [ - "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." + "GPU-ready Textures decoded from PNG, JPG, WebP, AVIF, GIF, BMP and ICO 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.", + "An `.ico` file may hold several resolutions of one image. Which of them the decode yields is the browser's choice and is not specified anywhere, so an icon whose exact size matters should be shipped as the single image it is meant to be rather than as a multi-resolution container." ], "importLine": "import { TextureAssetType } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/guide/assets/device-variants.mdx b/site/src/content/guide/assets/device-variants.mdx index 69f53327d..ad74cee6d 100644 --- a/site/src/content/guide/assets/device-variants.mdx +++ b/site/src/content/guide/assets/device-variants.mdx @@ -91,7 +91,9 @@ Two things do not apply to a compressed payload, and ignoring them is not a simp - **`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. +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-supercompressed files are rejected the same way. + +ZLIB supercompression (KTX2 scheme 3) does load: it is a plain zlib stream per mip level, which the browser inflates itself, so it costs no decoder and no bundle size. It saves download bytes on top of the block compression and is unpacked before upload - the texture in VRAM is the same either way. ## Constructing one directly diff --git a/site/src/content/guide/rendering/render-targets.mdx b/site/src/content/guide/rendering/render-targets.mdx index e825e7fa7..cb22bf8c5 100644 --- a/site/src/content/guide/rendering/render-targets.mdx +++ b/site/src/content/guide/rendering/render-targets.mdx @@ -130,8 +130,8 @@ 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: + +A fragment shader has to declare one output per attachment. Text, nine-slice and repeating sprites, video, and both default materials declare exactly one, so drawing any of them into a multi-attachment target throws — as does alpha-mask or backdrop-blend compositing. Give the drawable a `MeshMaterial` or a `SpriteMaterial` whose shader writes every slot: ```wgsl struct FragmentOut { @@ -149,6 +149,8 @@ fn fragmentMain(input: VertexOutput) -> FragmentOut { ``` The GLSL counterpart declares `layout(location = 0) out vec4 outColor;` and `layout(location = 1) out vec4 outId;`. + +A `SpriteMaterial` takes the same fragment shader, which is what a 2D G-buffer usually wants: the scene is already authored as sprites, so it needs no mesh geometry to write a normal or id buffer. 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. diff --git a/src/assets/factories/TextureFactory.ts b/src/assets/factories/TextureFactory.ts index 96bb18555..b25313e78 100644 --- a/src/assets/factories/TextureFactory.ts +++ b/src/assets/factories/TextureFactory.ts @@ -5,7 +5,7 @@ import { Texture } from '#rendering/texture/Texture'; import type { SamplerOptions, TextureOptions } from '#rendering/texture/TextureOptions'; import { decodeImageBlob } from './decodeImageBlob'; -import { isKtx2, parseKtx2 } from './ktx2'; +import { inflateKtx2Levels, isKtx2, parseKtx2 } from './ktx2'; import { ObjectUrlPool } from './ObjectUrlPool'; /** Options accepted by an asset of the built-in `texture` type. */ @@ -50,7 +50,10 @@ export class TextureFactory implements AssetFactory | undefined): Promise { - const payload = parseKtx2(source, name); + // ZLIB supercompression is inflated ahead of the parser rather than inside + // it: DecompressionStream is a stream, and keeping the parser synchronous + // keeps it testable without I/O. + const payload = parseKtx2(await inflateKtx2Levels(source, name), name); if (payload.kind === 'compressed') { // Copied key by key rather than picked with a destructure: a key present diff --git a/src/assets/factories/ktx2.ts b/src/assets/factories/ktx2.ts index 01547b160..dc685c9a9 100644 --- a/src/assets/factories/ktx2.ts +++ b/src/assets/factories/ktx2.ts @@ -28,8 +28,11 @@ const formatByVkFormat = new Map([ [137, Format.Bc3RgbaUnorm], [138, Format.Bc3RgbaUnorm], [139, Format.Bc4RUnorm], + [140, Format.Bc4RSnorm], [141, Format.Bc5RgUnorm], + [142, Format.Bc5RgSnorm], [143, Format.Bc6hRgbUfloat], + [144, Format.Bc6hRgbFloat], [145, Format.Bc7RgbaUnorm], [146, Format.Bc7RgbaUnorm], [147, Format.Etc2Rgb8Unorm], @@ -42,12 +45,32 @@ const formatByVkFormat = new Map([ [155, Format.EacRg11Unorm], [157, Format.Astc4x4Unorm], [158, Format.Astc4x4Unorm], + [159, Format.Astc5x4Unorm], + [160, Format.Astc5x4Unorm], [161, Format.Astc5x5Unorm], [162, Format.Astc5x5Unorm], + [163, Format.Astc6x5Unorm], + [164, Format.Astc6x5Unorm], [165, Format.Astc6x6Unorm], [166, Format.Astc6x6Unorm], + [167, Format.Astc8x5Unorm], + [168, Format.Astc8x5Unorm], + [169, Format.Astc8x6Unorm], + [170, Format.Astc8x6Unorm], [171, Format.Astc8x8Unorm], [172, Format.Astc8x8Unorm], + [173, Format.Astc10x5Unorm], + [174, Format.Astc10x5Unorm], + [175, Format.Astc10x6Unorm], + [176, Format.Astc10x6Unorm], + [177, Format.Astc10x8Unorm], + [178, Format.Astc10x8Unorm], + [179, Format.Astc10x10Unorm], + [180, Format.Astc10x10Unorm], + [181, Format.Astc12x10Unorm], + [182, Format.Astc12x10Unorm], + [183, Format.Astc12x12Unorm], + [184, Format.Astc12x12Unorm], ]); /** `VK_FORMAT_R8G8B8A8_UNORM` and `..._SRGB` - the one uncompressed payload this parser accepts. */ @@ -58,9 +81,16 @@ const vkFormatRgba8Srgb = 43; const supercompressionNames = new Map([ [1, 'BasisLZ'], [2, 'Zstandard'], - [3, 'ZLIB'], ]); +/** Scheme 3: every level is a zlib stream, which the browser inflates natively. */ +const zlibSupercompression = 3; + +const headerBytes = 80; +const levelIndexEntryBytes = 24; +const supercompressionSchemeOffset = 44; +const levelCountOffset = 40; + /** A KTX2 payload whose levels are already in a hardware format. */ export interface Ktx2CompressedPayload { readonly kind: 'compressed'; @@ -107,8 +137,6 @@ const fail = (source: string, message: string): never => { * 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.`); } @@ -129,8 +157,12 @@ export const parseKtx2 = (buffer: ArrayBuffer, source: string): Ktx2Payload => { // 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); + const levelCount = Math.max(view.getUint32(levelCountOffset, true), 1); + const supercompressionScheme = view.getUint32(supercompressionSchemeOffset, true); + + if (supercompressionScheme === zlibSupercompression) { + return fail(source, 'payload is still ZLIB-supercompressed. Run inflateKtx2Levels over the bytes before parsing them.'); + } if (supercompressionScheme !== 0) { const name = supercompressionNames.get(supercompressionScheme) ?? `scheme ${supercompressionScheme}`; @@ -150,14 +182,14 @@ export const parseKtx2 = (buffer: ArrayBuffer, source: string): Ktx2Payload => { return fail(source, `declares an empty extent of ${pixelWidth}x${pixelHeight}.`); } - const levelIndexBytes = levelCount * 24; + const levelIndexBytes = levelCount * levelIndexEntryBytes; 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; + const entry = headerBytes + index * levelIndexEntryBytes; // 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 @@ -213,3 +245,156 @@ export const parseKtx2 = (buffer: ArrayBuffer, source: string): Ktx2Payload => { return { kind: 'compressed', format, levels }; }; + +/** + * Inflate the levels of a ZLIB-supercompressed KTX2 container. + * + * Scheme 3 stores every mip level as a zlib stream, which the browser inflates + * natively through `DecompressionStream` - no decoder ships with the engine, so + * this is the one supercompression scheme that costs nothing to support. The + * other two need a WASM transcoder and stay refused. + * + * Returns `buffer` itself when the container is not ZLIB-supercompressed, so it + * can sit in front of {@link parseKtx2} unconditionally. The result is an + * equivalent container with scheme 0 and its level index rewritten, which keeps + * the parser synchronous and testable without I/O. + * + * `source` only names the file in error messages. + * + * @throws AssetDecodeError - a level whose inflated size does not match the one + * the container declares, or a runtime without `DecompressionStream`. + */ +export const inflateKtx2Levels = async (buffer: ArrayBuffer, source: string): Promise => { + if (buffer.byteLength < headerBytes) { + return buffer; + } + + const bytes = new Uint8Array(buffer); + + if (!isKtx2(bytes)) { + return buffer; + } + + const view = new DataView(buffer); + + if (view.getUint32(supercompressionSchemeOffset, true) !== zlibSupercompression) { + return buffer; + } + + if (typeof DecompressionStream === 'undefined') { + return fail(source, 'payload is ZLIB-supercompressed, which needs DecompressionStream. Ship the container uncompressed for this runtime.'); + } + + const levelCount = Math.max(view.getUint32(levelCountOffset, true), 1); + const levelIndexBytes = levelCount * levelIndexEntryBytes; + + if (buffer.byteLength < headerBytes + levelIndexBytes) { + return fail(source, `declares ${levelCount} levels, but the file is too short to hold their index.`); + } + + const levels: Array<{ readonly offset: number; readonly length: number; readonly inflatedLength: number }> = []; + + for (let index = 0; index < levelCount; index++) { + const entry = headerBytes + index * levelIndexEntryBytes; + + if (view.getUint32(entry + 4, true) !== 0 || view.getUint32(entry + 12, true) !== 0 || view.getUint32(entry + 20, true) !== 0) { + return fail(source, `level ${index} declares an offset or length above 4 GiB.`); + } + + const offset = view.getUint32(entry, true); + const length = view.getUint32(entry + 8, true); + + if (offset < headerBytes + levelIndexBytes || offset + length > buffer.byteLength) { + return fail(source, `level ${index} runs outside the file.`); + } + + levels.push({ offset, length, inflatedLength: view.getUint32(entry + 16, true) }); + } + + // Everything the header points at other than level data - the format + // descriptor, the key/value data, the supercompression global data - lives + // before the first level and is referenced by absolute offset, so that prefix + // is copied verbatim and only the levels move. + const prefixBytes = Math.min(...levels.map(({ offset }) => offset)); + const inflated = await Promise.all(levels.map(async ({ offset, length }) => inflateZlib(bytes.subarray(offset, offset + length)))); + + for (const [index, level] of inflated.entries()) { + const declared = levels[index]?.inflatedLength ?? 0; + + if (level.byteLength !== declared) { + return fail(source, `level ${index} inflates to ${level.byteLength} bytes but declares ${declared}.`); + } + } + + const result = new Uint8Array(prefixBytes + inflated.reduce((total, level) => total + level.byteLength, 0)); + + result.set(bytes.subarray(0, prefixBytes)); + + const resultView = new DataView(result.buffer); + + resultView.setUint32(supercompressionSchemeOffset, 0, true); + + // Written back in the container's own storage order (smallest level first), so + // the rewritten offsets stay monotonic with the bytes they name. + let cursor = prefixBytes; + + const storageOrder = levels.map((level, index) => ({ index, offset: level.offset })).sort((a, b) => a.offset - b.offset); + + for (const { index } of storageOrder) { + const data = inflated[index]; + + if (data === undefined) { + return fail(source, `level ${index} has no inflated payload.`); + } + + const entry = headerBytes + index * levelIndexEntryBytes; + + result.set(data, cursor); + resultView.setUint32(entry, cursor, true); + resultView.setUint32(entry + 8, data.byteLength, true); + cursor += data.byteLength; + } + + return result.buffer; +}; + +// The buffer generic is explicit because DecompressionStream only accepts a view +// over a plain ArrayBuffer, which a Uint8Array is not required to be. +const inflateZlib = async (data: Uint8Array): Promise => { + const decompressor = new DecompressionStream('deflate'); + // Written through the writer rather than piped from a source stream: the + // whole level is already in memory, and this keeps the chunk types the + // DecompressionStream declares on both ends. + const pump = (async (): Promise => { + const writer = decompressor.writable.getWriter(); + + await writer.write(data); + await writer.close(); + })(); + const reader = decompressor.readable.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + for (;;) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + chunks.push(value); + total += value.byteLength; + } + + await pump; + + const result = new Uint8Array(total); + let cursor = 0; + + for (const chunk of chunks) { + result.set(chunk, cursor); + cursor += chunk.byteLength; + } + + return result; +}; diff --git a/src/assets/types/textureType.ts b/src/assets/types/textureType.ts index 455bc0f87..c7963c67d 100644 --- a/src/assets/types/textureType.ts +++ b/src/assets/types/textureType.ts @@ -9,17 +9,22 @@ 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, or - * from a KTX2 container holding a hardware-compressed payload. + * GPU-ready {@link Texture}s decoded from PNG, JPG, WebP, AVIF, GIF, BMP and ICO + * 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. + * + * An `.ico` file may hold several resolutions of one image. Which of them the + * decode yields is the browser's choice and is not specified anywhere, so an + * icon whose exact size matters should be shipped as the single image it is + * meant to be rather than as a multi-resolution container. */ export class TextureAssetType extends AssetType { public readonly id = 'texture'; - public override readonly extensions = ['png', 'jpg', 'jpeg', 'webp', 'avif', 'gif', 'ktx2']; + public override readonly extensions = ['png', 'jpg', 'jpeg', 'webp', 'avif', 'gif', 'bmp', 'ico', 'ktx2']; public override readonly leaf = textureSeamlessAdapter; public override readonly _token: AssetConstructor = Texture; public override readonly codec: AssetSourceCodec = binarySourceCodec; diff --git a/src/rendering/multiAttachmentGuard.ts b/src/rendering/multiAttachmentGuard.ts index 78a5d1abd..5ee9d82fc 100644 --- a/src/rendering/multiAttachmentGuard.ts +++ b/src/rendering/multiAttachmentGuard.ts @@ -1,4 +1,5 @@ import { Mesh } from '#rendering/mesh/Mesh'; +import { Sprite } from '#rendering/sprite/Sprite'; import type { Drawable } from './Drawable'; import type { RenderBackendType } from './RenderBackendType'; @@ -8,20 +9,20 @@ 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 a mesh or a sprite with a custom material qualifies: every other + * renderer, and both default materials, 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) { + if ((drawable instanceof Mesh || drawable instanceof Sprite) && drawable.material !== null) { return; } @@ -29,7 +30,7 @@ export const assertDrawsAllAttachments = (drawable: Drawable, attachmentCount: n code: 'unsupported-format', backendType, message: - `The active render target has ${attachmentCount} colour attachments, which only a Mesh with a MeshMaterial can write. ` + + `The active render target has ${attachmentCount} colour attachments, which only a Mesh or Sprite with a material can write. ` + `Give the drawable a material whose fragment shader declares one output per attachment, or render it into a single-attachment RenderTexture.`, }); }; diff --git a/src/rendering/texture/CompressedTextureFormat.ts b/src/rendering/texture/CompressedTextureFormat.ts index 64e48d0fc..807f03b16 100644 --- a/src/rendering/texture/CompressedTextureFormat.ts +++ b/src/rendering/texture/CompressedTextureFormat.ts @@ -24,10 +24,16 @@ export enum CompressedTextureFormat { Bc3RgbaUnorm = 'bc3-rgba-unorm', /** BC4: single channel, 2:1 ratio. Masks, height and occlusion maps. */ Bc4RUnorm = 'bc4-r-unorm', + /** BC4, signed: the same blocks read as `[-1, 1]`. */ + Bc4RSnorm = 'bc4-r-snorm', /** BC5: two channels, 2:1 ratio. Tangent-space normal maps. */ Bc5RgUnorm = 'bc5-rg-unorm', + /** BC5, signed: the same blocks read as `[-1, 1]`, which is what a tangent-space normal map wants. */ + Bc5RgSnorm = 'bc5-rg-snorm', /** BC6H: HDR RGB half-float, 2:1 ratio. No alpha channel. */ Bc6hRgbUfloat = 'bc6h-rgb-ufloat', + /** BC6H, signed: HDR RGB half-float carrying negative values. */ + Bc6hRgbFloat = 'bc6h-rgb-float', /** 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. */ @@ -42,12 +48,32 @@ export enum CompressedTextureFormat { EacRg11Unorm = 'eac-rg11unorm', /** ASTC 4x4: RGBA at 8 bits per pixel. Highest ASTC quality. */ Astc4x4Unorm = 'astc-4x4-unorm', + /** ASTC 5x4: RGBA at 6.4 bits per pixel. */ + Astc5x4Unorm = 'astc-5x4-unorm', /** ASTC 5x5: RGBA at ~5.12 bits per pixel. */ Astc5x5Unorm = 'astc-5x5-unorm', + /** ASTC 6x5: RGBA at ~4.27 bits per pixel. */ + Astc6x5Unorm = 'astc-6x5-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. */ + /** ASTC 8x5: RGBA at 3.2 bits per pixel. */ + Astc8x5Unorm = 'astc-8x5-unorm', + /** ASTC 8x6: RGBA at ~2.67 bits per pixel. */ + Astc8x6Unorm = 'astc-8x6-unorm', + /** ASTC 8x8: RGBA at 2 bits per pixel. */ Astc8x8Unorm = 'astc-8x8-unorm', + /** ASTC 10x5: RGBA at 2.56 bits per pixel. */ + Astc10x5Unorm = 'astc-10x5-unorm', + /** ASTC 10x6: RGBA at ~2.13 bits per pixel. */ + Astc10x6Unorm = 'astc-10x6-unorm', + /** ASTC 10x8: RGBA at 1.6 bits per pixel. */ + Astc10x8Unorm = 'astc-10x8-unorm', + /** ASTC 10x10: RGBA at 1.28 bits per pixel. */ + Astc10x10Unorm = 'astc-10x10-unorm', + /** ASTC 12x10: RGBA at ~1.07 bits per pixel. */ + Astc12x10Unorm = 'astc-12x10-unorm', + /** ASTC 12x12: RGBA at ~0.89 bits per pixel. Highest ASTC compression. */ + Astc12x12Unorm = 'astc-12x12-unorm', } /** @@ -70,8 +96,11 @@ const blockLayouts: Readonly impleme pass.setPipeline( material === null ? this._getPipeline(payload.blendMode, backend.renderTargetFormat, coordinator.stencilActive) - : this._getOrCreateCustomPipeline(customResources!, payload.blendMode, backend.renderTargetFormat, coordinator.stencilActive, device), + : this._getOrCreateCustomPipeline(customResources!, payload.blendMode, backend.renderTargetFormats, coordinator.stencilActive, device), ); pass.setBindGroup(0, bundle.getBindGroup(device, this._uniformBindGroupLayout!, true)); pass.setBindGroup(1, textureBindGroup); @@ -1880,7 +1880,7 @@ export class WebGpuSpriteRenderer extends AbstractWebGpuRenderer impleme // material's values actually changed since its last upload. applyUserUniformUpload(uniformUpload, resources, device); - const pipeline = this._getOrCreateCustomPipeline(resources, this._currentBlendMode!, backend.renderTargetFormat, stencil, device); + const pipeline = this._getOrCreateCustomPipeline(resources, this._currentBlendMode!, backend.renderTargetFormats, stencil, device); pass.setPipeline(pipeline); pass.setBindGroup(0, transformBindGroup); @@ -1955,11 +1955,15 @@ export class WebGpuSpriteRenderer extends AbstractWebGpuRenderer impleme private _getOrCreateCustomPipeline( resources: CustomSpriteResources, blendMode: BlendModes, - format: GPUTextureFormat, + formats: readonly GPUTextureFormat[], stencil: boolean, device: GPUDevice, ): GPURenderPipeline { - 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'}`; const existing = resources.pipelines.get(cacheKey); if (existing !== undefined) { @@ -1988,13 +1992,11 @@ export class WebGpuSpriteRenderer extends AbstractWebGpuRenderer impleme 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/webgpuCompressedFormat.ts b/src/rendering/webgpu/webgpuCompressedFormat.ts index db5345c1d..10272bb70 100644 --- a/src/rendering/webgpu/webgpuCompressedFormat.ts +++ b/src/rendering/webgpu/webgpuCompressedFormat.ts @@ -13,8 +13,11 @@ const families: ReadonlyArray<{ readonly feature: GPUFeatureName; readonly forma [CompressedTextureFormat.Bc2RgbaUnorm]: 'bc2-rgba-unorm', [CompressedTextureFormat.Bc3RgbaUnorm]: 'bc3-rgba-unorm', [CompressedTextureFormat.Bc4RUnorm]: 'bc4-r-unorm', + [CompressedTextureFormat.Bc4RSnorm]: 'bc4-r-snorm', [CompressedTextureFormat.Bc5RgUnorm]: 'bc5-rg-unorm', + [CompressedTextureFormat.Bc5RgSnorm]: 'bc5-rg-snorm', [CompressedTextureFormat.Bc6hRgbUfloat]: 'bc6h-rgb-ufloat', + [CompressedTextureFormat.Bc6hRgbFloat]: 'bc6h-rgb-float', [CompressedTextureFormat.Bc7RgbaUnorm]: 'bc7-rgba-unorm', }, }, @@ -32,9 +35,19 @@ const families: ReadonlyArray<{ readonly feature: GPUFeatureName; readonly forma feature: 'texture-compression-astc', formats: { [CompressedTextureFormat.Astc4x4Unorm]: 'astc-4x4-unorm', + [CompressedTextureFormat.Astc5x4Unorm]: 'astc-5x4-unorm', [CompressedTextureFormat.Astc5x5Unorm]: 'astc-5x5-unorm', + [CompressedTextureFormat.Astc6x5Unorm]: 'astc-6x5-unorm', [CompressedTextureFormat.Astc6x6Unorm]: 'astc-6x6-unorm', + [CompressedTextureFormat.Astc8x5Unorm]: 'astc-8x5-unorm', + [CompressedTextureFormat.Astc8x6Unorm]: 'astc-8x6-unorm', [CompressedTextureFormat.Astc8x8Unorm]: 'astc-8x8-unorm', + [CompressedTextureFormat.Astc10x5Unorm]: 'astc-10x5-unorm', + [CompressedTextureFormat.Astc10x6Unorm]: 'astc-10x6-unorm', + [CompressedTextureFormat.Astc10x8Unorm]: 'astc-10x8-unorm', + [CompressedTextureFormat.Astc10x10Unorm]: 'astc-10x10-unorm', + [CompressedTextureFormat.Astc12x10Unorm]: 'astc-12x10-unorm', + [CompressedTextureFormat.Astc12x12Unorm]: 'astc-12x12-unorm', }, }, ]; diff --git a/test/assets/ktx2.test.ts b/test/assets/ktx2.test.ts index 4440f05c1..4edf8d5c3 100644 --- a/test/assets/ktx2.test.ts +++ b/test/assets/ktx2.test.ts @@ -11,7 +11,7 @@ import { describe, expect, test } from 'vitest'; -import { isKtx2, parseKtx2 } from '#assets/factories/ktx2'; +import { inflateKtx2Levels, isKtx2, parseKtx2 } from '#assets/factories/ktx2'; import { compressedLevelByteLength, CompressedTextureFormat } from '#rendering/texture/CompressedTextureFormat'; const HEADER_BYTES = 80; @@ -143,6 +143,50 @@ describe('parseKtx2', () => { }); }); + test('reads every ASTC block size the engine exposes, at both its vkFormat values', () => { + // VK_FORMAT_ASTC_4x4_UNORM_BLOCK is 157 and the LDR sizes run in pairs from + // there, UNORM then SRGB, in the same order as the engine's block table. + const sizes = [ + [4, 4], + [5, 4], + [5, 5], + [6, 5], + [6, 6], + [8, 5], + [8, 6], + [8, 8], + [10, 5], + [10, 6], + [10, 8], + [10, 10], + [12, 10], + [12, 12], + ] as const; + + sizes.forEach(([blockWidth, blockHeight], index) => { + const format = `astc-${blockWidth}x${blockHeight}-unorm` as CompressedTextureFormat; + const width = blockWidth * 2; + const height = blockHeight * 2; + const lengths = levelLengthsFor(format, width, height, 1); + + for (const vkFormat of [157 + index * 2, 158 + index * 2]) { + expect(parseKtx2(buildKtx2({ vkFormat, width, height, levelLengths: lengths }), 'a.ktx2')).toMatchObject({ format }); + } + }); + }); + + test('reads the signed BC variants as their own formats', () => { + const signed = [ + [140, CompressedTextureFormat.Bc4RSnorm], + [142, CompressedTextureFormat.Bc5RgSnorm], + [144, CompressedTextureFormat.Bc6hRgbFloat], + ] as const; + + for (const [vkFormat, format] of signed) { + expect(parseKtx2(buildKtx2({ vkFormat, width: 8, height: 8, levelLengths: levelLengthsFor(format, 8, 8, 1) }), 'a.ktx2')).toMatchObject({ format }); + } + }); + test('reads an uncompressed RGBA8 payload as pixels', () => { const payload = parseKtx2(buildKtx2({ vkFormat: 37, width: 4, height: 2, levelLengths: [4 * 2 * 4] }), 'hero.ktx2'); @@ -163,7 +207,7 @@ describe('parseKtx2', () => { test.each([ [1, /BasisLZ/], [2, /Zstandard/], - [3, /ZLIB/], + [3, /still ZLIB-supercompressed/], [9, /scheme 9/], ])('rejects supercompression scheme %i', (scheme, expected) => { const format = CompressedTextureFormat.Bc7RgbaUnorm; @@ -214,3 +258,113 @@ describe('parseKtx2', () => { expect(() => parseKtx2(new Uint8Array(16).buffer, 'levels/terrain.ktx2')).toThrow(/levels\/terrain\.ktx2/); }); }); + +/** Deflates every level of an uncompressed container into a scheme-3 one. */ +const deflateKtx2 = async (buffer: ArrayBuffer, levelCount: number): Promise => { + const source = new Uint8Array(buffer); + const view = new DataView(buffer); + const indexBytes = levelCount * LEVEL_ENTRY_BYTES; + const entries = Array.from({ length: levelCount }, (_unused, index) => ({ + index, + offset: view.getUint32(HEADER_BYTES + index * LEVEL_ENTRY_BYTES, true), + length: view.getUint32(HEADER_BYTES + index * LEVEL_ENTRY_BYTES + 8, true), + })); + const deflated = await Promise.all( + entries.map(async ({ offset, length }) => { + const compressor = new CompressionStream('deflate'); + const pump = (async (): Promise => { + const writer = compressor.writable.getWriter(); + + await writer.write(source.subarray(offset, offset + length)); + await writer.close(); + })(); + const reader = compressor.readable.getReader(); + const chunks: Uint8Array[] = []; + + for (;;) { + const { done, value } = await reader.read(); + + if (done) break; + + chunks.push(value); + } + + await pump; + + const deflatedBytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)); + let cursor = 0; + + for (const chunk of chunks) { + deflatedBytes.set(chunk, cursor); + cursor += chunk.byteLength; + } + + return deflatedBytes; + }), + ); + const result = new Uint8Array(HEADER_BYTES + indexBytes + deflated.reduce((total, level) => total + level.byteLength, 0)); + const resultView = new DataView(result.buffer); + + result.set(source.subarray(0, HEADER_BYTES + indexBytes)); + resultView.setUint32(44, 3, true); + + // Kept in the container's own storage order - smallest level last in mip order, + // so the offsets are written back to front like the uncompressed builder does. + let cursor = result.byteLength; + + for (let index = levelCount - 1; index >= 0; index--) { + const data = deflated[index]!; + const entry = HEADER_BYTES + index * LEVEL_ENTRY_BYTES; + + cursor -= data.byteLength; + result.set(data, cursor); + resultView.setUint32(entry, cursor, true); + resultView.setUint32(entry + 8, data.byteLength, true); + resultView.setUint32(entry + 16, entries[index]!.length, true); + } + + return result.buffer; +}; + +describe('inflateKtx2Levels', () => { + const format = CompressedTextureFormat.Bc7RgbaUnorm; + + test('a ZLIB container reads exactly like the uncompressed one it was made from', async () => { + const plain = buildKtx2({ vkFormat: 145, width: 16, height: 16, levelLengths: levelLengthsFor(format, 16, 16, 3) }); + const compressed = await deflateKtx2(plain, 3); + + expect(new DataView(compressed).getUint32(44, true)).toBe(3); + expect(compressed.byteLength).not.toBe(plain.byteLength); + + const expected = parseKtx2(plain, 'plain.ktx2'); + const actual = parseKtx2(await inflateKtx2Levels(compressed, 'zlib.ktx2'), 'zlib.ktx2'); + + expect(actual).toEqual(expected); + }); + + test('hands back a container that needs no inflating, untouched', async () => { + const plain = buildKtx2({ vkFormat: 145, width: 8, height: 8, levelLengths: levelLengthsFor(format, 8, 8, 1) }); + + expect(await inflateKtx2Levels(plain, 'plain.ktx2')).toBe(plain); + // Not a KTX2 file at all: the caller's own error belongs to the parser. + const foreign = new Uint8Array(HEADER_BYTES).buffer; + + expect(await inflateKtx2Levels(foreign, 'foreign.ktx2')).toBe(foreign); + }); + + test.each([1, 2])('leaves scheme %i alone for the parser to refuse', async scheme => { + const buffer = buildKtx2({ vkFormat: 145, width: 8, height: 8, levelLengths: levelLengthsFor(format, 8, 8, 1), supercompressionScheme: scheme }); + + expect(await inflateKtx2Levels(buffer, 'hero.ktx2')).toBe(buffer); + }); + + test('rejects a level whose inflated size is not the one declared', async () => { + const plain = buildKtx2({ vkFormat: 145, width: 8, height: 8, levelLengths: levelLengthsFor(format, 8, 8, 1) }); + const compressed = await deflateKtx2(plain, 1); + + // The level really inflates to 64 bytes (one 8x8 BC7 level); claim 48. + new DataView(compressed).setUint32(HEADER_BYTES + 16, 48, true); + + await expect(inflateKtx2Levels(compressed, 'zlib.ktx2')).rejects.toThrow(/inflates to 64 bytes but declares 48/); + }); +}); diff --git a/test/assets/texture-factory.test.ts b/test/assets/texture-factory.test.ts index 20d5aa44d..2ce7c295e 100644 --- a/test/assets/texture-factory.test.ts +++ b/test/assets/texture-factory.test.ts @@ -1,6 +1,7 @@ import type { MockInstance } from 'vitest'; import { TextureFactory } from '#assets/factories/TextureFactory'; +import { textureType } from '#assets/types/textureType'; import { Texture } from '#rendering/texture/Texture'; import { factoryContext } from './factory-context'; @@ -29,6 +30,14 @@ const lastImage = (): HTMLImageElement => { return image; }; +describe('textureType suffixes', () => { + test('claims every raster suffix the decode path already handles', () => { + // BMP and ICO reach createImageBitmap like any other blob; only the suffix + // list decided whether a bare path resolved to a texture at all. + expect(textureType.extensions).toEqual(expect.arrayContaining(['png', 'jpg', 'jpeg', 'webp', 'avif', 'gif', 'bmp', 'ico', 'ktx2'])); + }); +}); + describe('TextureFactory', () => { let revokeObjectUrlSpy: MockInstance; diff --git a/test/assets/utils.test.ts b/test/assets/utils.test.ts index 30adc19ba..b46ba423c 100644 --- a/test/assets/utils.test.ts +++ b/test/assets/utils.test.ts @@ -20,6 +20,13 @@ describe('determineMimeType', () => { expect(determineMimeType(png)).toBe('image/png'); }); + test('detects BMP and ICO by their magic bytes', () => { + expect(determineMimeType(toBuffer([0x42, 0x4d, 0x36, 0x00, 0x00, 0x00]))).toBe('image/bmp'); + // An ICO and a CUR differ only in the image-type word, and both decode. + expect(determineMimeType(toBuffer([0x00, 0x00, 0x01, 0x00, 0x01, 0x00]))).toBe('image/x-icon'); + expect(determineMimeType(toBuffer([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBe('image/x-icon'); + }); + test('falls back to text/plain for unrecognized bytes', () => { const unknown = toBuffer([0x01, 0x02, 0x03, 0x04, 0x05]); diff --git a/test/rendering/compressed-texture.test.ts b/test/rendering/compressed-texture.test.ts index be0fd5d13..ee0f065fe 100644 --- a/test/rendering/compressed-texture.test.ts +++ b/test/rendering/compressed-texture.test.ts @@ -59,6 +59,34 @@ describe('compressed texture formats', () => { expect(ordered).toEqual([CompressedTextureFormat.Bc7RgbaUnorm, CompressedTextureFormat.Bc3RgbaUnorm, CompressedTextureFormat.Bc1RgbaUnorm]); }); + test('every ASTC entry carries the block geometry its name states', () => { + for (const format of Object.values(CompressedTextureFormat)) { + const match = /^astc-(\d+)x(\d+)-unorm$/.exec(format); + + if (match === null) continue; + + expect(compressedBlockLayout(format)).toEqual({ blockWidth: Number(match[1]), blockHeight: Number(match[2]), bytesPerBlock: 16 }); + } + }); + + test('non-square ASTC block sizes pad each axis on its own', () => { + // Hand-computed: 32x18 in 8x6 blocks is 4 columns by 3 rows, 12 blocks of 16 bytes. + expect(compressedLevelByteLength(CompressedTextureFormat.Astc8x6Unorm, 32, 18)).toBe(192); + // 30x24 in 10x8 blocks is 3 by 3. + expect(compressedLevelByteLength(CompressedTextureFormat.Astc10x8Unorm, 30, 24)).toBe(144); + // 24x25 in 12x10 blocks is 2 by 3 - the height rounds up to a third row. + expect(compressedLevelByteLength(CompressedTextureFormat.Astc12x10Unorm, 24, 25)).toBe(96); + // 11x9 in 5x4 blocks is 3 by 3. + expect(compressedLevelByteLength(CompressedTextureFormat.Astc5x4Unorm, 11, 9)).toBe(144); + expect(compressedLevelByteLength(CompressedTextureFormat.Astc12x12Unorm, 1, 1)).toBe(16); + }); + + test('a SNORM format has the block geometry of its UNORM sibling', () => { + expect(compressedBlockLayout(CompressedTextureFormat.Bc4RSnorm)).toEqual(compressedBlockLayout(CompressedTextureFormat.Bc4RUnorm)); + expect(compressedBlockLayout(CompressedTextureFormat.Bc5RgSnorm)).toEqual(compressedBlockLayout(CompressedTextureFormat.Bc5RgUnorm)); + expect(compressedBlockLayout(CompressedTextureFormat.Bc6hRgbFloat)).toEqual(compressedBlockLayout(CompressedTextureFormat.Bc6hRgbUfloat)); + }); + test('the preference order covers every format exactly once', () => { const formats = Object.values(CompressedTextureFormat); diff --git a/test/rendering/multi-render-target.test.ts b/test/rendering/multi-render-target.test.ts index f68c0608d..65ccac669 100644 --- a/test/rendering/multi-render-target.test.ts +++ b/test/rendering/multi-render-target.test.ts @@ -18,6 +18,7 @@ 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 { SpriteMaterial } from '#rendering/material/SpriteMaterial'; import { Mesh } from '#rendering/mesh/Mesh'; import { MultiRenderTarget } from '#rendering/MultiRenderTarget'; import { RenderError } from '#rendering/RenderError'; @@ -61,6 +62,37 @@ fn fragmentMain(input: VertexOutput) -> FragmentOut { }), }); +/** The same two outputs from a sprite material - a 2D scene is sprites, not meshes. */ +const twoOutputSpriteMaterial = (): SpriteMaterial => + new SpriteMaterial({ + 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.25); }`, + }, + 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.25); + return out; +} +`.trim(), + }), + }); + const triangleGeometry = (): Geometry => { const stride = 20; const buffer = new ArrayBuffer(3 * stride); @@ -207,6 +239,23 @@ describe('WebGL2 multiple colour attachments', () => { target.destroy(); }); + test('a sprite with a two-output material writes both slots', () => { + 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)); + + sprite.material = twoOutputSpriteMaterial(); + + context.renderTo(sprite, { target }); + harness.backend.flush(); + + expect(harness.drawBufferLists).toEqual([[harness.colorAttachment0, harness.colorAttachment0 + 1]]); + + target.destroy(); + }); + test('a single-attachment RenderTexture still needs no drawBuffers call', () => { harness = createGlHarness(); @@ -299,6 +348,32 @@ describe('WebGPU multiple colour attachments', () => { } }); + test('a sprite material gets a pipeline per attachment count, like a mesh material', async () => { + const environment = createMockWebGpuEnvironment(); + + try { + const backend = await createMockBackend(environment); + const context = new RenderingContext(backend); + const target = new MultiRenderTarget(64, 64, { formats: [TextureFormat.Rgba8, TextureFormat.Rgba8] }); + const sprite = new Sprite(createCanvasTexture()); + + sprite.material = twoOutputSpriteMaterial(); + + context.renderTo(sprite, { target }); + backend.flush(); + + expect(environment.renderPassAttachmentCounts()).toContain(2); + // The custom sprite pipeline used to build a fixed single-entry target + // list and key on one format, so it could not be used in this pass at all. + expect(environment.pipelineTargetCounts()).toContain(2); + + target.destroy(); + backend.destroy(); + } finally { + environment.restore(); + } + }); + test('an ordinary single-attachment pass is unchanged', async () => { const environment = createMockWebGpuEnvironment(); diff --git a/test/rendering/webgl2-compressed-texture.test.ts b/test/rendering/webgl2-compressed-texture.test.ts index 2be72556d..1ca836e37 100644 --- a/test/rendering/webgl2-compressed-texture.test.ts +++ b/test/rendering/webgl2-compressed-texture.test.ts @@ -111,6 +111,23 @@ describe('WebGl2Backend.supportedTextureFormats', () => { CompressedTextureFormat.Bc2RgbaUnorm, CompressedTextureFormat.Bc1RgbaUnorm, CompressedTextureFormat.Bc6hRgbUfloat, + CompressedTextureFormat.Bc6hRgbFloat, + ]); + }); + + test('one ASTC extension carries every block size, and RGTC carries both signednesses', () => { + harness = createHarness(['WEBGL_compressed_texture_astc']); + + expect(harness.backend.supportedTextureFormats.filter(format => format.startsWith('astc-'))).toHaveLength(14); + + harness.destroy(); + harness = createHarness(['EXT_texture_compression_rgtc']); + + expect(harness.backend.supportedTextureFormats).toEqual([ + CompressedTextureFormat.Bc5RgUnorm, + CompressedTextureFormat.Bc5RgSnorm, + CompressedTextureFormat.Bc4RUnorm, + CompressedTextureFormat.Bc4RSnorm, ]); });