Дерева й ліси в three.js: LOD, інстансинг, KTX2 і вітер — практичний посібник
Як додати справжній ліс у вебгру на three.js і не вбити ні частоту кадрів, ні розмір завантаження: готовий до гри GLB, стиснення, THREE.LOD, InstancedMesh і BatchedMesh, вирізане листя, тіні та дешевий шейдер вітру.

Дедалі більше ігор створюють для браузера — і дедалі більше їхнього коду пишуть разом з ІІ-асистентами, які за замовчуванням тягнуться до three.js. Наприклад, Vibe Jam 2026 приймав браузерні ігри, що запускаються без завантаження, і рекомендував three.js. Рано чи пізно кожній такій грі потрібен ліс. А ліс — зазвичай перше, що перетворює 60 FPS на 15, а сторінку на 5 МБ — на 150 МБ.
Веб — найсуворіша платформа з усіх: слабкі ноутбуки й телефони, GPU, який доводиться ділити з браузером, і кожен мегабайт треба завантажити, перш ніж гравець щось побачить. Цей посібник показує, як зробити рослинність, що витримує ці обмеження, — крок за кроком, із кодом для актуальної версії three.js.
Вебний бюджет
Перш ніж писати код, встановіть межі. Це орієнтири для ноутбука чи телефона середнього класу, а не закони, — вимірюйте власну сцену:
| Що | Комфортна ціль |
|---|---|
| Draw call'и на кадр | щонайбільше кілька сотень |
| Трикутники на кадр | 0,5–2 мільйони |
| Одне дерево, ближній рівень | 5 000–15 000 трикутників |
| Одне дерево, дальній рівень | менше 1 500 трикутників; імпостер — 2 |
| Завантаження для всього лісу | кілька МБ, а не десятки |
Ключова ідея та сама, що й у будь-якому рушії: більша частина лісу далеко, тож більша частина лісу має бути дешевою. Кожен рівень деталізації має бути приблизно в 3–4 рази легшим за попередній.
Одне дерево, чотири рівні деталізації: силует зберігається, вартість падає.
Крок 1. Почніть із готового до гри GLB
Дерево для гри — це не просто гарний меш. Перш ніж писати хоч рядок коду рендерингу, перевірте, що у файлі є:
- ланцюжок LOD з однаковим силуетом (а в ідеалі — імпостер для найдальшого рівня);
- листя кластерними картками, а не тисячами окремих листків і не суцільною масою-«брокколі»;
- півот біля основи стовбура і реальний масштаб у метрах;
- проста колізія — капсула для стовбура, а не для крони;
- ваги вітру, збережені для кожної вершини.
Усе це можна перевірити в браузері безкоштовними інструментами: лічильником полігонів, переглядачем LOD і валідатором glTF.
Крок 2. Зменште файл: meshopt і KTX2
Два види стиснення зменшують типове дерево в рази:
- Геометрія — стиснення meshopt (або Draco).
- Текстури — KTX2 (Basis Universal). На відміну від PNG чи JPEG, KTX2 залишається стиснутим у відеопам'яті, тож заощаджує і трафік, і VRAM.
Це можна зробити в браузері нашим компресором glTF і конвертером KTX2 або в командному рядку через glTF Transform:
npx @gltf-transform/cli optimize tree.glb tree.min.glb --compress meshopt --texture-compress ktx2
(Для стиснення KTX2 у CLI потрібен встановлений KTX-Software; --texture-compress webp працює і без нього.)
Завантаження стиснутого файлу в 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');
Крок 3. Кілька дерев: THREE.LOD
Для невеликої кількості дерев поруч із гравцем — сільська площа, «геройське» дерево — достатньо вбудованого THREE.LOD. Він показує один дочірній об'єкт залежно від відстані до камери; третій аргумент — гістерезис, що не дає рівням мерехтіти на межі:
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 оновлюється автоматично, коли рендерер малює сцену. Його обмеження в тому, що кожне дерево все одно лишається окремим об'єктом — з окремими draw call'ами для кори й листя. Коли дерев більше кількох десятків, потрібен інстансинг.
Крок 4. Багато дерев: інстансинг по чанках
InstancedMesh малює тисячі копій одного мешу за один draw call. Але three.js вважає його одним об'єктом: він відсікається цілком і не має LOD — усі інстанси малюються з однаковою деталізацією, навіть ті, що за 500 м або позаду камери.
Рішення таке саме, як у Godot і Unity: розбийте світ на чанки і створіть один InstancedMesh на кожен чанк, кожен рівень LOD і кожен матеріал. Тоді кожен чанк обирає свій рівень за відстанню:
const CHUNK = 32; // метри — близько до дистанції LOD0
const STARTS = [0, 35, 80, 160]; // де починається кожен LOD
const CULL = 400;
function buildChunk(trees, lodMeshes) {
// lodMeshes[i] = [barkMesh, leavesMesh], узяті з 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; });
});
}
Викликайте updateChunk для кожного чанка кілька разів на секунду, а не щокадру, — відстані змінюються повільно. Розмір чанка важливий: відстань вимірюється до центру чанка, тож чанки, значно більші за зону LOD0, показували б грубі дерева просто поруч із гравцем.
BatchedMesh — сучасна альтернатива. Він малює багато різних геометрій з одним матеріалом, відсікає кожен об'єкт окремо (perObjectFrustumCulled увімкнено за замовчуванням) і дає змінити геометрію будь-якого інстансу через setGeometryIdAt — а це якраз і є перемикання 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);
}
// кілька разів на секунду:
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]);
}
Використовуйте один BatchedMesh на матеріал (кора, листя). Якщо вам більше до вподоби готове рішення, бібліотеки спільноти @three.ez/instanced-mesh (culling і LOD для кожного інстансу) та @three.ez/batched-mesh-extensions (LOD для BatchedMesh) зроблять цю роботу за вас.
Ще один короткий шлях: якщо ваш файл використовує розширення glTF EXT_mesh_gpu_instancing, GLTFLoader сам створить об'єкти InstancedMesh — цілий ліс в одному файлі.
Ліс з інстансованих дерев: кількість draw call'ів майже не зростає разом із кількістю дерев.
Крок 5. Листя: вирізання, а не прозорість
Більшість повільних лісів у вебі повільні через листя.
- Використовуйте
alphaTest, а неtransparent: true. Прозорі матеріали сортуються і змішуються, не записують глибину й множать overdraw. Вирізання (cutout) значно дешевше:
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // м'які краї, якщо рендерер створено з antialias: true
- Стежте за формою карток. Квадратна картка навколо круглого кластера листя обраховує багато невидимих пікселів. Картки, вирізані за силуетом кластера, суттєво економлять fill rate — а на телефонах саме fill rate часто і є справжнім обмеженням.
- Знижуйте роздільність на слабких пристроях.
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))— часто найбільший одиничний виграш для рослинності на телефонах із високою щільністю пікселів.
Крок 6. Тіні й вітер
Тіні малюють ліс удруге. Тримайте камеру тіней щільно навколо гравця, використовуйте помірний shadow.mapSize і дозволяйте відкидати тіні лише ближнім рівням:
lod2Leaves.castShadow = false;
impostor.castShadow = false;
Вітру місце у вершинному шейдері. Якщо модель зберігає ваги вітру для кожної вершини — скажімо, вигин стовбура в x і гойдання гілок в y другого UV-каналу, — можна додати рух у стандартний матеріал через onBeforeCompile:
const time = { value: 0 };
geometry.setAttribute('wind', geometry.getAttribute('uv1')); // TEXCOORD_1 з 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;`);
};
// у циклі рендерингу:
time.value = clock.getElapsedTime();
Копіювання атрибута під власною назвою запобігає конфлікту з three.js, який сам оголошує uv1, коли матеріал використовує другий набір UV.
Крок 7. Вимірюйте
renderer.info.render.callsіrenderer.info.render.triangles— draw call'и та трикутники на кадр;- панель Performance у браузері — що обмежує: CPU чи GPU;
- справжній телефон середнього класу — а не лише ваша робоча машина.
Вимірюйте до і після кожної зміни. Часто одне виправлення (вирізане листя, pixel ratio, дальність тіней) дає більше, ніж усі інші разом.
Чекліст
- GLB з ланцюжком LOD, кластерними картками листя, півотом біля кореня, колізією та вагами вітру.
- Геометрію стиснуто через meshopt, текстури — через KTX2.
-
THREE.LODдля кількох ближніх дерев;InstancedMeshпо чанках абоBatchedMeshдля лісів. - Листя з
alphaTestіalphaToCoverage, а неtransparent. - Тіні лише на ближніх рівнях; щільна камера тіней.
- Pixel ratio обмежено на телефонах.
- Draw call'и, трикутники та час кадру виміряно на справжньому пристрої.
Де тут FoliMesh
FoliMesh — генератор дерев і природи, що працює в браузері й побудований довкола ігрових бюджетів. Ви задаєте бюджет трикутників для кожного рівня, а він експортує GLB з LOD0–LOD3 і хрестоподібним білбордом для найдальшої дистанції, картки листя, вирізані за формою кластерів, півот біля кореня, ваги вітру в TEXCOORD_1 (three.js читає їх як uv1), а також дистанції LOD і капсули колізій в extras вузла (у three.js — object.userData). Цілі ліси можна експортувати одним GLB з EXT_mesh_gpu_instancing, який GLTFLoader перетворює на інстансовані меші.
Щоб дізнатися, як FoliMesh виглядає на тлі інших інструментів, прочитайте наше порівняння 3D-генераторів дерев. Працюєте в Godot? Дивіться, як зробити швидкий ліс у Godot 4.
FAQ
Чи є в three.js LOD?
Так, THREE.LOD перемикає дочірні об'єкти залежно від відстані. Для окремих інстансів усередині InstancedMesh він не працює — для цього використовуйте чанки, BatchedMesh із setGeometryIdAt або бібліотеку на кшталт @three.ez/instanced-mesh.
InstancedMesh чи BatchedMesh?
InstancedMesh — найпростіший і найшвидший варіант для багатьох копій одного мешу. BatchedMesh кращий, коли потрібні culling і LOD для кожного об'єкта окремо або багато різних мешів з одним матеріалом.
Чому листя на моїх деревах чорне або неправильно сортується?
Зазвичай тому, що матеріал має transparent: true. Перейдіть на alphaTest із DoubleSide: проблеми із сортуванням зникнуть, а продуктивність зросте.
Чи варто використовувати KTX2 для дерев? Так. Текстури кори й листя часто становлять найбільшу частину завантаження та відеопам'яті, а KTX2 зменшує і те, і інше.

