gamedev-skills/awesome-gamedev-agent-skillsCheck passed
SKILL DETAIL
threejs-gltf-loading
gamedev-skills/awesome-gamedev-agent-skills/threejs-gltf-loading
Load glTF/GLB models in three.js with GLTFLoader and play their skinned animations with AnimationMixer, including DRACO/Meshopt-compressed meshes and KTX2 textures. Use when importing 3D models into three.js — when the user mentions glTF, GLB, GLTFLoader, AnimationMixer, animation clips, DRACOLoader, or "load a 3D model". For scene/camera/renderer setup use threejs-scene-setup; for materials and lights use threejs-materials-lighting.
Installs · 289View source
Installation
npx skills add https://github.com/gamedev-skills/awesome-gamedev-agent-skills --skill threejs-gltf-loading
Skill files
SKILL.md
Last synced · Oct 26, 2026
references/loaders-and-animation.md›
# three.js glTF: loaders, compression & animation detail (r186)
Depth behind the glTF skill: combining decoders, async loading with progress,
cloning skinned meshes, and exporter hygiene. Notes call out where older releases
differ.
## Compression: DRACO, Meshopt, KTX2
A production glTF pipeline often compresses geometry (DRACO or Meshopt) and
textures (KTX2/Basis). Attach the matching loaders to `GLTFLoader`:
```js
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader, DRACO_GLTF_CONFIG } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
// r185+: both loaders default to the decoder files bundled with three
// (examples/jsm/libs/draco/ and examples/jsm/libs/basis/).
const draco = new DRACOLoader().setDecoderPath(DRACO_GLTF_CONFIG); // glTF-only WASM build
const ktx2 = new KTX2Loader().detectSupport(renderer);
const loader = new GLTFLoader()
.setDRACOLoader(draco)
.setKTX2Loader(ktx2)
.setMeshoptDecoder(MeshoptDecoder); // EXT_ and KHR_meshopt_compression (r183+)
loader.load('assets/scene.glb', (gltf) => scene.add(gltf.scene));
```
Notes:
- If the default files 404 (some bundler/host setups don't serve them), copy
`examples/jsm/libs/draco/` and `examples/jsm/libs/basis/` from the installed
`three` into your static assets and call `draco.setDecoderPath('/decoders/draco/')`
and `ktx2.setTranscoderPath('/decoders/basis/')`. A pinned CDN folder such as
`https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/libs/basis/` also works;
on releases before r185 one of these paths is required.
- The decoder/transcoder files must match your three.js version; a mismatch throws
at load time.
- `ktx2.detectSupport(renderer)` requires the renderer to exist first. With
`WebGPURenderer`, `await renderer.init()` before calling it
(`detectSupportAsync()` is deprecated since r181).
- `DRACOLoader.setDecoderConfig()` is deprecated since r185; the loader picks WASM
or JS itself.
- Compression shrinks downloads but adds CPU/Worker decode time; measure both.
## Async loading with a progress bar
```js
import { LoadingManager } from 'three';
const manager = new LoadingManager();
manager.onProgress = (url, loaded, total) => {
setBarWidth(`${(loaded / total) * 100}%`);
};
manager.onLoad = () => hideLoadingScreen();
const loader = new GLTFLoader(manager);
// Promise form — clean with async/await and Promise.all for many assets.
const [hero, level] = await Promise.all([
loader.loadAsync('assets/hero.glb'),
loader.loadAsync('assets/level.glb'),
]);
scene.add(hero.scene, level.scene);
```
A `LoadingManager` aggregates progress across every loader that shares it, so one
bar can cover models, textures, and audio.
## Reusing a model many times
Adding the same `gltf.scene` to the scene twice does not duplicate it — it moves
it. To place many instances of an animated character, clone with `SkeletonUtils`
so the skinned mesh and its skeleton are cloned correctly (a plain `.clone()` does
not duplicate the skeleton):
```js
import * as THREE from 'three';
import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
const enemy = await loader.loadAsync('assets/enemy.glb');
const mixers = [];
for (let i = 0; i < 10; i++) {
const instance = skeletonClone(enemy.scene);
instance.position.x = i * 2;
scene.add(instance);
const mixer = new THREE.AnimationMixer(instance); // each clone needs its own mixer
mixer.clipAction(enemy.animations[0]).play(); // clips can be shared
mixers.push(mixer);
}
// In the loop: for (const m of mixers) m.update(dt);
```
For static (non-skinned) meshes, prefer `InstancedMesh` for thousands of copies —
it draws them in one call.
## AnimationMixer detail
- One `AnimationMixer` per animated root object; create every `AnimationAction`
from that mixer (`mixer.clipAction(clip)`).
- `action.play()` starts it; `action.stop()`/`action.reset()` rewind it.
- Looping: `action.setLoop(THREE.LoopRepeat)` (default) or `THREE.LoopOnce` with
`action.clampWhenFinished = true` to hold the final pose.
- `action.timeScale` speeds/slows a clip; `action.weight` blends two playing
clips (e.g. walk↔run by speed).
- Blend transitions with `from.crossFadeTo(to, duration, warp)` or
`to.crossFadeFrom(from, duration, warp)`.
- Listen for completion: `mixer.addEventListener('finished', (e) => {...})`.
Always advance with real time: call `timer.update(time)` then
`mixer.update(timer.getDelta())` inside the loop (`THREE.Timer`; `Clock` is deprecated
since r183).
## Exporter hygiene (Blender → glTF)
Most "model looks wrong" issues are authoring problems, not loader bugs:
- **Apply transforms** before export (Object → Apply → All Transforms) so nodes have
identity position/rotation/scale; otherwise baked offsets fight runtime control.
- **One clean root.** Aim for a single root `Object3D` with no transform and
meaningful child names, so you can re-parent parts to the scene cheaply.
- **Author in metres**, real-world scale. Avoid scaling parents.
- **Name your nodes and clips** — you'll look them up by name (`getObjectByName`,
`AnimationClip.findByName`).
- Prefer `.glb` (single binary file) for the web; it bundles geometry, textures,
and animation into one request.
SKILL.md›
---
name: threejs-gltf-loading
description: >
Load glTF/GLB models in three.js with GLTFLoader and play their skinned
animations with AnimationMixer, including DRACO/Meshopt-compressed meshes and
KTX2 textures. Use when importing 3D models into three.js — when the user
mentions glTF, GLB, GLTFLoader, AnimationMixer, animation clips, DRACOLoader, or
"load a 3D model". For scene/camera/renderer setup use threejs-scene-setup; for
materials and lights use threejs-materials-lighting.
---
# three.js glTF Loading
Load `.gltf`/`.glb` models and play their animations in three.js, including
compressed geometry (DRACO/Meshopt) and textures (KTX2). Patterns target
**r186**; preserve an existing project's pinned release unless migration is requested.
## When to use
- Use to import a 3D model, add it to the scene, inspect its node hierarchy, and
play baked/skinned animation clips with an `AnimationMixer`.
- Use when files are `.gltf`/`.glb`, or code imports `GLTFLoader` /
`DRACOLoader` / `KTX2Loader` from `three/addons/loaders/...`.
**When *not* to use:** creating the renderer/camera/loop → `threejs-scene-setup`.
Tuning surface look, lights, or shadows on the loaded model →
`threejs-materials-lighting`. Authoring/exporting the model itself (Blender) is out
of scope; prefer glTF over OBJ/FBX for runtime.
## Core workflow
1. **Why glTF.** It's a transmission format: binary vertex data, PBR materials, and
animations are ready to render with minimal parsing. Prefer it over OBJ (no scene
graph, no animation) and FBX (heavy) for the web.
2. **Load with `GLTFLoader`.** `loader.load(url, onLoad, onProgress, onError)`. The
result `gltf` has `gltf.scene` (the `Object3D` root), `gltf.animations`
(`AnimationClip[]`), `gltf.cameras`, and `gltf.asset`.
3. **Add `gltf.scene` to your scene** and frame it. Inspect the hierarchy with
`traverse` / `getObjectByName` to find the parts you'll control.
4. **Play animations with an `AnimationMixer`.** One mixer per animated root;
`mixer.clipAction(clip).play()`; advance with `mixer.update(delta)` every frame.
5. **Decode compressed assets.** Attach a `DRACOLoader`, a `KTX2Loader`, and/or
the Meshopt decoder so DRACO meshes, KTX2 textures, and meshopt-compressed
buffers load. Since r185 the DRACO and KTX2 loaders find their bundled
decoder files on their own.
6. **Validate changed glTF files when the project already has the tool.** Run
`gltf-transform validate path/to/model.glb` for each changed `.gltf` or `.glb`;
fix reported errors before loading.
7. **Verify what loaded** — log the scene graph and `gltf.animations`, and confirm
the model is visible (right scale, lit) and the clip actually plays.
## Patterns
### 1. Load a model and frame it
```js
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
loader.load(
'assets/robot.glb',
(gltf) => {
const root = gltf.scene;
scene.add(root);
// Inspect: gltf.animations is an array of AnimationClip.
console.log('clips:', gltf.animations.map((c) => c.name));
},
(event) => {
// total is 0 when the server sends no Content-Length (e.g. compressed responses)
if (event.total) console.log(`${(event.loaded / event.total) * 100}% loaded`);
},
(error) => console.error('glTF load failed:', error)
);
```
### 2. Play a skinned animation with AnimationMixer
```js
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
let mixer; // declare outside so the loop can see it
const timer = new THREE.Timer(); // THREE.Clock is deprecated since r183
new GLTFLoader().load('assets/character.glb', (gltf) => {
scene.add(gltf.scene);
mixer = new THREE.AnimationMixer(gltf.scene); // one mixer per animated root
const clip = THREE.AnimationClip.findByName(gltf.animations, 'Run')
?? gltf.animations[0];
mixer.clipAction(clip).play();
});
renderer.setAnimationLoop((time) => {
timer.update(time);
const dt = timer.getDelta();
if (mixer) mixer.update(dt); // advance the animation by real seconds
renderer.render(scene, camera);
});
```
### 3. Cross-fade between two clips
```js
const actions = {};
mixer = new THREE.AnimationMixer(gltf.scene);
for (const clip of gltf.animations) {
actions[clip.name] = mixer.clipAction(clip);
}
let current = actions['Idle'];
current.play();
function transitionTo(name, duration = 0.3) {
const next = actions[name];
if (!next || next === current) return;
next.reset().play(); // rewind and clear any old fade
current.crossFadeTo(next, duration, false); // fade the old one out, new one in
current = next;
}
```
### 4. DRACO-compressed geometry
```js
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader, DRACO_GLTF_CONFIG } from 'three/addons/loaders/DRACOLoader.js';
// r185+: DRACOLoader finds the decoder that ships next to it in
// three/examples/jsm/libs/draco/, so no path is required. DRACO_GLTF_CONFIG
// (also r185+) selects the smaller glTF-only WASM build in libs/draco/gltf/.
const draco = new DRACOLoader().setDecoderPath(DRACO_GLTF_CONFIG);
const loader = new GLTFLoader();
loader.setDRACOLoader(draco); // share one DRACOLoader across loads
loader.load('assets/city-draco.glb', (gltf) => scene.add(gltf.scene));
```
On releases before r185, or when your host does not serve those files, pass a
folder instead: `draco.setDecoderPath('/decoders/draco/')` with the files copied
from the same `three` version.
### 5. Find and animate a named part
```js
const timer = new THREE.Timer();
new GLTFLoader().load('assets/car.glb', (gltf) => {
scene.add(gltf.scene);
const wheels = [];
gltf.scene.traverse((node) => {
if (node.name.startsWith('Wheel')) wheels.push(node);
});
renderer.setAnimationLoop((time) => {
timer.update(time);
const dt = timer.getDelta();
for (const w of wheels) w.rotation.x += dt * 4;
renderer.render(scene, camera);
});
});
```
## Pitfalls
- **Model loads but is invisible** → it has lit (PBR) materials and the scene has no
light or environment. Add a light or `scene.environment` (see
`threejs-materials-lighting`), and check scale — glTF is in metres, so a 0.01-scaled
asset is tiny.
- **`load` is async** → `gltf` only exists inside the callback; declare `mixer`/refs
outside and assign them in the callback, or use `await loader.loadAsync(url)`.
- **Animation never moves** → you didn't call `mixer.update(delta)` each frame, or you
passed milliseconds instead of seconds (use `timer.getDelta()` after
`timer.update(time)`), or you forgot
`action.play()`.
- **DRACO/KTX2 model fails, or the network tab shows a 404 for `draco_*`/`basis_*`
files** → the decoder/transcoder files are not being served, or come from a
different three.js version. Since r185 the loaders default to the copies inside
`three/examples/jsm/libs/`; if your bundler or host doesn't serve those, copy the
folders from the installed `three` and point `setDecoderPath`/`setTranscoderPath`
at them.
- **`setKTX2Loader must be called before loading KTX2 textures`** or
**`setMeshoptDecoder must be called before loading compressed files`** → the
asset uses `KHR_texture_basisu` or meshopt compression (`EXT_`/`KHR_meshopt_compression`);
attach the missing decoder (see the reference).
- **Multiple mixers fighting** → use **one** `AnimationMixer` per animated root and
create all actions from it; don't make a new mixer per clip.
- **Baked-in transforms surprise you** → exporters sometimes bake scale/rotation onto
child nodes. Dump the hierarchy (names + position/rotation/scale) before relying on
a node's local transform; re-export from the source if the rig is unusable.
- **Origins are off** → re-parent a part under a fresh `Object3D` to give it a clean
pivot rather than fighting baked offsets.
## References
- For the full decode/transcode setup (DRACO + Meshopt + KTX2 together), `loadAsync`
+ a `LoadingManager` progress bar, reusing models with `SkeletonUtils.clone`, and
exporter guidance (apply transforms, one clean root), read
`references/loaders-and-animation.md`.
## Related skills
- `threejs-scene-setup` — the renderer, camera, and loop this model renders into.
- `threejs-materials-lighting` — lighting/environment so PBR models look right.
- `fps-shooter` — a 3D genre that composes three.js skills.