ITSkillsCenter
Business Digital

Performance R3F : instancing, LOD et frustum culling

13 دقائق للقراءة

📍 Article principal de la série : Three.js, React Three Fiber et WebGPU en 2026 : 3D temps réel sur le web

Introduction

Une scène R3F qui tourne à 60 fps avec dix objets ne dit rien sur ce qui se passera avec mille. Trois techniques cumulées permettent de garder le framerate stable quand le nombre d’objets explose : l’instancing pour rendre des milliers de mesh identiques en un seul draw call, le LOD (Level Of Detail) pour réduire la complexité des objets éloignés, et le frustum culling pour éviter de traiter ce que la caméra ne voit pas. Ce tutoriel construit une scène de 50 000 cubes qui tourne à 60 fps sur un laptop 2020, en partant d’une version naïve qui crashe. À la fin, on saura quand chaque technique s’applique et comment les combiner.

Prérequis

  • Un projet R3F v9 fonctionnel
  • Notions de matrices de transformation et d’espaces 3D
  • Compréhension des concepts vertex shader et draw call
  • Le helper <Stats /> de Drei activé pour mesurer en direct
  • Temps estimé : 50 à 70 minutes

Étape 1 — Mesurer le baseline naïf

Avant d’optimiser, on mesure. On crée une scène qui rend 5000 cubes à des positions aléatoires, chacun déclaré comme un mesh React indépendant. C’est l’approche la plus directe et c’est aussi celle qui s’effondre en performance.

// src/scenes/NaiveCubes.tsx
import { Canvas } from '@react-three/fiber'
import { Stats } from '@react-three/drei'
import { useMemo } from 'react'

function CubeGrid({ count }: { count: number }) {
  const positions = useMemo(() => {
    return Array.from({ length: count }, () => [
      (Math.random() - 0.5) * 30,
      (Math.random() - 0.5) * 30,
      (Math.random() - 0.5) * 30,
    ] as [number, number, number])
  }, [count])

  return (
    <>
      {positions.map((p, i) => (
        <mesh key={i} position={p}>
          <boxGeometry args={[0.3, 0.3, 0.3]} />
          <meshStandardMaterial color="#ff6b00" />
        </mesh>
      ))}
    </>
  )
}

export default function NaiveCubes() {
  return (
    <Canvas camera={{ position: [0, 0, 25] }}>
      <Stats />
      <ambientLight intensity={0.4} />
      <directionalLight position={[10, 10, 10]} />
      <CubeGrid count={5000} />
    </Canvas>
  )
}

À l’écran, le panneau Stats en haut à gauche affiche typiquement 8 à 15 fps sur une machine de bureau standard, et l’onglet Frame du DevTools révèle 5000 draw calls par frame. Chaque cube exige un appel séparé au GPU, alors qu’ils partagent géométrie et matériau. C’est exactement le scénario que l’instancing résout.

Étape 2 — Passer à InstancedMesh

L’instancing exploite une fonctionnalité GPU qui rend N copies d’une même géométrie en un seul draw call, en variant les matrices de transformation par instance. En Three.js, l’objet est InstancedMesh ; en R3F avec Drei, le helper <Instances> + <Instance> en simplifie l’usage.

// src/scenes/InstancedCubes.tsx
import { Canvas } from '@react-three/fiber'
import { Stats, Instances, Instance } from '@react-three/drei'
import { useMemo } from 'react'

function CubeGrid({ count }: { count: number }) {
  const positions = useMemo(() => {
    return Array.from({ length: count }, () => [
      (Math.random() - 0.5) * 30,
      (Math.random() - 0.5) * 30,
      (Math.random() - 0.5) * 30,
    ] as [number, number, number])
  }, [count])

  return (
    <Instances limit={count} range={count}>
      <boxGeometry args={[0.3, 0.3, 0.3]} />
      <meshStandardMaterial color="#ff6b00" />
      {positions.map((p, i) => (
        <Instance key={i} position={p} />
      ))}
    </Instances>
  )
}

