Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to compress textures with Basis Universal #16142

Draft
wants to merge 19 commits into
base: master
Choose a base branch
from
Draft
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
21 changes: 1 addition & 20 deletions packages/dev/core/src/Meshes/Compression/dracoCodec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { _GetDefaultNumWorkers } from "core/Misc/workerUtils";
import { Tools } from "../../Misc/tools";
import { AutoReleaseWorkerPool } from "../../Misc/workerPool";
import type { WorkerPool } from "../../Misc/workerPool";
Expand Down Expand Up @@ -46,26 +47,6 @@ export interface IDracoCodecConfiguration {
jsModule?: unknown /* DracoDecoderModule | DracoEncoderModule */;
}

/**
* @internal
*/
export function _GetDefaultNumWorkers(): number {
if (typeof navigator !== "object" || !navigator.hardwareConcurrency) {
return 1;
}

// Use 50% of the available logical processors but capped at 4.
return Math.min(Math.floor(navigator.hardwareConcurrency * 0.5), 4);
}

/**
* @internal
*/
export function _IsConfigurationAvailable(config: IDracoCodecConfiguration): boolean {
return !!((config.wasmUrl && (config.wasmBinary || config.wasmBinaryUrl) && typeof WebAssembly === "object") || config.fallbackUrl);
// TODO: Account for jsModule
}

/**
* Base class for a Draco codec.
* @internal
Expand Down
4 changes: 2 additions & 2 deletions packages/dev/core/src/Meshes/Compression/dracoCompression.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { _GetDefaultNumWorkers, _IsConfigurationAvailable } from "./dracoCodec";
import { _GetDefaultNumWorkers } from "core/Misc/workerUtils";
import type { IDracoCodecConfiguration } from "./dracoCodec";
import { DracoDecoder } from "./dracoDecoder";
import type { MeshData } from "./dracoDecoder.types";
Expand Down Expand Up @@ -87,7 +87,7 @@ export class DracoCompression {
* Returns true if the decoder configuration is available.
*/
public static get DecoderAvailable(): boolean {
return _IsConfigurationAvailable(DracoDecoder.DefaultConfiguration);
return DracoDecoder.DefaultAvailable;
}

/**
Expand Down
6 changes: 4 additions & 2 deletions packages/dev/core/src/Meshes/Compression/dracoDecoder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DracoDecoderModule } from "draco3dgltf";
import { _IsConfigurationAvailable, DracoCodec, type IDracoCodecConfiguration } from "./dracoCodec";
import { DracoCodec, type IDracoCodecConfiguration } from "./dracoCodec";
import { Tools } from "../../Misc/tools";
import { Geometry } from "../geometry";
import { VertexBuffer } from "../buffer";
Expand All @@ -9,6 +9,7 @@ import type { Scene } from "../../scene";
import type { Nullable } from "../../types";
import { DecodeMesh, DecoderWorkerFunction } from "./dracoCompressionWorker";
import type { AttributeData, MeshData, DecoderMessage } from "./dracoDecoder.types";
import { _IsWasmConfigurationAvailable } from "core/Misc/workerUtils";

// eslint-disable-next-line @typescript-eslint/naming-convention
declare let DracoDecoderModule: DracoDecoderModule;
Expand Down Expand Up @@ -58,7 +59,8 @@ export class DracoDecoder extends DracoCodec {
* Returns true if the decoder's `DefaultConfiguration` is available.
*/
public static get DefaultAvailable(): boolean {
return _IsConfigurationAvailable(DracoDecoder.DefaultConfiguration);
const config = DracoDecoder.DefaultConfiguration;
return !!config.fallbackUrl || _IsWasmConfigurationAvailable(config.wasmUrl, config.wasmBinaryUrl, config.wasmBinary, config.jsModule);
}

protected static _Default: Nullable<DracoDecoder> = null;
Expand Down
6 changes: 4 additions & 2 deletions packages/dev/core/src/Meshes/Compression/dracoEncoder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { _IsConfigurationAvailable, DracoCodec, type IDracoCodecConfiguration } from "./dracoCodec";
import { DracoCodec, type IDracoCodecConfiguration } from "./dracoCodec";
import type { EncoderMessage, IDracoAttributeData, IDracoEncodedMeshData, IDracoEncoderOptions, DracoAttributeName } from "./dracoEncoder.types";
import { EncodeMesh, EncoderWorkerFunction } from "./dracoCompressionWorker";
import { Tools } from "../../Misc/tools";
Expand All @@ -10,6 +10,7 @@ import { Logger } from "../../Misc/logger";
import { deepMerge } from "../../Misc/deepMerger";
import type { EncoderModule } from "draco3d";
import { AreIndices32Bits, GetTypedArrayData } from "core/Buffers/bufferUtils";
import { _IsWasmConfigurationAvailable } from "core/Misc/workerUtils";

