Skip to content

Advanced Usage

Compression Formats

ETC1S vs UASTC

KTX2-Encoder supports two compression formats:

  • ETC1S: Better compression ratios, good for most textures
  • UASTC: Higher quality, better for normal maps and HDR textures

Choose between them using the isUASTC option:

typescript
// Use ETC1S compression
const ktx2Data = await encodeToKTX2(imageBuffer, {
  isUASTC: false
});

// Use UASTC compression
const ktx2Data = await encodeToKTX2(imageBuffer, {
  isUASTC: true
});

Quality Settings

For ETC1S compression:

typescript
const options = {
  isUASTC: false,
  qualityLevel: 128, // Range: 1-255
  compressionLevel: 2 // Range: 0-6
};

For UASTC compression:

typescript
const options = {
  isUASTC: true,
  needSupercompression: true, // Enable Zstandard supercompression (default: true)
  uastcLDRQualityLevel: 1 // Encoder perf vs. quality tradeoff (0-3); higher is slower/better
};

Rate-Distortion Optimization (RDO)

RDO shrinks the supercompressed UASTC LDR output at some quality cost. It is disabled by default; enable it and tune the quality scalar as needed:

typescript
const options = {
  isUASTC: true,
  needSupercompression: true,
  enableRDO: true,
  rdoQualityLevel: 1.0 // Range: 0.001-10; lower values favor quality, higher favor size
};

HDR Encoding

The encoder can produce HDR textures via UASTC HDR. HDR mode differs from the normal (LDR) path in a few important ways:

  • It is only supported together with UASTC (isHDR implies UASTC HDR encoding).
  • The source must be the raw bytes of a .hdr (Radiance RGBE) or .exr file, and imageType must say which container it is.
  • HDR buffers are handed to the encoder directly, so the browser helper does not need an imageDecoder. In Node.js, use NodeBasisEncoder directly to avoid the functional helper's decoder validation.
typescript
import { encodeToKTX2 } from "ktx2-encoder";

// `source` is the untouched contents of a .hdr or .exr file
const ktx2Data = await encodeToKTX2(source, {
  isHDR: true,
  imageType: "hdr", // "hdr" for Radiance .hdr, "exr" for OpenEXR
  hdrQualityLevel: 1 // Optional, range 0-4; higher is slower/better
});

In Node.js, encode the same input through an encoder instance:

typescript
import { NodeBasisEncoder } from "ktx2-encoder";

const encoder = new NodeBasisEncoder();
const ktx2Data = await encoder.encode(source, {
  isHDR: true,
  imageType: "hdr",
  hdrQualityLevel: 1
});

Multithreaded Encoding

The browser build ships a second, multithreaded encoder that can use WASM threads to speed up encoding. It is opt-in and off by default. Enable it per call with useThreads:

typescript
const ktx2Data = await encodeToKTX2(imageBuffer, {
  useThreads: true,
  numThreads: 4 // optional; extra worker threads (total = 1 + numThreads)
});
  • numThreads is the number of extra worker threads (total parallelism is 1 + numThreads). It is clamped to [0, 8]; 0 disables threading. The default is min(navigator.hardwareConcurrency - 1, 8).
  • Threading helps most on large textures. Small inputs see little benefit because the encoder's serial phases dominate; a 2K image is only modestly faster, while a near-cap (~11 Mpix) ETC1S encode can be several times faster.

Requirement: cross-origin isolation

WASM threads use SharedArrayBuffer, which browsers only expose to cross-origin isolated pages. Your page must be served with these response headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

You can confirm at runtime that crossOriginIsolated === true. If useThreads is requested on a page that is not cross-origin isolated, the encoder logs one warning and transparently falls back to the single-threaded build — encoding still succeeds. (Note that COEP require-corp also constrains other cross-origin resources your page loads.)

For local Vite development and preview, configure both servers (Vite server headers):

typescript
import { defineConfig } from "vite";

const isolationHeaders = {
  "Cross-Origin-Opener-Policy": "same-origin",
  "Cross-Origin-Embedder-Policy": "require-corp"
};

export default defineConfig({
  server: { headers: isolationHeaders },
  preview: { headers: isolationHeaders }
});

On Vercel, add response headers in vercel.json:

json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
        { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
      ]
    }
  ]
}

