Arbres et forêts dans three.js : LOD, instancing, KTX2 et vent — un guide pratique
Comment intégrer une vraie forêt à un jeu web three.js sans sacrifier le framerate ni le poids du téléchargement : GLB prêt pour le jeu, compression, THREE.LOD, InstancedMesh et BatchedMesh, feuilles en cutout, ombres et shader de vent léger.

De plus en plus de jeux sont conçus pour le navigateur — et une part croissante de leur code est écrite avec des assistants IA, qui se tournent par défaut vers three.js. La Vibe Jam 2026, par exemple, demandait des jeux web qui se lancent sans téléchargement et recommandait three.js. Tôt ou tard, chacun de ces jeux veut une forêt. Et une forêt est généralement la première chose qui transforme 60 FPS en 15 et une page de 5 Mo en 150 Mo.
Le web est la plateforme la plus exigeante de toutes : des portables et des téléphones modestes, un GPU partagé avec le navigateur, et chaque mégaoctet doit être téléchargé avant que le joueur ne voie quoi que ce soit. Ce guide montre comment créer une végétation qui tient dans ces limites — étape par étape, avec du code pour la version actuelle de three.js.
Le budget web
Avant d'écrire du code, fixez des limites. Ce sont des points de départ pour un portable ou un téléphone de milieu de gamme, pas des lois — mesurez votre propre scène :
| Quoi | Objectif confortable |
|---|---|
| Draw calls par frame | quelques centaines au maximum |
| Triangles par frame | 0,5–2 millions |
| Un arbre, niveau proche | 5 000–15 000 triangles |
| Un arbre, niveau lointain | moins de 1 500 triangles ; impostor — 2 |
| Téléchargement pour toute la forêt | quelques Mo, pas des dizaines |
L'idée clé est la même que dans n'importe quel moteur : la majeure partie de la forêt est loin, donc la majeure partie de la forêt doit être bon marché. Chaque niveau de détail doit être environ 3 à 4 fois plus léger que le précédent.
Un arbre, quatre niveaux de détail : la silhouette reste, le coût baisse.
Étape 1. Partir d'un GLB prêt pour le jeu
Un arbre de jeu n'est pas qu'un joli mesh. Avant d'écrire la moindre ligne de code de rendu, vérifiez que le fichier contient :
- une chaîne de LOD à la silhouette identique (et idéalement un impostor pour le niveau le plus lointain) ;
- des feuilles en cartes groupées, et non des milliers de feuilles séparées ni une masse pleine façon « brocoli » ;
- un pivot à la base du tronc et une échelle réelle en mètres ;
- une collision simple — une capsule pour le tronc, pas pour la couronne ;
- des poids de vent stockés par vertex.
Vous pouvez vérifier tout cela dans le navigateur avec des outils gratuits : le compteur de polygones, le visualiseur de LOD et le validateur glTF.
Étape 2. Alléger le fichier : meshopt et KTX2
Deux compressions divisent plusieurs fois le poids d'un arbre typique :
- Géométrie — compression meshopt (ou Draco).
- Textures — KTX2 (Basis Universal). Contrairement au PNG ou au JPEG, le KTX2 reste compressé en mémoire vidéo : il économise donc à la fois le téléchargement et la VRAM.
Vous pouvez le faire dans le navigateur avec notre compresseur glTF et notre convertisseur KTX2, ou en ligne de commande avec glTF Transform :
npx @gltf-transform/cli optimize tree.glb tree.min.glb --compress meshopt --texture-compress ktx2
(La compression KTX2 dans la CLI nécessite l'installation de KTX-Software ; --texture-compress webp fonctionne sans.)
Charger un fichier compressé dans three.js :
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const loader = new GLTFLoader().setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
const gltf = await loader.loadAsync('/models/oak.min.glb');
Étape 3. Quelques arbres : THREE.LOD
Pour une poignée d'arbres près du joueur — une place de village, un arbre « héros » —, le THREE.LOD intégré suffit. Il affiche un enfant selon la distance à la caméra ; le troisième argument est une hystérésis qui évite le scintillement à la limite :
const lod = new THREE.LOD();
lod.addLevel(lod0, 0);
lod.addLevel(lod1, 30, 0.1);
lod.addLevel(lod2, 70, 0.1);
lod.addLevel(impostor, 150, 0.1);
lod.position.set(x, 0, z);
scene.add(lod);
THREE.LOD se met à jour automatiquement quand le renderer dessine la scène. Sa limite : chaque arbre reste un objet séparé — avec des draw calls séparés pour l'écorce et les feuilles. Au-delà de quelques dizaines d'arbres, il vous faut de l'instancing.
Étape 4. Beaucoup d'arbres : de l'instancing, par chunks
InstancedMesh dessine des milliers de copies d'un mesh en un seul draw call. Mais three.js le traite comme un seul objet : il est cullé en bloc et n'a pas de LOD — toutes les instances sont dessinées avec le même niveau de détail, y compris celles situées à 500 m ou derrière la caméra.
La solution est la même que dans Godot et Unity : découpez le monde en chunks et créez un InstancedMesh par chunk, par niveau de LOD et par matériau. Chaque chunk choisit alors son niveau selon la distance :
const CHUNK = 32; // mètres — proche de la distance de LOD0
const STARTS = [0, 35, 80, 160]; // début de chaque LOD
const CULL = 400;
function buildChunk(trees, lodMeshes) {
// lodMeshes[i] = [barkMesh, leavesMesh] extraits du GLB
const levels = lodMeshes.map((parts) => parts.map((src) => {
const im = new THREE.InstancedMesh(src.geometry, src.material, trees.length);
trees.forEach((m, k) => im.setMatrixAt(k, m));
im.computeBoundingSphere();
return im;
}));
const group = new THREE.Group();
levels.flat().forEach((im) => group.add(im));
group.userData.levels = levels;
group.userData.center = centerOf(trees); // un THREE.Vector3
return group;
}
function updateChunk(group, camera) {
const d = camera.position.distanceTo(group.userData.center);
group.userData.levels.forEach((parts, i) => {
const visible = d >= STARTS[i] && d < (STARTS[i + 1] ?? CULL);
parts.forEach((im) => { im.visible = visible; });
});
}
Appelez updateChunk pour chaque chunk quelques fois par seconde plutôt qu'à chaque frame — les distances évoluent lentement. La taille des chunks compte : la distance est mesurée jusqu'au centre du chunk, donc des chunks bien plus grands que la plage de LOD0 afficheraient des arbres grossiers juste à côté du joueur.
BatchedMesh est l'alternative moderne. Il dessine de nombreuses géométries différentes avec un seul matériau, culle chaque objet séparément (perObjectFrustumCulled est activé par défaut) et permet de changer la géométrie de n'importe quelle instance avec setGeometryIdAt — ce qui est exactement un changement de LOD :
const bark = new THREE.BatchedMesh(maxTrees, maxVerts, maxIndices, barkMaterial);
const lodIds = barkLods.map((g) => bark.addGeometry(g));
for (const t of trees) {
t.id = bark.addInstance(lodIds[0]);
bark.setMatrixAt(t.id, t.matrix);
}
// quelques fois par seconde :
for (const t of trees) {
const d = camera.position.distanceTo(t.position);
const lod = STARTS.findLastIndex((s) => d >= s);
bark.setGeometryIdAt(t.id, lodIds[lod]);
}
Utilisez un BatchedMesh par matériau (écorce, feuilles). Si vous préférez une solution toute faite, les bibliothèques communautaires @three.ez/instanced-mesh (culling et LOD par instance pour l'instancing) et @three.ez/batched-mesh-extensions (LOD pour BatchedMesh) font ce travail à votre place.
Encore un raccourci : si votre fichier utilise l'extension glTF EXT_mesh_gpu_instancing, GLTFLoader crée les objets InstancedMesh pour vous — une forêt entière dans un seul fichier.
Une forêt d'arbres instanciés : le nombre de draw calls augmente à peine avec le nombre d'arbres.
Étape 5. Les feuilles : du cutout, pas de la transparence
La plupart des forêts lentes sur le web le sont à cause de leurs feuilles.
- Utilisez
alphaTest, pastransparent: true. Les matériaux transparents sont triés et mélangés, n'écrivent pas de profondeur et multiplient l'overdraw. Un cutout coûte bien moins cher :
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // bords doux quand le renderer a antialias: true
- Surveillez la forme des cartes. Une carte carrée autour d'une grappe de feuilles arrondie calcule beaucoup de pixels invisibles. Des cartes découpées selon la silhouette de la grappe économisent beaucoup de fill rate — et sur téléphone, le fill rate est souvent la vraie limite.
- Baissez la résolution sur les appareils modestes.
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))est souvent le plus gros gain à lui seul pour le feuillage sur les téléphones à haute densité de pixels.
Étape 6. Ombres et vent
Les ombres dessinent la forêt une seconde fois. Gardez la caméra d'ombre serrée autour du joueur, utilisez un shadow.mapSize raisonnable et ne laissez que les niveaux proches projeter des ombres :
lod2Leaves.castShadow = false;
impostor.castShadow = false;
Le vent a sa place dans le vertex shader. Si le modèle stocke des poids de vent par vertex — par exemple la flexion du tronc dans x et le balancement des branches dans y du second canal UV —, vous pouvez animer un matériau standard avec onBeforeCompile :
const time = { value: 0 };
geometry.setAttribute('wind', geometry.getAttribute('uv1')); // TEXCOORD_1 du GLB
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = time;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nattribute vec2 wind;\nuniform float uTime;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
float sway = sin(uTime * 1.3 + position.y * 0.2);
transformed.x += (sway * wind.x * 0.25 + sin(uTime * 2.7 + position.x) * wind.y * 0.1);
transformed.z += sway * wind.x * 0.1;`);
};
// dans la boucle de rendu :
time.value = clock.getElapsedTime();
Copier l'attribut sous un nom propre évite un conflit avec three.js, qui déclare lui-même uv1 lorsqu'un matériau utilise un second jeu d'UV.
Étape 7. Mesurer
renderer.info.render.callsetrenderer.info.render.triangles— draw calls et triangles par frame ;- le panneau Performance du navigateur — pour savoir si la limite vient du CPU ou du GPU ;
- un vrai téléphone de milieu de gamme — pas seulement votre machine de développement.
Mesurez avant et après chaque changement. Souvent, une seule correction (feuilles en cutout, pixel ratio, distance d'ombre) apporte plus que toutes les autres réunies.
Checklist
- GLB avec une chaîne de LOD, des cartes de feuilles groupées, un pivot à la base, une collision et des poids de vent.
- Géométrie compressée avec meshopt, textures en KTX2.
-
THREE.LODpour quelques arbres proches ;InstancedMeshpar chunks ouBatchedMeshpour les forêts. - Feuilles avec
alphaTestetalphaToCoverage, pastransparent. - Ombres uniquement sur les niveaux proches ; caméra d'ombre serrée.
- Pixel ratio plafonné sur téléphone.
- Draw calls, triangles et temps de frame mesurés sur un vrai appareil.
Où FoliMesh intervient
FoliMesh est un générateur d'arbres et de nature qui fonctionne dans le navigateur et qui est pensé autour des budgets des jeux. Vous fixez un budget de triangles pour chaque niveau, et il exporte un GLB avec LOD0–LOD3 et un billboard croisé pour la plus grande distance, des cartes de feuilles découpées selon la forme de leurs grappes, un pivot à la base, des poids de vent dans TEXCOORD_1 (three.js le lit comme uv1) ainsi que les distances de LOD et les capsules de collision dans les extras du nœud (dans three.js — object.userData). Des forêts entières peuvent être exportées en un seul GLB avec EXT_mesh_gpu_instancing, que GLTFLoader transforme en meshes instanciés.
Pour voir comment FoliMesh se situe par rapport aux autres outils, lisez notre comparatif des générateurs d'arbres 3D. Vous travaillez plutôt sous Godot ? Découvrez comment créer une forêt rapide dans Godot 4.
FAQ
three.js a-t-il un système de LOD ?
Oui, THREE.LOD bascule entre des objets enfants selon la distance. Il ne fonctionne pas par instance à l'intérieur d'un InstancedMesh — pour cela, utilisez des chunks, BatchedMesh avec setGeometryIdAt ou une bibliothèque comme @three.ez/instanced-mesh.
InstancedMesh ou BatchedMesh ?
InstancedMesh est le plus simple et le plus rapide pour de nombreuses copies d'un même mesh. BatchedMesh est préférable quand vous voulez du culling et du LOD par objet, ou de nombreux meshes différents avec un seul matériau.
Pourquoi les feuilles de mes arbres sont-elles noires ou mal triées ?
Généralement parce que le matériau est en transparent: true. Passez à alphaTest avec DoubleSide : les problèmes de tri disparaissent et les performances s'améliorent.
Le KTX2 vaut-il le coup pour les arbres ? Oui. Les textures d'écorce et de feuilles représentent souvent la plus grosse part du téléchargement et de la mémoire vidéo, et le KTX2 réduit les deux.