Le composant <Instances> déclare la géométrie et le matériau partagés. Chaque <Instance> n’envoie au GPU qu’une matrice de transformation, pas une géométrie. Le résultat à l’écran est identique, mais Stats remonte maintenant à 55-60 fps avec un seul draw call. La différence vient entièrement du GPU : il fait le même travail visible mais sans la surcharge des 5000 appels API.

Étape 3 — Pousser à 50 000 instances

Avec l’instancing, on peut multiplier par 10 le nombre d’objets sans dégrader le framerate de façon dramatique. On augmente count à 50 000 et on observe le comportement.

// Dans le composant de scène, on change juste le count
<CubeGrid count={50000} />

Le framerate reste autour de 50-60 fps sur une machine moderne. Le coût est passé du CPU (qui orchestrait les draw calls) au GPU (qui rasterise plus de fragments). C’est une bascule typique : l’instancing déplace le bottleneck du nombre d’objets vers la complexité totale de pixels et de vertices. Si on monte à 500 000 cubes, on retombe à 20 fps non plus à cause des draw calls mais parce que le GPU n’a plus assez de temps pour rasterizer chaque frame. C’est là qu’interviennent LOD et frustum culling.

Étape 4 — Implémenter du LOD basique

Le LOD remplace une géométrie haute résolution par une version simplifiée à mesure que l’objet s’éloigne de la caméra. Three.js fournit un objet LOD natif et Drei expose le helper <Detailed> pour un usage déclaratif.

// src/components/LODSphere.tsx
import { Detailed } from '@react-three/drei'

export default function LODSphere(props: { position: [number, number, number] }) {
  return (
    <Detailed distances={[0, 10, 30]} {...props}>
      <mesh>
        <sphereGeometry args={[0.5, 32, 32]} />
        <meshStandardMaterial color="#3366ff" />
      </mesh>
      <mesh>
        <sphereGeometry args={[0.5, 16, 16]} />
        <meshStandardMaterial color="#3366ff" />
      </mesh>
      <mesh>
        <sphereGeometry args={[0.5, 6, 6]} />
        <meshStandardMaterial color="#3366ff" />
      </mesh>
    </Detailed>
  )
}

L’array distances définit les seuils d’activation de chaque niveau. À moins de 10 unités de la caméra, on rend la sphère 32×32 (1024 triangles). Entre 10 et 30, c’est la version 16×16 (256 triangles). Au-delà, la version 6×6 (36 triangles). Le passage est automatique et géré par Three.js sans intervention. Sur une scène de 1000 sphères, le gain est massif : la majorité éloignée tourne en LOD bas et le coût total chute de 4 ms à moins de 1 ms côté GPU.

Étape 5 — Comprendre le frustum culling automatique

Le frustum culling élimine du rendu les objets en dehors du champ de vision de la caméra. Three.js l’active par défaut sur tous les meshes : à chaque frame, l’engine teste si la bounding sphere de l’objet intersecte le frustum de la caméra. Si non, l’objet est ignoré.

// Le culling est actif par défaut, mais on peut le désactiver pour des raisons spécifiques :
mesh.frustumCulled = false  // utile pour des shaders qui modifient la position des vertices au-delà de la bbox

La désactivation est rare. Le cas typique où on l’éteint est un mesh dont le shader déplace les vertices en dehors de la bounding box d’origine — par exemple un océan procédural dont la géométrie est un plan de 100 unités mais dont les vagues sortent par les bords. Sans frustumCulled = false, le moteur peut considérer l’objet hors champ alors qu’on en voit encore une partie. La règle est de ne désactiver que ces cas particuliers ; pour tout objet dont la géométrie reste dans sa bbox, le default est correct.

Étape 6 — Mesurer avant/après avec un test contrôlé

Pour valider objectivement les optimisations, on construit un test qui compare les versions baseline et optimisée sur une même machine. On charge alternativement les deux scènes et on mesure le temps de frame moyen sur 5 secondes via le hook benchmark vu dans le tutoriel WebGPU.

