14 KiB
name, description
| name | description |
|---|---|
| threejs-loaders | Three.js asset loading adapted for React Three Fiber and Suaveplan R3F packages. Covers useLoader, useGLTF, and useTexture hooks, React Suspense boundaries for async loading, preloading strategies, @suaveplan/r3f loading utilities, Draco/KTX2 mesh and texture compression, and progressive loading patterns. |
This skill covers asset loading in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets React 19 + TypeScript + R3F and follows Suaveplan CLAUDE.md conventions.
The user needs to load 3D models, textures, fonts, or other assets into an R3F scene. They may need GLTF models, compressed assets, preloading, or progress tracking.
Core Concepts
Three.js Loader System
Three.js provides loader classes for different asset types:
- GLTFLoader: Loads
.gltf/.glbfiles (the primary 3D model format) - TextureLoader: Loads images as GPU textures
- CubeTextureLoader: Loads cubemap textures (6 faces)
- FBXLoader: Loads FBX files
- OBJLoader: Loads OBJ files
- DRACOLoader: Decompresses Draco-compressed geometry
- KTX2Loader: Loads KTX2 GPU-compressed textures
- EXRLoader/RGBELoader: Loads HDR environment maps
R3F Loading Approach
R3F replaces manual loader management with hooks and React Suspense:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
new GLTFLoader().load(url, cb) |
useGLTF(url) + <Suspense> |
new TextureLoader().load(url, cb) |
useTexture(url) + <Suspense> |
| Manual progress tracking | useProgress() hook |
| Manual error handling | Error boundaries |
| Manual disposal | Automatic on unmount |
Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
@suaveplan/r3f |
Loading utilities, asset management | import { useAssetLoader } from '@suaveplan/r3f' |
@suaveplan/r3f-character |
Character model loading | import { useCharacterModel } from '@suaveplan/r3f-character' |
@suaveplan/r3f-performance |
Loading performance monitoring | import { usePerformanceMonitor } from '@suaveplan/r3f-performance' |
useGLTF: Loading 3D Models
The primary way to load 3D models in R3F. Returns the full GLTF structure.
Basic Model Loading
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { useGLTF } from '@react-three/drei';
interface ModelProps {
url: string;
position?: [number, number, number];
scale?: number;
}
function Model({ url, position = [0, 0, 0], scale = 1 }: ModelProps) {
const { scene } = useGLTF(url);
return (
<primitive
object={scene}
position={position}
scale={scale}
/>
);
}
export function ModelScene() {
return (
<Canvas>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Suspense fallback={<LoadingPlaceholder />}>
<Model url="/models/building.glb" />
</Suspense>
</Canvas>
);
}
function LoadingPlaceholder() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color="gray" wireframe />
</mesh>
);
}
GLTF Return Structure
const gltf = useGLTF('/model.glb');
// gltf.scene - THREE.Group (the root scene)
// gltf.nodes - Named nodes (meshes, bones, empties)
// gltf.materials - Named materials
// gltf.animations - AnimationClip[] (for useAnimations)
// gltf.asset - GLTF metadata
Accessing Named Nodes
GLTF files export named objects. Access them via gltf.nodes:
import { useGLTF } from '@react-three/drei';
function Character({ url }: { url: string }) {
const { nodes, materials } = useGLTF(url);
return (
<group>
{/* Access specific meshes by name */}
<mesh
geometry={(nodes.Body as THREE.Mesh).geometry}
material={materials.Skin}
castShadow
/>
<mesh
geometry={(nodes.Eyes as THREE.Mesh).geometry}
material={materials.Eye}
/>
</group>
);
}
GLTF with TypeScript (gltfjsx)
Use gltfjsx to generate typed React components from GLTF files:
# Generate typed component (do NOT run tsc, this outputs .tsx directly)
bunx gltfjsx model.glb --types --transform
Generated output pattern:
import { useRef } from 'react';
import { useGLTF } from '@react-three/drei';
import type { Group, Mesh, MeshStandardMaterial } from 'three';
import type { GLTF } from 'three-stdlib';
interface GLTFResult extends GLTF {
nodes: {
Body: Mesh;
Head: Mesh;
};
materials: {
Skin: MeshStandardMaterial;
Hair: MeshStandardMaterial;
};
}
export function CharacterModel(props: JSX.IntrinsicElements['group']) {
const groupRef = useRef<Group>(null);
const { nodes, materials } = useGLTF('/character.glb') as GLTFResult;
return (
<group ref={groupRef} {...props} dispose={null}>
<mesh geometry={nodes.Body.geometry} material={materials.Skin} castShadow />
<mesh geometry={nodes.Head.geometry} material={materials.Hair} castShadow />
</group>
);
}
useGLTF.preload('/character.glb');
Draco Compression
Draco compresses mesh geometry by 80-90%. R3F handles decompression automatically.
Using Draco with useGLTF
import { useGLTF } from '@react-three/drei';
function CompressedModel({ url }: { url: string }) {
// useGLTF auto-detects Draco and uses the bundled decoder
const { scene } = useGLTF(url, '/draco/');
// Second argument is the Draco decoder path
return <primitive object={scene} />;
}
// Default Draco decoder from drei CDN (no path needed usually)
function SimpleModel() {
const { scene } = useGLTF('/model-draco.glb');
return <primitive object={scene} />;
}
Custom Draco Decoder Path
Pass a second argument to useGLTF for the Draco decoder path: useGLTF('/model.glb', '/libs/draco/gltf/').
KTX2 Texture Compression
KTX2 provides GPU-compressed textures (ASTC, ETC2, BC7) for smaller VRAM footprint. Load with useLoader(KTX2Loader, url, (loader) => { loader.setTranscoderPath('/libs/basis/'); loader.detectSupport(gl); }).
useTexture: Loading Textures
import { useTexture } from '@react-three/drei';
import { RepeatWrapping, SRGBColorSpace } from 'three';
function TexturedMesh() {
const texture = useTexture('/textures/wood.jpg');
// Configure texture properties
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(2, 2);
texture.colorSpace = SRGBColorSpace;
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial map={texture} />
</mesh>
);
}
Loading Multiple Textures (PBR Set)
import { useTexture } from '@react-three/drei';
function PBRMaterial() {
const textures = useTexture({
map: '/textures/brick/diffuse.jpg',
normalMap: '/textures/brick/normal.jpg',
roughnessMap: '/textures/brick/roughness.jpg',
aoMap: '/textures/brick/ao.jpg',
displacementMap: '/textures/brick/displacement.jpg',
});
return (
<mesh>
<planeGeometry args={[4, 4, 64, 64]} />
<meshStandardMaterial
{...textures}
displacementScale={0.1}
/>
</mesh>
);
}
// Preload all textures
useTexture.preload({
map: '/textures/brick/diffuse.jpg',
normalMap: '/textures/brick/normal.jpg',
roughnessMap: '/textures/brick/roughness.jpg',
aoMap: '/textures/brick/ao.jpg',
displacementMap: '/textures/brick/displacement.jpg',
});
useLoader: Generic Loader Hook
For loaders not covered by drei helpers:
import { useLoader } from '@react-three/fiber';
import { FBXLoader } from 'three-stdlib';
import { OBJLoader } from 'three-stdlib';
import { FontLoader } from 'three-stdlib';
// FBX model
function FBXModel({ url }: { url: string }) {
const fbx = useLoader(FBXLoader, url);
return <primitive object={fbx} scale={0.01} />;
}
// OBJ model
function OBJModel({ url }: { url: string }) {
const obj = useLoader(OBJLoader, url);
return <primitive object={obj} />;
}
// Font
function LoadedFont({ url }: { url: string }) {
const font = useLoader(FontLoader, url);
// Use with TextGeometry
return null;
}
Loader with Extensions
Pass a configuration callback as the third argument to useLoader. Inside, attach DRACOLoader via loader.setDRACOLoader(dracoLoader) and MeshoptDecoder via loader.setMeshoptDecoder(MeshoptDecoder).
Suspense and Loading UI
Loading Progress
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { useProgress, Html } from '@react-three/drei';
function Loader() {
const { progress, active, errors, item, loaded, total } = useProgress();
return (
<Html center>
<div style={{ color: 'white', fontSize: '1.5rem' }}>
{progress.toFixed(0)}% loaded
<div style={{ fontSize: '0.8rem', opacity: 0.6 }}>
{loaded}/{total} assets
</div>
</div>
</Html>
);
}
export function SceneWithProgress() {
return (
<Canvas>
<Suspense fallback={<Loader />}>
<HeavyScene />
</Suspense>
</Canvas>
);
}
Nested Suspense Boundaries
Load critical assets first, then progressively load detail:
import { Suspense } from 'react';
export function ProgressiveScene() {
return (
<Canvas>
{/* Critical: load immediately */}
<ambientLight />
<directionalLight position={[5, 5, 5]} />
{/* Tier 1: primary content */}
<Suspense fallback={<PlaceholderBuilding />}>
<Building url="/models/building.glb" />
{/* Tier 2: secondary content (loads after building) */}
<Suspense fallback={null}>
<Furniture url="/models/furniture.glb" />
{/* Tier 3: decorative (loads last) */}
<Suspense fallback={null}>
<Plants url="/models/plants.glb" />
<Decorations url="/models/decor.glb" />
</Suspense>
</Suspense>
</Suspense>
</Canvas>
);
}
Preloading
Preload assets before they are needed (e.g., during a loading screen or on hover):
import { useGLTF, useTexture } from '@react-three/drei';
// Preload at module level (loads immediately)
useGLTF.preload('/models/character.glb');
useGLTF.preload('/models/environment.glb');
useTexture.preload('/textures/ground.jpg');
// Conditional preloading (e.g., on hover or route change)
function PreloadOnHover({ modelUrl }: { modelUrl: string }) {
return (
<button
onMouseEnter={() => useGLTF.preload(modelUrl)}
onClick={() => { /* navigate to 3D view */ }}
>
View in 3D
</button>
);
}
Preloading Multiple Assets
import { useGLTF } from '@react-three/drei';
const MODEL_URLS = [
'/models/scene.glb',
'/models/character.glb',
'/models/props.glb',
];
// Preload all at once
MODEL_URLS.forEach(useGLTF.preload);
Error Handling
Error Boundaries for Loading Failures
Wrap <Suspense> in a React error boundary class. Override getDerivedStateFromError to catch loading failures. Render a fallback mesh (e.g., red wireframe box) when errors occur. Nest as <ErrorBoundary><Suspense fallback={<Loader />}><Model /></Suspense></ErrorBoundary>.
Asset Optimization Guidelines
Model Optimization
| Technique | Savings | Tool |
|---|---|---|
| Draco compression | 80-90% geometry | gltf-transform draco |
| Meshopt compression | 60-80% geometry | gltf-transform meshopt |
| Texture compression (KTX2) | 75% texture VRAM | gltf-transform ktx2 |
| Mesh simplification | 50-90% triangles | gltf-transform simplify |
| Texture resize | Variable | gltf-transform resize |
| Remove unused data | Variable | gltf-transform prune |
Pipeline Command
# Optimize a GLTF file (run via bun, not tsc)
bunx gltf-transform optimize input.glb output.glb \
--compress draco \
--texture-compress ktx2 \
--simplify-ratio 0.5
Disposal
R3F automatically disposes of loaded assets when components unmount. For manual control:
import { useGLTF } from '@react-three/drei';
function DisposableModel({ url }: { url: string }) {
const { scene } = useGLTF(url);
// Pass dispose={null} to prevent auto-disposal (useful for shared assets)
return <primitive object={scene} dispose={null} />;
}
// Manually clear the cache
function clearModelCache(url: string) {
useGLTF.clear(url);
}
Testing Loading
Vitest Tests
import { describe, it, expect } from 'vitest';
describe('Asset loading', () => {
it('preload function exists on useGLTF', async () => {
const { useGLTF } = await import('@react-three/drei');
expect(typeof useGLTF.preload).toBe('function');
});
});
Storybook Stories
import type { Meta, StoryObj } from '@storybook/react';
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, useProgress, Html } from '@react-three/drei';
function LoaderUI() {
const { progress } = useProgress();
return <Html center><span>{progress.toFixed(0)}%</span></Html>;
}
function ModelStory() {
return (
<Canvas camera={{ position: [3, 3, 3] }} style={{ height: 400 }}>
<ambientLight />
<directionalLight position={[5, 5, 5]} />
<Suspense fallback={<LoaderUI />}>
{/* Model component here */}
</Suspense>
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/Loaders/ModelLoader', component: ModelStory };
export default meta;
export const Default: StoryObj = {};
Checklist
When loading assets in R3F:
- Use
useGLTFfor GLTF/GLB models (the standard 3D format) - Wrap loaded components in
<Suspense>with a fallback - Use
useProgressfor loading progress UI - Preload assets with
useGLTF.preload()at module level or on interaction - Use Draco compression for mesh geometry (80-90% savings)
- Use KTX2 for GPU-compressed textures
- Generate typed components with
gltfjsx --types - Use nested Suspense for progressive loading (critical first)
- Add error boundaries for loading failures
- Keep loader component files under 250 lines