From 12f128cced52097948e49a6a1fdaea94477f09b9 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:02:15 -0400 Subject: [PATCH] refactor: add shared image codecs Add reusable raster and KTX2 codecs, consolidate codec build handling, and infer KTX2 encoding from texture usage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- package.json | 4 + packages/core/build/codecBuildPlugin.ts | 66 ++++ packages/core/docs/basics.md | 4 + packages/core/docs/blocks.md | 4 +- packages/core/docs/usage.md | 9 + packages/core/package.json | 1 + packages/core/src/blocks/encodeKtx2Block.ts | 85 ++---- packages/core/src/helpers/fitImageSize.ts | 3 + packages/core/src/resources/ktx2Decoder.ts | 283 ++++++++++++++++++ .../core/src/resources/ktx2DecoderResource.ts | 11 + packages/core/src/resources/ktx2Encoder.ts | 73 +++++ .../core/src/resources/ktx2EncoderResource.ts | 11 + packages/core/src/resources/ktx2Image.ts | 12 + .../src/resources/nodeRasterImageCodec.ts | 67 +++++ .../core/src/resources/rasterImageCodec.ts | 24 ++ .../src/resources/rasterImageCodecResource.ts | 18 ++ .../core/src/resources/webRasterImageCodec.ts | 234 +++++++++++++++ packages/core/src/types/assets.d.ts | 10 + packages/core/vite.config.ts | 24 +- pnpm-lock.yaml | 24 ++ tests/bundle/browserConsumerBundle.test.ts | 56 +++- tests/integration/encodeKtx2.test.ts | 71 ++++- tests/integration/ktx2Codecs.test.ts | 59 ++++ vitest.config.ts | 3 + 24 files changed, 1078 insertions(+), 78 deletions(-) create mode 100644 packages/core/build/codecBuildPlugin.ts create mode 100644 packages/core/src/helpers/fitImageSize.ts create mode 100644 packages/core/src/resources/ktx2Decoder.ts create mode 100644 packages/core/src/resources/ktx2DecoderResource.ts create mode 100644 packages/core/src/resources/ktx2Encoder.ts create mode 100644 packages/core/src/resources/ktx2EncoderResource.ts create mode 100644 packages/core/src/resources/ktx2Image.ts create mode 100644 packages/core/src/resources/nodeRasterImageCodec.ts create mode 100644 packages/core/src/resources/rasterImageCodec.ts create mode 100644 packages/core/src/resources/rasterImageCodecResource.ts create mode 100644 packages/core/src/resources/webRasterImageCodec.ts create mode 100644 tests/integration/ktx2Codecs.test.ts diff --git a/package.json b/package.json index 59c066f..2d0d889 100644 --- a/package.json +++ b/package.json @@ -20,16 +20,20 @@ "typedocs": "pnpm --filter @babylonjs/node-assets typedocs" }, "devDependencies": { + "@babylonjs/core": "9.21.2", + "@babylonjs/ktx2decoder": "9.21.2", "@babylonjs/node-assets": "workspace:*", "@eslint/js": "^10.0.1", "@gltf-transform/core": "4.5.0", "@gltf-transform/extensions": "4.5.0", "@types/node": "^26.1.1", + "babylonpress-ktx2-encoder": "0.6.0", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", "globals": "^17.7.0", "prettier": "^3.9.6", + "sharp": "0.35.4", "typedoc": "^0.28.20", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", diff --git a/packages/core/build/codecBuildPlugin.ts b/packages/core/build/codecBuildPlugin.ts new file mode 100644 index 0000000..cc4820e --- /dev/null +++ b/packages/core/build/codecBuildPlugin.ts @@ -0,0 +1,66 @@ +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { Plugin, ResolvedConfig } from "vite"; + +export const MscTranscoderModuleId = "virtual:node-assets-msc-transcoder"; +const ResolvedMscTranscoderModuleId = `\0${MscTranscoderModuleId}`; +const BasisEncoderWasmUrlModuleId = "virtual:node-assets-basis-encoder-wasm-url"; +const DracoNodeRuntimeDetection = /"object"==typeof process&&"object"==typeof process\.versions&&"string"==typeof process\.versions\.node/g; + +export function codecBuildPlugin(): Plugin { + let resolvePackage: ReturnType | undefined; + let isBuild = false; + return { + name: "node-assets-codecs", + enforce: "pre", + configResolved(config) { + resolvePackage = config.createResolver(); + isBuild = config.command === "build"; + }, + async resolveId(id) { + if (id === MscTranscoderModuleId) { + return ResolvedMscTranscoderModuleId; + } + if (id === BasisEncoderWasmUrlModuleId) { + // TODO: Remove this override when the encoder keeps its relative WASM URL valid after Vite pre-bundling. + const encoderEntry = await resolvePackage?.("babylonpress-ktx2-encoder", fileURLToPath(import.meta.url)); + if (encoderEntry === undefined) { + throw new Error("Unable to resolve the KTX2 encoder package."); + } + return `${resolve(dirname(encoderEntry), "../basis/basis_encoder.wasm")}?url&no-inline`; + } + }, + async load(id) { + if (id !== ResolvedMscTranscoderModuleId) { + return; + } + // TODO: Remove this conversion when @babylonjs/ktx2decoder exports the MSC transcoder as an ES module. + const path = createRequire(import.meta.url).resolve("@babylonjs/ktx2decoder/wasm/msc_basis_transcoder.js"); + const source = await readFile(path, "utf8"); + const umdWrapperOffset = source.indexOf("\nif (typeof exports"); + if (umdWrapperOffset === -1) { + throw new Error("Unable to locate the MSC transcoder UMD wrapper."); + } + const nodeRuntimeDetection = /ENVIRONMENT_IS_NODE=typeof process==="object"&&typeof process\.versions==="object"&&typeof process\.versions\.node==="string"/; + const esmSource = source.slice(0, umdWrapperOffset).replace(nodeRuntimeDetection, "ENVIRONMENT_IS_NODE=false"); + if (esmSource === source.slice(0, umdWrapperOffset)) { + throw new Error("Unable to replace the MSC transcoder runtime detection."); + } + return `/*! @babylonjs/ktx2decoder MSC transcoder, Apache-2.0 */\n${esmSource}\nexport default MSC_TRANSCODER;\n`; + }, + transform(code, id) { + if (!isBuild || !id.includes("/draco3dgltf/") || !id.endsWith("_nodejs.js")) { + return; + } + // TODO: Remove this rewrite when draco3dgltf provides a browser-safe conditional export. + const transformed = code.replace(DracoNodeRuntimeDetection, "false"); + if (transformed === code) { + throw new Error(`Unable to replace the Draco runtime detection in "${id}".`); + } + return { code: transformed, map: null }; + }, + }; +} diff --git a/packages/core/docs/basics.md b/packages/core/docs/basics.md index 5dd7ada..3809bb2 100644 --- a/packages/core/docs/basics.md +++ b/packages/core/docs/basics.md @@ -37,6 +37,10 @@ Runtime data is passed by reference. Resources are reusable values owned by a pipeline execution's resource scope, such as a shared `PlatformIO` instance. They are created on demand and shared by blocks within that execution. Blocks borrow resources; the scope retains them until execution completes or fails, then performs any required cleanup and releases its references. +`RasterImageCodecResource` provides `NodeRasterImageCodec` (Sharp) or `WebRasterImageCodec` (browser APIs). + +`KTX2DecoderResource` and `KTX2EncoderResource` load their codec dependencies independently, on first use. Decoded images carry their source encoding settings through pixel edits. + Worker-backed encoding is future work. # Blocks diff --git a/packages/core/docs/blocks.md b/packages/core/docs/blocks.md index 4e538aa..fe791da 100644 --- a/packages/core/docs/blocks.md +++ b/packages/core/docs/blocks.md @@ -34,8 +34,8 @@ - `EncodeKTX2Block` - Input: `Document` - Output: `Document` (but in future should be type that locks images and/or textures) - - Uses: `encodeToKTX2` (`babylonpress-ktx2-encoder`); `sharp` (Node.js only) - - Behavior: Compresses textures to KTX2 using encoder defaults, preserving color-space and normal-map semantics. + - Uses: `RasterImageCodecResource`, `KTX2EncoderResource`, and `PlatformIOResource` + - Behavior: Compresses compatible textures to KTX2, inferring encoding from material usage. - `EncodeDracoBlock` - Input: `Document` - Output: `Document` (but in future should be type that locks geometry) diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index d154f5c..29b8c6b 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -59,6 +59,15 @@ const asset = new NodeAsset({ const result = await asset.executeAsync(); ``` +# Encoding KTX2 textures + +`EncodeKTX2Block` infers encoding from material usage: + +- Color textures use ETC1S with an sRGB transfer function. +- Normal and other data textures use UASTC with a linear transfer function. +- Unused textures default to UASTC and linear. +- Textures shared across color, normal, or other data categories remain unchanged. + # Creating blocks ```ts diff --git a/packages/core/package.json b/packages/core/package.json index ac069fd..5f145bd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@babylonjs/core": "^9.21.2", + "@babylonjs/ktx2decoder": "9.21.2", "@babylonjs/loaders": "^9.21.2", "@babylonjs/serializers": "^9.21.2", "@gltf-transform/core": "4.5.0", diff --git a/packages/core/src/blocks/encodeKtx2Block.ts b/packages/core/src/blocks/encodeKtx2Block.ts index 6c2d2e4..fb39005 100644 --- a/packages/core/src/blocks/encodeKtx2Block.ts +++ b/packages/core/src/blocks/encodeKtx2Block.ts @@ -1,16 +1,16 @@ import type { Document, Texture } from "@gltf-transform/core"; import { EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions"; import { listTextureSlots } from "@gltf-transform/functions"; -import type { IEncodeOptions } from "babylonpress-ktx2-encoder"; -import type sharpFactory from "sharp"; import { GltfDocumentType } from "../connectionPoints/gltfDocument"; -import { isNodeRuntime } from "../helpers/isNodeRuntime"; +import type { KTX2Encoder } from "../resources/ktx2Encoder"; +import { KTX2EncoderResource } from "../resources/ktx2EncoderResource"; import { PlatformIOResource } from "../resources/platformIOResource"; +import { isRasterImageMimeType, type RasterImageCodec } from "../resources/rasterImageCodec"; +import { RasterImageCodecResource } from "../resources/rasterImageCodecResource"; import { Block, type BlockOptions } from "./block"; import { defineBlock } from "./blockDefinition"; -const SupportedMimeTypes = new Set(["image/jpeg", "image/png", "image/webp"]); const ColorTextureSlotPattern = /color|emissive|diffuse/i; const NormalTextureSlotPattern = /normal/i; @@ -20,10 +20,12 @@ const EncodeKtx2BlockDefinition = /* @__PURE__ */ defineBlock({ output: GltfDocumentType, resources: { io: PlatformIOResource, + ktx2Encoder: KTX2EncoderResource, + rasterImageCodec: RasterImageCodecResource, }, - runAsync: async (document, _config, { io }) => { + runAsync: async (document, _config, { io, ktx2Encoder, rasterImageCodec }) => { io.registerExtensions([KHRTextureBasisu]); - return encodeKtx2Async(document); + return encodeKtx2Async(document, rasterImageCodec, ktx2Encoder); }, }); @@ -34,10 +36,7 @@ export class EncodeKTX2Block extends Block { } } -async function encodeKtx2Async(document: Document): Promise { - // The package transform exposes both platform implementations, causing browser bundlers to discover Node built-ins. - // Use the conditionally exported root encoder until the transform provides platform-conditional exports. - const [{ encodeToKTX2 }, platformOptions] = await Promise.all([import("babylonpress-ktx2-encoder"), createPlatformOptionsAsync()]); +async function encodeKtx2Async(document: Document, rasterImageCodec: RasterImageCodec, ktx2Encoder: KTX2Encoder): Promise { let encodedTexture = false; await Promise.all( @@ -46,16 +45,17 @@ async function encodeKtx2Async(document: Document): Promise { .listTextures() .map(async (texture) => { const image = texture.getImage(); - if (image === null || texture.getMimeType() === "image/ktx2" || !SupportedMimeTypes.has(texture.getMimeType())) { + const mimeType = texture.getMimeType(); + if (image === null || !isRasterImageMimeType(mimeType)) { return; } - const options = { - isHDR: false, - ...platformOptions, - ...getTextureEncodingOptions(texture), - } satisfies IEncodeOptions; - texture.setImage(await encodeToKTX2(image, options)); + const options = getTextureEncodingOptions(texture); + if (options === null) { + return; + } + const decoded = await rasterImageCodec.decodeAsync(image, mimeType); + texture.setImage(await ktx2Encoder.encodeAsync(decoded, options)); texture.setMimeType("image/ktx2"); updateTextureUri(texture); encodedTexture = true; @@ -80,26 +80,32 @@ interface TextureEncodingOptions { readonly isNormalMap?: boolean; readonly isPerceptual: boolean; readonly isSetKTX2SRGBTransferFunc: boolean; + readonly isUASTC: boolean; + readonly useZstdSupercompression: boolean; } -function getTextureEncodingOptions(texture: Texture): TextureEncodingOptions { +function getTextureEncodingOptions(texture: Texture): TextureEncodingOptions | null { const slots = listTextureSlots(texture); - if (slots.some((slot) => ColorTextureSlotPattern.test(slot))) { + const hasColorUsage = slots.some((slot) => ColorTextureSlotPattern.test(slot)); + const hasNormalUsage = slots.some((slot) => NormalTextureSlotPattern.test(slot)); + const hasDataUsage = slots.some((slot) => !ColorTextureSlotPattern.test(slot) && !NormalTextureSlotPattern.test(slot)); + if (Number(hasColorUsage) + Number(hasNormalUsage) + Number(hasDataUsage) > 1) { + return null; + } + if (hasColorUsage) { return { isPerceptual: true, isSetKTX2SRGBTransferFunc: true, - }; - } - if (slots.some((slot) => NormalTextureSlotPattern.test(slot))) { - return { - isNormalMap: true, - isPerceptual: false, - isSetKTX2SRGBTransferFunc: false, + isUASTC: false, + useZstdSupercompression: false, }; } return { + isNormalMap: hasNormalUsage, isPerceptual: false, isSetKTX2SRGBTransferFunc: false, + isUASTC: true, + useZstdSupercompression: true, }; } @@ -114,30 +120,3 @@ function updateTextureUri(texture: Texture): void { const dotIndex = path.lastIndexOf("."); texture.setURI(dotIndex > slashIndex ? `${path.slice(0, dotIndex + 1)}ktx2` : `${path}.ktx2`); } - -interface PlatformEncodeOptions { - readonly imageDecoder?: (buffer: Uint8Array) => Promise<{ - readonly data: Uint8Array; - readonly height: number; - readonly width: number; - }>; -} - -async function createPlatformOptionsAsync(): Promise { - if (!isNodeRuntime()) { - return {}; - } - - const sharpModuleName = "sharp"; - const { default: sharp } = (await import(/* @vite-ignore */ sharpModuleName)) as { default: typeof sharpFactory }; - return { - imageDecoder: async (buffer) => { - const { data, info } = await sharp(buffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); - return { - data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), - height: info.height, - width: info.width, - }; - }, - }; -} diff --git a/packages/core/src/helpers/fitImageSize.ts b/packages/core/src/helpers/fitImageSize.ts new file mode 100644 index 0000000..2ddc418 --- /dev/null +++ b/packages/core/src/helpers/fitImageSize.ts @@ -0,0 +1,3 @@ +export function fitImageSize(width: number, height: number, maxSize: number): readonly [number, number] { + return width >= height ? [maxSize, Math.max(1, Math.round((height * maxSize) / width))] : [Math.max(1, Math.round((width * maxSize) / height)), maxSize]; +} diff --git a/packages/core/src/resources/ktx2Decoder.ts b/packages/core/src/resources/ktx2Decoder.ts new file mode 100644 index 0000000..d21a5d9 --- /dev/null +++ b/packages/core/src/resources/ktx2Decoder.ts @@ -0,0 +1,283 @@ +import type * as BabylonKTX2 from "@babylonjs/ktx2decoder"; + +import { isNodeRuntime } from "../helpers/isNodeRuntime"; +import { loadNodePackageFileAsync } from "../helpers/loadNodePackageFile"; +import type { DecodedKTX2Image, KTX2Encoding } from "./ktx2Image"; + +interface Ktx2Info { + readonly colorModel: number; + readonly flags: number; + readonly height: number; + readonly layerCount: number; + readonly levelCount: number; + readonly pixelDepth: number; + readonly faceCount: number; + readonly supercompressionScheme: number; + readonly transferFunction: number; + readonly typeSize: number; + readonly vkFormat: number; + readonly width: number; +} + +type DecoderModule = typeof BabylonKTX2; + +const Ktx2Identifier = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a] as const; +const Ktx2ColorModelEtc1s = 163; +const Ktx2ColorModelUastc = 166; +const Ktx2TransferLinear = 1; +const Ktx2TransferSrgb = 2; +const Ktx2SupercompressionNone = 0; +const Ktx2SupercompressionBasisLz = 1; +const Ktx2SupercompressionZstd = 2; + +let mscDecoderPromise: Promise | undefined; +let uastcSrgbDecoderPromise: Promise | undefined; +let uastcUnormDecoderPromise: Promise | undefined; +let zstdDecoderPromise: Promise | undefined; +const DefaultZstdDecoderUrl = "https://cdn.babylonjs.com/zstddec.wasm"; + +export class KTX2Decoder { + #modulesPromise: ReturnType | undefined; + + public async inspectAsync(image: Uint8Array): Promise> { + const { width, height } = readKtx2Info(image); + return { width, height }; + } + + public async decodeAsync(image: Uint8Array): Promise { + const info = readKtx2Info(image); + validateSupportedKtx2(info); + const encoding: KTX2Encoding = { + mode: info.colorModel === Ktx2ColorModelUastc ? "uastc" : "etc1s", + transferFunction: info.transferFunction === Ktx2TransferSrgb ? "srgb" : "linear", + mipmaps: info.levelCount > 1, + supercompression: info.supercompressionScheme === Ktx2SupercompressionZstd ? "zstd" : info.supercompressionScheme === Ktx2SupercompressionBasisLz ? "basis-lz" : "none", + }; + const modulesPromise = (this.#modulesPromise ??= loadDecoderModulesAsync()); + let modules: Awaited; + try { + modules = await modulesPromise; + } catch (error) { + if (this.#modulesPromise === modulesPromise) { + this.#modulesPromise = undefined; + } + throw error; + } + const [decoders, { EngineFormat }] = modules; + await loadDecoderAssetsAsync(decoders, encoding); + const decoded = await new decoders.KTX2Decoder().decode(image, {}, { forceRGBA: true }); + const topLevel = decoded.mipmaps[0]; + if ( + decoded.errors || + decoded.layerCount !== 1 || + decoded.transcodedFormat !== EngineFormat.RGBA8Format || + decoded.width !== info.width || + decoded.height !== info.height || + topLevel?.data === null || + topLevel?.data === undefined || + topLevel.width !== decoded.width || + topLevel.height !== decoded.height || + topLevel.layerIndex !== 0 || + topLevel.data.byteLength !== decoded.width * decoded.height * 4 + ) { + throw new Error( + `Unable to decode KTX2 texture to RGBA pixels: ${decoded.errors ?? `format=${decoded.transcodedFormat}, dimensions=${decoded.width}x${decoded.height}, layers=${decoded.layerCount}, topLevelBytes=${topLevel?.data?.byteLength ?? 0}`}` + ); + } + return { + data: topLevel.data, + height: decoded.height, + width: decoded.width, + encoding, + }; + } +} + +function loadDecoderModulesAsync() { + return Promise.all([import("@babylonjs/ktx2decoder"), import("@babylonjs/core/Materials/Textures/ktx2decoderTypes.js")]); +} + +async function loadDecoderAssetsAsync(decoders: DecoderModule, encoding: KTX2Encoding): Promise { + const decoderPromise = encoding.mode === "uastc" ? loadUastcDecoderAsync(decoders, encoding.transferFunction === "srgb") : loadMscDecoderAsync(decoders); + await (encoding.supercompression === "zstd" ? Promise.all([decoderPromise, loadZstdDecoderAsync(decoders)]) : decoderPromise); +} + +function loadMscDecoderAsync({ MSCTranscoder }: DecoderModule): Promise { + if (MSCTranscoder.JSModule !== null && MSCTranscoder.WasmBinary !== null) { + return Promise.resolve(); + } + if (mscDecoderPromise !== undefined) { + return mscDecoderPromise; + } + const promise = (async () => { + const [{ default: createMscTranscoderModule }, wasmBinary] = await Promise.all([ + import("virtual:node-assets-msc-transcoder"), + loadDecoderAssetAsync("msc_basis_transcoder.wasm", () => import("@babylonjs/ktx2decoder/wasm/msc_basis_transcoder.wasm?url&no-inline")), + ]); + MSCTranscoder.JSModule ??= createMscTranscoderModule; + MSCTranscoder.WasmBinary ??= wasmBinary; + })(); + mscDecoderPromise = promise; + clearFailedInitialization( + promise, + () => mscDecoderPromise, + (value) => (mscDecoderPromise = value) + ); + return promise; +} + +function loadUastcDecoderAsync({ LiteTranscoder_UASTC_RGBA_SRGB, LiteTranscoder_UASTC_RGBA_UNORM }: DecoderModule, isInGammaSpace: boolean): Promise { + if (isInGammaSpace) { + if (LiteTranscoder_UASTC_RGBA_SRGB.WasmBinary !== null) { + return Promise.resolve(); + } + if (uastcSrgbDecoderPromise !== undefined) { + return uastcSrgbDecoderPromise; + } + const promise = loadDecoderAssetAsync("uastc_rgba8_srgb_v2.wasm", () => import("@babylonjs/ktx2decoder/wasm/uastc_rgba8_srgb_v2.wasm?url&no-inline")).then((wasmBinary) => { + LiteTranscoder_UASTC_RGBA_SRGB.WasmBinary ??= wasmBinary; + }); + uastcSrgbDecoderPromise = promise; + clearFailedInitialization( + promise, + () => uastcSrgbDecoderPromise, + (value) => (uastcSrgbDecoderPromise = value) + ); + return promise; + } + if (LiteTranscoder_UASTC_RGBA_UNORM.WasmBinary !== null) { + return Promise.resolve(); + } + if (uastcUnormDecoderPromise !== undefined) { + return uastcUnormDecoderPromise; + } + const promise = loadDecoderAssetAsync("uastc_rgba8_unorm_v2.wasm", () => import("@babylonjs/ktx2decoder/wasm/uastc_rgba8_unorm_v2.wasm?url&no-inline")).then((wasmBinary) => { + LiteTranscoder_UASTC_RGBA_UNORM.WasmBinary ??= wasmBinary; + }); + uastcUnormDecoderPromise = promise; + clearFailedInitialization( + promise, + () => uastcUnormDecoderPromise, + (value) => (uastcUnormDecoderPromise = value) + ); + return promise; +} + +function loadZstdDecoderAsync({ ZSTDDecoder }: DecoderModule): Promise { + if (ZSTDDecoder.WasmModuleURL !== DefaultZstdDecoderUrl) { + return Promise.resolve(); + } + if (zstdDecoderPromise !== undefined) { + return zstdDecoderPromise; + } + const promise = (async () => { + const loadUrl = () => import("@babylonjs/ktx2decoder/wasm/zstddec.wasm?url&no-inline"); + const zstd = await loadDecoderAssetAsync("zstddec.wasm", loadUrl); + if (ZSTDDecoder.WasmModuleURL !== DefaultZstdDecoderUrl) { + return; + } + const objectUrl = URL.createObjectURL(new Blob([zstd])); + ZSTDDecoder.WasmModuleURL = objectUrl; + try { + await new ZSTDDecoder().init(); + } finally { + if (ZSTDDecoder.WasmModuleURL === objectUrl) { + ZSTDDecoder.WasmModuleURL = DefaultZstdDecoderUrl; + } + URL.revokeObjectURL(objectUrl); + } + })(); + zstdDecoderPromise = promise; + clearFailedInitialization( + promise, + () => zstdDecoderPromise, + (value) => (zstdDecoderPromise = value) + ); + return promise; +} + +function clearFailedInitialization(promise: Promise, getCurrent: () => Promise | undefined, setCurrent: (value: Promise | undefined) => void): void { + void promise.catch(() => { + if (getCurrent() === promise) { + setCurrent(undefined); + } + }); +} + +async function loadDecoderAssetAsync(fileName: string, loadUrl: () => Promise<{ default: string }>): Promise { + if (isNodeRuntime()) { + return loadNodePackageFileAsync(`@babylonjs/ktx2decoder/wasm/${fileName}`); + } + const { default: url } = await loadUrl(); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Unable to load KTX2 decoder asset: ${response.status} ${response.statusText}`); + } + return response.arrayBuffer(); +} + +function readKtx2Info(image: Uint8Array): Ktx2Info { + if (image.byteLength < 80 || !Ktx2Identifier.every((value, index) => image[index] === value)) { + throw new Error("Unable to read KTX2 texture header."); + } + const data = new DataView(image.buffer, image.byteOffset, image.byteLength); + const levelCount = data.getUint32(40, true); + const dfdByteOffset = data.getUint32(48, true); + const dfdByteLength = data.getUint32(52, true); + if (levelCount < 1 || 80 + levelCount * 24 > image.byteLength || dfdByteLength < 28 || dfdByteOffset + dfdByteLength > image.byteLength) { + throw new Error("Unable to read KTX2 texture structure."); + } + for (let level = 0; level < levelCount; level++) { + const levelOffset = 80 + level * 24; + const byteOffset = readUint64(data, levelOffset); + const byteLength = readUint64(data, levelOffset + 8); + if (byteLength < 1 || byteOffset + byteLength > image.byteLength) { + throw new Error("Unable to read KTX2 texture levels."); + } + } + return { + colorModel: data.getUint8(dfdByteOffset + 12), + faceCount: data.getUint32(36, true), + flags: data.getUint8(dfdByteOffset + 15), + height: data.getUint32(24, true), + layerCount: data.getUint32(32, true), + levelCount, + pixelDepth: data.getUint32(28, true), + supercompressionScheme: data.getUint32(44, true), + transferFunction: data.getUint8(dfdByteOffset + 14), + typeSize: data.getUint32(16, true), + vkFormat: data.getUint32(12, true), + width: data.getUint32(20, true), + }; +} + +function readUint64(data: DataView, offset: number): number { + const value = data.getBigUint64(offset, true); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("KTX2 texture offset exceeds the supported range."); + } + return Number(value); +} + +function validateSupportedKtx2(info: Ktx2Info): void { + const isEtc1s = info.colorModel === Ktx2ColorModelEtc1s; + const isUastc = info.colorModel === Ktx2ColorModelUastc; + const supportedSupercompression = + (isEtc1s && info.supercompressionScheme === Ktx2SupercompressionBasisLz) || + (isUastc && (info.supercompressionScheme === Ktx2SupercompressionNone || info.supercompressionScheme === Ktx2SupercompressionZstd)); + if ( + info.width < 1 || + info.height < 1 || + info.pixelDepth !== 0 || + info.layerCount !== 0 || + info.faceCount !== 1 || + info.vkFormat !== 0 || + info.typeSize !== 1 || + (!isEtc1s && !isUastc) || + (info.transferFunction !== Ktx2TransferLinear && info.transferFunction !== Ktx2TransferSrgb) || + info.flags !== 0 || + !supportedSupercompression + ) { + throw new Error("This KTX2 texture variant cannot be decoded."); + } +} diff --git a/packages/core/src/resources/ktx2DecoderResource.ts b/packages/core/src/resources/ktx2DecoderResource.ts new file mode 100644 index 0000000..92ba95b --- /dev/null +++ b/packages/core/src/resources/ktx2DecoderResource.ts @@ -0,0 +1,11 @@ +import type { KTX2Decoder } from "./ktx2Decoder"; +import type { Resource } from "./resource"; + +export const KTX2DecoderResource = { + name: "KTX2Decoder", + create: async () => { + const { KTX2Decoder } = await import("./ktx2Decoder"); + return new KTX2Decoder(); + }, + dispose: () => {}, +} satisfies Resource; diff --git a/packages/core/src/resources/ktx2Encoder.ts b/packages/core/src/resources/ktx2Encoder.ts new file mode 100644 index 0000000..38fc912 --- /dev/null +++ b/packages/core/src/resources/ktx2Encoder.ts @@ -0,0 +1,73 @@ +import type { IEncodeOptions } from "babylonpress-ktx2-encoder"; + +import type { DecodedKTX2Image, KTX2Encoding } from "./ktx2Image"; +import { validateDecodedRasterImage, type DecodedRasterImage } from "./rasterImageCodec"; + +export interface KTX2EncodeOptions { + readonly generateMipmaps?: boolean; + readonly isNormalMap?: boolean; + readonly isPerceptual: boolean; + readonly isSetKTX2SRGBTransferFunc: boolean; + readonly isUASTC?: boolean; + readonly useZstdSupercompression?: boolean; +} + +export class KTX2Encoder { + #encoderPromise: ReturnType | undefined; + + public encodeAsync(image: DecodedKTX2Image, options?: Partial): Promise; + public encodeAsync(image: DecodedRasterImage, options: KTX2EncodeOptions): Promise; + public async encodeAsync(image: DecodedRasterImage & { readonly encoding?: KTX2Encoding }, options: Partial = {}): Promise { + validateDecodedRasterImage(image); + const encoding = image.encoding; + const sourceIsSrgb = encoding === undefined ? undefined : encoding.transferFunction === "srgb"; + const isPerceptual = options.isPerceptual ?? sourceIsSrgb; + const isSetKTX2SRGBTransferFunc = options.isSetKTX2SRGBTransferFunc ?? sourceIsSrgb; + if (isPerceptual === undefined || isSetKTX2SRGBTransferFunc === undefined) { + throw new Error("KTX2 encoding requires source encoding metadata or explicit color-space options."); + } + const encoderPromise = (this.#encoderPromise ??= loadEncoderAsync()); + let encoder: Awaited; + try { + encoder = await encoderPromise; + } catch (error) { + if (this.#encoderPromise === encoderPromise) { + this.#encoderPromise = undefined; + } + throw error; + } + const { encodeToKTX2, wasmUrl } = encoder; + const encodeOptions: IEncodeOptions = { + imageDecoder: async () => image, + isHDR: false, + isPerceptual, + isSetKTX2SRGBTransferFunc, + wasmUrl, + }; + const generateMipmaps = options.generateMipmaps ?? encoding?.mipmaps; + const isUASTC = options.isUASTC ?? (encoding === undefined ? undefined : encoding.mode === "uastc"); + const useZstdSupercompression = options.useZstdSupercompression ?? (encoding === undefined ? undefined : isUASTC === true && encoding.supercompression === "zstd"); + if (generateMipmaps !== undefined) { + encodeOptions.generateMipmap = generateMipmaps; + } + if (options.isNormalMap !== undefined) { + encodeOptions.isNormalMap = options.isNormalMap; + } + if (isUASTC !== undefined) { + encodeOptions.isUASTC = isUASTC; + } + if (useZstdSupercompression !== undefined) { + encodeOptions.needSupercompression = useZstdSupercompression; + } + const encoded = await encodeToKTX2(new Uint8Array(), encodeOptions); + return encoded.byteOffset === 0 && encoded.byteLength === encoded.buffer.byteLength ? encoded : Uint8Array.from(encoded); + } +} + +async function loadEncoderAsync() { + // The package transform exposes both platform implementations, causing browser bundlers to discover Node built-ins. + // Use the conditionally exported root encoder until the transform provides platform-conditional exports. + // The package's relative WASM URL breaks when Vite pre-bundles the dependency. + const [{ encodeToKTX2 }, { default: wasmUrl }] = await Promise.all([import("babylonpress-ktx2-encoder"), import("virtual:node-assets-basis-encoder-wasm-url")]); + return { encodeToKTX2, wasmUrl }; +} diff --git a/packages/core/src/resources/ktx2EncoderResource.ts b/packages/core/src/resources/ktx2EncoderResource.ts new file mode 100644 index 0000000..4059ecb --- /dev/null +++ b/packages/core/src/resources/ktx2EncoderResource.ts @@ -0,0 +1,11 @@ +import type { KTX2Encoder } from "./ktx2Encoder"; +import type { Resource } from "./resource"; + +export const KTX2EncoderResource = { + name: "KTX2Encoder", + create: async () => { + const { KTX2Encoder } = await import("./ktx2Encoder"); + return new KTX2Encoder(); + }, + dispose: () => {}, +} satisfies Resource; diff --git a/packages/core/src/resources/ktx2Image.ts b/packages/core/src/resources/ktx2Image.ts new file mode 100644 index 0000000..64571a9 --- /dev/null +++ b/packages/core/src/resources/ktx2Image.ts @@ -0,0 +1,12 @@ +import type { DecodedRasterImage } from "./rasterImageCodec"; + +export interface KTX2Encoding { + readonly mode: "etc1s" | "uastc"; + readonly transferFunction: "linear" | "srgb"; + readonly mipmaps: boolean; + readonly supercompression: "none" | "basis-lz" | "zstd"; +} + +export interface DecodedKTX2Image extends DecodedRasterImage { + readonly encoding: KTX2Encoding; +} diff --git a/packages/core/src/resources/nodeRasterImageCodec.ts b/packages/core/src/resources/nodeRasterImageCodec.ts new file mode 100644 index 0000000..a6ba424 --- /dev/null +++ b/packages/core/src/resources/nodeRasterImageCodec.ts @@ -0,0 +1,67 @@ +import type sharpFactory from "sharp"; + +import { fitImageSize } from "../helpers/fitImageSize"; +import { validateDecodedRasterImage, type DecodedRasterImage, type RasterImageCodec, type RasterImageMimeType } from "./rasterImageCodec"; + +export class NodeRasterImageCodec implements RasterImageCodec { + #sharpPromise: Promise | undefined; + #isDisposed = false; + + public async decodeAsync(image: Uint8Array, _mimeType: RasterImageMimeType): Promise { + const sharp = await this.#getSharpAsync(); + const { data, info } = await sharp(image).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + return { + data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), + height: info.height, + width: info.width, + }; + } + + public async encodeAsync(image: DecodedRasterImage, mimeType: RasterImageMimeType): Promise { + validateDecodedRasterImage(image); + const sharp = await this.#getSharpAsync(); + return sharp(image.data, { raw: { channels: 4, height: image.height, width: image.width } }) + .toFormat(toSharpFormat(mimeType)) + .toBuffer(); + } + + public async resizeAsync(image: Uint8Array, mimeType: RasterImageMimeType, maxSize: number): Promise { + const sharp = await this.#getSharpAsync(); + const instance = sharp(image); + const metadata = await instance.metadata(); + const { width, height } = metadata.autoOrient; + if (width === undefined || height === undefined || width < 1 || height < 1) { + throw new Error("Unable to read positive texture dimensions."); + } + if (width <= maxSize && height <= maxSize) { + await instance.stats(); + return null; + } + + const [targetWidth, targetHeight] = fitImageSize(width, height, maxSize); + return instance.autoOrient().resize(targetWidth, targetHeight, { fit: "fill" }).toFormat(toSharpFormat(mimeType)).toBuffer(); + } + + public async disposeAsync(): Promise { + this.#isDisposed = true; + await this.#sharpPromise?.catch(() => {}); + this.#sharpPromise = undefined; + } + + async #getSharpAsync(): Promise { + if (this.#isDisposed) { + throw new Error("Raster image codec is disposed."); + } + return (this.#sharpPromise ??= loadSharpAsync()); + } +} + +function toSharpFormat(mimeType: RasterImageMimeType): "jpeg" | "png" | "webp" { + return mimeType === "image/jpeg" ? "jpeg" : mimeType === "image/png" ? "png" : "webp"; +} + +async function loadSharpAsync(): Promise { + const moduleName = "sharp"; + const { default: sharp } = (await import(/* @vite-ignore */ moduleName)) as { default: typeof sharpFactory }; + return sharp; +} diff --git a/packages/core/src/resources/rasterImageCodec.ts b/packages/core/src/resources/rasterImageCodec.ts new file mode 100644 index 0000000..7bf3cf8 --- /dev/null +++ b/packages/core/src/resources/rasterImageCodec.ts @@ -0,0 +1,24 @@ +export type RasterImageMimeType = "image/jpeg" | "image/png" | "image/webp"; + +export interface DecodedRasterImage { + readonly data: Uint8Array; + readonly height: number; + readonly width: number; +} + +export interface RasterImageCodec { + decodeAsync(image: Uint8Array, mimeType: RasterImageMimeType): Promise; + encodeAsync(image: DecodedRasterImage, mimeType: RasterImageMimeType): Promise; + resizeAsync(image: Uint8Array, mimeType: RasterImageMimeType, maxSize: number): Promise; + disposeAsync(): Promise; +} + +export function isRasterImageMimeType(value: string): value is RasterImageMimeType { + return value === "image/jpeg" || value === "image/png" || value === "image/webp"; +} + +export function validateDecodedRasterImage(image: DecodedRasterImage): void { + if (image.width < 1 || image.height < 1 || image.data.byteLength !== image.width * image.height * 4) { + throw new Error("Raster image data must contain RGBA8 pixels with positive dimensions."); + } +} diff --git a/packages/core/src/resources/rasterImageCodecResource.ts b/packages/core/src/resources/rasterImageCodecResource.ts new file mode 100644 index 0000000..1dceb5f --- /dev/null +++ b/packages/core/src/resources/rasterImageCodecResource.ts @@ -0,0 +1,18 @@ +import { isNodeRuntime } from "../helpers/isNodeRuntime"; +import type { Resource } from "./resource"; +import type { RasterImageCodec } from "./rasterImageCodec"; + +export const RasterImageCodecResource = { + name: "RasterImageCodec", + create: createRasterImageCodecAsync, + dispose: (codec) => codec.disposeAsync(), +} satisfies Resource; + +async function createRasterImageCodecAsync(): Promise { + if (isNodeRuntime()) { + const { NodeRasterImageCodec } = await import("./nodeRasterImageCodec"); + return new NodeRasterImageCodec(); + } + const { WebRasterImageCodec } = await import("./webRasterImageCodec"); + return new WebRasterImageCodec(); +} diff --git a/packages/core/src/resources/webRasterImageCodec.ts b/packages/core/src/resources/webRasterImageCodec.ts new file mode 100644 index 0000000..c115106 --- /dev/null +++ b/packages/core/src/resources/webRasterImageCodec.ts @@ -0,0 +1,234 @@ +import { fitImageSize } from "../helpers/fitImageSize"; +import { validateDecodedRasterImage, type DecodedRasterImage, type RasterImageCodec, type RasterImageMimeType } from "./rasterImageCodec"; + +type RasterCanvas = HTMLCanvasElement | OffscreenCanvas; +type RasterCanvasContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + +export class WebRasterImageCodec implements RasterImageCodec { + #canvas: RasterCanvas | undefined; + #context: RasterCanvasContext | undefined; + #gl: WebGL2RenderingContext | undefined; + #canvasTail: Promise = Promise.resolve(); + readonly #pending = new Set>(); + #accepting = true; + #disposePromise: Promise | undefined; + + public decodeAsync(image: Uint8Array, mimeType: RasterImageMimeType): Promise { + return this.#acceptAsync(async () => { + if (isWebP(image) && typeof globalThis.document !== "undefined" && typeof globalThis.Image !== "undefined") { + const source = await decodeImageElementAsync(image, mimeType); + return await this.#withCanvasAsync(source.width, source.height, (context) => { + context.drawImage(source, 0, 0); + const pixels = context.getImageData(0, 0, source.width, source.height); + return { + data: new Uint8Array(pixels.data.buffer, pixels.data.byteOffset, pixels.data.byteLength), + height: source.height, + width: source.width, + }; + }); + } + if (typeof createImageBitmap !== "function") { + throw new Error("Texture decoding requires createImageBitmap in this browser."); + } + const bitmap = await createImageBitmap(new Blob([toBlobPart(image)])); + try { + return this.#readBitmap(bitmap); + } finally { + bitmap.close(); + } + }); + } + + public encodeAsync(image: DecodedRasterImage, mimeType: RasterImageMimeType): Promise { + return this.#acceptAsync(async () => { + validateDecodedRasterImage(image); + return this.#withCanvasAsync(image.width, image.height, async (context, canvas) => { + context.putImageData(new ImageData(new Uint8ClampedArray(image.data), image.width, image.height), 0, 0); + return encodeCanvasAsync(canvas, mimeType); + }); + }); + } + + public resizeAsync(image: Uint8Array, mimeType: RasterImageMimeType, maxSize: number): Promise { + return this.#acceptAsync(async () => { + if (typeof createImageBitmap !== "function") { + throw new Error("Texture resizing requires createImageBitmap in this browser."); + } + const bitmap = await createImageBitmap(new Blob([toBlobPart(image)], { type: mimeType })); + try { + if (bitmap.width <= maxSize && bitmap.height <= maxSize) { + return null; + } + const [width, height] = fitImageSize(bitmap.width, bitmap.height, maxSize); + return await this.#withCanvasAsync(width, height, async (context, canvas) => { + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.drawImage(bitmap, 0, 0, width, height); + return encodeCanvasAsync(canvas, mimeType); + }); + } finally { + bitmap.close(); + } + }); + } + + public disposeAsync(): Promise { + if (this.#disposePromise !== undefined) { + return this.#disposePromise; + } + this.#accepting = false; + return (this.#disposePromise = (async () => { + await Promise.allSettled(Array.from(this.#pending)); + await this.#canvasTail; + if (this.#canvas !== undefined) { + this.#canvas.width = 0; + this.#canvas.height = 0; + } + this.#context = undefined; + this.#canvas = undefined; + if (this.#gl !== undefined) { + this.#gl.getExtension("WEBGL_lose_context")?.loseContext(); + this.#gl.canvas.width = 0; + this.#gl.canvas.height = 0; + this.#gl = undefined; + } + })()); + } + + #readBitmap(bitmap: ImageBitmap): DecodedRasterImage { + // Preserve the encoder's existing WebGL readback, including its alpha handling. + if (this.#gl === undefined) { + if (typeof OffscreenCanvas === "undefined") { + throw new Error("Texture decoding requires OffscreenCanvas."); + } + const gl = new OffscreenCanvas(128, 128).getContext("webgl2", { premultipliedAlpha: false }); + if (gl === null) { + throw new Error("Texture decoding requires a WebGL2 context."); + } + this.#gl = gl; + } + const gl = this.#gl; + const texture = gl.createTexture(); + const framebuffer = gl.createFramebuffer(); + try { + if (texture === null || framebuffer === null) { + throw new Error("Unable to allocate texture decoding resources."); + } + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { + throw new Error("Unable to read decoded texture pixels."); + } + const data = new Uint8Array(bitmap.width * bitmap.height * 4); + gl.readPixels(0, 0, bitmap.width, bitmap.height, gl.RGBA, gl.UNSIGNED_BYTE, data); + return { data, height: bitmap.height, width: bitmap.width }; + } finally { + gl.bindTexture(gl.TEXTURE_2D, null); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.deleteTexture(texture); + gl.deleteFramebuffer(framebuffer); + } + } + + #acceptAsync(operation: () => Promise): Promise { + if (!this.#accepting) { + return Promise.reject(new Error("Raster image codec is disposed.")); + } + const pending = Promise.resolve().then(operation); + this.#pending.add(pending); + pending.then( + () => this.#pending.delete(pending), + () => this.#pending.delete(pending) + ); + return pending; + } + + #withCanvasAsync(width: number, height: number, operation: (context: RasterCanvasContext, canvas: RasterCanvas) => TValue | Promise): Promise { + const result = this.#canvasTail.then(async () => { + const { canvas, context } = this.#getCanvas(); + canvas.width = width; + canvas.height = height; + return operation(context, canvas); + }); + this.#canvasTail = result.then( + () => {}, + () => {} + ); + return result; + } + + #getCanvas(): { readonly canvas: RasterCanvas; readonly context: RasterCanvasContext } { + if (this.#canvas !== undefined && this.#context !== undefined) { + return { canvas: this.#canvas, context: this.#context }; + } + + let canvas: RasterCanvas; + let context: RasterCanvasContext | null; + if (typeof OffscreenCanvas !== "undefined") { + canvas = new OffscreenCanvas(1, 1); + context = canvas.getContext("2d", { willReadFrequently: true }); + } else if (typeof globalThis.document !== "undefined") { + canvas = globalThis.document.createElement("canvas"); + context = canvas.getContext("2d", { willReadFrequently: true }); + } else { + throw new Error("Texture processing requires canvas support in this browser."); + } + if (context === null) { + throw new Error("Texture processing requires a 2D canvas context."); + } + this.#canvas = canvas; + this.#context = context; + return { canvas, context }; + } +} + +async function decodeImageElementAsync(image: Uint8Array, mimeType: RasterImageMimeType): Promise { + const objectUrl = URL.createObjectURL(new Blob([toBlobPart(image)], { type: mimeType })); + try { + const element = new Image(); + element.decoding = "async"; + await new Promise((resolve, reject) => { + element.onload = () => resolve(); + element.onerror = () => reject(new Error("Failed to decode WebP image.")); + element.src = objectUrl; + }); + return element; + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +function toBlobPart(image: Uint8Array): BlobPart { + return image.buffer instanceof ArrayBuffer ? new Uint8Array(image.buffer, image.byteOffset, image.byteLength) : Uint8Array.from(image); +} + +async function encodeCanvasAsync(canvas: RasterCanvas, mimeType: RasterImageMimeType): Promise { + let blob: Blob; + if (typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas) { + blob = await canvas.convertToBlob({ type: mimeType }); + } else { + blob = await new Promise((resolve, reject) => { + (canvas as HTMLCanvasElement).toBlob((result) => (result === null ? reject(new Error("Unable to encode raster texture.")) : resolve(result)), mimeType); + }); + } + if (blob.type !== mimeType) { + throw new Error(`This browser cannot encode ${mimeType} textures.`); + } + return new Uint8Array(await blob.arrayBuffer()); +} + +function isWebP(image: Uint8Array): boolean { + return ( + image.byteLength >= 12 && + image[0] === 0x52 && + image[1] === 0x49 && + image[2] === 0x46 && + image[3] === 0x46 && + image[8] === 0x57 && + image[9] === 0x45 && + image[10] === 0x42 && + image[11] === 0x50 + ); +} diff --git a/packages/core/src/types/assets.d.ts b/packages/core/src/types/assets.d.ts index d4df950..4e3bc72 100644 --- a/packages/core/src/types/assets.d.ts +++ b/packages/core/src/types/assets.d.ts @@ -2,3 +2,13 @@ declare module "*?url&no-inline" { const url: string; export default url; } + +declare module "virtual:node-assets-msc-transcoder" { + const createModule: (options: { readonly wasmBinary: ArrayBuffer }) => Promise; + export default createModule; +} + +declare module "virtual:node-assets-basis-encoder-wasm-url" { + const url: string; + export default url; +} diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 11e8308..9777b85 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url"; import { defineConfig, type Plugin } from "vite"; import dts from "vite-plugin-dts"; +import { codecBuildPlugin } from "./build/codecBuildPlugin"; + const EmptyNodeBuiltinModuleId = "\0node-assets-empty-node-builtin"; export default defineConfig({ @@ -20,7 +22,7 @@ export default defineConfig({ }, rollupOptions: { external: (id) => - /^@babylonjs\//.test(id) || + (/^@babylonjs\//.test(id) && !/^@babylonjs\/ktx2decoder\/wasm\//.test(id)) || /^@gltf-transform\//.test(id) || /^babylonpress-ktx2-encoder(?:\/|$)/.test(id) || /^gltf-validator$/.test(id) || @@ -29,7 +31,7 @@ export default defineConfig({ }, }, plugins: [ - forceBundledDracoWebAssemblyRuntime(), + codecBuildPlugin(), emptyNodeBuiltins(), dts({ tsconfigPath: "./tsconfig.build.json", @@ -38,24 +40,6 @@ export default defineConfig({ ], }); -function forceBundledDracoWebAssemblyRuntime(): Plugin { - const nodeRuntimeDetection = /"object"==typeof process&&"object"==typeof process\.versions&&"string"==typeof process\.versions\.node/g; - return { - name: "node-assets-force-bundled-draco-wasm-runtime", - enforce: "pre", - transform(code, id) { - if (!id.includes("/draco3dgltf/") || !id.endsWith("_nodejs.js")) { - return; - } - const transformed = code.replace(nodeRuntimeDetection, "false"); - if (transformed === code) { - throw new Error(`Unable to replace the Draco runtime detection in "${id}".`); - } - return { code: transformed, map: null }; - }, - }; -} - function emptyNodeBuiltins(): Plugin { return { name: "node-assets-empty-node-builtins", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4398bc6..6eb8a4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: devDependencies: + '@babylonjs/core': + specifier: 9.21.2 + version: 9.21.2 + '@babylonjs/ktx2decoder': + specifier: 9.21.2 + version: 9.21.2(@babylonjs/core@9.21.2) '@babylonjs/node-assets': specifier: workspace:* version: link:packages/core @@ -23,6 +29,9 @@ importers: '@types/node': specifier: ^26.1.1 version: 26.2.0 + babylonpress-ktx2-encoder: + specifier: 0.6.0 + version: 0.6.0 eslint: specifier: ^10.7.0 version: 10.8.1 @@ -38,6 +47,9 @@ importers: prettier: specifier: ^3.9.6 version: 3.9.6 + sharp: + specifier: 0.35.4 + version: 0.35.4(@types/node@26.2.0) typedoc: specifier: ^0.28.20 version: 0.28.20(typescript@6.0.3) @@ -68,6 +80,9 @@ importers: '@babylonjs/core': specifier: ^9.21.2 version: 9.21.2 + '@babylonjs/ktx2decoder': + specifier: 9.21.2 + version: 9.21.2(@babylonjs/core@9.21.2) '@babylonjs/loaders': specifier: ^9.21.2 version: 9.21.2(@babylonjs/core@9.21.2)(babylonjs-gltf2interface@9.21.2) @@ -124,6 +139,11 @@ packages: '@babylonjs/core@9.21.2': resolution: {integrity: sha512-y4brONcZ4X5rfoiLqTdd9jSZzyIeJ+zHhvN6qgDCpgVIyM2sJlCouLBSvNn+Kl7jIxmzJhIvbBvUIS/sx8jFUw==} + '@babylonjs/ktx2decoder@9.21.2': + resolution: {integrity: sha512-G5DAxbkIVHQKAvDXvnDvH/PFWdi1PkjsTrDVNQux1+S6IG+EYrkyqt3EqFBLeprzyYoiV20r1g2wrOFCVslshA==} + peerDependencies: + '@babylonjs/core': ^9.0.0 + '@babylonjs/loaders@9.21.2': resolution: {integrity: sha512-ZPfVpNBbxZhL3Xns355gyIFBfEgq4qGAbW/Rf5rwvkivTbQcKQRTmp+GTieTdPtEcPGBTSBVFnr/LioKEmJlvQ==} peerDependencies: @@ -1690,6 +1710,10 @@ snapshots: '@babylonjs/core@9.21.2': {} + '@babylonjs/ktx2decoder@9.21.2(@babylonjs/core@9.21.2)': + dependencies: + '@babylonjs/core': 9.21.2 + '@babylonjs/loaders@9.21.2(@babylonjs/core@9.21.2)(babylonjs-gltf2interface@9.21.2)': dependencies: '@babylonjs/core': 9.21.2 diff --git a/tests/bundle/browserConsumerBundle.test.ts b/tests/bundle/browserConsumerBundle.test.ts index 1376f89..6c2908e 100644 --- a/tests/bundle/browserConsumerBundle.test.ts +++ b/tests/bundle/browserConsumerBundle.test.ts @@ -1,3 +1,5 @@ +import { readFile, readdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { pathToFileURL } from "node:url"; @@ -37,9 +39,46 @@ describe("browser consumer bundle", () => { expect(transformedModuleIds).toContain(PublishedEntryPath); expect(fileNames.some((fileName) => /draco_decoder_gltf.*\.wasm$/.test(fileName))).toBe(true); expect(fileNames.some((fileName) => /draco_encoder.*\.wasm$/.test(fileName))).toBe(true); + expect(fileNames.some((fileName) => /basis_encoder.*\.js$/.test(fileName))).toBe(true); + expect(fileNames.some((fileName) => /basis_encoder.*\.wasm$/.test(fileName))).toBe(true); + }, 120_000); + + it("tree-shakes KTX2 decoder code from an encoder-only published consumer", async () => { + const transformedModuleIds = new Set(); + const result = await build({ + configFile: false, + logLevel: "silent", + plugins: [rejectSharp(), createEncoderOnlyConsumerPlugin(), trackTransformedModules(transformedModuleIds)], + build: { + assetsInlineLimit: 0, + rollupOptions: { + input: "node-assets:encoder-only-browser-consumer", + }, + write: false, + }, + }); + if (Array.isArray(result) || !("output" in result)) { + throw new Error("Expected one consumer bundle"); + } + + const fileNames = result.output.map(({ fileName }) => fileName); + const publishedFiles = await readdir(dirname(PublishedEntryPath)); + const encoderFactory = await readFile(new URL("../../node_modules/babylonpress-ktx2-encoder/dist/basis/basis_encoder.js", import.meta.url)); + const publishedScripts = await Promise.all( + publishedFiles.filter((fileName) => fileName.endsWith(".js")).map((fileName) => readFile(resolve(dirname(PublishedEntryPath), fileName))) + ); + expect(transformedModuleIds).toContain(PublishedEntryPath); + expect(publishedScripts.some((script) => script.equals(encoderFactory))).toBe(false); + expect(publishedFiles.some((fileName) => /basis_encoder.*\.wasm$/.test(fileName))).toBe(true); + expect(fileNames.some((fileName) => /basis_encoder.*\.js$/.test(fileName))).toBe(true); + expect(fileNames.some((fileName) => /basis_encoder.*\.wasm$/.test(fileName))).toBe(true); + const chunks = result.output.filter((entry) => entry.type === "chunk"); + // Vite can emit unreferenced assets during module scanning; check the executable output graph. + expect(chunks.some((chunk) => Object.keys(chunk.modules).some((id) => id.includes("babylonpress-ktx2-encoder")))).toBe(true); + expect(chunks.some((chunk) => Object.keys(chunk.modules).some((id) => id.includes("ktx2Decoder") || id.includes("@babylonjs/ktx2decoder")))).toBe(false); + expect(chunks.some((chunk) => chunk.dynamicImports.some((id) => /ktx2Decoder|msc-transcoder/.test(id)))).toBe(false); }, 120_000); - // TODO: Execute the published entry in a real browser once browser integration testing is available. it("runs the published entry in Node", async () => { const url = "https://example.com/model.gltf"; vi.stubGlobal( @@ -87,6 +126,21 @@ function createConsumerPlugin(): Plugin { }; } +function createEncoderOnlyConsumerPlugin(): Plugin { + const moduleId = "\0node-assets-encoder-only-browser-consumer"; + return { + name: "node-assets-encoder-only-browser-consumer", + resolveId: (id) => (id === "node-assets:encoder-only-browser-consumer" ? moduleId : undefined), + load: (id) => + id === moduleId + ? ` + import { EncodeKTX2Block } from ${JSON.stringify(PublishedPackageName)}; + globalThis.EncodeKTX2Block = EncodeKTX2Block; + ` + : undefined, + }; +} + function rejectSharp(): Plugin { return { name: "node-assets-reject-sharp", diff --git a/tests/integration/encodeKtx2.test.ts b/tests/integration/encodeKtx2.test.ts index c451c9f..26a84cf 100644 --- a/tests/integration/encodeKtx2.test.ts +++ b/tests/integration/encodeKtx2.test.ts @@ -52,7 +52,7 @@ describe("KTX2 encoding", () => { } }); - it("encodes a texture shared by color and normal slots once", async () => { + it("leaves a texture shared by color and normal slots unchanged", async () => { const rootUrl = "https://example.com/assets/model.obj"; const mtlUrl = "https://example.com/assets/materials/model.mtl"; const textureUrl = "https://example.com/assets/materials/textures/diffuse.png"; @@ -86,7 +86,8 @@ describe("KTX2 encoding", () => { expect(parsed.json.images).toHaveLength(1); expect(colorImageIndex).toBe(normalImageIndex); - expectKtx2Image(parsed); + expect(parsed.json.images?.[0]?.mimeType).toBe("image/png"); + expect(parsed.json.extensionsUsed ?? []).not.toContain("KHR_texture_basisu"); } finally { vi.unstubAllGlobals(); } @@ -139,6 +140,51 @@ describe("KTX2 encoding", () => { ).toEqual(["albedo.ktx2", "normal.ktx2"]); }); + it.each([ + { + name: "color", + attach: (document: Document, texture: ReturnType) => document.createMaterial().setBaseColorTexture(texture), + expected: { mode: 163, transfer: 2, supercompression: 1 }, + }, + { + name: "normal", + attach: (document: Document, texture: ReturnType) => document.createMaterial().setNormalTexture(texture), + expected: { mode: 166, transfer: 1, supercompression: 2 }, + }, + { + name: "data", + attach: (document: Document, texture: ReturnType) => document.createMaterial().setOcclusionTexture(texture), + expected: { mode: 166, transfer: 1, supercompression: 2 }, + }, + { + name: "unused", + attach: () => {}, + expected: { mode: 166, transfer: 1, supercompression: 2 }, + }, + ])("infers $name texture encoding from usage", async ({ attach, expected }) => { + const document = new Document(); + const texture = document.createTexture().setMimeType("image/png").setImage(generateTextureData()); + attach(document, texture); + + await new NodeAsset({ name: "inferred-ktx2-options", outputBlock: new EncodeKTX2Block({ input: document }) }).executeAsync(); + + expect(readKtx2Encoding(texture.getImage()!)).toEqual(expected); + }); + + it("leaves a texture shared by normal and other data slots unchanged", async () => { + const document = new Document(); + const image = generateTextureData(); + const texture = document.createTexture().setMimeType("image/png").setImage(image); + const material = document.createMaterial().setNormalTexture(texture).setOcclusionTexture(texture); + + await new NodeAsset({ name: "conflicting-data-usage", outputBlock: new EncodeKTX2Block({ input: document }) }).executeAsync(); + + expect(material.getNormalTexture()).toBe(texture); + expect(material.getOcclusionTexture()).toBe(texture); + expect(texture.getImage()).toEqual(image); + expect(texture.getMimeType()).toBe("image/png"); + }); + it("removes the WebP extension after converting every WebP texture", async () => { const document = new Document(); document.createExtension(EXTTextureWebP).setRequired(true); @@ -148,6 +194,17 @@ describe("KTX2 encoding", () => { expect(result.hasExtension(EXTTextureWebP.EXTENSION_NAME)).toBe(false); }); + + it("returns encoded texture bytes without retaining encoder scratch storage", async () => { + const document = new Document(); + const texture = document.createTexture().setMimeType("image/png").setImage(generateTextureData()); + + await new NodeAsset({ name: "tight-ktx2-output", outputBlock: new EncodeKTX2Block({ input: document }) }).executeAsync(); + + const image = texture.getImage(); + expect(image).not.toBeNull(); + expect(image!.buffer.byteLength).toBe(image!.byteLength); + }); }); async function encodeGltfAsync(input: string): Promise { @@ -158,3 +215,13 @@ async function encodeGltfAsync(input: string): Promise { encoder.output.connectTo(destination.input); return new NodeAsset({ name: "encode-ktx2", outputBlock: destination }).executeAsync(); } + +function readKtx2Encoding(image: Uint8Array): { readonly mode: number; readonly transfer: number; readonly supercompression: number } { + const header = new DataView(image.buffer, image.byteOffset, image.byteLength); + const dfdOffset = header.getUint32(48, true); + return { + mode: header.getUint8(dfdOffset + 12), + transfer: header.getUint8(dfdOffset + 14), + supercompression: header.getUint32(44, true), + }; +} diff --git a/tests/integration/ktx2Codecs.test.ts b/tests/integration/ktx2Codecs.test.ts new file mode 100644 index 0000000..a779445 --- /dev/null +++ b/tests/integration/ktx2Codecs.test.ts @@ -0,0 +1,59 @@ +import { encodeToKTX2 } from "babylonpress-ktx2-encoder"; +import { describe, expect, it } from "vitest"; + +import { KTX2Decoder } from "../../packages/core/src/resources/ktx2Decoder"; +import { KTX2Encoder } from "../../packages/core/src/resources/ktx2Encoder"; + +describe("KTX2 codec round trips", () => { + it("keeps encoding settings with each decoded image", async () => { + const decoder = new KTX2Decoder(); + const encoder = new KTX2Encoder(); + const etc1s = await createImageAsync(false); + const uastc = await createImageAsync(true); + + await expect(decoder.inspectAsync(etc1s)).resolves.toEqual({ width: 32, height: 16 }); + const [first, second] = await Promise.all([decoder.decodeAsync(etc1s), decoder.decodeAsync(uastc)]); + const [secondResult, firstResult] = await Promise.all([encoder.encodeAsync(second), encoder.encodeAsync(first)]); + + expect(readEncoding(firstResult)).toEqual({ mode: 163, transfer: 2, levels: 1, compression: 1 }); + expect(readEncoding(secondResult)).toEqual({ mode: 166, transfer: 1, levels: 6, compression: 2 }); + }); + + it("applies partial overrides without changing the source encoding settings", async () => { + const decoder = new KTX2Decoder(); + const encoder = new KTX2Encoder(); + const image = await decoder.decodeAsync(await createImageAsync(true)); + + const withoutMipmaps = await encoder.encodeAsync(image, { generateMipmaps: false }); + const asEtc1s = await encoder.encodeAsync(image, { isUASTC: false }); + const unchangedSettings = await encoder.encodeAsync(image); + + expect(readEncoding(withoutMipmaps)).toEqual({ mode: 166, transfer: 1, levels: 1, compression: 2 }); + expect(readEncoding(asEtc1s)).toEqual({ mode: 163, transfer: 1, levels: 6, compression: 1 }); + expect(readEncoding(unchangedSettings)).toEqual({ mode: 166, transfer: 1, levels: 6, compression: 2 }); + }); +}); + +function createImageAsync(isUASTC: boolean): Promise { + const data = new Uint8Array(32 * 16 * 4).fill(127); + return encodeToKTX2(new Uint8Array(), { + imageDecoder: async () => ({ data, width: 32, height: 16 }), + isHDR: false, + isUASTC, + isPerceptual: !isUASTC, + isSetKTX2SRGBTransferFunc: !isUASTC, + generateMipmap: isUASTC, + needSupercompression: isUASTC, + }); +} + +function readEncoding(image: Uint8Array): { readonly mode: number; readonly transfer: number; readonly levels: number; readonly compression: number } { + const header = new DataView(image.buffer, image.byteOffset, image.byteLength); + const dfdOffset = header.getUint32(48, true); + return { + mode: header.getUint8(dfdOffset + 12), + transfer: header.getUint8(dfdOffset + 14), + levels: header.getUint32(40, true), + compression: header.getUint32(44, true), + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 499a798..0a2f26a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,14 @@ import { defineConfig } from "vitest/config"; +import { codecBuildPlugin } from "./packages/core/build/codecBuildPlugin"; + // Tests use a single Node project. The library API is identical in Node and the // browser. WebAssembly and worker loading differ between runtimes for Draco, // Meshopt, and KTX2. Add browser coverage as a separate Playwright project when // those codecs are implemented; do not use Vitest browser mode. Babylon.js and // Babylon-Lite use this configuration. export default defineConfig({ + plugins: [codecBuildPlugin()], test: { environment: "node", include: ["tests/**/*.test.ts"],