// Dans une scène de comparaison, switcher entre les deux implémentations
const [mode, setMode] = useState<'naive' | 'instanced'>('naive')

// Rendu :
<button onClick={() => setMode((m) => m === 'naive' ? 'instanced' : 'naive')}>
  Switch ({mode})
</button>
<Canvas>
  <Stats />
  {mode === 'naive' ? <NaiveGrid /> : <InstancedGrid />}
</Canvas>

On clique pour basculer, on attend 5 secondes pour stabiliser le panneau Stats, on note la valeur. Les chiffres typiques sur une RTX moderne avec 5000 objets : ~12 fps en naïf, ~60 fps en instancié. Sur un mobile mid-range : ~3 fps en naïf, ~50 fps en instancié. Ces chiffres font le procès clair de la version naïve : sans optimisation, il n’y a pas d’expérience mobile possible au-delà d’une centaine d’objets.

Étape 7 — Mémoriser les structures lourdes

Les optimisations GPU ne servent à rien si le CPU recalcule les positions à chaque frame. Le pattern useMemo avec une dépendance correcte est essentiel : il garantit que les structures coûteuses (tableaux de positions, matrices de transformation, géométries dérivées) ne sont calculées qu’une fois.

// ✅ Mémoïsation correcte
const positions = useMemo(() => computePositions(count), [count])

// ❌ Anti-pattern : recalcul à chaque render
const positions = computePositions(count)

Pour une scène avec 50 000 positions, le calcul peut prendre 50 ms ; faire ça à chaque render fige l’application visiblement. Avec useMemo, le calcul ne se déclenche qu’au montage initial puis à chaque changement de count. C’est le pattern le plus simple et le plus efficace pour éviter les régressions de performance silencieuses qui apparaissent quand la scène grossit.

Étape 8 — Profiler avec Chrome Performance

Quand les optimisations classiques ne suffisent plus, on sort le profiler. Le panneau Performance de Chrome enregistre une capture détaillée de l’activité CPU et GPU, frame par frame. On y identifie les fonctions qui consomment le plus de temps et les régions où le GPU attend.

La procédure : ouvrir DevTools, onglet Performance, cocher Screenshots et GPU, cliquer Record, interagir avec la scène pendant 3-5 secondes, arrêter. La timeline affiche en haut le framerate en temps réel ; en dessous, les threads CPU et la trace GPU. Une frame qui dépasse 16 ms est colorée en rouge. En cliquant sur une frame, on voit la pile d’appels : si updateMatrixWorld domine, c’est qu’on a trop d’objets non optimisés ; si compileMaterial apparaît, c’est qu’on recompile des shaders en cours de route ; si la barre GPU est complètement remplie, on est GPU-bound et il faut réduire la résolution ou la complexité des shaders. Cette lecture méthodique de la trace est la compétence qui fait la différence entre faire tourner sa scène et la faire tourner partout.

Étape 9 — Réduire le pixel ratio sur device contraint

Une optimisation souvent négligée mais à fort impact est de réduire le pixel ratio sur les écrans haute densité. Un iPhone à devicePixelRatio de 3 doit rasterizer 9 fois plus de pixels qu’un écran ratio 1 pour la même taille logique. Pour une scène 3D, la différence visuelle entre ratio 1.5 et ratio 3 est rarement perceptible alors que le coût GPU est multiplié par 4.

// src/scenes/AdaptiveCanvas.tsx
import { Canvas } from '@react-three/fiber'

const dpr = Math.min(window.devicePixelRatio, 1.75)

export default function AdaptiveCanvas() {
  return (
    <Canvas dpr={dpr}>
      {/* contenu de la scène */}
    </Canvas>
  )
}

Le clamp à 1.75 est un bon point de départ : il préserve le crispness sur écran rétina sans saturer les mobiles. Pour un contenu très demandeur (post-processing complet, scènes denses), on descend à 1.25 ; pour une UI 3D légère, on garde 2 voire 3. R3F accepte aussi un tableau [min, max] qui permet au renderer d’ajuster dynamiquement selon la charge — utile mais à utiliser avec prudence parce que les changements de résolution en cours d’animation peuvent provoquer du flicker. La bonne pratique en 2026 est de choisir un dpr fixe au démarrage selon le profil de qualité détecté, comme dans le tutoriel post-processing, et de ne pas le changer ensuite.