// Missing type from types/draco3d. Do not use in public scope; UMD tests will fail because of EncoderModule.
type DracoEncoderModule = (props: { wasmBinary?: ArrayBuffer }) => Promise<EncoderModule>;
Expand Down Expand Up @@ -161,7 +162,8 @@ export class DracoEncoder extends DracoCodec {
* Returns true if the encoder's `DefaultConfiguration` is available.
*/
public static get DefaultAvailable(): boolean {
return _IsConfigurationAvailable(DracoEncoder.DefaultConfiguration);
const config = DracoEncoder.DefaultConfiguration;
return !!config.fallbackUrl || _IsWasmConfigurationAvailable(config.wasmUrl, config.wasmBinaryUrl, config.wasmBinary, config.jsModule);
}

protected static _Default: Nullable<DracoEncoder> = null;
Expand Down
200 changes: 200 additions & 0 deletions packages/dev/core/src/Misc/basisEncoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import type { Nullable } from "../types";
import { Tools } from "./tools";
import { initializeWebWorker, EncodeImageData, workerFunction } from "./basisEncoderWorker";
import type { BasisEncoderParameters } from "./basisEncoderWorker";
import { AutoReleaseWorkerPool } from "./workerPool";
import type { WorkerPool } from "./workerPool";
import type { BaseTexture } from "core/Materials/Textures/baseTexture";
import { Constants } from "core/Engines/constants";
import { Logger } from "./logger";
import { GetTextureDataAsync, WhenTextureReadyAsync } from "./textureTools";
import { _GetDefaultNumWorkers, _IsWasmConfigurationAvailable } from "./workerUtils";

declare let BASIS: any; // FUTURE TODO: Create TS declaration file for the Basis Universal API

let _modulePromise: Nullable<Promise<any>> = null;
let _workerPoolPromise: Nullable<Promise<WorkerPool>> = null;

type IBasisEncoderConfiguration = {
/**
* The url to the WebAssembly module.
*/
wasmUrl: string;
/**
* The url to the WebAssembly module.
*/
wasmBinaryUrl: string;
/**
* The number of workers for async operations. Specify `0` to disable web workers and run synchronously in the current context.
*/
numWorkers: number;
};

/**
* Supported Basis Universal formats for encoding.
* For best results, use ETC1S with color data and UASTC_LDR_4x4 with non-color data.
*/
export type BasisFormat = "ETC1S" | "UASTC4x4";

/**
* Default configuration for the Basis Universal encoder. Defaults to the following:
* - numWorkers: 50% of the available logical processors, capped to 4. If no logical processors are available, defaults to 1.
* - wasmUrl: `"https://cdn.babylonjs.com/basis_encoder.js"`
* - wasmBinaryUrl: `"https://cdn.babylonjs.com/basis_encoder.wasm"`
*/
export const BasisEncoderConfiguration: IBasisEncoderConfiguration = {
wasmUrl: `${Tools._DefaultCdnUrl}/basis_encoder.js`,
wasmBinaryUrl: `${Tools._DefaultCdnUrl}/basis_encoder.wasm`,
numWorkers: _GetDefaultNumWorkers(),
};

/**
* Initialize resources for the Basis Universal encoder.
* @returns a promise that resolves when the Basis Universal encoder resources are initialized
*/
export async function InitializeBasisEncoderAsync(): Promise<void> {
Copy link
Contributor Author

@alexchuber alexchuber Feb 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitty, but any opinions about whether the encoder should be a class or not?

On the one hand,

  • It is stateful (workerpool management)
  • Module level functions clutter the BABYLON namespace and (I assume) require unique names

On the other,

  • Since this is a singleton, it makes sense to leave things at the module-level
  • Opportunity to move this all into basis.ts, our transcoder module that is also module-level. Only if you guys think so?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RaananW will probably vote for no class :) (And I agree!)

InitializeBasisEncoder();
if (_modulePromise) {
await _modulePromise;
return;
}
if (_workerPoolPromise) {
await _workerPoolPromise;
}
}

/**
* Dispose of resources for the Basis Universal encoder.
*/
export function DisposeBasisEncoder(): void {
if (_workerPoolPromise) {
_workerPoolPromise.then((workerPool) => {
workerPool.dispose();
});
}
_workerPoolPromise = null;
_modulePromise = null;
}

/**
* Encodes non-HDR, non-cube texture data to a KTX v2 image with Basis Universal supercompression. Example:
* ```typescript
* InitializeBasisEncoderAsync();
* const texture = new Texture("texture.png", scene);
* const ktx2Data = await EncodeTextureToBasisAsync(texture, { basisFormat: "UASTC4x4" });
* DisposeBasisEncoder();
* ```
* @param babylonTexture the Babylon texture to encode
* @param options additional options for encoding
* - `basisFormat` - the desired encoding format. Defaults to UASTC4x4. It is recommended
* to use ETC1S for color data (e.g., albedo, specular) and UASTC4x4 for non-color data (e.g. bump).
* For more details, see https://github.com/KhronosGroup/3D-Formats-Guidelines/blob/main/KTXArtistGuide.md.
* @returns a promise resolving with the basis-encoded image data
* @experimental This API is subject to change in the future.
*/
export async function EncodeTextureToBasisAsync(babylonTexture: BaseTexture, options?: Pick<BasisEncoderParameters, "basisFormat">): Promise<Uint8Array> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered making the input just an ArrayBuffer[View] of 32-bit RGBA pixel data, but decided against it for now for ease of use (and so I do not have to check so many cases :))

