FoliMesh
Blog

Árvores e florestas no three.js: LOD, instancing, KTX2 e vento — um guia prático

Como colocar uma floresta de verdade em um jogo web com three.js sem derrubar o frame rate nem inflar o download: GLB pronto para jogo, compressão, THREE.LOD, InstancedMesh e BatchedMesh, folhas em cutout, sombras e um shader de vento barato.

·10 min de leitura#threejs#webgl#optimization#lod#tutorial

Cada vez mais jogos são feitos para o navegador — e cada vez mais do código deles é escrito junto com assistentes de IA, que recorrem ao three.js por padrão. A Vibe Jam 2026, por exemplo, pedia jogos de navegador que abrissem sem download e recomendava o three.js. Mais cedo ou mais tarde, todo jogo desses quer uma floresta. E uma floresta costuma ser a primeira coisa que transforma 60 FPS em 15 e uma página de 5 MB em 150 MB.

A web é a plataforma mais exigente de todas: notebooks e celulares fracos, uma GPU dividida com o navegador, e cada megabyte precisa ser baixado antes de o jogador ver qualquer coisa. Este guia mostra como criar uma vegetação que sobrevive a esses limites — passo a passo, com código para o three.js atual.

O orçamento da web

Antes de escrever código, defina limites. Estes são pontos de partida para um notebook ou celular intermediário, não leis — meça a sua própria cena:

O quê Meta confortável
Draw calls por frame no máximo algumas centenas
Triângulos por frame 0,5–2 milhões
Uma árvore, nível próximo 5.000–15.000 triângulos
Uma árvore, nível distante menos de 1.500 triângulos; impostor — 2
Download da floresta inteira alguns MB, não dezenas

A ideia central é a mesma de qualquer engine: a maior parte da floresta está longe, então a maior parte da floresta precisa ser barata. Cada nível de detalhe deve ser aproximadamente 3–4 vezes mais leve que o anterior.

O mesmo carvalho em LOD0, LOD1, LOD2 e LOD3 Uma árvore, quatro níveis de detalhe: a silhueta se mantém, o custo cai.

Passo 1. Comece com um GLB pronto para jogo

Uma árvore para jogo não é só uma malha bonita. Antes de escrever uma única linha de código de renderização, confira se o arquivo tem:

  • uma cadeia de LODs com a mesma silhueta (e, de preferência, um impostor para o nível mais distante);
  • folhas como cards agrupados em clusters, e não milhares de folhas soltas nem uma massa sólida de "brócolis";
  • o pivô na base do tronco e escala real em metros;
  • colisão simples — uma cápsula para o tronco, não para a copa;
  • pesos de vento armazenados por vértice.

Dá para conferir tudo isso no navegador com ferramentas gratuitas: o contador de polígonos, o visualizador de LOD e o validador de glTF.

Passo 2. Deixe o arquivo pequeno: meshopt e KTX2

Duas compressões reduzem uma árvore típica várias vezes:

  • Geometria — compressão meshopt (ou Draco).
  • Texturas — KTX2 (Basis Universal). Diferente de PNG ou JPEG, o KTX2 continua comprimido na memória de vídeo, então economiza tanto no download quanto na VRAM.

Você pode fazer isso no navegador com o nosso compressor de glTF e o conversor KTX2, ou na linha de comando com o glTF Transform:

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

(A compressão KTX2 na CLI exige o KTX-Software instalado; --texture-compress webp funciona sem ele.)

Carregando um arquivo comprimido no 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');

Passo 3. Poucas árvores: THREE.LOD

Para um punhado de árvores perto do jogador — a praça de uma vila, uma árvore de destaque —, o THREE.LOD nativo basta. Ele mostra um filho de acordo com a distância até a câmera; o terceiro argumento é uma histerese que evita o "pisca-pisca" na fronteira:

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);

O THREE.LOD se atualiza automaticamente quando o renderer desenha a cena. O limite dele é que cada árvore continua sendo um objeto separado — draw calls separados para casca e folhas. Passando de algumas dezenas de árvores, você precisa de instancing.

Passo 4. Muitas árvores: instancing, em chunks

O InstancedMesh desenha milhares de cópias de uma malha em um único draw call. Mas o three.js o trata como um único objeto: ele é descartado pelo culling como um todo e não tem LOD — todas as instâncias são desenhadas com o mesmo detalhe, até as que estão a 500 m ou atrás da câmera.

A solução é a mesma do Godot e da Unity: divida o mundo em chunks e crie um InstancedMesh por chunk, por nível de LOD e por material. Aí cada chunk escolhe o seu nível pela distância:

const CHUNK = 32; // metros — próximo da distância do LOD0
const STARTS = [0, 35, 80, 160]; // onde cada LOD começa
const CULL = 400;

function buildChunk(trees, lodMeshes) {
  // lodMeshes[i] = [barkMesh, leavesMesh] tirados do 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); // um 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; });
  });
}

Chame updateChunk para cada chunk algumas vezes por segundo em vez de a cada frame — as distâncias mudam devagar. O tamanho do chunk importa: a distância é medida até o centro do chunk, então chunks muito maiores que a faixa do LOD0 mostrariam árvores grosseiras bem ao lado do jogador.

