Any3DAny3D
·Any3D Team

KTX2 in Practice: The Right Way to Do Texture Compression

3d-compressiontexture-compressionktx2basis-universalgltf

The previous article untangled GPU texture formats, Basis Universal, and KTX2. The theory's clear; this one is all hands-on: how to choose ETC1S vs UASTC, what tools to use, what commands to type, and how to load them in an engine.

You can follow along and do it as you read.

First, the most important choice: ETC1S or UASTC

Basis offers two intermediate encodings. Pick wrong and it's not "not good enough"—your normal map just turns to mush. Memorize this table:

ETC1SUASTC
Compression ratioVery high (JPG-like)Medium (high-quality PNG-like)
QualityFine for color mapsNear-original quality
VRAM (after transcode)Usually 4bpp (~1/8 original)Usually 8bpp (~1/4 original)
Encode speedSlow (tunable level)Faster
Use foralbedo/diffuse, emissivenormal, metalness-roughness, data maps
Don't use fornormals, images needing precise valuesColor maps (overkill, size runs large)

Why can't normal maps use ETC1S? Because a normal map stores direction vectors, and each pixel's RGB channels constrain each other (vector length ≈ 1). ETC1S is a block compression designed to make "colors look right"; it's not sensitive to single-channel precision, so after compression the vector direction drifts and lighting immediately looks off—especially highlight positions and high-frequency detail. UASTC preserves numeric values better and holds up to that precision demand.

Practical rules:

  • Color maps (albedo, emissive) → ETC1S
  • Data maps (normal, roughness, metallic, AO, thickness) → UASTC
  • Unsure but want to save size → try ETC1S first; if a close-up turns mushy, switch to UASTC

One image, four formats compared

Using a 2048×2048 albedo map as baseline (values are typical community figures, for reference only):

FormatDisk sizeVRAM usage (with mipmaps)Upload to GPUCross-platform
PNG~5MB~22MBSlow
WebP~1MB~22MBSlow
KTX2 (ETC1S)~0.5-0.8MB~2.8MBFast
KTX2 (UASTC)~3-4MB~5.6MBFast

Note WebP's VRAM usage is the same as PNG—it's only small on disk; in VRAM it still decompresses to raw pixels. KTX2's ETC1S drives both disk and VRAM very low at once—that's what makes it valuable.

Toolchain: three roads all lead there

There's more than one tool for compressing KTX2, ranked by "how easy to grab":

1. toktx (official, most powerful)

The Khronos official tool, with the most parameters, suited for processing textures individually.

# Convert PNG to an ETC1S-encoded KTX2
toktx --bcmp --uastc 0 albedo.ktx2 albedo.png

# Convert PNG to a UASTC-encoded KTX2
toktx --uastc 1 normal.ktx2 normal.png

Common parameters:

# ETC1S + quality level (1-255, default 128; higher = better quality and larger size)
toktx --bcmp --uastc 0 --qlevel 200 albedo.ktx2 albedo.png

# UASTC + supercompression (Zstandard, further shrinks disk size)
toktx --uastc 1 --zcmp 19 normal.ktx2 normal.png

# Auto-generate mipmaps (strongly recommended)
toktx --bcmp --genmipmap albedo.ktx2 albedo.png

# Specify sRGB color space (required for color maps)
toktx --bcmp --srgb albedo.ktx2 albedo.png

