From 522ccd3235557d1dc3dc4e287a962837337357e0 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Tue, 9 Jun 2026 16:18:19 -0400 Subject: [PATCH] fix(textures): enable compression extension before upload (fixes GL 1280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). KTX (S3TC/ETC/ETC2) and PVR (PVRTC) uploads never called getExtension — they relied on the boot-time getWebGlExtensions() probe (removed in #60, 1.4.0) incidentally enabling every compression extension. #60 saw the probe's result cache was dead and removed the whole function, not realizing the getExtension() calls themselves were load-bearing. ASTC survived only because uploadASTC re-queried its extension itself. Result: the first KTX or PVR upload throws 1280 on 1.4.0–1.4.3. Enable the owning extension at the point of use (resolved by GL internal format) for all three paths, throwing a clear "not supported by this device" error instead of leaking a silent 1280. Candidate name lists are hoisted to module constants so the resolver allocates nothing per upload. Also fix two VRT examples that hung the (timeout-less) snapshot runner: - tx-compression: promote to an automated VRT (it was manual-only, which is why this regression shipped). A format the GPU lacks now surfaces as a failed texture that never fires 'loaded', so wait with a timeout backstop. - texture-free-reload: 'idle' had already fired by the time automation() ran, so waitUntilIdle() waited forever; force a final frame instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/tests/texture-free-reload.ts | 9 +- examples/tests/tx-compression.ts | 37 +++- src/core/lib/textureCompression.test.ts | 158 ++++++++++++++++++ src/core/lib/textureCompression.ts | 82 ++++++++- .../chromium-ci/tx-compression-1.png | Bin 0 -> 11686 bytes 5 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 src/core/lib/textureCompression.test.ts create mode 100644 visual-regression/certified-snapshots/chromium-ci/tx-compression-1.png 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 0000000000000000000000000000000000000000..8135ec25e6cd9ae216bb126a1a6ff60ea4ef9e82 GIT binary patch literal 11686 zcmeHtS5%W(7%k%1Mv98ki!+R(R1u^|iHe;jB1Iq|M4FTkkc5_G6a}eKQ9$ZQRjQ%a z1ci{HNDv5wk_3p<03no+5R%-?z3V>TxBGCfFXu07oprwdod0}h?{Dvfdp5Vkf0zGV zL_|dV&h6{>MMMq=Z$$?WiwZASRPOE*5jiGu=lazL5jiX5LzzJDTsG6>L)PQBR#DeP z4-b7knD~$W@#81&Il3J^yr!;R0SD68mU|Ox602q>fPw(mhu0sJ-G6tseMjPt7cc(O z>#6I1JRyKB{AHNd^Z2%?T|Z+pa${XguTgC?Zxc=pBUFxarBLa@u^(AF*$ZR%`?udN z2%pX$ek5$~AL|gdMW_E3Ms@qD$bJ!#*Uv=`iin&(uun`F?eC(p!VV{Yb@;zDOBYIh z6Iu7whm~<)BK(WLT_9l08o?wueRpWSZYNdTWS~l8-%o>PbQBhdPd6xCk}CH=Z`ML( zRLs3LQ~=oTP`v<4k@bw%!M``4J^NRBE=ig;*VHkyx>3dvC}z@RRCQbQjfCx2TRc{@ zPq$1wkGq%7!VLL*A=rSh{2jf^tz1do-d-N&*@^X>y3UoB*d5tM`c%tqR@uuwVP{{Q z%G!z|^n+YlS-uPA;laeeFi%&uWWY|y?gLtY_JaDl$QTY=j+w#EU#Q<>I1mPa$(7~u zJkd#;YIq>T_K1iP)e^ zr|(K7CmaK-m@Gn=%&F*LY6wxL!I%W6-VDu} zB

tfrSy5ayMIO+UeoWm!2Ie?u+J3s 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μF1nx< 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