On Netlify, put a _headers file in the publish directory:

text
/*
  Cross-Origin-Opener-Policy: same-origin
  Cross-Origin-Embedder-Policy: require-corp

After deployment, check both headers on the top-level document and verify crossOriginIsolated in the browser console. Any cross-origin scripts, images, fonts, or other assets must also satisfy COEP through CORS or an appropriate Cross-Origin-Resource-Policy response header.

The multithreaded WASM (basis_encoder_threads.wasm, ~3.3 MB) is loaded lazily only when you first opt in, so it costs nothing for consumers that don't use it. To self-host it, point threadsWasmUrl at the threaded asset — this is a separate option from wasmUrl because the two builds ship different, non-interchangeable binaries:

typescript
const ktx2Data = await encodeToKTX2(imageBuffer, {
  useThreads: true,
  threadsWasmUrl: "/assets/basis_encoder_threads.wasm",
  // wasmUrl is used if the page is not cross-origin isolated and threading
  // falls back to single-threaded; point it at the single-threaded build.
  wasmUrl: "/assets/basis_encoder.wasm"
});

Multithreading is browser-only; useThreads is ignored in Node.js.

Thread scheduling can change work ordering, so output produced with different thread counts is not guaranteed to be byte-for-byte identical. Decode and compare quality when reproducibility matters; the library's tests use SSIM rather than encoded-byte equality across variants.

Custom WASM Loading

Browser builds load the package's version-matched, bundled basis_encoder.wasm asset automatically. No third-party or CDN request is ever made.

To host the WASM asset yourself, override wasmUrl:

typescript
const ktx2Data = await encodeToKTX2(imageBuffer, {
  wasmUrl: "/assets/basis_encoder.wasm"
});

The custom WASM file must come from the same ktx2-encoder version as the bundled JavaScript glue. The legacy jsUrl option is deprecated and ignored because replacing only the glue can create an incompatible module pair.

If the WASM asset (bundled or custom) cannot be fetched, the encoder throws an error that includes the URL and HTTP status — it does not fall back to any other source.

When self-hosting the .wasm file, serve it with the application/wasm MIME type so the browser can stream-compile it. If your host sends a generic type (e.g. application/octet-stream), loading still works but skips streaming compilation.

Node.js Usage

When using in Node.js, you need to provide an image decoder:

typescript
import sharp from "sharp";

const imageDecoder = async (buffer: Uint8Array) => {
  const { data, info } = await sharp(buffer).raw().ensureAlpha().toBuffer({ resolveWithObject: true });

  return {
    data: new Uint8Array(data),
    width: info.width,
    height: info.height
  };
};

const ktx2Data = await encodeToKTX2(pngBuffer, {
  imageDecoder
});

Custom KV Data

You can include custom key-value metadata in the KTX2 file:

typescript
const ktx2Data = await encodeToKTX2(imageBuffer, {
  kvData: {
    myKey: "myValue",
    customData: new Uint8Array([1, 2, 3])
  }
});

Output Buffer Size

Encoding writes into a pre-allocated output buffer. By default its capacity is estimated from the decoded input dimensions, and if encoding overflows that buffer the encoder automatically retries once with twice the capacity.

You normally don't need to touch this. Set outputBufferSize (in bytes) only to skip the estimate — for example when you already know the output is unusually large and want to avoid the retry, or to cap the initial allocation:

typescript
const ktx2Data = await encodeToKTX2(imageBuffer, {
  outputBufferSize: 8 * 1024 * 1024 // 8 MB initial capacity
});

Using an Encoder Instance

The package also exports the encoder classes directly. Reusing a single instance keeps the WebAssembly module initialized once across many encode() calls, which is useful in batch tools or long-running workers:

typescript
// Browser
import { BrowserBasisEncoder } from "ktx2-encoder";

const encoder = new BrowserBasisEncoder();
const a = await encoder.encode(bufferA, { isUASTC: true });
const b = await encoder.encode(bufferB, { isUASTC: true });
typescript
// Node.js
import { NodeBasisEncoder } from "ktx2-encoder";

const encoder = new NodeBasisEncoder();
const ktx2Data = await encoder.encode(pngBuffer, { imageDecoder });

The functional encodeToKTX2 helpers are thin wrappers around shared default instances (browserEncoder / nodeEncoder), so most callers can just use those.