diff --git a/examples/tests/texture-free-reload.ts b/examples/tests/texture-free-reload.ts index 96402c8..f08e3fc 100644 --- a/examples/tests/texture-free-reload.ts +++ b/examples/tests/texture-free-reload.ts @@ -1,6 +1,5 @@ import type { INode } from '@lightningjs/renderer'; import type { ExampleSettings } from '../common/ExampleSettings.js'; -import { waitUntilIdle } from '../common/utils.js'; import rockoPng from '../assets/rocko.png'; @@ -49,7 +48,13 @@ async function waitFor( export async function automation(settings: ExampleSettings) { await test(settings); - await waitUntilIdle(settings.renderer); + // test() already awaited the reload's 'loaded' event, so the scene is fully + // settled. waitUntilIdle() must NOT be used here: 'idle' fires once per + // active->idle transition, which has already happened by now, so the listener + // would wait forever and hang the (timeout-less) VRT runner. Force a final + // frame and let it draw instead. + settings.renderer.rerender(); + await delay(100); await settings.snapshot(); } diff --git a/examples/tests/tx-compression.ts b/examples/tests/tx-compression.ts index 63659de..ec128b4 100644 --- a/examples/tests/tx-compression.ts +++ b/examples/tests/tx-compression.ts @@ -1,6 +1,35 @@ +import type { INode } from '@lightningjs/renderer'; import type { ExampleSettings } from '../common/ExampleSettings.js'; +import { waitForLoadedDimensions } from '../common/utils.js'; -export default async function ({ renderer, testRoot }: ExampleSettings) { +/** + * Resolve once `node`'s texture has finished uploading, or after `timeoutMs`. + * + * @remarks + * Compressed-texture support is device/driver-specific. A format the running + * GPU lacks (e.g. ETC1 under the headless CI SwiftShader driver) now surfaces + * as a `failed` texture, which never fires `loaded`. `waitForLoadedDimensions` + * alone would then wait forever — and the VRT runner has no per-test timeout, + * so the whole capture/compare run hangs. The timeout backstop lets the + * snapshot capture whatever the device produced (a blank tile for an + * unsupported format) instead of hanging. + */ +function waitForUpload(node: INode, timeoutMs = 2000): Promise { + return Promise.race([ + waitForLoadedDimensions(node), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +} + +export async function automation(settings: ExampleSettings) { + const { pvr, ktx } = await test(settings); + // Wait for both to settle (loaded or, on an unsupported format, timed out) so + // the snapshot captures the decoded result rather than an empty frame. + await Promise.all([waitForUpload(pvr), waitForUpload(ktx)]); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { renderer.createTextNode({ x: 100, y: 100, @@ -12,7 +41,7 @@ export default async function ({ renderer, testRoot }: ExampleSettings) { parent: testRoot, }); - renderer.createNode({ + const pvr = renderer.createNode({ x: 100, y: 170, w: 550, @@ -32,7 +61,7 @@ export default async function ({ renderer, testRoot }: ExampleSettings) { parent: testRoot, }); - renderer.createNode({ + const ktx = renderer.createNode({ x: 800, y: 170, w: 400, @@ -40,4 +69,6 @@ export default async function ({ renderer, testRoot }: ExampleSettings) { src: '../assets/test-s3tc.ktx', parent: testRoot, }); + + return { pvr, ktx }; } diff --git a/src/core/lib/textureCompression.test.ts b/src/core/lib/textureCompression.test.ts new file mode 100644 index 0000000..df7652b --- /dev/null +++ b/src/core/lib/textureCompression.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from 'vitest'; +import { uploadCompressedTexture } from './textureCompression.js'; +import type { WebGlContextWrapper } from './WebGlContextWrapper.js'; +import type { CompressedData } from '../textures/Texture.js'; + +/** + * A compressed format enum is only valid in compressedTexImage2D after its + * owning extension has been enabled via getExtension; otherwise the driver + * rejects it with GL_INVALID_ENUM (1280). These tests pin that every upload + * path enables its extension *before* uploading, and fails loudly when the + * device exposes none of the candidates. + */ + +interface MockGlw { + glw: WebGlContextWrapper; + order: string[]; + getExtension: ReturnType; + compressedTexImage2D: ReturnType; +} + +function makeGlw(supported: Set): MockGlw { + const order: string[] = []; + const getExtension = vi.fn((name: string) => { + order.push(`getExtension:${name}`); + return supported.has(name) === true ? {} : null; + }); + const compressedTexImage2D = vi.fn(() => { + order.push('compressedTexImage2D'); + }); + const glw = { + getExtension, + compressedTexImage2D, + bindTexture: vi.fn(), + texParameteri: vi.fn(), + TEXTURE_WRAP_S: 0, + TEXTURE_WRAP_T: 0, + TEXTURE_MAG_FILTER: 0, + TEXTURE_MIN_FILTER: 0, + CLAMP_TO_EDGE: 0, + LINEAR: 0, + LINEAR_MIPMAP_LINEAR: 0, + } as unknown as WebGlContextWrapper; + return { glw, order, getExtension, compressedTexImage2D }; +} + +function makeData( + type: 'ktx' | 'pvr' | 'astc', + glInternalFormat: number, +): CompressedData { + return { + type, + glInternalFormat, + w: 4, + h: 4, + mipmaps: [new ArrayBuffer(16)], + blockInfo: { width: 4, height: 4, bytes: 16 }, + }; +} + +const texture = {} as WebGLTexture; + +// COMPRESSED_RGBA_S3TC_DXT5_EXT +const S3TC_DXT5 = 0x83f3; +// COMPRESSED_RGB_PVRTC_4BPPV1_IMG +const PVRTC_4BPP = 0x8c00; +// COMPRESSED_RGB_ETC1_WEBGL +const ETC1 = 0x8d64; +// COMPRESSED_RGBA_ASTC_4x4_KHR +const ASTC_4x4 = 0x93b0; + +describe('compressed texture extension guards', () => { + it('KTX enables the s3tc extension before uploading', () => { + const m = makeGlw(new Set(['WEBGL_compressed_texture_s3tc'])); + uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', S3TC_DXT5)); + + expect(m.getExtension).toHaveBeenCalledWith( + 'WEBGL_compressed_texture_s3tc', + ); + expect(m.compressedTexImage2D).toHaveBeenCalled(); + // getExtension must precede the first compressedTexImage2D call + expect(m.order.indexOf('getExtension:WEBGL_compressed_texture_s3tc')).toBe( + 0, + ); + expect( + m.order.indexOf('getExtension:WEBGL_compressed_texture_s3tc') < + m.order.indexOf('compressedTexImage2D'), + ).toBe(true); + }); + + it('KTX throws (no silent 1280) when the s3tc extension is unavailable', () => { + const m = makeGlw(new Set()); + expect(() => + uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', S3TC_DXT5)), + ).toThrow(/not supported/); + expect(m.compressedTexImage2D).not.toHaveBeenCalled(); + }); + + it('KTX enables the etc1 extension for ETC1 formats', () => { + const m = makeGlw(new Set(['WEBGL_compressed_texture_etc1'])); + uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', ETC1)); + expect(m.getExtension).toHaveBeenCalledWith( + 'WEBGL_compressed_texture_etc1', + ); + expect(m.compressedTexImage2D).toHaveBeenCalled(); + }); + + it('PVR enables the pvrtc extension before uploading', () => { + const m = makeGlw(new Set(['WEBGL_compressed_texture_pvrtc'])); + uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP)); + expect(m.getExtension).toHaveBeenCalledWith( + 'WEBGL_compressed_texture_pvrtc', + ); + expect( + m.order.indexOf('getExtension:WEBGL_compressed_texture_pvrtc') < + m.order.indexOf('compressedTexImage2D'), + ).toBe(true); + }); + + it('PVR falls back to the WebKit-prefixed pvrtc extension', () => { + const m = makeGlw(new Set(['WEBKIT_WEBGL_compressed_texture_pvrtc'])); + uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP)); + expect(m.getExtension).toHaveBeenCalledWith( + 'WEBGL_compressed_texture_pvrtc', + ); + expect(m.getExtension).toHaveBeenCalledWith( + 'WEBKIT_WEBGL_compressed_texture_pvrtc', + ); + expect(m.compressedTexImage2D).toHaveBeenCalled(); + }); + + it('PVR throws when neither pvrtc extension is available', () => { + const m = makeGlw(new Set()); + expect(() => + uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP)), + ).toThrow(/not supported/); + expect(m.compressedTexImage2D).not.toHaveBeenCalled(); + }); + + it('ASTC enables the astc extension before uploading', () => { + const m = makeGlw(new Set(['WEBGL_compressed_texture_astc'])); + uploadCompressedTexture.astc!(m.glw, texture, makeData('astc', ASTC_4x4)); + expect(m.getExtension).toHaveBeenCalledWith( + 'WEBGL_compressed_texture_astc', + ); + expect( + m.order.indexOf('getExtension:WEBGL_compressed_texture_astc') < + m.order.indexOf('compressedTexImage2D'), + ).toBe(true); + }); + + it('ASTC throws when the astc extension is unavailable', () => { + const m = makeGlw(new Set()); + expect(() => + uploadCompressedTexture.astc!(m.glw, texture, makeData('astc', ASTC_4x4)), + ).toThrow(/not supported/); + expect(m.compressedTexImage2D).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/lib/textureCompression.ts b/src/core/lib/textureCompression.ts index f17f706..2283aef 100644 --- a/src/core/lib/textureCompression.ts +++ b/src/core/lib/textureCompression.ts @@ -171,18 +171,90 @@ const loadASTC = async function (view: DataView): Promise { }; }; +// Candidate extension name lists, hoisted to module constants so the per-upload +// resolver returns a shared reference instead of allocating a new array each +// call (zero GC pressure on the texture-upload path). +const EXT_ASTC = ['WEBGL_compressed_texture_astc']; +const EXT_S3TC = ['WEBGL_compressed_texture_s3tc']; +const EXT_ETC1 = ['WEBGL_compressed_texture_etc1']; +const EXT_ETC = ['WEBGL_compressed_texture_etc']; +// WebKit-prefixed name is the legacy fallback. +const EXT_PVRTC = [ + 'WEBGL_compressed_texture_pvrtc', + 'WEBKIT_WEBGL_compressed_texture_pvrtc', +]; +const EXT_NONE: string[] = []; + +/** + * Resolve the WebGL extension(s) that must be enabled before a given compressed + * GL internal format may be used. + * + * @remarks + * `getExtension` is the call that actually enables a compressed format on a + * context — until it is called, the format enum is rejected by + * `compressedTexImage2D` with `GL_INVALID_ENUM` (1280). Listed in priority + * order; the first name the device exposes is used. + */ +const requiredExtensionsForFormat = (glInternalFormat: number): string[] => { + // ASTC (incl. sRGB variants): 0x93b0–0x93d5 + if (glInternalFormat >= 0x93b0 && glInternalFormat <= 0x93d5) { + return EXT_ASTC; + } + // S3TC / DXTn: 0x83f0–0x83f3 + if (glInternalFormat >= 0x83f0 && glInternalFormat <= 0x83f3) { + return EXT_S3TC; + } + // ETC1: 0x8d64 + if (glInternalFormat === 0x8d64) { + return EXT_ETC1; + } + // ETC2 / EAC: 0x9274–0x9279 + if (glInternalFormat >= 0x9274 && glInternalFormat <= 0x9279) { + return EXT_ETC; + } + // PVRTC: 0x8c00–0x8c03 + if (glInternalFormat >= 0x8c00 && glInternalFormat <= 0x8c03) { + return EXT_PVRTC; + } + return EXT_NONE; +}; + +/** + * Enable the extension owning `glInternalFormat` so the format enum is valid in + * `compressedTexImage2D`, throwing a clear error if the device exposes none of + * the candidate extensions (instead of leaking a silent `GL_INVALID_ENUM`). + */ +const ensureCompressedFormatEnabled = ( + glw: WebGlContextWrapper, + glInternalFormat: number, +): void => { + const names = requiredExtensionsForFormat(glInternalFormat); + const len = names.length; + if (len === 0) { + return; + } + for (let i = 0; i < len; i++) { + if (glw.getExtension(names[i]!) !== null) { + return; + } + } + throw new Error( + `Compressed texture format 0x${glInternalFormat.toString( + 16, + )} is not supported by this device (requires ${names.join(' or ')})`, + ); +}; + const uploadASTC = function ( glw: WebGlContextWrapper, texture: WebGLTexture, data: CompressedData, ) { - if (glw.getExtension('WEBGL_compressed_texture_astc') === null) { - throw new Error('ASTC compressed textures not supported by this device'); - } + const { glInternalFormat, mipmaps, w, h } = data; + ensureCompressedFormatEnabled(glw, glInternalFormat); glw.bindTexture(texture); - const { glInternalFormat, mipmaps, w, h } = data; if (mipmaps === undefined) { return; } @@ -277,6 +349,7 @@ const uploadKTX = function ( data: CompressedData, ) { const { glInternalFormat, mipmaps, w: width, h: height, blockInfo } = data; + ensureCompressedFormatEnabled(glw, glInternalFormat); if (mipmaps === undefined) { return; } @@ -415,6 +488,7 @@ const uploadPVR = function ( data: CompressedData, ) { const { glInternalFormat, mipmaps, w: width, h: height } = data; + ensureCompressedFormatEnabled(glw, glInternalFormat); if (mipmaps === undefined) { return; } diff --git a/visual-regression/certified-snapshots/chromium-ci/tx-compression-1.png b/visual-regression/certified-snapshots/chromium-ci/tx-compression-1.png new file mode 100644 index 0000000..8135ec2 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/tx-compression-1.png differ