Skip to content
← Back to Blog tutorials

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

By BitSoul3D5 min read231 views

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.

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

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:

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:

// 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:

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:

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

SymptomCauseFix
Silent empty scene, no errorDraco decoder path wrongCheck network tab for 404 on draco_decoder.wasm
Black textures, KTX2 GLBdetectSupport() called after loadMove ktx2Loader.detectSupport(renderer) before any .load()
setKTX2Loader must be called errorsetKTX2Loader called inside load callbackMove both set*Loader calls to module-level setup
WASM blocked by CORSWrong Content-Type headerSet Content-Type: application/wasm on WASM files at server
Progress progress.total is 0Server not sending Content-LengthGuard with if (progress.total > 0) before dividing
AnimationMixer does nothinggetDelta() not called in render loopCall 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.


Need drop-in assets for this workflow? Grab the Architecture Modern Bundle on the BitSoul marketplace and drop them straight into your project.

Tags: architecture

Skip the modeling — download it instead

A free BitSoul3D account gets you 2 GLB downloads every month for personal use plus 25 one-time AI Engine credits, no card required. PBR-textured GLB downloads with a full 3D preview before you buy, for Unreal, Unity, Godot or Blender — OBJ and 3D-printable STL come with any purchase or paid plan.

Browse 1,051 models — from $4.99 → or start free (2 downloads a month)