Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions examples/tests/texture-free-reload.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
}

Expand Down
37 changes: 34 additions & 3 deletions examples/tests/tx-compression.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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,
Expand All @@ -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,
Expand All @@ -32,12 +61,14 @@ export default async function ({ renderer, testRoot }: ExampleSettings) {
parent: testRoot,
});

renderer.createNode({
const ktx = renderer.createNode({
x: 800,
y: 170,
w: 400,
h: 400,
src: '../assets/test-s3tc.ktx',
parent: testRoot,
});

return { pvr, ktx };
}
158 changes: 158 additions & 0 deletions src/core/lib/textureCompression.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
compressedTexImage2D: ReturnType<typeof vi.fn>;
}

function makeGlw(supported: Set<string>): 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();
});
});
82 changes: 78 additions & 4 deletions src/core/lib/textureCompression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,18 +171,90 @@ const loadASTC = async function (view: DataView): Promise<TextureData> {
};
};

// 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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -415,6 +488,7 @@ const uploadPVR = function (
data: CompressedData,
) {
const { glInternalFormat, mipmaps, w: width, h: height } = data;
ensureCompressedFormatEnabled(glw, glInternalFormat);
if (mipmaps === undefined) {
return;
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading