From fc13edb9e398937062e5cf736ea3f26d7307cdd9 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 28 Aug 2026 05:36:46 +0200 Subject: [PATCH 1/4] feat(rendering): complete the ASTC block-size and signed BC format tables Both backends already implement the full LDR ASTC set, but the engine exposed four of the fourteen block sizes - leaving out 8x6 and 10x8, the two steps a mobile VRAM budget is usually met with. The signed BC variants were missing for the same reason: same blocks, different interpretation, and tangent-space normal maps need the [-1, 1] range rather than a shader-side "v * 2 - 1". Adds the ten missing ASTC sizes plus bc4-r-snorm, bc5-rg-snorm and bc6h-rgb-float to the format enum, the block-geometry table, the preference order, both backend format tables and the KTX2 vkFormat mapping. The sRGB twins stay out: the code would be trivial, the decision is not - they belong to an engine-wide color space contract. --- .../api/compressed-texture-format.json | 171 +++++++++++++++++- src/assets/factories/ktx2.ts | 23 +++ .../texture/CompressedTextureFormat.ts | 54 +++++- .../webgl2/webgl2CompressedFormat.ts | 26 +++ .../webgpu/webgpuCompressedFormat.ts | 13 ++ test/assets/ktx2.test.ts | 44 +++++ test/rendering/compressed-texture.test.ts | 28 +++ .../webgl2-compressed-texture.test.ts | 17 ++ 8 files changed, 374 insertions(+), 2 deletions(-) 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/src/assets/factories/ktx2.ts b/src/assets/factories/ktx2.ts index 01547b160..d26be52cd 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. */ 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 { }); }); + 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'); 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/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, ]); }); From 32c6ac40ee8b88c9740b6750dc04e8d901653c6b Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 28 Aug 2026 05:38:55 +0200 Subject: [PATCH 2/4] feat(assets): claim the .bmp and .ico suffixes for the texture type The load path already served both: decodeImageBlob hands the blob to createImageBitmap, which decodes BMP and ICO in every browser, and the MIME sniffer already carried their magic bytes. Only the suffix list was missing, so a bare "icon.ico" path resolved to no asset type at all. An .ico container may hold several resolutions and which one the browser picks is unspecified. That is documented as belonging to the runtime rather than resolved by a bespoke ICO parser: the format is practically absent as a game asset, and parsing it ourselves would cost more than it settles. --- site/src/content/api/texture-asset-type.json | 7 ++++--- src/assets/types/textureType.ts | 11 ++++++++--- test/assets/texture-factory.test.ts | 9 +++++++++ test/assets/utils.test.ts | 7 +++++++ 4 files changed, 28 insertions(+), 6 deletions(-) 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/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/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]); From 874fc1dc3252443f30839df6f2b9715bf5221a4c Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 28 Aug 2026 05:43:32 +0200 Subject: [PATCH 3/4] feat(rendering): let a SpriteMaterial write every colour attachment A G-buffer could only be authored from meshes, while a 2D scene is sprites - so exactly the case multiple render targets exist for (deferred lighting, normal mapping, selection ids) was unreachable in the form the scene already has. It is the same seam and mechanically the same change as on the mesh side: the custom sprite pipeline built a fixed single-entry target list and keyed on one format, and now keys on the whole format list from renderTargetFormats and declares one target per attachment. WebGL2 needs nothing beyond letting the refusal guard through - it has no pipeline object. Deliberately not extended to the built-in shaders: sprite, text, nine-slice, repeating and video all declare exactly one output, and minting pipeline variants that never write slot 1 is the unused API surface the multi-attachment work package set out to avoid. A sprite WITHOUT a material is still refused. --- .../guide/rendering/render-targets.mdx | 6 +- src/rendering/multiAttachmentGuard.ts | 19 ++--- src/rendering/webgpu/WebGpuSpriteRenderer.ts | 24 +++--- test/rendering/multi-render-target.test.ts | 75 +++++++++++++++++++ 4 files changed, 102 insertions(+), 22 deletions(-) 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/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/webgpu/WebGpuSpriteRenderer.ts b/src/rendering/webgpu/WebGpuSpriteRenderer.ts index 974be5424..b54ebed54 100644 --- a/src/rendering/webgpu/WebGpuSpriteRenderer.ts +++ b/src/rendering/webgpu/WebGpuSpriteRenderer.ts @@ -1417,7 +1417,7 @@ export class WebGpuSpriteRenderer extends AbstractWebGpuRenderer 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/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(); From b09f0501e1af479ebecdb860f3fe4c99aaf2ed9f Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 28 Aug 2026 05:49:41 +0200 Subject: [PATCH 4/4] feat(assets): read ZLIB-supercompressed KTX2 containers Scheme 3 stores every mip level as a plain zlib stream, which DecompressionStream inflates natively in every browser we target - no decoder, no dependency, no bundle cost. Refusing it alongside BasisLZ and Zstandard, which genuinely need a WASM transcoder, cost download bytes for nothing. The inflation happens ahead of the parser rather than inside it: DecompressionStream is a stream and parseKtx2 is synchronous, and keeping it that way keeps the parser testable without I/O. inflateKtx2Levels rewrites the container into an equivalent scheme-0 one - level index patched, everything the header points at by absolute offset copied verbatim - and hands back the input untouched when there is nothing to inflate. A level whose inflated size is not the one the container declares is refused rather than uploaded short. --- .../content/guide/assets/device-variants.mdx | 4 +- src/assets/factories/TextureFactory.ts | 7 +- src/assets/factories/ktx2.ts | 176 +++++++++++++++++- test/assets/ktx2.test.ts | 114 +++++++++++- 4 files changed, 289 insertions(+), 12 deletions(-) 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/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 d26be52cd..dc685c9a9 100644 --- a/src/assets/factories/ktx2.ts +++ b/src/assets/factories/ktx2.ts @@ -81,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'; @@ -130,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.`); } @@ -152,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}`; @@ -173,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 @@ -236,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/test/assets/ktx2.test.ts b/test/assets/ktx2.test.ts index 5a49ec43e..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; @@ -207,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; @@ -258,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/); + }); +});