Key answer: KTX2 texture compression in Three.js keeps your textures compressed all the way onto the GPU. A .ktx2 file stores the image Basis-Universal-encoded as ETC1S or UASTC; at load time KTX2Loader detects which compressed formats the device supports and transcodes the Basis payload to a native GPU format such as ASTC, BC7 or ETC2. Because the texture stays block-compressed in VRAM instead of expanding to RGBA8, memory drops roughly four to eight times, uploads shrink, and the main thread skips decoding and mipmapping a large PNG. The cost is a small transcoder bundle and a CPU transcode step. This guide explains how KTX2 and Basis work, how to wire up KTX2Loader, how to build .ktx2 with toktx, basisu and gltf-transform, and when a plain JPEG is still the better call.
Table of contents
- What KTX2 and Basis Universal are
- Why texture compression matters
- The transcode pipeline, end to end
- GPU block formats and why transcoding exists
- Mipmaps and the memory math
- Wiring up KTX2Loader in Three.js
- Loading a standalone KTX2 texture
- Building .ktx2 with toktx and basisu
- Compressing a whole glTF with gltf-transform
- Choosing ETC1S or UASTC per texture
- The hero scene, in full
- Performance and trade-offs
- When to use it, and when not
- AETumi Expert Notes
- Technical proof on GitHub
- FAQ
- Related resources
- Conclusion
What KTX2 and Basis Universal are
KTX2 is a container format for GPU textures, standardized by Khronos as the successor to KTX. Basis Universal is the supercompression that goes inside it: a single intermediate encoding that can be transcoded cheaply into whichever block-compressed format the current GPU actually supports. Together they answer a problem that plagued 3D on the web for years — every GPU family speaks a different compressed-texture dialect, so you either shipped an uncompressed image and paid for it in memory, or you shipped several device-specific files and paid for it in complexity.
A KTX2 file wraps a Basis payload in one of two modes. ETC1S is a low-bitrate mode that produces tiny files at a quality good enough for most color maps. UASTC is a high-quality mode that produces larger files but preserves fine detail, which matters for normal maps and other data textures. In Three.js the whole thing is fronted by one class, KTX2Loader, which reads the file, asks the renderer what the GPU can do, and hands back a ready CompressedTexture. That is the core of KTX2 texture compression in Three.js: the image never expands to a full RGBA8 buffer on the GPU.
Why texture compression matters
Textures are usually the largest single consumer of GPU memory in a 3D web scene, and memory is where mobile devices fall over first. An uncompressed 2048×2048 RGBA8 texture occupies 16 MB in VRAM, and with a full mipmap chain roughly 21 MB. Load a handful of those for a product model — albedo, normal, roughness, metalness, ambient occlusion — and a mid-range phone is suddenly juggling more than a hundred megabytes of texture memory before a single frame renders. That is the failure mode behind context-loss crashes and janky first paints on real devices.
A block-compressed format stores the same 2048×2048 image in roughly 2.6–5.3 MB depending on the format, because it encodes fixed-size texel blocks instead of per-pixel bytes. KTX2 lets you ship that compression to every device from one asset. The payoff is not primarily a smaller download — a tuned JPEG can beat ETC1S on disk — it is smaller VRAM, smaller GPU uploads, and a main thread that no longer stalls decoding and mipmapping a giant bitmap. On the WebGL side, that is often the difference between a scene that holds 60 fps and one that thrashes.
The transcode pipeline, end to end
The mental model is a straight line from your source image to the GPU. You encode a PNG or JPEG into a .ktx2 file once, at build time. At runtime the browser fetches that file, a small transcoder — shipped as a WebAssembly module and its worker — decodes the Basis payload into the specific block format the current GPU supports, and Three.js uploads that compressed data directly. The source image is never reconstructed to full RGBA8 in memory.
The key insight is the split. Encoding is expensive and happens once on your machine. Transcoding is cheap and happens in the browser, off the main thread, sized to whatever the visitor's GPU supports. That is why one .ktx2 asset can serve a desktop with BC7, an iPhone with ASTC, and an older Android with ETC2, with no branching in your application code.
GPU block formats and why transcoding exists
GPUs do not sample arbitrary compressed images; each supports a specific set of block-compressed formats through WebGL extensions such as WEBGL_compressed_texture_s3tc, WEBGL_compressed_texture_etc and WEBGL_compressed_texture_astc. Each format packs a fixed block of texels — commonly 4×4 — into a fixed number of bytes. BC7 and ASTC-4×4 use 16 bytes per 16-texel block (8 bits per texel); ETC1/ETC2 RGB use 8 bytes per block (4 bits per texel). The GPU decodes a block on the fly as it samples, so the texture never occupies more than its compressed size.
Before KTX2, targeting all of these meant shipping DDS for desktop S3TC, PVRTC for older iOS, and ETC for Android — three assets and branching logic. Basis Universal collapses that: you ship one intermediate, and KTX2Loader.detectSupport(renderer) picks the transcode target. That is the entire reason the format exists, and why it is the default texture path for modern Three.js pipelines.
Mipmaps and the memory math
A mipmap is a precomputed chain of smaller copies of a texture — full size, half, quarter, and so on down to 1×1 — that the GPU samples from when a surface is far away or steeply angled. Without them, minified textures shimmer and alias; with them, sampling is stable and faster because the GPU reads from a smaller level. The right-hand stack in the hero scene is exactly this pyramid: each quad is a smaller mip level of the same source pattern.
Mipmaps add roughly 33% to a texture's size, so they make compression more valuable, not less. The important detail with KTX2 is that mipmaps are baked into the file at encode time. That saves the runtime cost of generating them and, critically, block-compressed textures generally cannot be mipmapped by the GPU on the fly — so if you skip mips at encode time you lose them entirely. Always encode with mipmaps for any texture applied to a 3D surface.
Wiring up KTX2Loader in Three.js
Problem: you have a GLB whose textures are KTX2-compressed via the KHR_texture_basisu extension, and GLTFLoader needs to know how to decode them. Solution: construct a KTX2Loader, point it at the transcoder files, call detectSupport with your renderer, and register it on the glTF loader.
import { WebGLRenderer } from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const renderer = new WebGLRenderer({ antialias: true });
// Transcoder = the Basis wasm + worker, copied from three/examples/jsm/libs/basis/
const ktx2 = new KTX2Loader()
.setTranscoderPath('/basis/') // ship basis_transcoder.js + .wasm here
.detectSupport(renderer); // asks the GPU which block formats it has
const gltf = new GLTFLoader().setKTX2Loader(ktx2);
gltf.load('/models/product.glb', ({ scene }) => {
addToStage(scene); // textures arrive already GPU-compressed
});
Why it matters: detectSupport is what makes one asset portable — it inspects the renderer's extensions and chooses ASTC, BC7, ETC2 or a fallback so you never branch per device. Forgetting it, or forgetting to actually copy the transcoder files to the path you set, is the most common reason KTX2 textures silently fail to appear.
Loading a standalone KTX2 texture
Problem: you want a single compressed texture — a hero material map — without a glTF wrapper. Solution: load the .ktx2 directly and set the color space and filtering yourself, because a raw load will not assume them.
import { MeshStandardMaterial, SRGBColorSpace, LinearMipmapLinearFilter } from 'three';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const ktx2 = new KTX2Loader()
.setTranscoderPath('/basis/')
.detectSupport(renderer);
ktx2.load('/textures/panel_albedo.ktx2', (texture) => {
texture.colorSpace = SRGBColorSpace; // color maps are sRGB
texture.minFilter = LinearMipmapLinearFilter; // trilinear, uses baked mips
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
material.map = texture;
material.needsUpdate = true;
});
Why it matters: a KTX2 color texture must be tagged SRGBColorSpace or it will render washed out; a data texture such as a normal or roughness map must stay linear or lighting breaks. The loader will not guess — setting color space per texture is part of using KTX2 correctly.
Building .ktx2 with toktx and basisu
Problem: you have source PNGs and need .ktx2 files. Solution: use toktx from KTX-Software, or basisu from Basis Universal, choosing the mode per texture and always requesting mipmaps.
# UASTC — high quality, for normal / data maps (keep linear, --assign_oetf linear)
toktx --t2 --genmipmap --uastc 2 --uastc_rdo_l 1.0 \
--assign_oetf linear normal.ktx2 normal.png
# ETC1S — small, for color maps (sRGB); --clevel/--qlevel trade size vs quality
toktx --t2 --genmipmap --encode etc1s --clevel 4 --qlevel 190 \
--assign_oetf srgb albedo.ktx2 albedo.png
# basisu equivalent, writing a .ktx2 container directly
basisu -ktx2 -mipmap -uastc -uastc_level 2 normal.png
basisu -ktx2 -mipmap -q 190 albedo.png
Why it matters: the two knobs that decide everything are mode (UASTC vs ETC1S) and quality level. Encoding is slow and one-time, so it belongs in a build step, not at runtime. Get the OETF/color space right here — an ETC1S albedo tagged linear, or a UASTC normal tagged sRGB, will look wrong no matter how the Three.js side is written.
Compressing a whole glTF with gltf-transform
Problem: a GLB has a dozen textures and encoding each by hand is error-prone. Solution: use gltf-transform to compress every texture into KTX2 in one pass, mixing UASTC and ETC1S by slot.
# CLI — UASTC for normals, ETC1S for everything else, in one command
gltf-transform uastc product.glb product.ktx2.glb \
--slots "{normalTexture}" --level 4 --rdo 1
gltf-transform etc1s product.ktx2.glb product.final.glb \
--slots "!{normalTexture}" --quality 190
// Programmatic API — same idea, scriptable in a build
import { NodeIO } from '@gltf-transform/core';
import { KHRTextureBasisu } from '@gltf-transform/extensions';
import { toktx } from '@gltf-transform/functions';
const io = new NodeIO().registerExtensions([KHRTextureBasisu]);
const doc = await io.read('product.glb');
await doc.transform(
toktx({ mode: 'uastc', slots: /normalTexture/, level: 4, rdo: 1 }),
toktx({ mode: 'etc1s', slots: /^(?!normalTexture)/, quality: 190 })
);
await io.write('product.final.glb', doc);
Why it matters: compressing per slot is what keeps quality where it counts. Albedo and emissive tolerate ETC1S; normal and ORM maps need UASTC or their detail collapses into visible banding. One pass over the whole model, with the right mode per slot, is the practical way to ship KTX2 across a real asset library.
Choosing ETC1S or UASTC per texture
The decision is not per model, it is per map. Color maps — albedo, emissive — carry information the eye forgives, so ETC1S's aggressive compression is usually invisible and the file savings are large. Data maps — normal, roughness, metalness, ambient occlusion — encode geometry and lighting math, where ETC1S banding turns into visible artifacts on the lit surface. Those want UASTC. A quick heuristic: if the texture is seen, ETC1S is often fine; if the texture is computed with, use UASTC.
What this preview proves is the everyday case: rich, colorful surfaces where the eye never notices ETC1S loss but the GPU notices the memory it saves. Reserve UASTC for the maps that drive lighting, and a whole scene like this stays light on VRAM without visible quality loss — the balance the AETumi 3D components library is tuned for.
The hero scene, in full
The scene at the top of this article is a live Three.js demonstration, not a recording. The left plane cycles its material map through four canvas textures standing in for the quality tiers — a clean RGBA8 source, a heavily block-quantized ETC1S look, a lightly quantized UASTC look, and the clean KTX2 result — so the block artifacts are visible as they appear and clear. The right group is a real mipmap pyramid: six quads, each textured with a progressively smaller copy of the same pattern. It runs on a single requestAnimationFrame loop, pauses when scrolled out of view via IntersectionObserver, respects prefers-reduced-motion by rendering one static frame, and falls back to a message if WebGL is unavailable.
<div class="ae-live3d" id="ktx2-hero-box">
<canvas id="ktx2-hero" role="img" aria-label="Texture compression tiers and a mipmap pyramid"></canvas>
<div class="ae-3d-fb">Live texture demo needs WebGL.</div>
</div>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
const canvas = document.getElementById('ktx2-hero');
const box = document.getElementById('ktx2-hero-box');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
// A stand-in for a decoded texture: high-frequency amber pattern.
function basePattern(size){
const c = Object.assign(document.createElement('canvas'), { width:size, height:size });
const x = c.getContext('2d');
x.fillStyle = '#141017'; x.fillRect(0,0,size,size);
const s = size / 8;
for (let j=0;j<8;j++) for (let i=0;i<8;i++){
x.fillStyle = (i+j)%2 ? '#ffb84d' : '#ff8a3d';
x.globalAlpha = 0.25 + 0.75*Math.abs(Math.sin((i+1)*(j+1)));
x.fillRect(i*s+2, j*s+2, s-4, s-4);
}
x.globalAlpha = 1; x.strokeStyle = '#ffd9a0'; x.lineWidth = size/128;
for (let r=size/12;r<size;r+=size/12){ x.beginPath(); x.arc(size/2,size/2,r,0,6.2832); x.stroke(); }
return c;
}
// Simulate block-compression: average + quantize NxN blocks, then label it.
function blockify(src, block, steps, label){
const size = src.width;
const c = Object.assign(document.createElement('canvas'), { width:size, height:size });
const x = c.getContext('2d');
const d = src.getContext('2d').getImageData(0,0,size,size).data;
for (let by=0;by<size;by+=block) for (let bx=0;bx<size;bx+=block){
let r=0,g=0,b=0,n=0;
for (let y=0;y<block;y++) for (let xx=0;xx<block;xx++){
const p=((by+y)*size+(bx+xx))*4; r+=d[p]; g+=d[p+1]; b+=d[p+2]; n++;
}
const q=v=>Math.round(Math.round((v/n)/255*steps)/steps*255);
x.fillStyle=`rgb(${q(r)},${q(g)},${q(b)})`; x.fillRect(bx,by,block,block);
}
labelStrip(x, size, label); return c;
}
function labelStrip(x, size, label){
x.fillStyle='rgba(10,7,14,.82)'; x.fillRect(0,size-30,size,30);
x.fillStyle='#ffb84d'; x.font='700 15px system-ui'; x.textAlign='center';
x.fillText(label, size/2, size-10);
}
let renderer;
try { renderer = new THREE.WebGLRenderer({ canvas, antialias:true, alpha:true }); }
catch (e) { box.classList.add('ae-3d-fallback'); throw e; }
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scene = new THREE.Scene();
const cam = new THREE.PerspectiveCamera(45, 16/9, 0.1, 100);
cam.position.set(0, 0, 6);
const base = basePattern(256);
const clean = (() => { const c=basePattern(256); labelStrip(c.getContext('2d'),256,'RGBA8 · uncompressed'); return c; })();
const tiers = [
clean,
blockify(base, 16, 4, 'ETC1S · blocky'),
blockify(base, 4, 16, 'UASTC · near-lossless'),
(() => { const c=basePattern(256); labelStrip(c.getContext('2d'),256,'KTX2 · GPU native'); return c; })()
].map(cv => { const t=new THREE.CanvasTexture(cv); t.colorSpace=THREE.SRGBColorSpace; t.anisotropy=4; return t; });
const planeMat = new THREE.MeshBasicMaterial({ map: tiers[0] });
const plane = new THREE.Mesh(new THREE.PlaneGeometry(3.4, 3.4), planeMat);
plane.position.x = -2.1; scene.add(plane);
// Mipmap pyramid: shrinking textured quads, nearest-filtered so levels read clearly.
const pyr = new THREE.Group(); pyr.position.x = 2.2;
[256,128,64,32,16,8].forEach((sz, i) => {
const cv = Object.assign(document.createElement('canvas'), { width:sz, height:sz });
cv.getContext('2d').drawImage(base, 0, 0, sz, sz);
const t = new THREE.CanvasTexture(cv); t.magFilter = THREE.NearestFilter; t.colorSpace = THREE.SRGBColorSpace;
const w = 2.6 * Math.pow(0.72, i);
const q = new THREE.Mesh(new THREE.PlaneGeometry(w, w), new THREE.MeshBasicMaterial({ map: t }));
q.position.set(0, 1.7 - i*0.62, -i*0.34); pyr.add(q);
});
scene.add(pyr);
let vis = true;
try { new IntersectionObserver(e => { vis = e[0].isIntersecting; }).observe(canvas); } catch (e) {}
// Single rAF loop: IO-gated, self-sizing (no init-timing dependence), reduced-motion aware.
let tier=0, last=0, lw=0, lh=0;
function frame(t){
if (!vis){ requestAnimationFrame(frame); return; }
const w=box.clientWidth, h=box.clientHeight;
if (w>=2 && h>=2 && (w!==lw || h!==lh)){
lw=w; lh=h; renderer.setSize(w,h,false); cam.aspect=w/h; cam.updateProjectionMatrix();
}
if (reduce){ if (lw) renderer.render(scene, cam); else requestAnimationFrame(frame); return; } // one static frame
if (t-last > 1600){ last=t; tier=(tier+1)%tiers.length; planeMat.map=tiers[tier]; planeMat.needsUpdate=true; }
plane.rotation.y = Math.sin(t/1400)*0.25;
pyr.rotation.y = Math.sin(t/1800)*0.20;
renderer.render(scene, cam);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
Why it matters: the same discipline that makes this hero cheap is the discipline KTX2 rewards — one render loop, gated to visibility, capped pixel ratio, mip-aware sampling, and a graceful fallback. Swap the canvas stand-ins for real KTX2Loader textures and the scene is production-shaped.
Performance and trade-offs
KTX2 is a memory and pipeline optimization, and it is honest about its costs. The transcoder is an extra asset — the Basis WebAssembly module and its worker are roughly 200 KB — and transcoding spends a little CPU per texture at load, though it runs off the main thread. On disk, ETC1S files are small but not always smaller than a carefully tuned JPEG, and UASTC files are frequently larger than the JPEG they replace. The wins that are reliable are VRAM, GPU upload size, and skipping runtime decode and mipmap generation.
That shape favors scenes with many textures or large textures, and models loaded through glTF. It favors mobile, where memory pressure and context loss are real. It favors any site that reuses the same 3D across products, because the encode cost amortizes. It is a poorer fit for one small image or a single hero where a JPEG is both smaller and simpler — a trade-off the AETumi docs spell out per pipeline.
When to use it, and when not
Use KTX2 when texture memory is the constraint: multi-material product models, scenes with several 2K or 4K maps, mobile targets, or a library of models sharing an asset pipeline. Skip KTX2 when the texture budget is trivial — a few small UI images — when a single hero JPEG is smaller than its UASTC equivalent and you have no other textures to amortize the transcoder against, or when you genuinely cannot afford the extra request for the transcoder files.
| Scenario | Best choice | Why |
|---|---|---|
| Multi-map product model in GLB | KTX2 (UASTC normals, ETC1S color) | Large VRAM saving; one asset serves every GPU |
| Normal / roughness / data maps | KTX2 UASTC | Near-lossless; avoids banding in lighting math |
| Albedo / emissive color maps | KTX2 ETC1S | Tiny files; loss is imperceptible on color |
| Single hero image, no other textures | Tuned JPEG / WebP | Smaller on disk; no transcoder to ship |
| A handful of small UI sprites | PNG / WebP | Texture budget too small to justify KTX2 |
| Mobile scene near memory limits | KTX2 | Block-compressed VRAM prevents context loss |
AETumi Expert Notes
AETumi Expert Note — ship the transcoder, and cache it. The single most common KTX2 failure is a setTranscoderPath that points at files you never copied. Copy basis_transcoder.js and basis_transcoder.wasm from three/examples/jsm/libs/basis/ into your public path, serve them with a long cache header, and they load once for the whole session.
AETumi Expert Note — color space is per texture, not global. Tag color maps SRGBColorSpace and leave normal, roughness and metalness maps linear. Getting this backwards is the usual reason a KTX2 material looks washed out or a normal map lights incorrectly — and it is invisible until you compare against the uncompressed original.
AETumi Expert Note — dispose transcoded textures on route change. A CompressedTexture still holds GPU memory. In a single-page app, call texture.dispose() and renderer.dispose() when a scene unmounts, or the VRAM you saved with KTX2 leaks back as the user navigates between products.
Technical proof on GitHub
The KTX2 loading and material patterns in this guide sit alongside the runnable examples in the AETumi component repositories. AETumiApp/threejs-product-viewer shows a full glTF product viewer — see examples/product-viewer.html and its GUIDE.md for the loader wiring and material setup. AETumiApp/aetumi-3d-components collects reusable scene parts; examples/product-showcase.html and its GUIDE.md show how a compressed-texture material fits a componentized scene. GitHub is the proof; this article is the explanation — read them together.
FAQ
What is KTX2 texture compression in Three.js? KTX2 is a GPU-texture container storing images Basis-Universal-supercompressed as ETC1S or UASTC. KTX2Loader reads the file, detects the GPU's supported formats, and transcodes the Basis payload to a native block format such as ASTC, BC7 or ETC2. The texture stays block-compressed in VRAM, cutting memory roughly four to eight times versus an uncompressed RGBA8 upload.
What is the difference between ETC1S and UASTC? ETC1S makes very small files at lower quality, good for albedo and color maps. UASTC makes larger files at near-lossless quality, good for normal, roughness and data maps where banding would break lighting. Both live inside KTX2 and are transcoded by KTX2Loader; you choose per texture.
How do I create a .ktx2 file? Use toktx or basisu for standalone textures, or gltf-transform to compress every texture in a glTF or GLB at once. Pass UASTC for detail-critical maps and ETC1S for color maps, keep sRGB on color and linear on data textures, and generate mipmaps at encode time.
Does KTX2 make textures load faster? It reduces GPU memory and upload size and removes the main-thread cost of decoding and mipmapping a large image. Files are not always smaller on disk than a tuned JPEG, and transcoding adds a small CPU step, so the reliable win is VRAM, upload bandwidth and a smoother first render.
When should I not use KTX2 in Three.js? Skip it for a few small UI images, for a single hero texture where a JPEG is smaller, or when you cannot ship the ~200 KB transcoder and cannot afford the extra request. For tiny texture budgets the complexity outweighs the memory saving.
Related resources
Continue with Three.js, WebGL, reusable 3D components, the AETumi docs, MCP workflows for adapting a compressed-texture pipeline to a new product, and pricing.
#ktx2#basis#textures#gpu#mipmaps#threejs#webgl#gltf#uastc#etc1s
Conclusion
KTX2 texture compression in Three.js is the modern default for any scene where texture memory matters: one Basis-encoded asset, transcoded per device by KTX2Loader, that stays block-compressed on the GPU and cuts VRAM four to eight times. Encode UASTC for the maps that drive lighting and ETC1S for the maps the eye only sees, bake mipmaps at build time, tag color space per texture, and ship the transcoder cached. Reach for it when textures are the constraint, and keep a tuned JPEG for the cases where they are not — then wire it into your models the way the AETumi product-viewer repository shows.
More from the AETumi library
Real, production-ready assets — preview the motion, grab the source.