Erreurs fréquentes

Erreur Cause Solution
Instances n’apparaissent pas limit ou range trop bas Mettre limit au max prévu et range au nombre actuel
FPS chute après ajout d’un effet Recompilation shader à chaque frame Mémoïser le matériau via useMemo
LOD ne change jamais de niveau Tous les enfants à distance 0 Définir distances avec des seuils croissants
Objet disparaît anormalement Bounding sphere mal calculée Appeler geometry.computeBoundingSphere() après modification
Performance OK en dev mais lent en prod StrictMode double-mount masquait le coût Tester sur le build de production

Cette analyse différentielle, faite régulièrement à mesure que le projet grossit, révèle les régressions silencieuses et oriente les décisions d’optimisation vers les zones réellement problématiques. Sans elle, on optimise au feeling, on rate souvent le vrai goulot et on perd un temps précieux sur des micro-optimisations dont le gain réel ne se mesure pas chez les utilisateurs finaux. Garder une trace écrite des mesures successives, datées par version du projet, transforme l’intuition en méthode reproductible.

Tutoriels frères

Lectures complémentaires

FAQ

À partir de combien d’objets faut-il passer à InstancedMesh ?
La règle pratique est dès qu’on a 50 objets identiques. Au-delà, le gain de l’instancing devient mesurable. En dessous, la différence reste imperceptible et la complexité ajoutée n’en vaut pas la peine.

Peut-on animer chaque instance individuellement ?
Oui, en mutant la matrice par instance dans useFrame via instancedMesh.setMatrixAt(i, matrix) et en signalant instanceMatrix.needsUpdate = true. Pour des cas plus avancés (couleurs, états par instance), on utilise InstancedBufferAttribute.

Le LOD fonctionne-t-il avec InstancedMesh ?
Pas directement. Pour combiner les deux, on construit plusieurs InstancedMesh avec géométries différentes et on switche par groupe selon la distance moyenne du cluster. C’est plus complexe mais permet de scaler à des centaines de milliers d’objets.

Frustum culling sur GPU est-il possible ?
Oui via le indirect drawing ou les compute shaders WebGPU. Pour la majorité des cas, le culling CPU de Three.js est suffisant et plus simple à mettre en œuvre.

Comment éviter les re-renders qui tuent l’animation ?
Tout ce qui anime doit passer par des refs et des mutations directes des objets Three.js, jamais par useState. Le hook useFrame donne un point d’entrée pour ça à 60 fps sans intervention React.

InstancedMesh consomme-t-il moins de mémoire que N meshes ?
Massivement. Une géométrie partagée par 50 000 instances prend la mémoire de 1 géométrie + 50 000 matrices. La version naïve prendrait 50 000 géométries. La différence se compte en centaines de mégaoctets sur des scènes denses.

Comment décider quand un projet est CPU-bound ou GPU-bound ?
Le test pratique est de baisser temporairement la résolution interne (dpr=0.5). Si le framerate remonte significativement, on est GPU-bound : c’est le coût de rasterisation et de shading qui pose problème, et il faut réduire la complexité visuelle. Si rien ne bouge, on est CPU-bound : c’est l’orchestration côté JavaScript qui sature, et il faut chercher des re-renders inutiles, du setState dans une boucle, ou une géométrie reconstruite à chaque frame.

Le BVH peut-il accélérer le raycasting sur de grosses scènes ?
Oui. La bibliothèque three-mesh-bvh construit une structure d’accélération qui rend le raycasting des centaines de fois plus rapide sur les meshes complexes. C’est essentiel pour des configurateurs qui détectent les clics sur des modèles à plusieurs centaines de milliers de triangles. À ajouter dès qu’on observe des freezes au moindre clic dans une scène lourde.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité