Á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.

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.
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 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, notransparent: 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.callsyrenderer.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.LODpara unos pocos árboles cercanos;InstancedMeshpor chunks oBatchedMeshpara bosques. - Hojas con
alphaTestyalphaToCoverage, notransparent. - 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.

