← Back to Blog tutorials

Load GLB files in Three.js: Draco, KTX2, and animations in 2026

By BitSoul Team8/2/20265 min read57 views
Load GLB files in Three.js: Draco, KTX2, and animations in 2026

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

Wiring Draco and KTX2 loaders before the first load — illustrated

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

GPU memory filling when disposed models are never freed — illustrated

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.*

Tags: tutorials workflow 3d-models game-assets optimization

Skip the modelling — download it instead

A free BitSoul account gets you 2 game-ready models every month plus 25 AI Engine credits to generate one of your own, no card required. Clean topology, PBR textures, and GLB downloads that drop straight into Unreal, Unity, Godot or Blender — plus OBJ and 3D-printable STL export.

Create a free account → Browse 846 models