FoliMesh
Blog

Drzewa i lasy w three.js: LOD, instancing, KTX2 i wiatr — praktyczny poradnik

Jak wstawić prawdziwy las do gry webowej w three.js, nie zabijając liczby klatek ani rozmiaru pobierania: GLB gotowy do gry, kompresja, THREE.LOD, InstancedMesh i BatchedMesh, liście jako cutout, cienie i tani shader wiatru.

·8 min czytania#threejs#webgl#optimization#lod#tutorial

Coraz więcej gier powstaje z myślą o przeglądarce — i coraz większa część ich kodu jest pisana razem z asystentami AI, którzy domyślnie sięgają po three.js. Na przykład Vibe Jam 2026 wymagał gier przeglądarkowych, które uruchamiają się bez pobierania, i polecał three.js. Prędzej czy później każda taka gra chce mieć las. A las to zwykle pierwsza rzecz, która zamienia 60 FPS w 15, a stronę o wadze 5 MB w 150 MB.

Web to najbardziej wymagająca platforma ze wszystkich: słabe laptopy i telefony, GPU dzielone z przeglądarką, a każdy megabajt trzeba pobrać, zanim gracz cokolwiek zobaczy. Ten poradnik pokazuje, jak zbudować roślinność, która przetrwa te ograniczenia — krok po kroku, z kodem dla aktualnej wersji three.js.

Budżet dla weba

Zanim zaczniesz pisać kod, ustal limity. To punkty wyjścia dla laptopa lub telefonu ze średniej półki, a nie prawa — zmierz własną scenę:

Co Komfortowy cel
Draw calle na klatkę najwyżej kilkaset
Trójkąty na klatkę 0,5–2 miliony
Jedno drzewo, bliski poziom 5000–15 000 trójkątów
Jedno drzewo, daleki poziom poniżej 1500 trójkątów; impostor — 2
Pobieranie całego lasu kilka MB, nie kilkadziesiąt

Kluczowa idea jest taka sama jak w każdym silniku: większość lasu jest daleko, więc większość lasu musi być tania. Każdy poziom szczegółowości powinien być mniej więcej 3–4 razy lżejszy od poprzedniego.

Ten sam dąb w LOD0, LOD1, LOD2 i LOD3 Jedno drzewo, cztery poziomy szczegółowości: sylwetka zostaje, koszt spada.

Krok 1. Zacznij od GLB gotowego do gry

Drzewo do gry to nie tylko ładna siatka. Zanim napiszesz choćby jedną linijkę kodu renderującego, sprawdź, czy plik ma:

  • łańcuch LOD-ów o tej samej sylwetce (a najlepiej impostor dla najdalszego poziomu);
  • liście jako karty zgrupowane w kępy, a nie tysiące osobnych liści ani zbita masa w stylu „brokuła”;
  • pivot u podstawy pnia i rzeczywistą skalę w metrach;
  • prostą kolizję — kapsułę dla pnia, nie dla korony;
  • wagi wiatru zapisane dla każdego wierzchołka.

Wszystko to sprawdzisz w przeglądarce darmowymi narzędziami: licznikiem polygonów, przeglądarką LOD i walidatorem glTF.

Krok 2. Zmniejsz plik: meshopt i KTX2

Dwie kompresje zmniejszają typowe drzewo kilkukrotnie:

  • Geometria — kompresja meshopt (lub Draco).
  • Tekstury — KTX2 (Basis Universal). W przeciwieństwie do PNG czy JPEG, KTX2 pozostaje skompresowany w pamięci wideo, więc oszczędza zarówno na pobieraniu, jak i na VRAM.

Możesz to zrobić w przeglądarce naszym kompresorem glTF i konwerterem KTX2 albo w wierszu poleceń za pomocą glTF Transform:

npx @gltf-transform/cli optimize tree.glb tree.min.glb --compress meshopt --texture-compress ktx2

(Kompresja KTX2 w CLI wymaga zainstalowanego KTX-Software; --texture-compress webp działa bez niego.)

