# Loading in three.js

> Set up GLTFLoader for KTX2, WebP and AVIF textures, share textures between GLBs, and show real loading progress.

Texture Exporter docs · https://tools.ashthornton.dev/blender-texture-exporter/docs/threejs/

The GLBs the add-on writes are standard glTF. `GLTFLoader` reads WebP and AVIF textures by itself; KTX2 needs a `KTX2Loader`. Two optional [helper scripts](#helper-scripts), [`shared-texture-cache.js`](https://tools.ashthornton.dev/blender-texture-exporter/js/shared-texture-cache.js) and [`gltf-progress.js`](https://tools.ashthornton.dev/blender-texture-exporter/js/gltf-progress.js), make several GLBs share their textures, and show loading progress that includes the textures.

## Basic setup

KTX2 textures are transcoded in the browser by the Basis transcoder (`basis_transcoder.js` + `.wasm`), which ships with three.js in `examples/jsm/libs/basis/`. Copy that folder into your public folder (or use a CDN path for your exact three.js version), and keep the transcoder and three.js on the same version.

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

const renderer = new THREE.WebGLRenderer({ antialias: true });

const ktx2Loader = new KTX2Loader()
  // Copy node_modules/three/examples/jsm/libs/basis/ to /basis/ in your public folder,
  // or use e.g. 'https://cdn.jsdelivr.net/npm/three@0.186.0/examples/jsm/libs/basis/'
  .setTranscoderPath('/basis/')
  .detectSupport(renderer); // picks ASTC / BC7 / ETC2 / … for this GPU

const loader = new GLTFLoader().setKTX2Loader(ktx2Loader);

const gltf = await loader.loadAsync('models/scene.glb');
scene.add(gltf.scene);
// The textures load from models/textures/*.ktx2 / *.webp / *.avif, relative to the .glb.
```

With `WebGPURenderer`, make sure the renderer is initialised (`await renderer.init()`) before calling `detectSupport(renderer)`.

### React Three Fiber

```jsx
import { useGLTF } from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';

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

function Model() {
  const gl = useThree((s) => s.gl);
  const { scene } = useGLTF('/models/scene.glb', false, false, (loader) =>
    loader.setKTX2Loader(ktx2.detectSupport(gl)));
  return <primitive object={scene} />;
}
```

### Notes

- **Upload the `textures/` folder with the `.glb`.** The URIs are relative to the GLB.
- **Use one `KTX2Loader` for the whole app**, and call `ktx2Loader.dispose()` when you no longer need it, to stop its workers.
- **AVIF** needs Chrome 85+, Firefox 93+ or Safari 16+ (macOS 13 / iOS 16). There's no fallback image.
- **Textures exported without mipmaps** have `minFilter` LINEAR in the glTF. Leave it: with a mipmap filter, three.js renders a KTX2 texture without mips black.
- **Draco or meshopt** compressed GLBs also need `loader.setDRACOLoader(new DRACOLoader().setDecoderPath('/draco/'))` (from `examples/jsm/libs/draco/`) or `loader.setMeshoptDecoder(MeshoptDecoder)` (from `three/addons/libs/meshopt_decoder.module.js`).
- **Hashed file names never change content**, so serve the textures folder with `Cache-Control: public, max-age=31536000, immutable`.

## Helper scripts

Two small modules, MIT-licensed so you can ship them in your own apps. Download them, or copy their code with the button beside each name, and add them to your project:

- [`shared-texture-cache.js`](https://tools.ashthornton.dev/blender-texture-exporter/js/shared-texture-cache.js): load a shared texture once for every GLB that uses it.
- [`gltf-progress.js`](https://tools.ashthornton.dev/blender-texture-exporter/js/gltf-progress.js): loading progress that counts the textures too.
- [`LICENSE`](https://tools.ashthornton.dev/blender-texture-exporter/js/LICENSE) (MIT)

Both import from `'three'` and are tested with three.js r186 on `WebGLRenderer` and `WebGPURenderer`.

## Several GLBs sharing textures

`GLTFLoader` only shares textures within one file. Two GLBs that reference the same `../textures/albedo-1a2b3c4d.ktx2` each fetch it, transcode it and upload it to the GPU. On `WebGPURenderer`, even a texture used twice inside one GLB is uploaded twice.

`SharedTextureCache`, from [`shared-texture-cache.js`](https://tools.ashthornton.dev/blender-texture-exporter/js/shared-texture-cache.js), is a `GLTFLoader` plugin that keeps one cache for the page. Every GLB gets **the same `THREE.Texture`** for the same file and sampler, so a shared texture is fetched, transcoded and uploaded once.

```js
import { SharedTextureCache } from './shared-texture-cache.js';

const ktx2Loader = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer); // one for the whole app
const loader = new GLTFLoader().setKTX2Loader(ktx2Loader).register(SharedTextureCache);