--bcmp is the ETC1S-mode switch (Basis Universal's base mode), and --uastc 1 is UASTC mode. The two are mutually exclusive.

2. gltf-transform (most effortless, highly recommended)

If you have a whole glTF/GLB model, use gltf-transform to swap all its textures for KTX2 in one command, automatically choosing ETC1S/UASTC by texture purpose.

# Install
npm install -g @gltf-transform/cli

# One-shot compress the whole model
gltf-transform optimize model.glb model-optimized.glb \
  --texture-compress basisu

It internally judges texture purpose: color types use ETC1S, data types use UASTC, and it writes the KHR_texture_basisu extension automatically. This is the best choice for 90% of cases—no need to type toktx manually one by one.

3. Online tools (fastest to start)

When you don't want to set up an environment, use a browser tool: gltf.report (online gltf-transform), KTX2 Converter, etc. Upload, download, done. Good for early experiments or one-off tasks.

Full pipeline: from source to production

Here's the standard flow (flow diagram):

Source file (PNG/JPG/PSD/TGA)
        │
        ├── [whole model] gltf-transform optimize model.glb → auto-detect texture type
        │            └─ color maps → ETC1S
        │            └─ data maps → UASTC
        │            └─ writes KHR_texture_basisu extension
        │
        └── [single texture] toktx → manually specify ETC1S/UASTC + color space + mipmap
        │
        ▼
Compressed KTX2 / GLB
        │
        ▼
Engine load (Three.js / Babylon.js) ── runtime transcode → GPU native format

Loading KTX2 in Three.js

Three.js has natively supported KTX2 since r129, but you must provide a KTX2Loader and configure the transcoder (basis transcoder wasm).

import * as THREE from "three";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";

// 1. Initialize the KTX2 transcoder and probe which native format the current GPU supports
const ktx2Loader = new KTX2Loader()
  .setTranscoderPath("/basis/")            // directory of the basis transcoder wasm
  .detectSupport(renderer);                // must pass renderer to probe capabilities

// 2. Configure GLTFLoader, attaching KTX2/Draco/MeshOpt
const gltfLoader = new GLTFLoader();
gltfLoader.setKTX2Loader(ktx2Loader);
gltfLoader.setDRACOLoader(
  new DRACOLoader().setDecoderPath("/draco/")
);
gltfLoader.setMeshoptDecoder(MeshoptDecoder);

// 3. Load the model; textures will be transcoded automatically
gltfLoader.load("/models/model-optimized.glb", (gltf) => {
  scene.add(gltf.scene);
});

A few key points:

  • detectSupport(renderer) is mandatory—it decides which native format to transcode to at runtime
  • setTranscoderPath points to the basis transcoder wasm files (copy them from the basis_transcoder release to your public directory)
  • If the model also uses Draco, remember to attach the DRACOLoader too; if it uses MeshOpt, attach MeshoptDecoder

Loading a single KTX2 texture:

const texture = await ktx2Loader.loadAsync("/textures/albedo.ktx2");
texture.colorSpace = THREE.SRGBColorSpace;   // set color maps to sRGB
material.map = texture;

The most common pitfall is right here: forgetting to set colorSpace = SRGBColorSpace on a color map makes the whole image look gray and dark. Data maps (normal/roughness) instead stay NoColorSpace (linear)—don't flip them.

Loading KTX2 in Babylon.js

Babylon's approach is similar but more automatic—KHR_texture_basisu is enabled by default in GLTFFileLoader, as long as the transcoder files can be found.

import { Scene } from "@babylonjs/core/scene";
import { Engine } from "@babylonjs/core/Engines/engine";
import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader";
import { GLTFFileLoader } from "@babylonjs/loaders/glTF/glTFFileLoader";
import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_texture_basisu";

const engine = new Engine(canvas);
const scene = new Scene(engine);

SceneLoader.ImportMesh(
  "",
  "/models/",
  "model-optimized.glb",
  scene,
  (meshes) => {
    // Model loaded; KTX2 has been transcoded automatically
  }
);

Babylon automatically fetches the basis transcoder from a CDN; for offline/intranet environments you need to manually configure BASISFileLoader.TranscoderModule.

Compression parameter tuning: balancing quality and size

ETC1S's core parameter is --qlevel (1-255). How it affects the result:

qlevelSizeQualityEncode timeUse case
128 (default)SmallAdequateMediumMost cases
200-255LargerNear-losslessLong (several times)High-quality requirements
60-100Very smallVisible blockingFastDistant/small textures

UASTC's size is relatively fixed; you mainly tune disk size with --zcmp (Zstandard supercompression), which doesn't affect VRAM (still 8bpp after decompression).

Recommended tuning order:

  1. First compress once with defaults and check size and quality
  2. Not satisfied → tune --qlevel (ETC1S) or add --zcmp (UASTC)
  3. Normal map turned mushy → confirm you're using UASTC, not ETC1S
  4. Colors look dark → check color-space settings (sRGB flag, colorSpace in the engine)

Common troubleshooting

Transcode failure / load error

  • Check the transcoder wasm path is correct (Three.js needs setTranscoderPath)
  • Check whether your engine version supports the current KTX2 version (older basis encodings aren't supported by newer transcoders)
  • The console usually has a specific error; search by keyword

Colors too dark / too bright

  • A color map (albedo) wasn't set to sRGB, or got set backwards
  • You omitted --srgb in toktx (color maps need it)
  • A data map (normal) was mistakenly given sRGB

Missing mipmaps, flickering at distance

  • Compression didn't add --genmipmap
  • The engine didn't enable texture.generateMipmaps (in Three.js, KTX2 follows the file by default, but the material's minFilter still needs a mipmap mode)

Normal direction wrong in close-up

  • The normal map used ETC1S; switch to UASTC
  • Confirm the normal map is OpenGL style (green channel up); DirectX style needs the G channel flipped in some engines

File got bigger instead

  • Small textures (< 128×128) aren't worth KTX2—block compression has fixed overhead and fills a whole block
  • Don't KTX2 a solid-color texture; using a material color value is cheaper

What's next

That basically clears texture compression. But "knowing how to use the tools" isn't the same as "using the right tools"—the next article rolls up all the knowledge from the previous four into a selection framework: desktop, mobile, VR, Mini Programs—exactly which combination each scenario should use.

Support Us