Wczytywanie skompresowanego pliku w 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');

Krok 3. Kilka drzew: THREE.LOD

Dla garstki drzew blisko gracza — placu we wsi, drzewa na pierwszym planie — wystarczy wbudowany THREE.LOD. Pokazuje jedno z dzieci w zależności od odległości od kamery; trzeci argument to histereza, która zapobiega migotaniu na granicy:

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 aktualizuje się automatycznie, gdy renderer rysuje scenę. Jego ograniczenie polega na tym, że każde drzewo nadal jest osobnym obiektem — osobne draw calle dla kory i liści. Powyżej kilkudziesięciu drzew potrzebujesz instancingu.

Krok 4. Dużo drzew: instancing w chunkach

InstancedMesh rysuje tysiące kopii jednej siatki w jednym draw callu. Ale three.js traktuje go jako jeden obiekt: jest odrzucany przez culling w całości i nie ma LOD — wszystkie instancje rysowane są z tą samą szczegółowością, nawet te oddalone o 500 m albo znajdujące się za kamerą.

Rozwiązanie jest takie samo jak w Godocie i Unity: podziel świat na chunki i utwórz jeden InstancedMesh na chunk, na poziom LOD i na materiał. Wtedy każdy chunk wybiera swój poziom według odległości:

const CHUNK = 32; // metry — blisko dystansu LOD0
const STARTS = [0, 35, 80, 160]; // gdzie zaczyna się każdy LOD
const CULL = 400;

function buildChunk(trees, lodMeshes) {
  // lodMeshes[i] = [barkMesh, leavesMesh] wzięte z 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); // 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; });
  });
}

Wywołuj updateChunk dla każdego chunka kilka razy na sekundę zamiast w każdej klatce — odległości zmieniają się powoli. Rozmiar chunka ma znaczenie: odległość jest mierzona do środka chunka, więc chunki dużo większe niż pasmo LOD0 pokazywałyby zgrubne drzewa tuż obok gracza.

BatchedMesh to nowoczesna alternatywa. Rysuje wiele różnych geometrii z jednym materiałem, odrzuca każdy obiekt osobno (perObjectFrustumCulled jest domyślnie włączone) i pozwala zmienić geometrię dowolnej instancji przez setGeometryIdAt — a to jest właśnie przełączenie 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);
}

// kilka razy na sekundę:
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]);
}

Używaj jednego BatchedMesh na materiał (kora, liście). Jeśli wolisz gotowe rozwiązanie, biblioteki społeczności @three.ez/instanced-mesh (culling i LOD per instancja dla instancingu) oraz @three.ez/batched-mesh-extensions (LOD dla BatchedMesh) zrobią to za ciebie.

Jeszcze jeden skrót: jeśli twój plik używa rozszerzenia glTF EXT_mesh_gpu_instancing, GLTFLoader sam utworzy obiekty InstancedMesh — cały las w jednym pliku.

Las w edytorze FoliMesh Las z instancjonowanych drzew: liczba draw calli prawie nie rośnie wraz z liczbą drzew.

Krok 5. Liście: cutout, nie przezroczystość

Większość wolnych lasów w przeglądarce jest wolna z powodu liści.

  • Używaj alphaTest, a nie transparent: true. Materiały przezroczyste są sortowane i blendowane, nie zapisują głębi i mnożą overdraw. Cutout jest dużo tańszy:
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // miękkie krawędzie, gdy renderer ma antialias: true
  • Pilnuj kształtu kart. Kwadratowa karta wokół okrągłej kępy liści cieniuje mnóstwo niewidocznych pikseli. Karty przycięte do sylwetki kępy oszczędzają sporo fill rate — a na telefonach to właśnie fill rate jest często prawdziwym limitem.
  • Obniż rozdzielczość na słabych urządzeniach. renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)) to często największy pojedynczy zysk dla listowia na telefonach z ekranami o wysokim DPI.

Krok 6. Cienie i wiatr

Cienie rysują las po raz drugi. Trzymaj kamerę cieni ciasno wokół gracza, używaj umiarkowanego shadow.mapSize i pozwól rzucać cienie tylko bliskim poziomom:

lod2Leaves.castShadow = false;
impostor.castShadow = false;

Wiatr należy do vertex shadera. Jeśli model przechowuje wagi wiatru dla każdego wierzchołka — powiedzmy zgięcie pnia w x i kołysanie gałęzi w y drugiego kanału UV — możesz dodać ruch do standardowego materiału przez onBeforeCompile:

const time = { value: 0 };
geometry.setAttribute('wind', geometry.getAttribute('uv1')); // TEXCOORD_1 z 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;`);
};

// w pętli renderowania:
time.value = clock.getElapsedTime();

Skopiowanie atrybutu pod własną nazwą pozwala uniknąć konfliktu z three.js, który sam deklaruje uv1, gdy materiał używa drugiego zestawu UV.

Krok 7. Mierz

  • renderer.info.render.calls i renderer.info.render.triangles — draw calle i trójkąty na klatkę;
  • panel Performance w przeglądarce — czy ograniczeniem jest CPU, czy GPU;
  • prawdziwy telefon ze średniej półki — nie tylko twoja maszyna deweloperska.

Mierz przed każdą zmianą i po niej. Często jedna poprawka (liście jako cutout, pixel ratio, zasięg cieni) daje więcej niż wszystkie pozostałe razem.

Checklista

  • GLB z łańcuchem LOD-ów, kartami liści w kępach, pivotem przy korzeniu, kolizją i wagami wiatru.
  • Geometria skompresowana przez meshopt, tekstury przez KTX2.
  • THREE.LOD dla kilku bliskich drzew; InstancedMesh w chunkach lub BatchedMesh dla lasów.
  • Liście z alphaTest i alphaToCoverage, nie transparent.
  • Cienie tylko na bliskich poziomach; ciasna kamera cieni.
  • Ograniczony pixel ratio na telefonach.
  • Draw calle, trójkąty i czas klatki zmierzone na prawdziwym urządzeniu.

Gdzie w tym wszystkim jest FoliMesh

FoliMesh to generator drzew i przyrody działający w przeglądarce, zbudowany wokół budżetów gier. Ustawiasz budżet trójkątów dla każdego poziomu, a on eksportuje GLB z LOD0–LOD3 i skrzyżowanym billboardem dla największej odległości, karty liści przycięte do kształtu kęp, pivot przy korzeniu, wagi wiatru w TEXCOORD_1 (three.js odczytuje je jako uv1) oraz dystanse LOD i kapsuły kolizji w extras węzła (w three.js — object.userData). Całe lasy można wyeksportować jako jeden GLB z EXT_mesh_gpu_instancing, który GLTFLoader zamienia na siatki instancjonowane.

Aby zobaczyć, jak FoliMesh wypada na tle innych narzędzi, przeczytaj nasze porównanie generatorów drzew 3D. Pracujesz w Godocie? Zobacz, jak zbudować szybki las w Godot 4.

FAQ

Czy three.js ma LOD? Tak, THREE.LOD przełącza obiekty potomne w zależności od odległości. Nie działa dla pojedynczych instancji wewnątrz InstancedMesh — do tego użyj chunków, BatchedMesh z setGeometryIdAt albo biblioteki takiej jak @three.ez/instanced-mesh.

InstancedMesh czy BatchedMesh? InstancedMesh jest najprostszy i najszybszy dla wielu kopii jednej siatki. BatchedMesh sprawdza się lepiej, gdy chcesz cullingu i LOD dla każdego obiektu albo wielu różnych siatek z jednym materiałem.

Dlaczego liście moich drzew są czarne albo źle posortowane? Zwykle dlatego, że materiał ma transparent: true. Przełącz się na alphaTest z DoubleSide; problemy z sortowaniem znikną, a wydajność wzrośnie.

Czy KTX2 opłaca się przy drzewach? Tak. Tekstury kory i liści to często największa część pobieranych danych i pamięci wideo, a KTX2 zmniejsza jedno i drugie.