Skip to content

Commit 1eccd38

Browse files
chiefcllclaude
andcommitted
fix(textures): enable compression extension before upload (fixes GL 1280) (#88)
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) <noreply@anthropic.com>
1 parent 9fa7987 commit 1eccd38

5 files changed

Lines changed: 277 additions & 9 deletions

File tree

examples/tests/texture-free-reload.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { INode } from '@lightningjs/renderer';
22
import type { ExampleSettings } from '../common/ExampleSettings.js';
3-
import { waitUntilIdle } from '../common/utils.js';
43

54
import rockoPng from '../assets/rocko.png';
65

@@ -49,7 +48,13 @@ async function waitFor(
4948

5049
export async function automation(settings: ExampleSettings) {
5150
await test(settings);
52-
await waitUntilIdle(settings.renderer);
51+
// test() already awaited the reload's 'loaded' event, so the scene is fully
52+
// settled. waitUntilIdle() must NOT be used here: 'idle' fires once per
53+
// active->idle transition, which has already happened by now, so the listener
54+
// would wait forever and hang the (timeout-less) VRT runner. Force a final
55+
// frame and let it draw instead.
56+
settings.renderer.rerender();
57+
await delay(100);
5358
await settings.snapshot();
5459
}
5560

examples/tests/tx-compression.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
1+
import type { INode } from '@lightningjs/renderer';
12
import type { ExampleSettings } from '../common/ExampleSettings.js';
3+
import { waitForLoadedDimensions } from '../common/utils.js';
24

3-
export default async function ({ renderer, testRoot }: ExampleSettings) {
5+
/**
6+
* Resolve once `node`'s texture has finished uploading, or after `timeoutMs`.
7+
*
8+
* @remarks
9+
* Compressed-texture support is device/driver-specific. A format the running
10+
* GPU lacks (e.g. ETC1 under the headless CI SwiftShader driver) now surfaces
11+
* as a `failed` texture, which never fires `loaded`. `waitForLoadedDimensions`
12+
* alone would then wait forever — and the VRT runner has no per-test timeout,
13+
* so the whole capture/compare run hangs. The timeout backstop lets the
14+
* snapshot capture whatever the device produced (a blank tile for an
15+
* unsupported format) instead of hanging.
16+
*/
17+
function waitForUpload(node: INode, timeoutMs = 2000): Promise<unknown> {
18+
return Promise.race([
19+
waitForLoadedDimensions(node),
20+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
21+
]);
22+
}
23+
24+
export async function automation(settings: ExampleSettings) {
25+
const { pvr, ktx } = await test(settings);
26+
// Wait for both to settle (loaded or, on an unsupported format, timed out) so
27+
// the snapshot captures the decoded result rather than an empty frame.
28+
await Promise.all([waitForUpload(pvr), waitForUpload(ktx)]);
29+
await settings.snapshot();
30+
}
31+
32+
export default async function test({ renderer, testRoot }: ExampleSettings) {
433
renderer.createTextNode({
534
x: 100,
635
y: 100,
@@ -12,7 +41,7 @@ export default async function ({ renderer, testRoot }: ExampleSettings) {
1241
parent: testRoot,
1342
});
1443

15-
renderer.createNode({
44+
const pvr = renderer.createNode({
1645
x: 100,
1746
y: 170,
1847
w: 550,
@@ -32,12 +61,14 @@ export default async function ({ renderer, testRoot }: ExampleSettings) {
3261
parent: testRoot,
3362
});
3463

35-
renderer.createNode({
64+
const ktx = renderer.createNode({
3665
x: 800,
3766
y: 170,
3867
w: 400,
3968
h: 400,
4069
src: '../assets/test-s3tc.ktx',
4170
parent: testRoot,
4271
});
72+
73+
return { pvr, ktx };
4374
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { uploadCompressedTexture } from './textureCompression.js';
3+
import type { WebGlContextWrapper } from './WebGlContextWrapper.js';
4+
import type { CompressedData } from '../textures/Texture.js';
5+
6+
/**
7+
* A compressed format enum is only valid in compressedTexImage2D after its
8+
* owning extension has been enabled via getExtension; otherwise the driver
9+
* rejects it with GL_INVALID_ENUM (1280). These tests pin that every upload
10+
* path enables its extension *before* uploading, and fails loudly when the
11+
* device exposes none of the candidates.
12+
*/
13+
14+
interface MockGlw {
15+
glw: WebGlContextWrapper;
16+
order: string[];
17+
getExtension: ReturnType<typeof vi.fn>;
18+
compressedTexImage2D: ReturnType<typeof vi.fn>;
19+
}
20+
21+
function makeGlw(supported: Set<string>): MockGlw {
22+
const order: string[] = [];
23+
const getExtension = vi.fn((name: string) => {
24+
order.push(`getExtension:${name}`);
25+
return supported.has(name) === true ? {} : null;
26+
});
27+
const compressedTexImage2D = vi.fn(() => {
28+
order.push('compressedTexImage2D');
29+
});
30+
const glw = {
31+
getExtension,
32+
compressedTexImage2D,
33+
bindTexture: vi.fn(),
34+
texParameteri: vi.fn(),
35+
TEXTURE_WRAP_S: 0,
36+
TEXTURE_WRAP_T: 0,
37+
TEXTURE_MAG_FILTER: 0,
38+
TEXTURE_MIN_FILTER: 0,
39+
CLAMP_TO_EDGE: 0,
40+
LINEAR: 0,
41+
LINEAR_MIPMAP_LINEAR: 0,
42+
} as unknown as WebGlContextWrapper;
43+
return { glw, order, getExtension, compressedTexImage2D };
44+
}
45+
46+
function makeData(
47+
type: 'ktx' | 'pvr' | 'astc',
48+
glInternalFormat: number,
49+
): CompressedData {
50+
return {
51+
type,
52+
glInternalFormat,
53+
w: 4,
54+
h: 4,
55+
mipmaps: [new ArrayBuffer(16)],
56+
blockInfo: { width: 4, height: 4, bytes: 16 },
57+
};
58+
}
59+
60+
const texture = {} as WebGLTexture;
61+
62+
// COMPRESSED_RGBA_S3TC_DXT5_EXT
63+
const S3TC_DXT5 = 0x83f3;
64+
// COMPRESSED_RGB_PVRTC_4BPPV1_IMG
65+
const PVRTC_4BPP = 0x8c00;
66+
// COMPRESSED_RGB_ETC1_WEBGL
67+
const ETC1 = 0x8d64;
68+
// COMPRESSED_RGBA_ASTC_4x4_KHR
69+
const ASTC_4x4 = 0x93b0;
70+
71+
describe('compressed texture extension guards', () => {
72+
it('KTX enables the s3tc extension before uploading', () => {
73+
const m = makeGlw(new Set(['WEBGL_compressed_texture_s3tc']));
74+
uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', S3TC_DXT5));
75+
76+
expect(m.getExtension).toHaveBeenCalledWith(
77+
'WEBGL_compressed_texture_s3tc',
78+
);
79+
expect(m.compressedTexImage2D).toHaveBeenCalled();
80+
// getExtension must precede the first compressedTexImage2D call
81+
expect(m.order.indexOf('getExtension:WEBGL_compressed_texture_s3tc')).toBe(
82+
0,
83+
);
84+
expect(
85+
m.order.indexOf('getExtension:WEBGL_compressed_texture_s3tc') <
86+
m.order.indexOf('compressedTexImage2D'),
87+
).toBe(true);
88+
});
89+
90+
it('KTX throws (no silent 1280) when the s3tc extension is unavailable', () => {
91+
const m = makeGlw(new Set());
92+
expect(() =>
93+
uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', S3TC_DXT5)),
94+
).toThrow(/not supported/);
95+
expect(m.compressedTexImage2D).not.toHaveBeenCalled();
96+
});
97+
98+
it('KTX enables the etc1 extension for ETC1 formats', () => {
99+
const m = makeGlw(new Set(['WEBGL_compressed_texture_etc1']));
100+
uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', ETC1));
101+
expect(m.getExtension).toHaveBeenCalledWith(
102+
'WEBGL_compressed_texture_etc1',
103+
);
104+
expect(m.compressedTexImage2D).toHaveBeenCalled();
105+
});
106+
107+
it('PVR enables the pvrtc extension before uploading', () => {
108+
const m = makeGlw(new Set(['WEBGL_compressed_texture_pvrtc']));
109+
uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP));
110+
expect(m.getExtension).toHaveBeenCalledWith(
111+
'WEBGL_compressed_texture_pvrtc',
112+
);
113+
expect(
114+
m.order.indexOf('getExtension:WEBGL_compressed_texture_pvrtc') <
115+
m.order.indexOf('compressedTexImage2D'),
116+
).toBe(true);
117+
});
118+
119+
it('PVR falls back to the WebKit-prefixed pvrtc extension', () => {
120+
const m = makeGlw(new Set(['WEBKIT_WEBGL_compressed_texture_pvrtc']));
121+
uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP));
122+
expect(m.getExtension).toHaveBeenCalledWith(
123+
'WEBGL_compressed_texture_pvrtc',
124+
);
125+
expect(m.getExtension).toHaveBeenCalledWith(
126+
'WEBKIT_WEBGL_compressed_texture_pvrtc',
127+
);
128+
expect(m.compressedTexImage2D).toHaveBeenCalled();
129+
});
130+
131+
it('PVR throws when neither pvrtc extension is available', () => {
132+
const m = makeGlw(new Set());
133+
expect(() =>
134+
uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP)),
135+
).toThrow(/not supported/);
136+
expect(m.compressedTexImage2D).not.toHaveBeenCalled();
137+
});
138+
139+
it('ASTC enables the astc extension before uploading', () => {
140+
const m = makeGlw(new Set(['WEBGL_compressed_texture_astc']));
141+
uploadCompressedTexture.astc!(m.glw, texture, makeData('astc', ASTC_4x4));
142+
expect(m.getExtension).toHaveBeenCalledWith(
143+
'WEBGL_compressed_texture_astc',
144+
);
145+
expect(
146+
m.order.indexOf('getExtension:WEBGL_compressed_texture_astc') <
147+
m.order.indexOf('compressedTexImage2D'),
148+
).toBe(true);
149+
});
150+
151+
it('ASTC throws when the astc extension is unavailable', () => {
152+
const m = makeGlw(new Set());
153+
expect(() =>
154+
uploadCompressedTexture.astc!(m.glw, texture, makeData('astc', ASTC_4x4)),
155+
).toThrow(/not supported/);
156+
expect(m.compressedTexImage2D).not.toHaveBeenCalled();
157+
});
158+
});

src/core/lib/textureCompression.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,18 +171,90 @@ const loadASTC = async function (view: DataView): Promise<TextureData> {
171171
};
172172
};
173173

174+
// Candidate extension name lists, hoisted to module constants so the per-upload
175+
// resolver returns a shared reference instead of allocating a new array each
176+
// call (zero GC pressure on the texture-upload path).
177+
const EXT_ASTC = ['WEBGL_compressed_texture_astc'];
178+
const EXT_S3TC = ['WEBGL_compressed_texture_s3tc'];
179+
const EXT_ETC1 = ['WEBGL_compressed_texture_etc1'];
180+
const EXT_ETC = ['WEBGL_compressed_texture_etc'];
181+
// WebKit-prefixed name is the legacy fallback.
182+
const EXT_PVRTC = [
183+
'WEBGL_compressed_texture_pvrtc',
184+
'WEBKIT_WEBGL_compressed_texture_pvrtc',
185+
];
186+
const EXT_NONE: string[] = [];
187+
188+
/**
189+
* Resolve the WebGL extension(s) that must be enabled before a given compressed
190+
* GL internal format may be used.
191+
*
192+
* @remarks
193+
* `getExtension` is the call that actually enables a compressed format on a
194+
* context — until it is called, the format enum is rejected by
195+
* `compressedTexImage2D` with `GL_INVALID_ENUM` (1280). Listed in priority
196+
* order; the first name the device exposes is used.
197+
*/
198+
const requiredExtensionsForFormat = (glInternalFormat: number): string[] => {
199+
// ASTC (incl. sRGB variants): 0x93b0–0x93d5
200+
if (glInternalFormat >= 0x93b0 && glInternalFormat <= 0x93d5) {
201+
return EXT_ASTC;
202+
}
203+
// S3TC / DXTn: 0x83f0–0x83f3
204+
if (glInternalFormat >= 0x83f0 && glInternalFormat <= 0x83f3) {
205+
return EXT_S3TC;
206+
}
207+
// ETC1: 0x8d64
208+
if (glInternalFormat === 0x8d64) {
209+
return EXT_ETC1;
210+
}
211+
// ETC2 / EAC: 0x9274–0x9279
212+
if (glInternalFormat >= 0x9274 && glInternalFormat <= 0x9279) {
213+
return EXT_ETC;
214+
}
215+
// PVRTC: 0x8c00–0x8c03
216+
if (glInternalFormat >= 0x8c00 && glInternalFormat <= 0x8c03) {
217+
return EXT_PVRTC;
218+
}
219+
return EXT_NONE;
220+
};
221+
222+
/**
223+
* Enable the extension owning `glInternalFormat` so the format enum is valid in
224+
* `compressedTexImage2D`, throwing a clear error if the device exposes none of
225+
* the candidate extensions (instead of leaking a silent `GL_INVALID_ENUM`).
226+
*/
227+
const ensureCompressedFormatEnabled = (
228+
glw: WebGlContextWrapper,
229+
glInternalFormat: number,
230+
): void => {
231+
const names = requiredExtensionsForFormat(glInternalFormat);
232+
const len = names.length;
233+
if (len === 0) {
234+
return;
235+
}
236+
for (let i = 0; i < len; i++) {
237+
if (glw.getExtension(names[i]!) !== null) {
238+
return;
239+
}
240+
}
241+
throw new Error(
242+
`Compressed texture format 0x${glInternalFormat.toString(
243+
16,
244+
)} is not supported by this device (requires ${names.join(' or ')})`,
245+
);
246+
};
247+
174248
const uploadASTC = function (
175249
glw: WebGlContextWrapper,
176250
texture: WebGLTexture,
177251
data: CompressedData,
178252
) {
179-
if (glw.getExtension('WEBGL_compressed_texture_astc') === null) {
180-
throw new Error('ASTC compressed textures not supported by this device');
181-
}
253+
const { glInternalFormat, mipmaps, w, h } = data;
254+
ensureCompressedFormatEnabled(glw, glInternalFormat);
182255

183256
glw.bindTexture(texture);
184257

185-
const { glInternalFormat, mipmaps, w, h } = data;
186258
if (mipmaps === undefined) {
187259
return;
188260
}
@@ -277,6 +349,7 @@ const uploadKTX = function (
277349
data: CompressedData,
278350
) {
279351
const { glInternalFormat, mipmaps, w: width, h: height, blockInfo } = data;
352+
ensureCompressedFormatEnabled(glw, glInternalFormat);
280353
if (mipmaps === undefined) {
281354
return;
282355
}
@@ -415,6 +488,7 @@ const uploadPVR = function (
415488
data: CompressedData,
416489
) {
417490
const { glInternalFormat, mipmaps, w: width, h: height } = data;
491+
ensureCompressedFormatEnabled(glw, glInternalFormat);
418492
if (mipmaps === undefined) {
419493
return;
420494
}
11.4 KB
Loading

0 commit comments

Comments
 (0)