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

Всё больше игр делается для браузера — и всё больше их кода пишется вместе с ИИ-ассистентами, которые по умолчанию берут 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 раза легче предыдущего.
Одно дерево, четыре уровня детализации: силуэт остаётся, стоимость падает.
Шаг 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 — целый лес в одном файле.
Лес из деревьев-экземпляров: число вызовов отрисовки почти не растёт вместе с числом деревьев.
Шаг 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 уменьшает и то, и другое.

