Tutorial

How to Load and Optimize GLB Models in three.js and React Three Fiber

· 8 min read

Load GLB in three.js & React Three Fiber

To load a GLB in three.js, import GLTFLoader from three/addons/loaders/GLTFLoader.js, call await loader.loadAsync('model.glb') and add gltf.scene to your scene. If the file uses Draco, Meshopt or KTX2 compression, register the matching decoder on the loader first. In React Three Fiber, useGLTF from @react-three/drei handles loading, caching and the Draco and Meshopt setup in one line, and gltfjsx turns the model into a reusable JSX component.

This tutorial covers the plain three.js setup, the decoders for compressed models, progress and error handling, the errors people actually run into, React Three Fiber with gltfjsx, and how to keep models fast. Code targets three.js r186, drei 10 and gltfjsx 6.5.

Load a GLB with GLTFLoader

Put the .glb file somewhere your web server can serve it (for Vite, the public/ folder), then:

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const scene = new THREE.Scene();
const loader = new GLTFLoader();

const gltf = await loader.loadAsync('/models/chair.glb');
scene.add(gltf.scene);

three/addons/ is an alias for three/examples/jsm/ in the three package. Without a bundler, map both three and three/addons/ in an import map, as the three.js installation docs show.

The resolved gltf object contains scene (the default scene), scenes, animations (an array of AnimationClip), cameras, asset (generator and version info), parser and userData. To play animations:

const mixer = new THREE.AnimationMixer(gltf.scene);
for (const clip of gltf.animations) {
  mixer.clipAction(clip).play();
}

const timer = new THREE.Timer(); // replaces THREE.Clock, deprecated since r183
renderer.setAnimationLoop((time) => {
  timer.update(time);
  mixer.update(timer.getDelta());
  renderer.render(scene, camera);
});

glTF materials are physically based, so a model with no lights and no environment map renders black. Add a light or set scene.environment.

Load compressed models: Draco, KTX2 and Meshopt

GLTFLoader does not bundle any decoders. If the file needs one you haven't registered, loading fails. Not sure what your file uses? The free glTF validator lists the extensions in it: KHR_draco_mesh_compression, EXT_meshopt_compression or KHR_texture_basisu (KTX2). A loader that handles all three:

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';

const dracoLoader = new DRACOLoader().setDecoderPath('/draco/');

const ktx2Loader = new KTX2Loader()
  .setTranscoderPath('/basis/')
  .detectSupport(renderer); // your THREE.WebGLRenderer

const loader = new GLTFLoader()
  .setDRACOLoader(dracoLoader)
  .setKTX2Loader(ktx2Loader)
  .setMeshoptDecoder(MeshoptDecoder);

Create these once and reuse them for every model. The Draco decoder and Basis transcoder are only fetched when a file needs them; the Meshopt decoder is part of your bundle, but it is small.

DRACOLoader and the decoder path

setDecoderPath takes a folder URL that contains draco_wasm_wrapper.js, draco_decoder.wasm and draco_decoder.js. three.js appends the file names to the string as-is, so the trailing slash is required. You have three options:

  • Self-host (recommended). Copy node_modules/three/examples/jsm/libs/draco/ into your public folder, for example cp -r node_modules/three/examples/jsm/libs/draco public/draco. Its gltf/ subfolder contains a smaller build (about 250 KB instead of 345 KB) made for the glTF extension; either works.
  • Google's CDN. Use https://www.gstatic.com/draco/versioned/decoders/1.5.7/. The Draco team asks you to use the versioned URLs rather than the older /v1/decoders/ path.
  • The default. Since r185, DRACOLoader and KTX2Loader default to the decoder files inside the three package, resolved relative to the loader module. Whether that works depends on your bundler, so setting the path explicitly is still the most predictable choice.

The three.js docs recommend creating one DRACOLoader and reusing it, to avoid loading multiple decoder instances; each one runs its own pool of Web Workers (up to four by default). Call dracoLoader.dispose() when you no longer need it.

KTX2Loader and detectSupport

KTX2 textures (Basis Universal) stay compressed in GPU memory, which WebP and AVIF don't. The loader has to transcode them to a format the device supports, so it needs the Basis transcoder (copy three/examples/jsm/libs/basis/ to your public folder) and a call to detectSupport(renderer) before loading. With WebGPURenderer, run await renderer.init() first; the old detectSupportAsync() is deprecated.

MeshoptDecoder

Meshopt files only need setMeshoptDecoder(MeshoptDecoder). The decoder is a single 29 KB module with WebAssembly embedded, and since r183 it also handles KHR_meshopt_compression. For how Meshopt differs from Draco, see Draco vs Meshopt.

Show loading progress and handle errors

