diff --git a/examples/assets/test-etc1.pvr b/examples/assets/test-etc1.pvr new file mode 100644 index 00000000..79cbcfce Binary files /dev/null and b/examples/assets/test-etc1.pvr differ diff --git a/examples/assets/test-s3tc.ktx b/examples/assets/test-s3tc.ktx new file mode 100644 index 00000000..0f52d610 Binary files /dev/null and b/examples/assets/test-s3tc.ktx differ diff --git a/examples/tests/tx-compression.ts b/examples/tests/tx-compression.ts new file mode 100644 index 00000000..8031c850 --- /dev/null +++ b/examples/tests/tx-compression.ts @@ -0,0 +1,62 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export default async function ({ renderer, testRoot }: ExampleSettings) { + renderer.createTextNode({ + x: 100, + y: 100, + color: 0xffffffff, + alpha: 1.0, + text: 'etc1 compression in .pvr', + fontFamily: 'Ubuntu', + fontSize: 30, + parent: testRoot, + }); + + renderer.createNode({ + x: 100, + y: 170, + width: 550, + height: 550, + src: '../assets/test-etc1.pvr', + parent: testRoot, + }); + + renderer.createTextNode({ + x: 800, + y: 100, + color: 0xffffffff, + alpha: 1.0, + text: 's3tc compression in .ktx', + fontFamily: 'Ubuntu', + fontSize: 30, + parent: testRoot, + }); + + renderer.createNode({ + x: 800, + y: 170, + width: 400, + height: 400, + src: '../assets/test-s3tc.ktx', + parent: testRoot, + }); +} diff --git a/src/core/lib/WebGlContextWrapper.ts b/src/core/lib/WebGlContextWrapper.ts index 209421fc..7458ede0 100644 --- a/src/core/lib/WebGlContextWrapper.ts +++ b/src/core/lib/WebGlContextWrapper.ts @@ -335,7 +335,34 @@ export class WebGlContextWrapper { ); } } + /** + * ``` + * gl.compressedTexImage2D(gl.TEXTURE_2D, level, internalFormat, width, height, border, data); + * ``` + * + * @remarks + * **WebGL Difference**: Bind target is always `gl.TEXTURE_2D` + */ + compressedTexImage2D( + level: GLint, + internalformat: GLenum, + width: GLsizei, + height: GLsizei, + border: GLint, + data?: ArrayBufferView, + ): void { + const { gl } = this; + gl.compressedTexImage2D( + gl.TEXTURE_2D, + level, + internalformat, + width, + height, + border, + data as ArrayBufferView, + ); + } /** * ``` * gl.pixelStorei(pname, param); diff --git a/src/core/lib/textureCompression.ts b/src/core/lib/textureCompression.ts new file mode 100644 index 00000000..7148ece2 --- /dev/null +++ b/src/core/lib/textureCompression.ts @@ -0,0 +1,152 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type TextureData } from '../textures/Texture.js'; + +/** + * 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(url: string): boolean { + return /\.(ktx|pvr)$/.test(url); +} + +/** + * Loads a compressed texture container + * @param url + * @returns + */ +export const loadCompressedTexture = async ( + url: string, +): Promise => { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + + if (url.indexOf('.ktx') !== -1) { + return loadKTXData(arrayBuffer); + } + + return loadPVRData(arrayBuffer); +}; + +/** + * Loads a KTX texture container and returns the texture data + * @param buffer + * @returns + */ +const loadKTXData = async (buffer: ArrayBuffer): Promise => { + const view = new DataView(buffer); + const littleEndian = view.getUint32(12) === 16909060 ? true : false; + const mipmaps = []; + + const data = { + glInternalFormat: view.getUint32(28, littleEndian), + pixelWidth: view.getUint32(36, littleEndian), + pixelHeight: view.getUint32(40, littleEndian), + numberOfMipmapLevels: view.getUint32(56, littleEndian), + bytesOfKeyValueData: view.getUint32(60, littleEndian), + }; + + let offset = 64; + + // Key Value Pairs of data start at byte offset 64 + // But the only known kvp is the API version, so skipping parsing. + offset += data.bytesOfKeyValueData; + + for (let i = 0; i < data.numberOfMipmapLevels; i++) { + const imageSize = view.getUint32(offset); + offset += 4; + + mipmaps.push(view.buffer.slice(offset, imageSize)); + offset += imageSize; + } + + return { + data: { + glInternalFormat: data.glInternalFormat, + mipmaps, + width: data.pixelWidth || 0, + height: data.pixelHeight || 0, + type: 'ktx', + }, + premultiplyAlpha: false, + }; +}; + +/** + * Loads a PVR texture container and returns the texture data + * @param buffer + * @returns + */ +const loadPVRData = async (buffer: ArrayBuffer): Promise => { + // pvr header length in 32 bits + const pvrHeaderLength = 13; + // for now only we only support: COMPRESSED_RGB_ETC1_WEBGL + const pvrFormatEtc1 = 0x8d64; + const pvrWidth = 7; + const pvrHeight = 6; + const pvrMipmapCount = 11; + const pvrMetadata = 12; + const arrayBuffer = buffer; + const header = new Int32Array(arrayBuffer, 0, pvrHeaderLength); + + // @ts-expect-error Object possibly undefined + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + const dataOffset = header[pvrMetadata] + 52; + const pvrtcData = new Uint8Array(arrayBuffer, dataOffset); + const mipmaps = []; + const data = { + pixelWidth: header[pvrWidth], + pixelHeight: header[pvrHeight], + numberOfMipmapLevels: header[pvrMipmapCount] || 0, + }; + + let offset = 0; + let width = data.pixelWidth || 0; + let height = data.pixelHeight || 0; + + for (let i = 0; i < data.numberOfMipmapLevels; i++) { + const level = ((width + 3) >> 2) * ((height + 3) >> 2) * 8; + const view = new Uint8Array( + arrayBuffer, + pvrtcData.byteOffset + offset, + level, + ); + + mipmaps.push(view); + offset += level; + width = width >> 1; + height = height >> 1; + } + + return { + data: { + glInternalFormat: pvrFormatEtc1, + mipmaps: mipmaps, + width: data.pixelWidth || 0, + height: data.pixelHeight || 0, + type: 'pvr', + }, + premultiplyAlpha: false, + }; +}; diff --git a/src/core/renderers/webgl/WebGlCoreCtxTexture.ts b/src/core/renderers/webgl/WebGlCoreCtxTexture.ts index 905db02f..57b247bd 100644 --- a/src/core/renderers/webgl/WebGlCoreCtxTexture.ts +++ b/src/core/renderers/webgl/WebGlCoreCtxTexture.ts @@ -168,6 +168,26 @@ export class WebGlCoreCtxTexture extends CoreContextTexture { glw.UNSIGNED_BYTE, TRANSPARENT_TEXTURE_DATA, ); + } else if ('mipmaps' in textureData.data && textureData.data.mipmaps) { + const { + mipmaps, + width = 0, + height = 0, + type, + glInternalFormat, + } = textureData.data; + const view = + type === 'ktx' + ? new DataView(mipmaps[0] ?? new ArrayBuffer(0)) + : (mipmaps[0] as unknown as ArrayBufferView); + + glw.bindTexture(this._nativeCtxTexture); + glw.compressedTexImage2D(0, glInternalFormat, width, height, 0, view); + + 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); } else { console.error( `WebGlCoreCtxTexture.onLoadRequest: Unexpected textureData returned`, diff --git a/src/core/text-rendering/font-face-types/SdfTrFontFace/SdfTrFontFace.ts b/src/core/text-rendering/font-face-types/SdfTrFontFace/SdfTrFontFace.ts index 1e2689e1..f591b518 100644 --- a/src/core/text-rendering/font-face-types/SdfTrFontFace/SdfTrFontFace.ts +++ b/src/core/text-rendering/font-face-types/SdfTrFontFace/SdfTrFontFace.ts @@ -98,7 +98,10 @@ export class SdfTrFontFace< }); // We know `data` is defined here, because we just set it // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (this.shaper as FontShaper) = new SdfFontShaper(this.data!, this.glyphMap); + (this.shaper as FontShaper) = new SdfFontShaper( + this.data!, + this.glyphMap, + ); this.checkLoaded(); }) .catch(console.error); diff --git a/src/core/text-rendering/font-face-types/SdfTrFontFace/internal/SdfFontShaper.test.ts b/src/core/text-rendering/font-face-types/SdfTrFontFace/internal/SdfFontShaper.test.ts index 4e490aa8..880a16f1 100644 --- a/src/core/text-rendering/font-face-types/SdfTrFontFace/internal/SdfFontShaper.test.ts +++ b/src/core/text-rendering/font-face-types/SdfTrFontFace/internal/SdfFontShaper.test.ts @@ -34,7 +34,10 @@ sdfData.chars.forEach((glyph) => { describe('SdfFontShaper', () => { it('should be able to shape text.', () => { - const shaper = new SdfFontShaper(sdfData as unknown as SdfFontData, glyphMap); + const shaper = new SdfFontShaper( + sdfData as unknown as SdfFontData, + glyphMap, + ); const peekableCodepoints = new PeekableIterator( getUnicodeCodepoints('Hi!'), ); @@ -88,7 +91,10 @@ describe('SdfFontShaper', () => { }); it('should be able to shape text that we know have kerning pairs.', () => { - const shaper = new SdfFontShaper(sdfData as unknown as SdfFontData, glyphMap); + const shaper = new SdfFontShaper( + sdfData as unknown as SdfFontData, + glyphMap, + ); const peekableCodepoints = new PeekableIterator( getUnicodeCodepoints('WeVo'), ); @@ -130,8 +136,10 @@ describe('SdfFontShaper', () => { }); it('should be able to shape text with letterSpacing.', () => { - - const shaper = new SdfFontShaper(sdfData as unknown as SdfFontData, glyphMap); + const shaper = new SdfFontShaper( + sdfData as unknown as SdfFontData, + glyphMap, + ); const peekableCodepoints = new PeekableIterator( getUnicodeCodepoints('We!'), ); diff --git a/src/core/text-rendering/font-face-types/SdfTrFontFace/internal/SdfFontShaper.ts b/src/core/text-rendering/font-face-types/SdfTrFontFace/internal/SdfFontShaper.ts index 1120c461..6f6b2810 100644 --- a/src/core/text-rendering/font-face-types/SdfTrFontFace/internal/SdfFontShaper.ts +++ b/src/core/text-rendering/font-face-types/SdfTrFontFace/internal/SdfFontShaper.ts @@ -39,7 +39,10 @@ export class SdfFontShaper extends FontShaper { private readonly glyphMap: Map; private readonly kernings: KerningTable; - constructor(data: SdfFontData, glyphMap: Map) { + constructor( + data: SdfFontData, + glyphMap: Map, + ) { super(); this.data = data; this.glyphMap = glyphMap; diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/internal/measureText.test.ts b/src/core/text-rendering/renderers/SdfTextRenderer/internal/measureText.test.ts index 08506c42..0f567635 100644 --- a/src/core/text-rendering/renderers/SdfTextRenderer/internal/measureText.test.ts +++ b/src/core/text-rendering/renderers/SdfTextRenderer/internal/measureText.test.ts @@ -33,7 +33,10 @@ sdfData.chars.forEach((glyph) => { describe('measureText', () => { it('should measure text width', () => { const PERIOD_WIDTH = 10.332; - const shaper = new SdfFontShaper(sdfData as unknown as SdfFontData, glyphMap); + const shaper = new SdfFontShaper( + sdfData as unknown as SdfFontData, + glyphMap, + ); expect(measureText('', { letterSpacing: 0 }, shaper)).toBe(0); expect(measureText('.', { letterSpacing: 0 }, shaper)).toBe(PERIOD_WIDTH); expect(measureText('..', { letterSpacing: 0 }, shaper)).toBeCloseTo( diff --git a/src/core/textures/ImageTexture.ts b/src/core/textures/ImageTexture.ts index ec1f1dc4..e35bc8d4 100644 --- a/src/core/textures/ImageTexture.ts +++ b/src/core/textures/ImageTexture.ts @@ -19,6 +19,10 @@ import type { CoreTextureManager } from '../CoreTextureManager.js'; import { Texture, type TextureData } from './Texture.js'; +import { + isCompressedTextureContainer, + loadCompressedTexture, +} from '../lib/textureCompression.js'; /** * Properties of the {@link ImageTexture} @@ -84,6 +88,11 @@ export class ImageTexture extends Texture { }; } + // Handle compressed textures + if (isCompressedTextureContainer(src)) { + return loadCompressedTexture(src); + } + if (this.txManager.imageWorkerManager.imageWorkersEnabled) { return await this.txManager.imageWorkerManager.getImage( src, diff --git a/src/core/textures/Texture.ts b/src/core/textures/Texture.ts index 580ca713..9d206001 100644 --- a/src/core/textures/Texture.ts +++ b/src/core/textures/Texture.ts @@ -35,6 +35,38 @@ export type TextureLoadedEventHandler = ( dimensions: Readonly, ) => void; +/** + * Represents compressed texture data. + */ +interface CompressedData { + /** + * GLenum spcifying compression format + */ + glInternalFormat: number; + + /** + * All mipmap levels + */ + mipmaps?: ArrayBuffer[]; + + /** + * Supported container types ('pvr' or 'ktx'). + */ + type: 'pvr' | 'ktx'; + + /** + * The width of the compressed texture in pixels. Defaults to 0. + * + * @default 0 + */ + width: number; + + /** + * The height of the compressed texture in pixels. + **/ + height: number; +} + /** * Event handler for when a Texture fails to load */ @@ -47,7 +79,7 @@ export interface TextureData { /** * The texture data */ - data: ImageBitmap | ImageData | SubTextureProps | null; + data: ImageBitmap | ImageData | SubTextureProps | CompressedData | null; /** * Premultiply alpha when uploading texture data to the GPU *