KTX2 in Practice: The Right Way to Do Texture Compression
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:
| ETC1S | UASTC | |
|---|---|---|
| Compression ratio | Very high (JPG-like) | Medium (high-quality PNG-like) |
| Quality | Fine for color maps | Near-original quality |
| VRAM (after transcode) | Usually 4bpp (~1/8 original) | Usually 8bpp (~1/4 original) |
| Encode speed | Slow (tunable level) | Faster |
| Use for | albedo/diffuse, emissive | normal, metalness-roughness, data maps |
| Don't use for | normals, images needing precise values | Color 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):
| Format | Disk size | VRAM usage (with mipmaps) | Upload to GPU | Cross-platform |
|---|---|---|---|---|
| PNG | ~5MB | ~22MB | Slow | ✅ |
| WebP | ~1MB | ~22MB | Slow | ✅ |
| KTX2 (ETC1S) | ~0.5-0.8MB | ~2.8MB | Fast | ✅ |
| KTX2 (UASTC) | ~3-4MB | ~5.6MB | Fast | ✅ |
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
--bcmpis the ETC1S-mode switch (Basis Universal's base mode), and--uastc 1is 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 runtimesetTranscoderPathpoints 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 = SRGBColorSpaceon a color map makes the whole image look gray and dark. Data maps (normal/roughness) instead stayNoColorSpace(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:
| qlevel | Size | Quality | Encode time | Use case |
|---|---|---|---|---|
| 128 (default) | Small | Adequate | Medium | Most cases |
| 200-255 | Larger | Near-lossless | Long (several times) | High-quality requirements |
| 60-100 | Very small | Visible blocking | Fast | Distant/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:
- First compress once with defaults and check size and quality
- Not satisfied → tune
--qlevel(ETC1S) or add--zcmp(UASTC) - Normal map turned mushy → confirm you're using UASTC, not ETC1S
- 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
--srgbin 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'sminFilterstill 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.