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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions packages/core/build/codecBuildPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<ResolvedConfig["createResolver"]> | 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 };
},
};
}
4 changes: 4 additions & 0 deletions packages/core/docs/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/core/docs/blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions packages/core/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 32 additions & 53 deletions packages/core/src/blocks/encodeKtx2Block.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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);
},
});

Expand All @@ -34,10 +36,7 @@ export class EncodeKTX2Block extends Block<typeof EncodeKtx2BlockDefinition> {
}
}

async function encodeKtx2Async(document: Document): Promise<Document> {
// 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<Document> {
let encodedTexture = false;

await Promise.all(
Expand All @@ -46,16 +45,17 @@ async function encodeKtx2Async(document: Document): Promise<Document> {
.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;
Expand All @@ -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,
};
}

Expand All @@ -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<PlatformEncodeOptions> {
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,
};
},
};
}
3 changes: 3 additions & 0 deletions packages/core/src/helpers/fitImageSize.ts
Original file line number Diff line number Diff line change
@@ -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];
}
Loading
Loading