loadAsync accepts a progress callback that receives the browser's ProgressEvent, and rejects on failure:

try {
  const gltf = await loader.loadAsync('/models/chair.glb', (event) => {
    if (event.lengthComputable) {
      progressBar.value = event.loaded / event.total;
    }
  });
  scene.add(gltf.scene);
} catch (error) {
  console.error('Model failed to load:', error);
}

three.js reads the total from the Content-Length (or X-File-Size) response header. If your server leaves it out, lengthComputable is false and you can only show a spinner.

To track several files at once, pass a LoadingManager to the loader. It counts items, not bytes:

const manager = new THREE.LoadingManager();
manager.onProgress = (url, itemsLoaded, itemsTotal) => {
  console.log(itemsLoaded + ' of ' + itemsTotal + ' loaded: ' + url);
};
manager.onLoad = () => hideLoadingScreen();
manager.onError = (url) => console.error('Error loading ' + url);

const loader = new GLTFLoader(manager);

Common GLTFLoader errors and how to fix them

ErrorCause and fix
THREE.GLTFLoader: No DRACOLoader instance provided.The file is Draco-compressed. Call loader.setDRACOLoader(dracoLoader).
fetch for ".../draco_wasm_wrapper.js" responded with 404: Not FoundWrong decoder path. Check that the folder exists, holds the decoder files and that the path ends in /.
THREE.GLTFLoader: setMeshoptDecoder must be called before loading compressed filesThe file uses Meshopt. Call loader.setMeshoptDecoder(MeshoptDecoder).
THREE.GLTFLoader: setKTX2Loader must be called before loading KTX2 texturesThe file has KTX2 textures. Register a KTX2Loader, and call detectSupport(renderer) on it, or you get Missing initialization with `.detectSupport( renderer )`.
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSONThe server returned an HTML page instead of your file, usually a 404 page or a single-page-app fallback to index.html. The loader then tries to parse HTML as glTF JSON. Check the URL in the Network tab. In Vite, files in public/ are served from the root, so use /model.glb, not /public/model.glb. The wording differs slightly between browsers.
blocked by CORS policy: No 'Access-Control-Allow-Origin' headerThe model is on another domain (a CDN or bucket) that doesn't allow your site. Add an Access-Control-Allow-Origin header there, or serve the file from your own origin. Opening your page as a file:// URL doesn't work either; use a local dev server.
THREE.GLTFLoader: Unsupported asset. glTF versions >=2.0 are supported.It's a glTF 1.0 file. Re-export it from your 3D tool as glTF 2.0.

If the model loads without errors but you can't see it, it is often far too large or too small for your camera. Compute its size with new THREE.Box3().setFromObject(gltf.scene) and position the camera to fit.

React Three Fiber: useGLTF from drei

In React Three Fiber, use the useGLTF hook from @react-three/drei. It wraps GLTFLoader in R3F's useLoader, so results are cached by URL and the component suspends while loading:

import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { useGLTF, OrbitControls } from '@react-three/drei';

function Chair(props) {
  const { scene } = useGLTF('/models/chair.glb');
  return <primitive object={scene} {...props} />;
}

export default function App() {
  return (
    <Canvas camera={{ position: [0, 1, 3] }}>
      <ambientLight intensity={0.5} />
      <directionalLight position={[5, 5, 5]} />
      <Suspense fallback={null}>
        <Chair />
      </Suspense>
      <OrbitControls />
    </Canvas>
  );
}

useGLTF.preload('/models/chair.glb');

