Bäume und Wälder in three.js: LOD, Instancing, KTX2 und Wind – ein Praxisleitfaden
Wie man einen echten Wald in ein three.js-Browserspiel bringt, ohne Framerate oder Downloadgröße zu ruinieren: spielfertige GLB, Kompression, THREE.LOD, InstancedMesh und BatchedMesh, Cutout-Blätter, Schatten und ein günstiger Wind-Shader.

Immer mehr Spiele entstehen für den Browser – und immer mehr ihres Codes wird zusammen mit KI-Assistenten geschrieben, die standardmäßig zu three.js greifen. Der Vibe Jam 2026 etwa verlangte Browserspiele, die ohne Download starten, und empfahl three.js. Früher oder später will jedes dieser Spiele einen Wald. Und ein Wald ist meist das Erste, was aus 60 FPS 15 macht und aus einer 5-MB-Seite 150 MB.
Das Web ist die strengste Plattform von allen: schwache Laptops und Smartphones, eine GPU, die man sich mit dem Browser teilt, und jedes Megabyte muss heruntergeladen werden, bevor der Spieler überhaupt etwas sieht. Dieser Leitfaden zeigt Schritt für Schritt und mit Code für aktuelles three.js, wie man Vegetation baut, die diese Grenzen übersteht.
Das Web-Budget
Bevor du Code schreibst, setz dir Grenzen. Das sind Startwerte für einen Mittelklasse-Laptop oder ein Mittelklasse-Smartphone, keine Gesetze – miss deine eigene Szene:
| Was | Komfortables Ziel |
|---|---|
| Draw Calls pro Frame | höchstens ein paar Hundert |
| Dreiecke pro Frame | 0,5–2 Millionen |
| Ein Baum, nahe Stufe | 5.000–15.000 Dreiecke |
| Ein Baum, ferne Stufe | unter 1.500 Dreiecke; Impostor – 2 |
| Download für den ganzen Wald | ein paar MB, nicht Dutzende |
Die Kernidee ist dieselbe wie in jeder Engine: Der größte Teil des Waldes ist weit weg, also muss der größte Teil des Waldes billig sein. Jede Detailstufe sollte ungefähr 3- bis 4-mal leichter sein als die vorherige.
Ein Baum, vier Detailstufen: Die Silhouette bleibt, die Kosten sinken.
Schritt 1. Mit einer spielfertigen GLB beginnen
Ein Baum für ein Spiel ist nicht einfach nur ein hübsches Mesh. Bevor du auch nur eine Zeile Rendering-Code schreibst, prüfe, ob die Datei Folgendes hat:
- eine LOD-Kette mit derselben Silhouette (und idealerweise einen Impostor für die fernste Stufe);
- Blätter als Cluster-Karten, nicht Tausende einzelner Blätter und auch keine massive „Brokkoli“-Masse;
- einen Pivot am Fuß des Stamms und reale Größe in Metern;
- einfache Kollision – eine Kapsel für den Stamm, nicht für die Krone;
- Windgewichte, pro Vertex gespeichert.
All das kannst du im Browser mit kostenlosen Tools prüfen: dem Polygonzähler, dem LOD-Viewer und dem glTF-Validator.
Schritt 2. Die Datei klein machen: meshopt und KTX2
Zwei Kompressionen verkleinern einen typischen Baum um ein Mehrfaches:
- Geometrie – Kompression mit meshopt (oder Draco).
- Texturen – KTX2 (Basis Universal). Anders als PNG oder JPEG bleibt KTX2 im Videospeicher komprimiert, spart also sowohl Download als auch VRAM.
Das geht im Browser mit unserem glTF-Kompressor und dem KTX2-Konverter oder auf der Kommandozeile mit glTF Transform:
npx @gltf-transform/cli optimize tree.glb tree.min.glb --compress meshopt --texture-compress ktx2
(Für die KTX2-Kompression in der CLI muss KTX-Software installiert sein; --texture-compress webp funktioniert auch ohne.)
Eine komprimierte Datei in three.js laden:
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');
Schritt 3. Ein paar Bäume: THREE.LOD
Für eine Handvoll Bäume in der Nähe des Spielers – einen Dorfplatz, einen Hero-Baum – reicht das eingebaute THREE.LOD. Es zeigt abhängig von der Distanz zur Kamera eines seiner Kinder an; das dritte Argument ist eine Hysterese, die Flackern an der Grenze verhindert:
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 aktualisiert sich automatisch, wenn der Renderer die Szene zeichnet. Seine Grenze: Jeder Baum bleibt ein eigenes Objekt – mit eigenen Draw Calls für Rinde und Blätter. Ab ein paar Dutzend Bäumen brauchst du Instancing.
Schritt 4. Viele Bäume: Instancing, in Chunks
InstancedMesh zeichnet Tausende Kopien eines Meshes in einem einzigen Draw Call. three.js behandelt es aber als ein Objekt: Es wird als Ganzes gecullt und hat kein LOD – alle Instanzen werden im selben Detailgrad gezeichnet, auch die 500 m entfernten oder die hinter der Kamera.
Die Lösung ist dieselbe wie in Godot und Unity: Teile die Welt in Chunks und erstelle ein InstancedMesh pro Chunk, pro LOD-Stufe und pro Material. Dann wählt jeder Chunk seine Stufe nach der Distanz:
const CHUNK = 32; // Meter — nahe an der LOD0-Distanz
const STARTS = [0, 35, 80, 160]; // wo jedes LOD beginnt
const CULL = 400;
function buildChunk(trees, lodMeshes) {
// lodMeshes[i] = [barkMesh, leavesMesh] aus der 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); // ein 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; });
});
}
Ruf updateChunk für jeden Chunk ein paar Mal pro Sekunde auf statt in jedem Frame – Distanzen ändern sich langsam. Die Chunk-Größe ist wichtig: Die Distanz wird zum Mittelpunkt des Chunks gemessen, Chunks, die viel größer als der LOD0-Bereich sind, würden also direkt neben dem Spieler grobe Bäume zeigen.
BatchedMesh ist die moderne Alternative. Es zeichnet viele unterschiedliche Geometrien mit einem Material, cullt jedes Objekt einzeln (perObjectFrustumCulled ist standardmäßig aktiv) und erlaubt, mit setGeometryIdAt die Geometrie jeder Instanz zu wechseln – und genau das ist ein LOD-Wechsel:
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);
}
// ein paar Mal pro Sekunde:
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]);
}
Verwende ein BatchedMesh pro Material (Rinde, Blätter). Wenn du lieber eine fertige Lösung willst: Die Community-Bibliotheken @three.ez/instanced-mesh (Culling und LOD pro Instanz für Instancing) und @three.ez/batched-mesh-extensions (LOD für BatchedMesh) nehmen dir diese Arbeit ab.
Noch eine Abkürzung: Wenn deine Datei die glTF-Erweiterung EXT_mesh_gpu_instancing nutzt, erzeugt GLTFLoader die InstancedMesh-Objekte für dich – ein ganzer Wald in einer Datei.
Ein Wald aus instanzierten Bäumen: Die Zahl der Draw Calls wächst mit der Zahl der Bäume kaum.
Schritt 5. Blätter: Cutout statt Transparenz
Die meisten langsamen Wälder im Web sind wegen ihrer Blätter langsam.
alphaTeststatttransparent: trueverwenden. Transparente Materialien werden sortiert und geblendet, schreiben keine Tiefe und vervielfachen den Overdraw. Ein Cutout ist viel günstiger:
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // weiche Kanten, wenn der Renderer antialias: true hat
- Auf die Form der Karten achten. Eine quadratische Karte um einen runden Blattcluster schattiert viele unsichtbare Pixel. Karten, die auf die Silhouette des Clusters zugeschnitten sind, sparen viel Füllrate – und auf Smartphones ist die Füllrate oft das eigentliche Limit.
- Auf schwachen Geräten die Auflösung senken.
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))ist auf Smartphones mit hoher Pixeldichte oft der größte Einzelgewinn für Vegetation.
Schritt 6. Schatten und Wind
Schatten zeichnen den Wald ein zweites Mal. Halte die Schattenkamera eng um den Spieler, nutze eine moderate shadow.mapSize und lass nur die nahen Stufen Schatten werfen:
lod2Leaves.castShadow = false;
impostor.castShadow = false;
Wind gehört in den Vertex-Shader. Wenn das Modell Windgewichte pro Vertex speichert – etwa die Stammbiegung in x und das Astschwingen in y des zweiten UV-Kanals –, kannst du einem Standardmaterial mit onBeforeCompile Bewegung hinzufügen:
const time = { value: 0 };
geometry.setAttribute('wind', geometry.getAttribute('uv1')); // TEXCOORD_1 aus der 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;`);
};
// in der Render-Schleife:
time.value = clock.getElapsedTime();
Das Attribut unter eigenem Namen zu kopieren vermeidet einen Konflikt mit three.js, das uv1 selbst deklariert, wenn ein Material einen zweiten UV-Satz nutzt.
Schritt 7. Messen
renderer.info.render.callsundrenderer.info.render.triangles– Draw Calls und Dreiecke pro Frame;- das Performance-Panel des Browsers – ob CPU oder GPU das Limit ist;
- ein echtes Mittelklasse-Smartphone – nicht nur dein Entwicklungsrechner.
Miss vor und nach jeder Änderung. Oft bringt eine einzige Korrektur (Cutout-Blätter, Pixel Ratio, Schattendistanz) mehr als alle anderen zusammen.
Checkliste
- GLB mit LOD-Kette, Cluster-Blattkarten, Pivot an der Wurzel, Kollision und Windgewichten.
- Geometrie mit meshopt komprimiert, Texturen mit KTX2.
-
THREE.LODfür ein paar nahe Bäume;InstancedMeshin Chunks oderBatchedMeshfür Wälder. - Blätter mit
alphaTestundalphaToCoverage, nichttransparent. - Schatten nur auf nahen Stufen; enge Schattenkamera.
- Pixel Ratio auf Smartphones begrenzt.
- Draw Calls, Dreiecke und Frame-Zeit auf einem echten Gerät gemessen.
Wie FoliMesh hier hineinpasst
FoliMesh ist ein Baum- und Naturgenerator, der im Browser läuft und um Spielbudgets herum gebaut ist. Du legst für jede Stufe ein Dreiecksbudget fest, und er exportiert eine GLB mit LOD0–LOD3 und einem Kreuz-Billboard für die größte Distanz, Blattkarten, die auf die Form ihrer Cluster zugeschnitten sind, einen Pivot an der Wurzel, Windgewichte in TEXCOORD_1 (three.js liest das als uv1) sowie LOD-Distanzen und Kollisionskapseln in den extras des Nodes (in three.js – object.userData). Ganze Wälder lassen sich als eine GLB mit EXT_mesh_gpu_instancing exportieren, die GLTFLoader in instanzierte Meshes verwandelt.
Wie FoliMesh im Vergleich zu anderen Tools abschneidet, liest du in unserem Vergleich von 3D-Baumgeneratoren. Du arbeitest stattdessen mit Godot? Lies, wie man in Godot 4 einen schnellen Wald baut.
FAQ
Hat three.js LOD?
Ja, THREE.LOD wechselt je nach Distanz zwischen Kindobjekten. Pro Instanz innerhalb eines InstancedMesh funktioniert es nicht – dafür nimmst du Chunks, BatchedMesh mit setGeometryIdAt oder eine Bibliothek wie @three.ez/instanced-mesh.
InstancedMesh oder BatchedMesh?
InstancedMesh ist am einfachsten und schnellsten für viele Kopien eines einzigen Meshes. BatchedMesh ist besser, wenn du Culling und LOD pro Objekt willst oder viele verschiedene Meshes mit einem Material hast.
Warum sind die Blätter meiner Bäume schwarz oder falsch sortiert?
Meist, weil das Material transparent: true ist. Wechsle zu alphaTest mit DoubleSide; die Sortierprobleme verschwinden, und die Performance steigt.
Lohnt sich KTX2 für Bäume? Ja. Rinden- und Blatttexturen sind oft der größte Teil des Downloads und des Videospeichers, und KTX2 verringert beides.

