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 МБ.

Веб — самая строгая платформа: слабые ноутбуки и телефоны, видеокарта, которую делишь с браузером, и каждый мегабайт нужно скачать до того, как игрок что-то увидит. Это руководство показывает, как сделать растительность, которая выдерживает эти ограничения, — шаг за шагом, с кодом для современного three.js.

Бюджет для веба

Прежде чем писать код, поставьте границы. Это отправные точки для ноутбука или телефона среднего уровня, а не законы — замеряйте свою сцену:

Что Комфортная цель
Вызовов отрисовки в кадре не больше нескольких сотен
Треугольников в кадре 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 командной строке нужен установленный 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 обновляется сам, когда рендерер рисует сцену. Его предел в том, что каждое дерево — всё равно отдельный объект с отдельными вызовами отрисовки для коры и листвы. Больше нескольких десятков деревьев — нужен инстансинг.

Шаг 4. Много деревьев: инстансинг по чанкам

InstancedMesh рисует тысячи копий одной сетки за один вызов отрисовки. Но 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 (отсечение и LOD для каждого экземпляра) и @three.ez/batched-mesh-extensions (LOD для BatchedMesh) сделают эту работу за вас.

Ещё один короткий путь: если в файле используется расширение glTF EXT_mesh_gpu_instancing, GLTFLoader сам создаёт объекты InstancedMesh — целый лес в одном файле.

Лес в редакторе FoliMesh Лес из деревьев-экземпляров: число вызовов отрисовки почти не растёт вместе с числом деревьев.

Шаг 5. Листва: вырез, а не прозрачность

Большинство медленных лесов в вебе медленные из-за листвы.

  • Используйте alphaTest, а не transparent: true. Прозрачные материалы сортируются и смешиваются, не пишут глубину и умножают перерисовку. Вырез намного дешевле:
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // мягкие края, если у рендерера antialias: true
  • Следите за формой карточек. Квадратная карточка вокруг круглого пучка обсчитывает много невидимых пикселей. Карточки, обрезанные по силуэту пучка, сильно экономят заливку — а на телефонах именно заливка часто и есть настоящий предел.
  • Снижайте разрешение на слабых устройствах. 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 — вызовы отрисовки и треугольники в кадре;
  • панель Performance в браузере — что упирается: процессор или видеокарта;
  • настоящий телефон среднего уровня — а не только ваш рабочий компьютер.

Замеряйте до и после каждого изменения. Часто одна правка (листва с вырезом, ограничение разрешения, дальность теней) даёт больше, чем все остальные вместе.

Чек-лист

  • GLB с цепочкой LOD, пучками листвы на карточках, точкой опоры у корня, коллизией и весами ветра.
  • Геометрия сжата через meshopt, текстуры — в KTX2.
  • THREE.LOD для нескольких ближних деревьев; InstancedMesh по чанкам или BatchedMesh для леса.
  • Листва с alphaTest и alphaToCoverage, а не transparent.
  • Тени только у ближних уровней; плотная камера теней.
  • Разрешение на телефонах ограничено.
  • Вызовы отрисовки, треугольники и время кадра замерены на настоящем устройстве.

Как здесь помогает 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.

Частые вопросы

Есть ли в three.js LOD? Да, THREE.LOD переключает дочерние объекты по дистанции. Внутри InstancedMesh он не работает для отдельных экземпляров — для этого есть чанки, BatchedMesh с setGeometryIdAt или библиотеки вроде @three.ez/instanced-mesh.

InstancedMesh или BatchedMesh? InstancedMesh — самый простой и быстрый для множества копий одной сетки. BatchedMesh лучше, когда нужны отсечение и LOD для каждого объекта или много разных сеток с одним материалом.

Почему листва чёрная или неправильно сортируется? Обычно потому, что у материала transparent: true. Переключитесь на alphaTest с DoubleSide — проблемы сортировки пропадут, а скорость вырастет.

Стоит ли KTX2 для деревьев? Да. Текстуры коры и листвы — часто самая большая часть загрузки и видеопамяти, и KTX2 уменьшает и то, и другое.