FoliMesh
Blog

Árboles y bosques en three.js: LOD, instancing, KTX2 y viento — una guía práctica

Cómo poner un bosque de verdad en un juego web con three.js sin hundir la tasa de fotogramas ni disparar la descarga: GLB listo para juego, compresión, THREE.LOD, InstancedMesh y BatchedMesh, hojas con cutout, sombras y un shader de viento barato.

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

Cada vez se hacen más juegos para el navegador, y cada vez más parte de su código se escribe junto a asistentes de IA, que recurren a three.js por defecto. La Vibe Jam 2026, por ejemplo, pedía juegos de navegador que arrancaran sin descarga y recomendaba three.js. Tarde o temprano, todos esos juegos quieren un bosque. Y un bosque suele ser lo primero que convierte 60 FPS en 15 y una página de 5 MB en 150 MB.

La web es la plataforma más exigente de todas: laptops y celulares modestos, una GPU compartida con el navegador, y cada megabyte tiene que descargarse antes de que el jugador vea nada. Esta guía muestra cómo crear vegetación que sobreviva a esos límites, paso a paso y con código para la versión actual de three.js.

El presupuesto web

Antes de escribir código, fija límites. Son puntos de partida para una laptop o un celular de gama media, no leyes: mide tu propia escena.

Qué Objetivo cómodo
Draw calls por frame unos pocos cientos como mucho
Triángulos por frame 0.5–2 millones
Un árbol, nivel cercano 5,000–15,000 triángulos
Un árbol, nivel lejano menos de 1,500 triángulos; impostor: 2
Descarga de todo el bosque unos pocos MB, no decenas

La idea clave es la misma que en cualquier motor: la mayor parte del bosque está lejos, así que la mayor parte del bosque tiene que ser barata. Cada nivel de detalle debería ser aproximadamente 3–4 veces más ligero que el anterior.

El mismo roble en LOD0, LOD1, LOD2 y LOD3 Un árbol, cuatro niveles de detalle: la silueta se mantiene, el costo baja.

Paso 1. Empieza con un GLB listo para juego

Un árbol para un juego no es solo una malla bonita. Antes de escribir una sola línea de código de renderizado, comprueba que el archivo tenga:

  • una cadena de LOD con la misma silueta (e idealmente un impostor para el nivel más lejano);
  • hojas en tarjetas agrupadas, no miles de hojas sueltas ni una masa sólida tipo «brócoli»;
  • un pivote en la base del tronco y escala real en metros;
  • colisión simple: una cápsula para el tronco, no para la copa;
  • pesos de viento guardados por vértice.

Puedes comprobar todo esto en el navegador con herramientas gratuitas: el contador de polígonos, el visor de LOD y el validador de glTF.

Paso 2. Reduce el archivo: meshopt y KTX2

Dos compresiones reducen varias veces el tamaño de un árbol típico:

  • Geometría: compresión meshopt (o Draco).
  • Texturas: KTX2 (Basis Universal). A diferencia de PNG o JPEG, KTX2 sigue comprimido en la memoria de video, así que ahorra tanto descarga como VRAM.

Puedes hacerlo en el navegador con nuestro compresor de glTF y nuestro conversor a KTX2, o desde la línea de comandos con glTF Transform:

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

(La compresión KTX2 en la CLI requiere tener instalado KTX-Software; --texture-compress webp funciona sin él.)

Cargar un archivo comprimido en 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');

Paso 3. Unos pocos árboles: THREE.LOD

Para un puñado de árboles cerca del jugador —la plaza de un pueblo, un árbol protagonista— basta con el THREE.LOD integrado. Muestra un hijo según la distancia a la cámara; el tercer argumento es una histéresis que evita el parpadeo en el límite:

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 se actualiza automáticamente cuando el renderer dibuja la escena. Su límite es que cada árbol sigue siendo un objeto independiente, con draw calls separados para la corteza y las hojas. A partir de unas pocas docenas de árboles necesitas instancing.

Paso 4. Muchos árboles: instancing, por chunks

InstancedMesh dibuja miles de copias de una malla en un único draw call. Pero three.js lo trata como un solo objeto: se descarta entero y no tiene LOD; todas las instancias se dibujan con el mismo detalle, incluso las que están a 500 m o detrás de la cámara.

La solución es la misma que en Godot y Unity: divide el mundo en chunks y crea un InstancedMesh por chunk, por nivel de LOD y por material. Así cada chunk elige su nivel según la distancia:

const CHUNK = 32; // metros — cerca de la distancia de LOD0
const STARTS = [0, 35, 80, 160]; // dónde empieza cada LOD
const CULL = 400;

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

Llama a updateChunk para cada chunk unas cuantas veces por segundo en lugar de en cada frame: las distancias cambian despacio. El tamaño del chunk importa: la distancia se mide hasta el centro del chunk, así que chunks mucho más grandes que la franja de LOD0 mostrarían árboles toscos justo al lado del jugador.

BatchedMesh es la alternativa moderna. Dibuja muchas geometrías distintas con un solo material, descarta cada objeto por separado (perObjectFrustumCulled está activado por defecto) y permite cambiar la geometría de cualquier instancia con setGeometryIdAt, que es exactamente lo que supone un cambio 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);
}