// drei: useGLTF(url, false, false, (l) => l.setKTX2Loader(ktx2Loader).register(SharedTextureCache))
```

It pairs with [shared texture folders](https://tools.ashthornton.dev/blender-texture-exporter/docs/shared-folders.md), where hashed file names make the same texture the same URL in every GLB.

### Disposal

Disposal works with the usual three.js calls, counted per material:

- A shared texture is freed when the **last material using it is disposed**. Disposing one scene's materials (and their maps) frees what only that scene used, and keeps what other scenes still show.
- `texture.dispose()` on a shared texture does nothing while materials still use it.
- If your app disposes textures but never materials, or clones materials after loading, call `releaseShared(gltf)` when a scene goes away, or `clearShared()` to free everything (e.g. on a route change).
- If one GLB uses a texture as colour and another as data (sRGB vs linear), the second use gets its own copy, because the colour space is part of the GPU format.

| Export | What it does |
|---|---|
| `SharedTextureCache` | The plugin: `loader.register(SharedTextureCache)` |
| `releaseShared(gltf)` | Stop counting a loaded glTF's materials; textures no other scene uses are freed |
| `clearShared()` | Free every shared texture and empty the cache |
| `hasSharedTexture(url)` | True while the cache holds or is loading a texture from that absolute URL |
| `sharedTextureStats()` | `{ textures, materials }`, for debugging |

## Loading progress

`GLTFLoader`'s `onProgress` only covers the `.glb` itself, but the textures are separate files and usually most of the bytes. `loadWithProgress`, from [`gltf-progress.js`](https://tools.ashthornton.dev/blender-texture-exporter/js/gltf-progress.js), downloads the GLB and every texture it uses (or a `.gltf` and its `.bin`) and reports real byte progress. It then hands the files to `GLTFLoader` from memory, so nothing is fetched twice.

```js
import { loadWithProgress } from './gltf-progress.js';
import { SharedTextureCache, hasSharedTexture } from './shared-texture-cache.js';

const loader = new GLTFLoader().setKTX2Loader(ktx2Loader).register(SharedTextureCache);

const gltf = await loadWithProgress(loader, 'models/scene.glb', (p, { loaded, total }) => {
  bar.style.transform = `scaleX(${p})`;
}, {
  skip: hasSharedTexture, // textures the page already holds aren't downloaded again
});
```

- The GLB's JSON comes first in the file, so texture downloads start as soon as it has arrived, while the geometry is still downloading.
- Two loads running at once that use the same file in a shared folder download it once.
- Progress runs from 0 to 1 and never goes backwards. Downloads fill 95% of the bar; the rest fills once parsing has finished (KTX2 transcoding, Draco / meshopt decoding).
- Sizes come from `Content-Length`. A file whose size isn't known yet counts as the average size of the others. With gzip or Brotli compression the bar can't overshoot, but its pace is approximate.

| Option | Default | What it does |
|---|---|---|
| `skip(url)` | none | Return `true` to not prefetch that file, e.g. `hasSharedTexture` |
| `decodeShare` | `0.05` | The part of the bar kept for parsing after the downloads |
| `installURLModifier` | `true` | Sets a URL modifier on the loaders' `LoadingManager` so they read the prefetched files |
| `signal` | none | An `AbortSignal` for the downloads |

> **Note**
>
> If you set your own URL modifier on the `LoadingManager`, pass `installURLModifier: false` and call `prefetchedURL(url)` from yours: `manager.setURLModifier((url) => mine(prefetchedURL(url)))`.
