Texture Exporter/three.js
Loading in three.js
Set up GLTFLoader for KTX2, WebP and AVIF textures, share textures between GLBs, and show real loading progress.
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, shared-texture-cache.js and 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.
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
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
KTX2Loaderfor the whole app, and callktx2Loader.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
minFilterLINEAR 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/'))(fromexamples/jsm/libs/draco/) orloader.setMeshoptDecoder(MeshoptDecoder)(fromthree/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: load a shared texture once for every GLB that uses it.gltf-progress.js: loading progress that counts the textures too.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, 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.
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, 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, orclearShared()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, 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.
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 |