import { type CompressedData, type TextureData } from '../textures/Texture.js'; import type { WebGlContextWrapper } from './WebGlContextWrapper.js'; export type UploadCompressedTextureFunction = ( glw: WebGlContextWrapper, texture: WebGLTexture, data: CompressedData, ) => void; /** * Tests if the given location is a compressed texture container * @param url * @remarks * This function is used to determine if the given image url is a compressed * and only supports the following extensions: .ktx and .pvr * @returns */ export function isCompressedTextureContainer(src: string): boolean { return /\.(ktx|pvr)$/.test(src); } const PVR_MAGIC = 0x03525650; // 'PVR3' in little-endian const PVR_TO_GL_INTERNAL_FORMAT: Record = { 0: 0x8c01, 1: 0x8c03, 2: 0x8c00, 3: 0x8c02, // PVRTC1 6: 0x8d64, // ETC1 7: 0x83f0, 8: 0x83f2, 9: 0x83f2, 10: 0x83f3, 11: 0x83f3, // DXT variants }; const ASTC_MAGIC = 0x5ca1ab13; const ASTC_TO_GL_INTERNAL_FORMAT: Record = { '4x4': 0x93b0, // COMPRESSED_RGBA_ASTC_4x4_KHR '5x5': 0x93b1, // COMPRESSED_RGBA_ASTC_5x5_KHR '6x6': 0x93b2, // COMPRESSED_RGBA_ASTC_6x6_KHR '8x8': 0x93b3, // COMPRESSED_RGBA_ASTC_8x8_KHR '10x10': 0x93b4, // COMPRESSED_RGBA_ASTC_10x10_KHR '12x12': 0x93b5, // COMPRESSED_RGBA_ASTC_12x12_KHR }; // KTX file identifier const KTX_IDENTIFIER = [ 0xab, 0x4b, 0x54, 0x58, 0x20, 0x31, 0x31, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a, ]; /** * Loads a compressed texture container * @param url * @returns */ export const loadCompressedTexture = async ( url: string, ): Promise => { try { const arrayBuffer = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.responseType = 'arraybuffer'; xhr.onload = () => { if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0) { resolve(xhr.response as ArrayBuffer); } else { reject( new Error( `Failed to fetch compressed texture: ${xhr.status} ${xhr.statusText}`, ), ); } }; xhr.onerror = () => { reject( new Error( 'Network error occurred while trying to fetch the compressed texture.', ), ); }; xhr.send(null); }); // Ensure we have enough data to check magic numbers if (arrayBuffer.byteLength < 16) { throw new Error( `File too small to be a valid compressed texture (${arrayBuffer.byteLength} bytes). Expected at least 16 bytes for header inspection.`, ); } const view = new DataView(arrayBuffer); const magic = view.getUint32(0, true); if (magic === PVR_MAGIC) { return loadPVR(view); } if (magic === ASTC_MAGIC) { return loadASTC(view); } let isKTX = true; for (let i = 0; i < KTX_IDENTIFIER.length; i++) { if (view.getUint8(i) !== KTX_IDENTIFIER[i]) { isKTX = false; break; } } if (isKTX === true) { return loadKTX(view); } else { throw new Error('Unrecognized compressed texture format'); } } catch (error) { throw new Error(`Failed to load compressed texture from ${url}: ${error}`); } }; function readUint24(view: DataView, offset: number) { return ( view.getUint8(offset) + (view.getUint8(offset + 1) << 8) + (view.getUint8(offset + 2) << 16) ); } /** * Loads an ASTC texture container and returns the texture data * @param view * @returns */ const loadASTC = async function (view: DataView): Promise { const blockX = view.getUint8(4); const blockY = view.getUint8(5); const sizeX = readUint24(view, 7); const sizeY = readUint24(view, 10); if (sizeX === 0 || sizeY === 0) { throw new Error(`Invalid ASTC texture dimensions: ${sizeX}x${sizeY}`); } const expected = Math.ceil(sizeX / blockX) * Math.ceil(sizeY / blockY) * 16; const dataSize = view.byteLength - 16; if (expected !== dataSize) { throw new Error( `Invalid ASTC texture data size: expected ${expected}, got ${dataSize}`, ); } const internalFormat = ASTC_TO_GL_INTERNAL_FORMAT[`${blockX}x${blockY}`]; if (internalFormat === undefined) { throw new Error(`Unsupported ASTC block size: ${blockX}x${blockY}`); } const buffer = view.buffer as ArrayBuffer; const mipmaps: ArrayBuffer[] = []; mipmaps.push(buffer.slice(16)); return { data: { blockInfo: blockInfoMap[internalFormat]!, glInternalFormat: internalFormat, mipmaps, w: sizeX, h: sizeY, type: 'astc', }, premultiplyAlpha: false, }; }; // 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, ) { const { glInternalFormat, mipmaps, w, h } = data; ensureCompressedFormatEnabled(glw, glInternalFormat); glw.bindTexture(texture); if (mipmaps === undefined) { return; } const view = new Uint8Array(mipmaps[0]!); glw.compressedTexImage2D(0, glInternalFormat, w, h, 0, view); // ASTC textures MUST use no mipmaps unless stored glw.texParameteri(glw.TEXTURE_WRAP_S, glw.CLAMP_TO_EDGE); glw.texParameteri(glw.TEXTURE_WRAP_T, glw.CLAMP_TO_EDGE); glw.texParameteri(glw.TEXTURE_MAG_FILTER, glw.LINEAR); glw.texParameteri(glw.TEXTURE_MIN_FILTER, glw.LINEAR); }; /** * Loads a KTX texture container and returns the texture data * @param view * @returns */ const loadKTX = async function (view: DataView): Promise { const endianness = view.getUint32(12, true); const littleEndian = endianness === 0x04030201; if (littleEndian === false && endianness !== 0x01020304) { throw new Error('Invalid KTX endianness value'); } const glType = view.getUint32(16, littleEndian); const glFormat = view.getUint32(24, littleEndian); if (glType !== 0 || glFormat !== 0) { throw new Error( `KTX texture is not compressed (glType: ${glType}, glFormat: ${glFormat})`, ); } const glInternalFormat = view.getUint32(28, littleEndian); if (blockInfoMap[glInternalFormat] === undefined) { throw new Error( `Unsupported KTX compressed texture format: 0x${glInternalFormat.toString( 16, )}`, ); } const width = view.getUint32(36, littleEndian); const height = view.getUint32(40, littleEndian); if (width === 0 || height === 0) { throw new Error(`Invalid KTX texture dimensions: ${width}x${height}`); } const mipmapLevels = view.getUint32(56, littleEndian); if (mipmapLevels === 0) { throw new Error('KTX texture has no mipmap levels'); } const bytesOfKeyValueData = view.getUint32(60, littleEndian); const mipmaps: ArrayBuffer[] = []; const buffer = view.buffer as ArrayBuffer; let offset = 64 + bytesOfKeyValueData; if (offset > view.byteLength) { throw new Error('Invalid KTX file: key/value data exceeds file size'); } for (let i = 0; i < mipmapLevels; i++) { const imageSize = view.getUint32(offset, littleEndian); offset += 4; const end = offset + imageSize; mipmaps.push(buffer.slice(offset, end)); offset = end; if (offset % 4 !== 0) { offset += 4 - (offset % 4); } } return { data: { blockInfo: blockInfoMap[glInternalFormat]!, glInternalFormat: glInternalFormat, mipmaps, w: width, h: height, type: 'ktx', }, premultiplyAlpha: false, }; }; const uploadKTX = function ( glw: WebGlContextWrapper, texture: WebGLTexture, data: CompressedData, ) { const { glInternalFormat, mipmaps, w: width, h: height, blockInfo } = data; ensureCompressedFormatEnabled(glw, glInternalFormat); if (mipmaps === undefined) { return; } glw.bindTexture(texture); const blockWidth = blockInfo.width; const blockHeight = blockInfo.height; let w = width; let h = height; for (let i = 0; i < mipmaps!.length; i++) { let view = new Uint8Array(mipmaps![i]!); const uploadW = Math.ceil(w / blockWidth) * blockWidth; const uploadH = Math.ceil(h / blockHeight) * blockHeight; const expectedBytes = Math.ceil(w / blockWidth) * Math.ceil(h / blockHeight) * blockInfo.bytes; if (view.byteLength < expectedBytes) { const padded = new Uint8Array(expectedBytes); padded.set(view); view = padded; } glw.compressedTexImage2D(i, glInternalFormat, uploadW, uploadH, 0, view); w = Math.max(1, w >> 1); h = Math.max(1, h >> 1); } glw.texParameteri(glw.TEXTURE_WRAP_S, glw.CLAMP_TO_EDGE); glw.texParameteri(glw.TEXTURE_WRAP_T, glw.CLAMP_TO_EDGE); glw.texParameteri(glw.TEXTURE_MAG_FILTER, glw.LINEAR); glw.texParameteri( glw.TEXTURE_MIN_FILTER, mipmaps!.length > 1 ? glw.LINEAR_MIPMAP_LINEAR : glw.LINEAR, ); }; function pvrtcMipSize(width: number, height: number, bpp: 2 | 4) { const minW = bpp === 2 ? 16 : 8; const minH = 8; const w = Math.max(width, minW); const h = Math.max(height, minH); return (w * h * bpp) / 8; } const loadPVR = async function (view: DataView): Promise { const pixelFormatLow = view.getUint32(8, true); const internalFormat = PVR_TO_GL_INTERNAL_FORMAT[pixelFormatLow]; if (internalFormat === undefined) { throw new Error( `Unsupported PVR pixel format: 0x${pixelFormatLow.toString(16)}`, ); } const height = view.getInt32(24, true); const width = view.getInt32(28, true); // validate dimensions if (width === 0 || height === 0) { throw new Error(`Invalid PVR texture dimensions: ${width}x${height}`); } const mipmapLevels = view.getInt32(44, true); const metadataSize = view.getUint32(48, true); const buffer = view.buffer as ArrayBuffer; let offset = 52 + metadataSize; if (offset > buffer.byteLength) { throw new Error('Invalid PVR file: metadata exceeds file size'); } const mipmaps: ArrayBuffer[] = []; const block = blockInfoMap[internalFormat]!; for (let i = 0; i < mipmapLevels; i++) { const declaredSize = view.getUint32(offset, true); const max = buffer.byteLength - (offset + 4); if (declaredSize > 0 && declaredSize <= max) { offset += 4; const start = offset; const end = offset + declaredSize; mipmaps.push(buffer.slice(start, end)); offset = end; offset = (offset + 3) & ~3; // align to 4 bytes continue; } if ( pixelFormatLow === 0 || pixelFormatLow === 1 || pixelFormatLow === 2 || pixelFormatLow === 3 ) { const bpp = pixelFormatLow === 0 || pixelFormatLow === 1 ? 2 : 4; const computed = pvrtcMipSize(width >> i, height >> i, bpp); mipmaps.push(buffer.slice(offset, offset + computed)); offset += computed; offset = (offset + 3) & ~3; // align to 4 bytes continue; } if (block !== undefined) { const blockW = Math.ceil((width >> i) / block.width); const blockH = Math.ceil((height >> i) / block.height); const computed = blockW * blockH * block.bytes; mipmaps.push(buffer.slice(offset, offset + computed)); offset += computed; offset = (offset + 3) & ~3; } } return { data: { blockInfo: blockInfoMap[internalFormat]!, glInternalFormat: internalFormat, mipmaps, w: width, h: height, type: 'pvr', }, premultiplyAlpha: false, }; }; const uploadPVR = function ( glw: WebGlContextWrapper, texture: WebGLTexture, data: CompressedData, ) { const { glInternalFormat, mipmaps, w: width, h: height } = data; ensureCompressedFormatEnabled(glw, glInternalFormat); if (mipmaps === undefined) { return; } glw.bindTexture(texture); let w = width; let h = height; for (let i = 0; i < mipmaps!.length; i++) { glw.compressedTexImage2D( i, glInternalFormat, w, h, 0, new Uint8Array(mipmaps[i]!), ); w = Math.max(1, w >> 1); h = Math.max(1, h >> 1); } glw.texParameteri(glw.TEXTURE_WRAP_S, glw.CLAMP_TO_EDGE); glw.texParameteri(glw.TEXTURE_WRAP_T, glw.CLAMP_TO_EDGE); glw.texParameteri(glw.TEXTURE_MAG_FILTER, glw.LINEAR); glw.texParameteri( glw.TEXTURE_MIN_FILTER, mipmaps.length > 1 ? glw.LINEAR_MIPMAP_LINEAR : glw.LINEAR, ); }; type BlockInfo = { width: number; height: number; bytes: number; }; // Predefined block info for common compressed texture formats const BLOCK_4x4x8: BlockInfo = { width: 4, height: 4, bytes: 8 }; const BLOCK_4x4x16: BlockInfo = { width: 4, height: 4, bytes: 16 }; const BLOCK_5x5x16: BlockInfo = { width: 5, height: 5, bytes: 16 }; const BLOCK_6x6x16: BlockInfo = { width: 6, height: 6, bytes: 16 }; const BLOCK_8x4x8: BlockInfo = { width: 8, height: 4, bytes: 8 }; const BLOCK_8x8x16: BlockInfo = { width: 8, height: 8, bytes: 16 }; const BLOCK_10x10x16: BlockInfo = { width: 10, height: 10, bytes: 16 }; const BLOCK_12x12x16: BlockInfo = { width: 12, height: 12, bytes: 16 }; // Map of GL internal formats to their corresponding block info export const blockInfoMap: { [key: number]: BlockInfo } = { // S3TC / DXTn (WEBGL_compressed_texture_s3tc, sRGB variants) 0x83f0: BLOCK_4x4x8, // COMPRESSED_RGB_S3TC_DXT1_EXT 0x83f1: BLOCK_4x4x8, // COMPRESSED_RGBA_S3TC_DXT1_EXT 0x83f2: BLOCK_4x4x16, // COMPRESSED_RGBA_S3TC_DXT3_EXT 0x83f3: BLOCK_4x4x16, // COMPRESSED_RGBA_S3TC_DXT5_EXT // ETC1 / ETC2 / EAC 0x8d64: BLOCK_4x4x8, // COMPRESSED_RGB_ETC1_WEBGL 0x9274: BLOCK_4x4x8, // COMPRESSED_RGB8_ETC2 0x9275: BLOCK_4x4x8, // COMPRESSED_SRGB8_ETC2 0x9278: BLOCK_4x4x16, // COMPRESSED_RGBA8_ETC2_EAC 0x9279: BLOCK_4x4x16, // COMPRESSED_SRGB8_ALPHA8_ETC2_EAC // PVRTC (WEBGL_compressed_texture_pvrtc) 0x8c00: BLOCK_4x4x8, // COMPRESSED_RGB_PVRTC_4BPPV1_IMG 0x8c02: BLOCK_4x4x8, // COMPRESSED_RGBA_PVRTC_4BPPV1_IMG 0x8c01: BLOCK_8x4x8, // COMPRESSED_RGB_PVRTC_2BPPV1_IMG 0x8c03: BLOCK_8x4x8, // ASTC (WEBGL_compressed_texture_astc) 0x93b0: BLOCK_4x4x16, // COMPRESSED_RGBA_ASTC_4x4_KHR 0x93d0: BLOCK_4x4x16, // COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR 0x93b1: BLOCK_5x5x16, // 5x5 0x93d1: BLOCK_5x5x16, 0x93b2: BLOCK_6x6x16, // 6x6 0x93d2: BLOCK_6x6x16, 0x93b3: BLOCK_8x8x16, // 8x8 0x93d3: BLOCK_8x8x16, 0x93b4: BLOCK_10x10x16, // 10x10 0x93d4: BLOCK_10x10x16, 0x93b5: BLOCK_12x12x16, // 12x12 0x93d5: BLOCK_12x12x16, }; export const uploadCompressedTexture: Record< string, UploadCompressedTextureFunction > = { ktx: uploadKTX, pvr: uploadPVR, astc: uploadASTC, };