// unas cuantas veces 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]);
}

Usa un BatchedMesh por material (corteza, hojas). Si prefieres una solución hecha, las bibliotecas de la comunidad @three.ez/instanced-mesh (culling y LOD por instancia para instancing) y @three.ez/batched-mesh-extensions (LOD para BatchedMesh) hacen este trabajo por ti.

Un atajo más: si tu archivo usa la extensión de glTF EXT_mesh_gpu_instancing, GLTFLoader crea los objetos InstancedMesh por ti: un bosque entero en un solo archivo.

Un bosque en el editor de FoliMesh Un bosque de árboles instanciados: el número de draw calls apenas crece con el número de árboles.

Paso 5. Hojas: cutout, no transparencia

La mayoría de los bosques lentos en la web son lentos por culpa de sus hojas.

  • Usa alphaTest, no transparent: true. Los materiales transparentes se ordenan y se mezclan, no escriben profundidad y multiplican el overdraw. Un cutout es mucho más barato:
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // bordes suaves cuando el renderer tiene antialias: true
  • Cuida la forma de las tarjetas. Una tarjeta cuadrada alrededor de un grupo de hojas redondeado sombrea muchos píxeles invisibles. Las tarjetas recortadas con la silueta del grupo ahorran mucho fill rate, y en celulares el fill rate suele ser el verdadero límite.
  • Baja la resolución en dispositivos modestos. renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)) suele ser, por sí solo, la mayor mejora para el follaje en celulares con alta densidad de píxeles.

Paso 6. Sombras y viento

Las sombras dibujan el bosque por segunda vez. Mantén la cámara de sombras ajustada alrededor del jugador, usa un shadow.mapSize moderado y deja que solo los niveles cercanos proyecten sombras:

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

El viento va en el vertex shader. Si el modelo guarda pesos de viento por vértice —por ejemplo, la flexión del tronco en x y el balanceo de las ramas en y del segundo canal UV—, puedes añadir movimiento a un material estándar con onBeforeCompile:

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

// en el bucle de renderizado:
time.value = clock.getElapsedTime();

Copiar el atributo con su propio nombre evita un conflicto con three.js, que declara uv1 por su cuenta cuando un material usa un segundo conjunto de UV.

Paso 7. Mide

  • renderer.info.render.calls y renderer.info.render.triangles: draw calls y triángulos por frame;
  • el panel Performance del navegador: si el límite es la CPU o la GPU;
  • un celular real de gama media, no solo tu computadora de desarrollo.

Mide antes y después de cada cambio. A menudo un solo arreglo (hojas con cutout, pixel ratio, distancia de sombras) aporta más que todos los demás juntos.

Checklist

  • GLB con cadena de LOD, tarjetas de hojas agrupadas, pivote en la raíz, colisión y pesos de viento.
  • Geometría comprimida con meshopt, texturas con KTX2.
  • THREE.LOD para unos pocos árboles cercanos; InstancedMesh por chunks o BatchedMesh para bosques.
  • Hojas con alphaTest y alphaToCoverage, no transparent.
  • Sombras solo en los niveles cercanos; cámara de sombras ajustada.
  • Pixel ratio limitado en celulares.
  • Draw calls, triángulos y tiempo de frame medidos en un dispositivo real.

Dónde encaja FoliMesh

FoliMesh es un generador de árboles y naturaleza que funciona en el navegador y está diseñado en torno a los presupuestos de los juegos. Fijas un presupuesto de triángulos para cada nivel y exporta un GLB con LOD0–LOD3 y un billboard cruzado para la distancia más lejana, tarjetas de hojas recortadas con la forma de sus grupos, un pivote en la raíz, pesos de viento en TEXCOORD_1 (three.js lo lee como uv1) y las distancias de LOD y las cápsulas de colisión en los extras del nodo (en three.js, object.userData). Puedes exportar bosques enteros como un solo GLB con EXT_mesh_gpu_instancing, que GLTFLoader convierte en mallas instanciadas.

Para ver cómo se compara FoliMesh con otras herramientas, lee nuestra comparativa de generadores de árboles 3D. ¿Trabajas con Godot? Mira cómo crear un bosque rápido en Godot 4.

Preguntas frecuentes

¿three.js tiene LOD? Sí, THREE.LOD cambia entre objetos hijos según la distancia. No funciona por instancia dentro de un InstancedMesh; para eso, usa chunks, BatchedMesh con setGeometryIdAt o una biblioteca como @three.ez/instanced-mesh.

¿InstancedMesh o BatchedMesh? InstancedMesh es lo más sencillo y rápido para muchas copias de una misma malla. BatchedMesh es mejor cuando quieres culling y LOD por objeto, o muchas mallas distintas con un solo material.

¿Por qué las hojas de mis árboles se ven negras o mal ordenadas? Normalmente porque el material tiene transparent: true. Cambia a alphaTest con DoubleSide: los problemas de orden desaparecen y el rendimiento mejora.

¿Vale la pena KTX2 para árboles? Sí. Las texturas de corteza y hojas suelen ser la mayor parte de la descarga y de la memoria de video, y KTX2 reduce ambas.