We can always create another method later, should a user want to be able to pass raw image data.

Does this make sense?

// Wait for texture to load so we can get its size
await WhenTextureReadyAsync(babylonTexture);
Copy link
Contributor Author

@alexchuber alexchuber Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a texture never loads (maybe it is delay-loaded), then this function will hang indefinitely. Is this OK?

On a higher-level note, I keep going back and forth over whether we should wait for the texture to load, or if the user should.
I initially thought that we should handle it internally, which is the case right now. But then I considered the delay loading case above, and now I'm not so sure.

(For context, the texture needs to be loaded for using readPixels and getting the texture's size.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's fine by me, the error will still be on the console for the user to debug. That being said, you can detect that the texture is delay loaded and force load it


// Validate texture properties
const size = babylonTexture.getSize();
if ((size.width & 3) !== 0 || (size.height & 3) !== 0) {
throw new Error(`Texture dimensions must be a multiple of 4 for Basis encoding.`);
}
if (babylonTexture.isCube) {
throw new Error(`Cube textures are not currently supported for Basis encoding.`);
}
if (babylonTexture.textureType !== Constants.TEXTURETYPE_UNSIGNED_BYTE && babylonTexture.textureType !== Constants.TEXTURETYPE_BYTE) {
Logger.Warn("Texture data will be converted into unsigned bytes for Basis encoding. This may result in loss of precision.");
}

const pixels = await GetTextureDataAsync(babylonTexture, size.width, size.height);

const finalOptions: BasisEncoderParameters = {
width: size.width,
height: size.height,
basisFormat: options?.basisFormat ?? "UASTC4x4",
isSRGB: babylonTexture._texture?._useSRGBBuffer || babylonTexture.gammaSpace,
};

return EncodeDataAsync(pixels, finalOptions);
}

async function EncodeDataAsync(slicedSourceImage: Uint8Array, parameters: BasisEncoderParameters): Promise<Uint8Array> {
if (_modulePromise) {
const module = await _modulePromise;
return EncodeImageData(module, slicedSourceImage, parameters);
}

if (_workerPoolPromise) {
const workerPool = await _workerPoolPromise;
return new Promise<Uint8Array>((resolve, reject) => {
workerPool.push((worker, onComplete) => {
const onError = (error: ErrorEvent) => {
worker.removeEventListener("error", onError);
worker.removeEventListener("message", onMessage);
reject(error);
onComplete();
};

const onMessage = (msg: MessageEvent) => {
if (msg.data.id === "encodeDone") {
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
resolve(msg.data.encodedImageData);
onComplete();
}
};

worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);

worker.postMessage({ id: "encode", imageData: slicedSourceImage, params: parameters }, [slicedSourceImage.buffer]);
});
});
}

throw new Error("Basis encoder resources are not initialized.");
}

function InitializeBasisEncoder(): void {
const config = BasisEncoderConfiguration;
if (!_IsWasmConfigurationAvailable(config.wasmUrl, config.wasmBinaryUrl)) {
throw new Error("Cannot use Basis Encoder configuration. Check configuration and verify environment WebAssembly support.");
}

// Use main thread if no workers are available
const workerSupported = typeof Worker === "function" && typeof URL === "function" && typeof URL.createObjectURL === "function";
if (!workerSupported || BasisEncoderConfiguration.numWorkers === 0) {
if (!_modulePromise) {
_modulePromise = CreateModuleAsync();
}
return;
}

if (!_workerPoolPromise) {
_workerPoolPromise = CreateWorkerPoolAsync();
}
}

async function CreateWorkerPoolAsync(): Promise<WorkerPool> {
const url = Tools.GetBabylonScriptURL(BasisEncoderConfiguration.wasmUrl);
const wasmBinary = await Tools.LoadFileAsync(Tools.GetBabylonScriptURL(BasisEncoderConfiguration.wasmBinaryUrl, true));

const workerContent = `${EncodeImageData}(${workerFunction})()`;
const workerBlobUrl = URL.createObjectURL(new Blob([workerContent], { type: "application/javascript" }));

return new AutoReleaseWorkerPool(BasisEncoderConfiguration.numWorkers, () => {
const worker = new Worker(workerBlobUrl);
return initializeWebWorker(worker, wasmBinary, url);
});
}

async function CreateModuleAsync(): Promise<any> {
// If module was already loaded in this context
if (typeof BASIS === "undefined") {
await Tools.LoadBabylonScriptAsync(BasisEncoderConfiguration.wasmUrl);
}
const wasmBinary = await Tools.LoadFileAsync(Tools.GetBabylonScriptURL(BasisEncoderConfiguration.wasmBinaryUrl, true));
return BASIS({ wasmBinary });
}
Loading