Files
suaveplan/.claude/plugins/suaveplan-plugins/plugins/suaveplan-threejs/skills/threejs-loaders/SKILL.md
2026-05-27 23:29:53 +01:00

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/.glb files (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:

  1. Use useGLTF for GLTF/GLB models (the standard 3D format)
  2. Wrap loaded components in <Suspense> with a fallback
  3. Use useProgress for loading progress UI
  4. Preload assets with useGLTF.preload() at module level or on interaction
  5. Use Draco compression for mesh geometry (80-90% savings)
  6. Use KTX2 for GPU-compressed textures
  7. Generate typed components with gltfjsx --types
  8. Use nested Suspense for progressive loading (critical first)
  9. Add error boundaries for loading failures
  10. Keep loader component files under 250 lines