FoliMesh
Блог

Дерева й ліси в three.js: LOD, інстансинг, KTX2 і вітер — практичний посібник

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

·8 хв читання#threejs#webgl#optimization#lod#tutorial

Дедалі більше ігор створюють для браузера — і дедалі більше їхнього коду пишуть разом з ІІ-асистентами, які за замовчуванням тягнуться до 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 рази легшим за попередній.

Той самий дуб на рівнях LOD0, LOD1, LOD2 і LOD3 Одне дерево, чотири рівні деталізації: силует зберігається, вартість падає.

Крок 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 — цілий ліс в одному файлі.

Ліс у редакторі FoliMesh Ліс з інстансованих дерев: кількість 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 зменшує і те, і інше.