O BatchedMesh é a alternativa moderna. Ele desenha muitas geometrias diferentes com um único material, faz culling de cada objeto separadamente (perObjectFrustumCulled vem ativado por padrão) e permite trocar a geometria de qualquer instância com setGeometryIdAt — que é exatamente o que uma troca de 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);
}

// algumas vezes por segundo:
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]);
}

Use um BatchedMesh por material (casca, folhas). Se preferir uma solução pronta, as bibliotecas da comunidade @three.ez/instanced-mesh (culling e LOD por instância para instancing) e @three.ez/batched-mesh-extensions (LOD para BatchedMesh) fazem esse trabalho por você.

Mais um atalho: se o seu arquivo usa a extensão glTF EXT_mesh_gpu_instancing, o GLTFLoader cria os objetos InstancedMesh para você — uma floresta inteira em um só arquivo.

Uma floresta no editor do FoliMesh Uma floresta de árvores instanciadas: o número de draw calls quase não cresce com o número de árvores.

Passo 5. Folhas: cutout, não transparência

A maioria das florestas lentas na web é lenta por causa das folhas.

  • Use alphaTest, não transparent: true. Materiais transparentes são ordenados e mesclados, não gravam profundidade e multiplicam o overdraw. Um cutout é muito mais barato:
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // bordas suaves quando o renderer tem antialias: true
  • Fique de olho no formato dos cards. Um card quadrado em volta de um cluster de folhas redondo sombreia muitos pixels invisíveis. Cards recortados na silhueta do cluster economizam bastante fill rate — e, nos celulares, o fill rate muitas vezes é o verdadeiro limite.
  • Reduza a resolução em aparelhos fracos. renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)) costuma ser o maior ganho isolado para folhagem em celulares com tela de alta densidade.

Passo 6. Sombras e vento

As sombras desenham a floresta pela segunda vez. Mantenha a câmera de sombra bem ajustada em volta do jogador, use um shadow.mapSize moderado e deixe só os níveis próximos projetarem sombra:

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

O vento pertence ao vertex shader. Se o modelo armazena pesos de vento por vértice — digamos, a flexão do tronco em x e o balanço dos galhos em y do segundo canal UV —, você pode adicionar movimento a um material padrão com onBeforeCompile:

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

// no loop de renderização:
time.value = clock.getElapsedTime();

Copiar o atributo com um nome próprio evita conflito com o three.js, que declara uv1 por conta própria quando um material usa um segundo conjunto de UV.

Passo 7. Meça

  • renderer.info.render.calls e renderer.info.render.triangles — draw calls e triângulos por frame;
  • o painel Performance do navegador — para saber se o limite é a CPU ou a GPU;
  • um celular intermediário de verdade — não só a sua máquina de desenvolvimento.

Meça antes e depois de cada mudança. Muitas vezes uma única correção (folhas em cutout, pixel ratio, distância das sombras) rende mais do que todas as outras juntas.

Checklist

  • GLB com cadeia de LODs, cards de folhas em clusters, pivô na raiz, colisão e pesos de vento.
  • Geometria comprimida com meshopt, texturas com KTX2.
  • THREE.LOD para poucas árvores próximas; InstancedMesh em chunks ou BatchedMesh para florestas.
  • Folhas com alphaTest e alphaToCoverage, não transparent.
  • Sombras só nos níveis próximos; câmera de sombra bem ajustada.
  • Pixel ratio limitado nos celulares.
  • Draw calls, triângulos e tempo de frame medidos em um aparelho real.

Onde o FoliMesh entra nisso

O FoliMesh é um gerador de árvores e natureza que roda no navegador e foi pensado em torno dos orçamentos de jogo. Você define um orçamento de triângulos para cada nível, e ele exporta um GLB com LOD0–LOD3 e um billboard cruzado para a distância mais longa, cards de folhas recortados no formato dos clusters, pivô na raiz, pesos de vento em TEXCOORD_1 (o three.js lê como uv1) e as distâncias de LOD e cápsulas de colisão nos extras do nó (no three.js, object.userData). Florestas inteiras podem ser exportadas como um único GLB com EXT_mesh_gpu_instancing, que o GLTFLoader transforma em malhas instanciadas.

Para ver como o FoliMesh se compara a outras ferramentas, leia a nossa comparação de geradores de árvores 3D. Está trabalhando com Godot? Veja como criar uma floresta rápida no Godot 4.

FAQ

O three.js tem LOD? Sim, o THREE.LOD alterna entre objetos filhos de acordo com a distância. Ele não funciona por instância dentro de um InstancedMesh — para isso, use chunks, BatchedMesh com setGeometryIdAt ou uma biblioteca como a @three.ez/instanced-mesh.

InstancedMesh ou BatchedMesh? O InstancedMesh é o mais simples e o mais rápido para muitas cópias de uma mesma malha. O BatchedMesh é melhor quando você quer culling e LOD por objeto, ou muitas malhas diferentes com um único material.

Por que as folhas das minhas árvores ficam pretas ou com ordenação errada? Normalmente porque o material está com transparent: true. Troque para alphaTest com DoubleSide; os problemas de ordenação somem e o desempenho melhora.

Vale a pena usar KTX2 em árvores? Sim. As texturas de casca e folhas costumam ser a maior parte do download e da memória de vídeo, e o KTX2 reduz as duas coisas.