feat: support ktx2 hdr(BC6H) (#2784)

This commit is contained in:
Hu Song
2025-08-04 12:44:19 +09:00
committed by GitHub
parent 6f3b755f5c
commit 2d0ecc3e77
13 changed files with 133 additions and 337 deletions

Binary file not shown.

View File

@@ -0,0 +1,55 @@
/**
* @title HDR KTX2
* @category Texture
*/
import {
BloomEffect,
Camera,
DirectLight,
Logger,
MeshRenderer,
PostProcess,
PrimitiveMesh,
Texture2D,
TonemappingEffect,
UnlitMaterial,
Vector3,
WebGLEngine
} from "@galacean/engine";
import { OrbitControl } from "@galacean/engine-toolkit";
import { initScreenshot, updateForE2E } from "./.mockForE2E";
Logger.enable();
WebGLEngine.create({ canvas: "canvas", ktx2Loader: { workerCount: 0 } }).then((engine) => {
engine.canvas.resizeByClientSize(2);
const scene = engine.sceneManager.activeScene;
const rootEntity = scene.createRootEntity();
// camera
const cameraEntity = rootEntity.createChild("camera_node");
cameraEntity.transform.position = new Vector3(0, 1, 5);
const camera = cameraEntity.addComponent(Camera);
cameraEntity.addComponent(OrbitControl).target = new Vector3(0, 0, 0);
const lightNode = rootEntity.createChild("light_node");
lightNode.addComponent(DirectLight).intensity = 0.6;
lightNode.transform.lookAt(new Vector3(0, 0, 1));
lightNode.transform.rotate(new Vector3(0, 90, 0));
const planeEntity = rootEntity.createChild("plane");
const meshRenderer = planeEntity.addComponent(MeshRenderer);
meshRenderer.mesh = PrimitiveMesh.createCuboid(engine);
const mtl = new UnlitMaterial(engine);
meshRenderer.setMaterial(mtl);
const postProcess = rootEntity.addComponent(PostProcess);
postProcess.addEffect(BloomEffect);
postProcess.addEffect(TonemappingEffect);
engine.resourceManager.load<Texture2D>("/autumn_field_puresky_1k.ktx2").then((tex) => {
mtl.baseTexture = tex;
updateForE2E(engine);
initScreenshot(engine, camera);
});
});

View File

@@ -151,6 +151,11 @@ export const E2E_CONFIG = {
category: "Texture",
caseFileName: "texture-R8G8",
threshold: 0.1
},
KTX2HDR: {
category: "Texture",
caseFileName: "texture-hdr-ktx2",
threshold: 0.1
}
},
Shadow: {

View File

@@ -24,13 +24,14 @@ export enum TextureFormat {
R8 = 36,
/** RG float format, 8 bits per channel. */
R8G8 = 37,
/** RGB compressed format, 4 bits per pixel. */
BC1 = 10,
/** RGBA compressed format, 8 bits per pixel. */
BC3 = 11,
/** RGB(A) compressed format, 128 bits per 4x4 pixel block. */
BC7 = 12,
/** RGB HDR compressed format, 8 bits per pixel.. */
BC6H = 100,
/** RGB compressed format, 4 bits per pixel. */
ETC1_RGB = 13,
/** RGB compressed format, 4 bits per pixel. */

View File

@@ -30,7 +30,7 @@ export * from "./SceneLoader";
export type { Texture2DParams } from "./Texture2DLoader";
export { parseSingleKTX } from "./compressed-texture";
export * from "./gltf";
export { KTX2Loader, KTX2Transcoder } from "./ktx2/KTX2Loader";
export { KTX2Loader } from "./ktx2/KTX2Loader";
export { KTX2TargetFormat } from "./ktx2/KTX2TargetFormat";
export * from "./resource-deserialize";
export * from "./prefab/PrefabResource";

View File

@@ -6,9 +6,10 @@ export enum DFDTransferFunction {
sRGB = 2
}
enum ColorModel {
export enum ColorModel {
ETC1S = 163,
UASTC = 166
UASTC_LDR_4X4 = 166,
UASTC_HDR_4X4 = 167
}
export enum SupercompressionScheme {
@@ -52,8 +53,8 @@ export class KTX2Container {
return this.dataFormatDescriptor.transferFunction === DFDTransferFunction.sRGB;
}
get isUASTC() {
return this.dataFormatDescriptor.colorModel === ColorModel.UASTC;
get colorModel(): ColorModel {
return this.dataFormatDescriptor.colorModel;
}
private parse(data: Uint8Array) {

View File

@@ -17,32 +17,30 @@ import {
resourceLoader
} from "@galacean/engine-core";
import { MathUtil } from "@galacean/engine-math";
import { DFDTransferFunction, KTX2Container } from "./KTX2Container";
import { DFDTransferFunction, KTX2Container, ColorModel } from "./KTX2Container";
import { KTX2TargetFormat } from "./KTX2TargetFormat";
import { TranscodeResult } from "./transcoder/AbstractTranscoder";
import { BinomialLLCTranscoder } from "./transcoder/BinomialLLCTranscoder";
import { KhronosTranscoder } from "./transcoder/KhronosTranscoder";
@resourceLoader(AssetType.KTX2, ["ktx2"])
export class KTX2Loader extends Loader<Texture2D | TextureCube> {
private static _isBinomialInit: boolean = false;
private static _binomialLLCTranscoder: BinomialLLCTranscoder;
private static _khronosTranscoder: KhronosTranscoder;
private static _priorityFormats = {
etc1s: [
[ColorModel.ETC1S]: [
KTX2TargetFormat.ETC,
KTX2TargetFormat.BC7,
KTX2TargetFormat.ASTC,
KTX2TargetFormat.BC1_BC3,
KTX2TargetFormat.PVRTC
],
uastc: [
[ColorModel.UASTC_LDR_4X4]: [
KTX2TargetFormat.ASTC,
KTX2TargetFormat.BC7,
KTX2TargetFormat.ETC,
KTX2TargetFormat.BC1_BC3,
KTX2TargetFormat.PVRTC
]
],
[ColorModel.UASTC_HDR_4X4]: [KTX2TargetFormat.BC6H, KTX2TargetFormat.R16G16B16A16]
};
private static _capabilityMap = {
[KTX2TargetFormat.ASTC]: {
@@ -61,6 +59,14 @@ export class KTX2Loader extends Loader<Texture2D | TextureCube> {
[DFDTransferFunction.linear]: [GLCapabilityType.s3tc],
[DFDTransferFunction.sRGB]: [GLCapabilityType.s3tc_srgb]
},
[KTX2TargetFormat.BC6H]: {
[DFDTransferFunction.linear]: [GLCapabilityType.bptc],
[DFDTransferFunction.sRGB]: [GLCapabilityType.bptc]
},
[KTX2TargetFormat.R16G16B16A16]: {
[DFDTransferFunction.linear]: [GLCapabilityType.textureHalfFloatLinear],
[DFDTransferFunction.sRGB]: [GLCapabilityType.textureHalfFloat]
},
[KTX2TargetFormat.PVRTC]: { [DFDTransferFunction.linear]: [GLCapabilityType.pvrtc, GLCapabilityType.pvrtc_webkit] }
};
@@ -70,35 +76,27 @@ export class KTX2Loader extends Loader<Texture2D | TextureCube> {
*/
static release(): void {
if (this._binomialLLCTranscoder) this._binomialLLCTranscoder.destroy();
if (this._khronosTranscoder) this._khronosTranscoder.destroy();
this._binomialLLCTranscoder = null;
this._khronosTranscoder = null;
this._isBinomialInit = false;
}
/** @internal */
static _parseBuffer(buffer: Uint8Array, engine: Engine, params?: KTX2Params) {
const ktx2Container = new KTX2Container(buffer);
const formatPriorities =
params?.priorityFormats ?? KTX2Loader._priorityFormats[ktx2Container.isUASTC ? "uastc" : "etc1s"];
const formatPriorities = params?.priorityFormats ?? KTX2Loader._priorityFormats[ktx2Container.colorModel];
const targetFormat = KTX2Loader._decideTargetFormat(engine, ktx2Container, formatPriorities);
let transcodeResultPromise: Promise<TranscodeResult>;
if (KTX2Loader._isBinomialInit || !KhronosTranscoder.transcoderMap[targetFormat] || !ktx2Container.isUASTC) {
const binomialLLCWorker = KTX2Loader._getBinomialLLCTranscoder();
transcodeResultPromise = binomialLLCWorker.init().then(() => binomialLLCWorker.transcode(buffer, targetFormat));
} else {
const khronosWorker = KTX2Loader._getKhronosTranscoder();
transcodeResultPromise = khronosWorker.init().then(() => khronosWorker.transcode(ktx2Container));
}
return transcodeResultPromise.then((result) => {
return {
ktx2Container,
engine,
result,
targetFormat,
params: ktx2Container.keyValue["GalaceanTextureParams"] as Uint8Array
};
});
const binomialLLCWorker = KTX2Loader._getBinomialLLCTranscoder();
return binomialLLCWorker
.init()
.then(() => binomialLLCWorker.transcode(buffer, targetFormat))
.then((result) => {
return {
ktx2Container,
engine,
result,
targetFormat,
params: ktx2Container.keyValue["GalaceanTextureParams"] as Uint8Array
};
});
}
/** @internal */
@@ -193,14 +191,9 @@ export class KTX2Loader extends Loader<Texture2D | TextureCube> {
}
private static _getBinomialLLCTranscoder(workerCount: number = 4) {
KTX2Loader._isBinomialInit = true;
return (this._binomialLLCTranscoder ??= new BinomialLLCTranscoder(workerCount));
}
private static _getKhronosTranscoder(workerCount: number = 4) {
return (this._khronosTranscoder ??= new KhronosTranscoder(workerCount, KTX2TargetFormat.ASTC));
}
private static _getEngineTextureFormat(
basisFormat: KTX2TargetFormat,
transcodeResult: TranscodeResult
@@ -219,6 +212,10 @@ export class KTX2Loader extends Loader<Texture2D | TextureCube> {
return hasAlpha ? TextureFormat.PVRTC_RGBA4 : TextureFormat.PVRTC_RGB4;
case KTX2TargetFormat.R8G8B8A8:
return TextureFormat.R8G8B8A8;
case KTX2TargetFormat.BC6H:
return TextureFormat.BC6H;
case KTX2TargetFormat.R16G16B16A16:
return TextureFormat.R16G16B16A16;
}
}
@@ -230,11 +227,7 @@ export class KTX2Loader extends Loader<Texture2D | TextureCube> {
KTX2Loader._priorityFormats["uastc"] = options.priorityFormats;
}
if (options.transcoder === KTX2Transcoder.Khronos) {
return KTX2Loader._getKhronosTranscoder(options.workerCount).init();
} else {
return KTX2Loader._getBinomialLLCTranscoder(options.workerCount).init();
}
return KTX2Loader._getBinomialLLCTranscoder(options.workerCount).init();
}
}
@@ -307,14 +300,6 @@ export interface KTX2Params {
priorityFormats: KTX2TargetFormat[];
}
/** Used for initialize KTX2 transcoder. */
export enum KTX2Transcoder {
/** BinomialLLC transcoder. */
BinomialLLC,
/** Khronos transcoder. */
Khronos
}
declare module "@galacean/engine-core" {
interface EngineConfiguration {
/** KTX2 loader options. If set this option and workCount is great than 0, workers will be created. */
@@ -324,8 +309,6 @@ declare module "@galacean/engine-core" {
/** Global transcoding format queue which will be used if not specified in per-instance param, default is BC7/ASTC/BC3_BC1/ETC/PVRTC/R8G8B8A8. */
/** @deprecated */
priorityFormats?: KTX2TargetFormat[];
/** Used for initialize KTX2 transcoder, default is BinomialLLC. */
transcoder?: KTX2Transcoder;
};
}
}

View File

@@ -17,5 +17,9 @@ export enum KTX2TargetFormat {
/** RG format, 16 bits per pixel. */
R8G8,
/** RGBA format, 32 bits per pixel. */
R8G8B8A8
R8G8B8A8,
/** RGB HDR compressed format, 8 bits per pixel. */
BC6H,
/** RGBA format, 16 bits per channel. */
R16G16B16A16
}

View File

@@ -80,9 +80,15 @@ export function transcode(buffer: Uint8Array, targetFormat: any, KTX2File: any):
PVRTC1_4_RGB = 8,
PVRTC1_4_RGBA = 9,
ASTC_4x4 = 10,
RGBA8 = 13
RGBA8 = 13,
BC6H = 22,
RGB_HALF = 24,
RGBA_HALF = 25
}
/**
* A copy of `KTX2TargetFormat`, which need be to specify in the worker.
*/
enum TargetFormat {
ASTC,
BC7,
@@ -91,7 +97,9 @@ export function transcode(buffer: Uint8Array, targetFormat: any, KTX2File: any):
ETC,
R8,
RG8,
RGBA8
RGBA8,
BC6H,
RGBA16
}
function getTranscodeFormatFromTarget(target: TargetFormat, hasAlpha: boolean) {
@@ -102,16 +110,20 @@ export function transcode(buffer: Uint8Array, targetFormat: any, KTX2File: any):
return hasAlpha ? BasisFormat.ETC2 : BasisFormat.ETC1;
case TargetFormat.PVRTC:
return hasAlpha ? BasisFormat.PVRTC1_4_RGBA : BasisFormat.PVRTC1_4_RGB;
case TargetFormat.BC6H:
return BasisFormat.BC6H;
case TargetFormat.RGBA8:
return BasisFormat.RGBA8;
case TargetFormat.ASTC:
return BasisFormat.ASTC_4x4;
case TargetFormat.BC7:
return BasisFormat.BC7;
case TargetFormat.RGBA16:
return BasisFormat.RGBA_HALF;
}
}
function concat(arrays: Uint8Array[]) {
function concat(arrays: Uint8Array[] | Uint16Array[]) {
if (arrays.length === 1) return arrays[0];
let totalByteLength = 0;
@@ -160,7 +172,7 @@ export function transcode(buffer: Uint8Array, targetFormat: any, KTX2File: any):
for (let face = 0; face < faceCount; face++) {
const mipmaps = new Array(levelCount);
for (let mip = 0; mip < levelCount; mip++) {
const layerMips: Uint8Array[] = new Array(layerCount);
const layerMips: Uint8Array[] | Uint16Array[] = new Array(layerCount);
let mipWidth: number, mipHeight: number;
for (let layer = 0; layer < layerCount; layer++) {
@@ -177,10 +189,16 @@ export function transcode(buffer: Uint8Array, targetFormat: any, KTX2File: any):
mipHeight = levelInfo.origHeight;
}
const dst = new Uint8Array(ktx2File.getImageTranscodedSizeInBytes(mip, layer, 0, format));
let dst: Uint8Array | Uint16Array = new Uint8Array(
ktx2File.getImageTranscodedSizeInBytes(mip, layer, 0, format)
);
const status = ktx2File.transcodeImage(dst, mip, layer, face, format, 0, -1, -1);
if (targetFormat === TargetFormat.RGBA16) {
dst = new Uint16Array(dst.buffer, dst.byteOffset, dst.byteLength / Uint16Array.BYTES_PER_ELEMENT);
}
if (!status) {
cleanup();
throw new Error("transcodeImage failed.");

View File

@@ -1,83 +0,0 @@
import { KTX2Container, SupercompressionScheme } from "../KTX2Container";
import { KTX2TargetFormat } from "../KTX2TargetFormat";
import { AbstractTranscoder, EncodedData, KhronosTranscoderMessage, TranscodeResult } from "./AbstractTranscoder";
import { TranscodeWorkerCode } from "./KhronosWorkerCode";
/** @internal */
export class KhronosTranscoder extends AbstractTranscoder {
public static transcoderMap = {
// TODO: support bc7
[KTX2TargetFormat.ASTC]:
"https://mdn.alipayobjects.com/rms/afts/file/A*0jiKRK6D1-kAAAAAAAAAAAAAARQnAQ/uastc_astc.wasm"
};
constructor(
workerLimitCount: number,
public readonly type: KTX2TargetFormat
) {
super(workerLimitCount);
}
_initTranscodeWorkerPool() {
return fetch(KhronosTranscoder.transcoderMap[this.type])
.then((res) => res.arrayBuffer())
.then((wasmBuffer) => {
const funcCode = TranscodeWorkerCode.toString();
const workerURL = URL.createObjectURL(
new Blob([funcCode.substring(funcCode.indexOf("{") + 1, funcCode.lastIndexOf("}"))], {
type: "application/javascript"
})
);
return this._createTranscodePool(workerURL, wasmBuffer);
});
}
transcode(ktx2Container: KTX2Container): Promise<TranscodeResult> {
const needZstd = ktx2Container.supercompressionScheme === SupercompressionScheme.Zstd;
const levelCount = ktx2Container.levels.length;
const faceCount = ktx2Container.faceCount;
const decodedData: any = {
width: ktx2Container.pixelWidth,
height: ktx2Container.pixelHeight,
mipmaps: null
};
const postMessageData: KhronosTranscoderMessage = {
type: "transcode",
format: 0,
needZstd,
data: new Array<EncodedData[]>(faceCount)
};
const messageData = postMessageData.data;
for (let faceIndex = 0; faceIndex < faceCount; faceIndex++) {
const mipmapData = new Array(levelCount);
for (let mipmapIndex = 0; mipmapIndex < levelCount; mipmapIndex++) {
const level = ktx2Container.levels[mipmapIndex];
const levelWidth = Math.floor(ktx2Container.pixelWidth / (1 << mipmapIndex)) || 1;
const levelHeight = Math.floor(ktx2Container.pixelHeight / (1 << mipmapIndex)) || 1;
const originBuffer = level.levelData.buffer;
const originOffset = level.levelData.byteOffset;
const originByteLength = level.levelData.byteLength;
mipmapData[mipmapIndex] = {
buffer: new Uint8Array(originBuffer, originOffset, originByteLength),
levelWidth,
levelHeight,
uncompressedByteLength: level.uncompressedByteLength
};
}
messageData[faceIndex] = mipmapData;
}
return this._transcodeWorkerPool.postMessage(postMessageData).then((data) => {
decodedData.faces = data;
decodedData.hasAlpha = true;
return decodedData;
});
}
}

View File

@@ -1,191 +0,0 @@
import { EncodedData, IKhronosMessageMessage } from "./AbstractTranscoder";
interface WasmModule extends WebAssembly.Exports {
memory: WebAssembly.Memory;
transcode: (nBlocks: number) => number;
}
interface DecoderExports {
memory: Uint8Array;
ZSTD_findDecompressedSize: (compressedPtr: number, compressedSize: number) => number;
ZSTD_decompress: (
uncompressedPtr: number,
uncompressedSize: number,
compressedPtr: number,
compressedSize: number
) => number;
malloc: (ptr: number) => number;
free: (ptr: number) => void;
}
export function TranscodeWorkerCode() {
let wasmPromise: Promise<WasmModule>;
/**
* ZSTD (Zstandard) decoder.
*/
class ZSTDDecoder {
public static heap: Uint8Array;
public static IMPORT_OBJECT = {
env: {
emscripten_notify_memory_growth: function (): void {
ZSTDDecoder.heap = new Uint8Array(ZSTDDecoder.instance.exports.memory.buffer);
}
}
};
public static instance: { exports: DecoderExports };
public static WasmModuleURL =
"https://mdn.alipayobjects.com/rms/afts/file/A*awNJR7KqIAEAAAAAAAAAAAAAARQnAQ/zstddec.wasm";
public _initPromise: Promise<any>;
init(): Promise<void> {
if (!this._initPromise) {
this._initPromise = fetch(ZSTDDecoder.WasmModuleURL)
.then((response) => {
if (response.ok) {
return response.arrayBuffer();
}
throw new Error(
`Could not fetch the wasm component for the Zstandard decompression lib: ${response.status} - ${response.statusText}`
);
})
.then((arrayBuffer) => WebAssembly.instantiate(arrayBuffer, ZSTDDecoder.IMPORT_OBJECT))
.then(this._init);
}
return this._initPromise;
}
_init(result: WebAssembly.WebAssemblyInstantiatedSource): void {
ZSTDDecoder.instance = result.instance as unknown as {
exports: DecoderExports;
};
ZSTDDecoder.IMPORT_OBJECT.env.emscripten_notify_memory_growth(); // initialize heap.
}
decode(array: Uint8Array, uncompressedSize = 0): Uint8Array {
if (!ZSTDDecoder.instance) {
throw new Error(`ZSTDDecoder: Await .init() before decoding.`);
}
const exports = ZSTDDecoder.instance.exports;
// Write compressed data into WASM memory
const compressedSize = array.byteLength;
const compressedPtr = exports.malloc(compressedSize);
ZSTDDecoder.heap.set(array, compressedPtr);
// Decompress into WASM memory
uncompressedSize = uncompressedSize || Number(exports.ZSTD_findDecompressedSize(compressedPtr, compressedSize));
const uncompressedPtr = exports.malloc(uncompressedSize);
const actualSize = exports.ZSTD_decompress(uncompressedPtr, uncompressedSize, compressedPtr, compressedSize);
// Read decompressed data and free WASM memory
const dec = ZSTDDecoder.heap.slice(uncompressedPtr, uncompressedPtr + actualSize);
exports.free(compressedPtr);
exports.free(uncompressedPtr);
return dec;
}
}
function transcodeASTCAndBC7(wasmTranscoder: WasmModule, compressedData: Uint8Array, width: number, height: number) {
const nBlocks = ((width + 3) >> 2) * ((height + 3) >> 2);
const texMemoryPages = (nBlocks * 16 + 65535) >> 16;
const memory = wasmTranscoder.memory;
const delta = texMemoryPages + 1 - (memory.buffer.byteLength >> 16);
if (delta > 0) memory.grow(delta);
const textureView = new Uint8Array(memory.buffer, 65536, nBlocks * 16);
textureView.set(compressedData);
return wasmTranscoder.transcode(nBlocks) === 0 ? textureView : null;
}
function initWasm(buffer: ArrayBuffer): Promise<WasmModule> {
wasmPromise = WebAssembly.instantiate(buffer, {
env: { memory: new WebAssembly.Memory({ initial: 16 }) }
}).then((moduleWrapper) => <WasmModule>moduleWrapper.instance.exports);
return wasmPromise;
}
const zstdDecoder = new ZSTDDecoder();
function transcode(data: EncodedData[][], needZstd: boolean, wasmModule: WasmModule) {
const faceCount = data.length;
const result = new Array<
{
width: number;
height: number;
data: Uint8Array;
}[]
>(faceCount);
let promise = Promise.resolve();
if (needZstd) {
zstdDecoder.init();
promise = zstdDecoder._initPromise;
}
return promise.then(() => {
for (let faceIndex = 0; faceIndex < faceCount; faceIndex++) {
const mipmapCount = data[faceIndex].length;
const decodedData = new Array<{
width: number;
height: number;
data: Uint8Array;
}>(mipmapCount);
for (let i = 0; i < mipmapCount; i++) {
let { buffer, levelHeight, levelWidth, uncompressedByteLength } = data[faceIndex][i];
if (needZstd) buffer = zstdDecoder.decode(buffer.slice(), uncompressedByteLength);
const faceByteLength = buffer.byteLength / faceCount;
const originByteOffset = buffer.byteOffset;
const decodedBuffer = transcodeASTCAndBC7(
wasmModule,
new Uint8Array(buffer.buffer, originByteOffset + faceIndex * faceByteLength, faceByteLength),
levelWidth,
levelHeight
);
if (decodedBuffer) {
decodedData[i] = {
// use wasm memory as buffer, should slice to avoid duplicate
data: decodedBuffer.slice(),
width: levelWidth,
height: levelHeight
};
} else {
throw "buffer decoded error";
}
}
result[faceIndex] = decodedData;
}
return result;
});
}
self.onmessage = function onmessage(event: MessageEvent<IKhronosMessageMessage>) {
const message = event.data;
switch (message.type) {
case "init":
initWasm(message.transcoderWasm)
.then(() => {
self.postMessage("init-completed");
})
.catch((e) => {
self.postMessage({ error: e });
});
break;
case "transcode":
wasmPromise.then((module) => {
transcode(message.data, message.needZstd, module)
.then((decodedData) => {
self.postMessage(decodedData);
})
.catch((e) => self.postMessage({ error: e }));
});
break;
}
};
}

View File

@@ -161,6 +161,11 @@ export class GLTexture implements IPlatformTexture {
: GLCompressedTextureInternalFormat.RGBA_BPTC_UNORM_EXT,
isCompressed: true
};
case TextureFormat.BC6H:
return {
internalFormat: GLCompressedTextureInternalFormat.RGB_BPTC_UNSIGNED_FLOAT_EXT,
isCompressed: true
};
case TextureFormat.ETC1_RGB:
return {
internalFormat: GLCompressedTextureInternalFormat.RGB_ETC1_WEBGL,

View File

@@ -45,8 +45,6 @@ describe("ktx2 Loader test", function () {
expect(transcoder).not.to.be.null;
KTX2Loader.release();
// @ts-ignore
expect(KTX2Loader._khronosTranscoder).to.be.null;
// @ts-ignore
expect(KTX2Loader._binomialLLCTranscoder).to.be.null;
});