What to know about useGLTF:

  • The signature is useGLTF(path, useDraco = true, useMeshopt = true, extendLoader). Draco and Meshopt are on by default.
  • The Draco decoder comes from https://www.gstatic.com/draco/versioned/decoders/1.5.5/ by default. To self-host it, pass a path as the second argument or call useGLTF.setDecoderPath('/draco/') once.
  • KTX2 is not set up for you. Use the fourth argument, which receives the loader (see below).
  • useGLTF.preload(url) starts the download before the component mounts, and useGLTF.clear(url) removes it from the cache.
  • For a progress bar, drei's useProgress hook returns progress, active, loaded, total and errors.
  • A three.js object can only be in the scene once, and R3F does not dispose objects passed to <primitive>. To show the same model twice, clone it (drei's <Clone>) or use a gltfjsx component.

KTX2 textures with useGLTF:

import { useThree } from '@react-three/fiber';
import { useGLTF } from '@react-three/drei';
import { KTX2Loader } from 'three-stdlib';

const ktx2Loader = new KTX2Loader().setTranscoderPath('/basis/');

function Model() {
  const gl = useThree((state) => state.gl);
  const { scene } = useGLTF('/models/chair.glb', true, true, (loader) => {
    loader.setKTX2Loader(ktx2Loader.detectSupport(gl));
  });
  return <primitive object={scene} />;
}

gltfjsx: turn a GLB into a React component

gltfjsx is a command-line tool that reads a glTF file and writes a JSX component that lays out every mesh, material and group declaratively:

npx gltfjsx chair.glb --transform --types

This writes Chair.tsx (Chair.jsx without --types) and chair-transformed.glb to the current folder. Move the component into src/ and the .glb into public/. The generated component looks roughly like this:

export function Model(props) {
  const { nodes, materials } = useGLTF('/chair-transformed.glb');
  return (
    <group {...props} dispose={null}>
      <mesh geometry={nodes.Seat.geometry} material={materials.Wood} />
      <mesh geometry={nodes.Legs.geometry} material={materials.Metal} />
    </group>
  );
}

useGLTF.preload('/chair-transformed.glb');

Because it references geometries and materials rather than the loaded scene object, you can render <Model /> as many times as you like, change materials per mesh, or attach events. The dispose={null} stops R3F from disposing the cached geometry when the component unmounts.

What --transform does

--transform (-T) leaves your original file alone and writes a separate [name]-transformed.glb in the same folder as the generated component. Under the hood it runs a series of gltf-transform operations:

  • Packs simple materials into shared palette textures, removes duplicates, flattens the node tree and joins compatible meshes.
  • Welds vertices, resamples animations and prunes unused data.
  • Caps texture size at --resolution (default 1024 px) and re-encodes textures in --format (default webp). Normal maps get a higher cap, 2048 px or your --resolution if larger, and are saved as JPEG.
  • Compresses geometry with Draco, which useGLTF decodes automatically.

Other useful flags: --simplify (-S) to reduce triangles, --keepmeshes and --keepmaterials to skip joining, --instance (-i) to instance repeated geometry, --shadows, and --output (-o) to choose the file name.

Watch the texture resolution: 1024 px is small for hero product shots. Use --resolution 2048 if detail matters.

Performance tips

  • Compress before you ship. Draco or Meshopt for geometry, plus resized WebP or AVIF textures, typically cuts a GLB by well over half. See how to reduce GLB file size for the full process.
  • Size textures for the screen. File size and GPU memory are different things: a 4096 × 4096 texture takes about 64 MB of GPU memory uncompressed (plus a third more for mipmaps), whether the file was a 2 MB JPEG or a 500 KB WebP. Only KTX2 stays compressed on the GPU.
  • Instance repeated objects. GLTFLoader turns EXT_mesh_gpu_instancing into an InstancedMesh, and gltfjsx has --instance. Check renderer.info.render.calls to see your draw call count.
  • Reuse loaders and decoders. One GLTFLoader, one DRACOLoader, one KTX2Loader for the whole app.
  • Dispose what you remove. Removing a model from the scene does not free GPU memory. Call dispose() on its geometries, materials and textures (disposing a material does not dispose its textures), and watch renderer.info.memory.
function disposeModel(root) {
  root.traverse((object) => {
    if (!object.isMesh) return;
    object.geometry.dispose();
    const materials = Array.isArray(object.material) ? object.material : [object.material];
    for (const material of materials) {
      for (const value of Object.values(material)) {
        if (value && value.isTexture) value.dispose();
      }
      material.dispose();
    }
  });
}

GLTFLoader decodes images into ImageBitmaps where it can. The three.js manual notes these also need ImageBitmap.close() to free their CPU-side memory.

FAQ

What is the difference between GLTFLoader and useGLTF?

useGLTF is a React hook from drei that wraps a GLTFLoader (from three-stdlib) in React Three Fiber's useLoader. On top of the loader it adds caching by URL, Suspense support, preloading, and Draco and Meshopt decoding switched on by default.

Where do I put the Draco decoder files?

Copy node_modules/three/examples/jsm/libs/draco/ into your public folder and call setDecoderPath('/draco/') with a trailing slash. Alternatively, point it at Google's versioned CDN URL.

Does gltfjsx compress my model?

Only with --transform (the --instance flags run the same step). It then writes a separate -transformed.glb with Draco geometry and textures capped at 1024 px WebP by default, and the generated component loads that file.

Can three.js load .gltf files as well as .glb?

Yes, GLTFLoader handles both. A .gltf file references separate .bin and texture files that must sit at the right relative paths, which is why a single .glb is easier to deploy. The free glTF to GLB converter packs them into one file in your browser.

Why is my GLB model black in three.js?

glTF uses physically based materials, which need light. Add lights or an environment map (scene.environment, or drei's <Environment> in React Three Fiber).

Related articles