Three.js GLTFLoader handles simple GLBs without complaint. Add Draco mesh compression or KTX2 textures — both common in any real production GLB — and you get one of three outcomes: a silent empty scene, black textures, or `THREE.GLTFLoader: setKTX2Loader must be called before loading KTX2 textures`. The fix is a one-time loader setup. Once it's wired correctly, every compressed GLB in your project loads.
The solution in one sentence: install the decoder files, serve them statically, and attach `DRACOLoader` and `KTX2Loader` to your `GLTFLoader` before the first `.load()` call. The rest of this post is the small amount of code that matters, the gotchas the docs skip, and the memory cleanup you'll need the moment your page swaps models.
Install packages and host the decoder files
The decoders ship inside the `three` package itself — no extra install beyond copying their WASM files somewhere your server exposes:
```bash
npm install three
cp -r node_modules/three/examples/jsm/libs/draco/ public/draco/
cp -r node_modules/three/examples/jsm/libs/basis/ public/basis/
```
`public/draco/` needs `draco_decoder.wasm` and `draco_decoder.js`; `public/basis/` needs the `basis_transcoder` pair. Any static server works — with one constraint that costs people hours: WASM files must be served with `Content-Type: application/wasm`. Some hosts default to `application/octet-stream`, which Chrome and Firefox both reject with a MIME error. Set the header in your server config or `_headers` file before debugging anything else.
Wire up DRACOLoader and KTX2Loader before any load call
![]()
Create your `WebGLRenderer` first — `KTX2Loader.detectSupport()` inspects it to pick a transcode target. Then the entire setup is four objects and three wiring calls:
```js
// imports: GLTFLoader, DRACOLoader, KTX2Loader from three/examples/jsm/loaders/
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/'); // trailing slash is required
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath('/basis/'); // trailing slash is required
ktx2Loader.detectSupport(renderer); // must run before any load
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.setKTX2Loader(ktx2Loader);
```
Two details here break more projects than everything else combined. First, both `set*Loader` calls must happen before `.load()` — Three.js reads the GLB's extension list synchronously from the binary header, so attaching loaders inside the load callback is always too late. Second, the trailing slash on decoder paths is not optional: Three.js appends filenames directly to the string, so `/draco` without the slash requests `/dracodraco_decoder.js` at runtime — a 404 with no useful error message.
Loading, and what a real GLB weighs
With the loaders wired, `loader.load(url, onLoad, onProgress, onError)` behaves exactly as documented: add `gltf.scene` to your scene in the success callback (and walk it with `traverse` to enable `castShadow`/`receiveShadow` on meshes), report progress from `progress.loaded / progress.total`, and log the error callback — with Draco and KTX2 attached, the mysterious failures are gone and anything left is an honest network or path problem.
The payoff is the transfer size. The Character Battle Mage is 62 MB as a raw GLB; with Draco on geometry and KTX2 on textures it typically lands between 8 and 14 MB on the wire. Run `gltf-transform inspect model.glb` to see the exact breakdown for any file — per-texture VRAM, triangle counts, and the extension list in one pass. For the compression step itself, the GLTF optimization and compression guide covers the full `gltf-transform` pipeline.
Drive animations with AnimationMixer
Animations live in `gltf.animations` as an array of clips. Create one mixer per model, pick a clip, play it:
```js
const mixer = new THREE.AnimationMixer(gltf.scene);
const clip = THREE.AnimationClip.findByName(gltf.animations, 'Idle')
?? gltf.animations[0];
mixer.clipAction(clip).play();
// in your render loop:
mixer.update(clock.getDelta());
```
`clock.getDelta()` returns seconds since the last call and resets the timer — feed it to `mixer.update()` every frame. Forgetting it is why animations appear frozen, or run double-speed on high-refresh monitors. If the file has no animations, the mixer silently no-ops; it's safe to create unconditionally.
Clip names come verbatim from the source tool — Blender exports the NLA action name, so a Blender action called `ArmatureAction` is exactly the string `findByName` needs. Log `gltf.animations.map(a => a.name)` once before hardcoding anything. For export settings that preserve clean clip names, see glTF export settings for game-ready GLB files.
Memory management — the part the docs skip
![]()
Three.js does not garbage-collect GPU resources. Every geometry, material and texture stays in VRAM until you explicitly dispose it. A page that loads one model and never swaps can ignore this; any viewer, configurator or game that swaps models at runtime cannot:
```js
function disposeModel(root) {
root.traverse((child) => {
if (!child.isMesh) return;
child.geometry.dispose();
const mats = Array.isArray(child.material) ? child.material : [child.material];
mats.forEach((mat) => {
for (const val of Object.values(mat))
if (val && val.isTexture) val.dispose();
mat.dispose();
});
});
}
disposeModel(currentModel); // before loading the next model
scene.remove(currentModel);
```
VRAM leaks are invisible in Chrome's memory profiler, which tracks the JS heap, not GPU memory. Profile with `about:gpu` in Chrome or the GPU counter in Firefox DevTools. An un-disposed model swap typically holds 50–300 MB of VRAM — enough to crash a mobile tab after three or four swaps.
What breaks that the docs don't mention
| Symptom | Cause | Fix |
|---|---|---|
| Silent empty scene, no error | Draco decoder path wrong | Check network tab for 404 on `draco_decoder.wasm` |
| Black textures, KTX2 GLB | `detectSupport()` called after load | Move `ktx2Loader.detectSupport(renderer)` before any `.load()` |
| `setKTX2Loader must be called` error | `setKTX2Loader` called inside load callback | Move both `set*Loader` calls to module-level setup |
| WASM blocked by CORS | Wrong Content-Type header | Set `Content-Type: application/wasm` on WASM files at server |
| Progress `progress.total` is 0 | Server not sending Content-Length | Guard with `if (progress.total > 0)` before dividing |
| AnimationMixer does nothing | `getDelta()` not called in render loop | Call `mixer.update(clock.getDelta())` inside `requestAnimationFrame` callback |
The correct initialization order is always: create renderer → create decoders → `detectSupport(renderer)` → `set*Loader` on `GLTFLoader` → `.load()`. Any deviation triggers one of the bugs in the table above.
---
*This post is part of the Ultimate Guide to Free 3D Game Assets — BitSoul's complete reference for formats, texturing, rigging, optimization, and engine integration.*