three.js guide
How to load a free GLB model in three.js
A working GLTFLoader setup, straight from a CDN URL with no build step, plus animation, sensible scaling and the four mistakes that account for most blank screens.

Loading a model in three.js is genuinely a five-line job, and most of the difficulty people hit is in the four lines around it. Here is the whole thing, with the parts that actually go wrong called out.
The minimal version
GLTFLoader is not in the three.js core bundle; it lives in the addons directory. Import it, point it at a URL, add the result to your scene:
1import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'23const loader = new GLTFLoader()4loader.load('https://cdn.3dassets.dev/assets/28312/v1/model.glb', (gltf) => {5 scene.add(gltf.scene)6})
That URL is a real model, a young oak, and you can paste that snippet as it stands. Every asset page on the site generates this snippet with its own URL already in it, so in practice you copy rather than type.
A .glb is the binary form of glTF with everything packed into one file, textures included. There is no second request, no material wiring and no texture path to fix up.
The version that also handles failure
The three-argument form gives you progress and, more usefully, errors:
1loader.load(2 'https://cdn.3dassets.dev/assets/28312/v1/model.glb',3 (gltf) => {4 scene.add(gltf.scene)5 },6 (event) => {7 console.log(`${((event.loaded / event.total) * 100).toFixed(0)}% loaded`)8 },9 (error) => {10 console.error('Model failed to load', error)11 },12)
If you skip the error callback, a failed load is silent and you spend twenty minutes looking at your camera setup. Add it before you need it.
Loading from a CDN rather than your own server
Passing a remote URL straight to GLTFLoader works as long as the host sends the right CORS headers. Models on cdn.3dassets.dev allow GET from any origin, and each URL is immutable: the bytes behind it never change, so you can cache it hard and never think about invalidation.
Hotlinking is fine for prototypes, demos and production alike. Copy the file into your own build when you need it available offline, or when you want it bundled with the rest of your assets for a single deployment artefact.
To get the browser fetching the model in parallel with your JavaScript instead of after it, preload:
1<link rel="preload" as="fetch" crossorigin href="https://cdn.3dassets.dev/assets/28312/v1/model.glb">
Animated models
Models with moving parts carry their animation clips in the same file. The model below is a wooden supply crate that opens and closes. Play a clip with an AnimationMixer, and update it from your render loop:
1import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'2import { AnimationMixer } from 'three'34let mixer56const loader = new GLTFLoader()7loader.load('https://cdn.3dassets.dev/assets/27447/v1/model.glb', (gltf) => {8 scene.add(gltf.scene)9 mixer = new AnimationMixer(gltf.scene)10 mixer.clipAction(gltf.animations[0]).play()11})1213// Call from your render loop; deltaSeconds is time since the previous frame.14function updateAssetAnimation(deltaSeconds) {15 mixer?.update(deltaSeconds)16}
The forgettable half is the update call. A mixer that is never updated produces a model frozen in its first frame, which looks exactly like a model with no animation at all.
Clips are named rather than numbered, and the names are published on each asset, so you can pick one deliberately instead of taking index zero:
1const open = gltf.animations.find((clip) => clip.name === 'open')2mixer.clipAction(open).play()
The vocabulary is small on purpose: open and close come as a pair, and spin, roll, idle and similar say what the motion is. A crate reports open and close; a ceiling fan reports spin.
Scale, orientation and pivots
Models on 3DAssets.dev are authored in metres with +Y up, which is what three.js expects, and they sit on the ground plane rather than floating around their own centre. So a five-metre tree arrives five units tall and its base is at y = 0. You place it with a position, not a scale factor.
This matters more than it sounds. If you find yourself writing model.scale.set(0.01, 0.01, 0.01) to make something fit, the usual cause is not the model: it is a camera set up for a scene measured in centimetres, or two assets from unrelated sources with different conventions. Every asset publishes its bounding box and its size in metres in the API, so you can check rather than guess.
To fit a camera to whatever you just loaded, measure it:
1import { Box3, Vector3 } from 'three'23const box = new Box3().setFromObject(gltf.scene)4const size = box.getSize(new Vector3())5const centre = box.getCenter(new Vector3())6camera.position.set(centre.x, centre.y + size.y * 0.5, centre.z + size.length())7camera.lookAt(centre)
Why your model is invisible
In rough order of how often each one is the answer:
- No light. Every material except
MeshBasicMaterialneeds one. An ambient light plus a directional light is enough to prove the model is there. - Camera position. At the wrong scale you are either inside the model or a kilometre away. Use the bounding box above rather than nudging numbers.
- The load failed. Check the console and the network tab. A 404 or a CORS error is a completely different problem from a rendering one, and without an error callback it looks identical.
- Metallic material, no environment. A physically based material with high metalness and nothing to reflect renders close to black. Give the scene an environment map. Three's own
RoomEnvironmentis generated locally and needs no external file, which is the sensible default:
1import { PMREMGenerator } from 'three'2import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'34const pmrem = new PMREMGenerator(renderer)5scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture
Reach for a downloaded HDR only when you specifically want that lighting. A preset that fetches a large HDR from a third-party CDN on every page load is a slow first paint and a dependency on somebody else's uptime.
No decoders required
Geometry here is compressed with KHR_mesh_quantization and textures are re-encoded to WebP with EXT_texture_webp. Both are read natively by three.js, so the plain loader above is the whole setup. There is no DRACOLoader to configure, no decoder path to host and no KTX2Loader needing a renderer handed to it before it will work.
Next
If you are working in React rather than plain three.js, the same models load through drei with rather less ceremony: see free 3D models in React Three Fiber. If you would rather have an assistant find and place the models for you, connect it to the catalogue first.

