Lesson 44 - Three.js Game Development

Three.js GLB Assets: Loading, DRACO, Caching, Cloning, and Cleanup

A model loader is infrastructure, not a one-line demo. We will make repeated requests cheap, errors visible, and imported models predictable enough to use as gameplay objects.

IntermediateThree.js75-100 minutesGLTFLoaderDRACO

1. Configure GLTF and DRACO once

Loaders should be long-lived. DRACO decoder files must be served from a real HTTP path and use the version expected by your Three.js package.

import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";

const draco = new DRACOLoader();
draco.setDecoderPath("../vendor/draco/");

const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(draco);

How this code works

Execution flow

The module creates a DRACOLoader, points it at decoder assets, injects it into one GLTFLoader, and retains both instances for future model requests. Every compressed GLB then follows the same decoder configuration.

Key Three.js decisions

GLTFLoader understands scenes, materials, skins, and animations, while DRACOLoader handles compressed mesh payloads. Central configuration avoids downloading or initializing decoders repeatedly and keeps Three.js addon versions aligned.

Adapt it

Host decoder files with the site for predictable offline and cache behavior, or use uncompressed GLB files when models are tiny. Add KTX2Loader through the same shared loader infrastructure when texture compression becomes valuable.

Watch out

An incorrect decoder path produces model-loading failures that look like missing assets. Mixing addon files from another Three.js revision can fail subtly, so serve all loaders and decoders from the dependency version used by the game.

2. Cache the loading promise

Caching the promise prevents duplicate network and parse work when several systems request the same tree simultaneously. Delete failed entries so a later retry remains possible.

const cache = new Map();

async function loadGltf(url) {
  if (!cache.has(url)) {
    const pending = gltfLoader.loadAsync(url).catch((error) => {
      cache.delete(url);
      throw error;
    });
    cache.set(url, pending);
  }
  return cache.get(url);
}

How this code works

Execution flow

The first request creates loadAsync and stores its pending Promise immediately. Concurrent callers receive that same Promise; success remains cached, while a rejection deletes the entry so a later request can retry.

Key Three.js decisions

Caching the Promise rather than only the completed GLTF prevents duplicate network, decoding, and parsing work during simultaneous biome generation. The catch handler preserves recoverability without hiding the original error from callers.

Adapt it

Add reference counts when assets can be unloaded, preload a manifest before gameplay, and cache normalized templates separately from raw GLTF data. Include loader options in the cache key if the same URL can be processed differently.

Watch out

Returning the cached scene directly lets callers move or mutate the shared template. Cache failures forever and a temporary network problem becomes permanent until reload; never swallow rejection details needed by loading UI and diagnostics.

3. Clone and normalize an instance

Clone the loaded scene instead of moving the cached original. Compute its bounds, fit the longest dimension to a target size, and place the lowest point on the ground.

async function createModel(url, targetSize = 2) {
  const gltf = await loadGltf(url);
  const model = gltf.scene.clone(true);
  const box = new THREE.Box3().setFromObject(model);
  const size = box.getSize(new THREE.Vector3());
  model.scale.multiplyScalar(targetSize / Math.max(size.x, size.y, size.z));
  box.setFromObject(model);
  model.position.y -= box.min.y;
  return model;
}

How this code works

Execution flow

The function awaits the cached GLTF, clones its scene, calculates world bounds, finds the longest dimension, scales to the requested size, recalculates bounds, and shifts the model until its lowest point rests at local ground level.

Key Three.js decisions

Box3 and getSize make imported authoring units predictable without hard-coded model knowledge. clone creates an independent transform hierarchy, while a second bounding calculation is necessary because scale changes the model's minimum Y.

Adapt it

Use SkeletonUtils.clone for skinned characters, then attach animations from the original GLTF. Add authored metadata for forward direction or collision size when automatic bounds cannot express gameplay intent.

Watch out

Object3D.clone still shares geometry and materials, which is efficient but means material edits affect other instances. Models with hidden helpers or extreme outlier vertices can produce misleading bounds, so inspect and filter the imported hierarchy.

4. Show a fallback and clean GPU data

A missing asset should not leave the game blank. Return a readable placeholder, report the URL, and traverse unique resources when an asset is permanently removed.

async function loadOrFallback(url) {
  try {
    return await createModel(url);
  } catch (error) {
    console.error("GLB failed", url, error);
    return new THREE.Mesh(
      new THREE.BoxGeometry(1, 1, 1),
      new THREE.MeshStandardMaterial({ color: 0xff5577 })
    );
  }
}

How this code works

Execution flow

The wrapper attempts normal model creation, reports URL and error on failure, and returns a bright placeholder mesh so gameplay can continue visibly. The caller receives a valid Object3D in both success and failure paths.

Key Three.js decisions

A procedural BoxGeometry fallback has no network dependency and makes asset defects obvious instead of producing an empty scene. Centralized error handling preserves the failed URL and stack while keeping level construction deterministic.

Adapt it

Use themed fallback silhouettes for trees, rocks, or enemies and show a nonblocking loading warning to developers. On permanent removal, traverse unique geometries, materials, and textures while respecting resources shared by cached templates.

Watch out

Silently replacing every failed model can hide broken production paths from testing. Disposing shared cached resources when one instance leaves the scene makes remaining clones render incorrectly, so define cache ownership before cleanup.

5. Real Supagames example

Production source: js/environment-engine/loaders/glb.js

Play the published example: Valley Racer

The shared GLB loader centralizes DRACO configuration, caching, cloning, transform normalization, shadow flags, and fallbacks so every biome subsystem does not invent a different asset pipeline.

The teaching snippets deliberately isolate one Three.js responsibility at a time. The production file also handles integration, lifecycle, quality settings, and game-specific state, so read it after the smaller examples make sense.

6. Common mistakes

  • Creating geometry, materials, vectors, or arrays inside the frame loop and causing avoidable garbage collection pauses.
  • Treating the Three.js scene graph as the only source of gameplay state instead of keeping explicit JavaScript state.
  • Testing only on a fast desktop and leaving pixel ratio, shadows, and object counts uncapped on mobile devices.

7. Build checklist

  • Serve ES modules through HTTP during development instead of opening the page through file://.
  • Test resize, pause, restart, and cleanup paths as carefully as the first successful frame.
  • Use browser performance tools and an on-screen FPS sample before guessing where a slowdown comes from.
  • Verify keyboard, pointer, and touch controls without letting game shortcuts break forms or page navigation.
Previous: Cannon-es physics Next: Terrain, water, and procedural worlds