Cómo funciona el generador de terreno
El cuadrado isométrico que ves en la home es un terreno voxel generado en cliente cada vez que cargas (o haces clic). El seed va en #seed= de la URL — copia el link y reproduces el mismo terreno.
El pipeline#
buildWorld(seed) ejecuta una secuencia determinista:
- Heightmap con FBM (Fractal Brownian Motion) — 3 octavas de simplex noise sumadas con amplitud y frecuencia distintas. Da relieve a varias escalas: montañas grandes con rugosidad fina encima.
- Domain warp — perturba las coordenadas antes de samplear el noise. Quita la sensación de "grid" que da el simplex puro.
- Talus erosion — busca el vecino más bajo de cada celda; si la diferencia supera un umbral, baja la celda. Suaviza acantilados sin tocar pendientes suaves.
- Biomas híbridos — un bioma primario derivado del seed, más parches secundarios via noise de baja frecuencia. Resultado: un desierto puede tener un oasis temperate, una tundra puede tener parches helados.
- Coast rule — cualquier celda no-río adyacente a agua o hielo se vuelve arena. Playas automáticas sin lógica especial.
- Decoraciones por bioma: cactus en desierto, flores y rocas en temperate, lily pads sobre el agua en humid, ice spikes en icy.
El renderer#
Aquí está la parte interesante. La versión inicial creaba un THREE.Mesh por cada bloque visible — con un grid de 48×48 y columnas de ~12 bloques de alto, eso son ~30 000 meshes. Inviable.
La solución es InstancedMesh:
const counts = new Map<BlockKind, number>();
// pasada 1: contar bloques visibles por tipo
for (const cell of grid) {
if (isFullyOccluded(cell)) continue;
counts.set(cell.kind, (counts.get(cell.kind) ?? 0) + 1);
}
// pasada 2: una InstancedMesh por tipo
counts.forEach((count, kind) => {
const mesh = new THREE.InstancedMesh(geom[kind], mat[kind], count);
scene.add(mesh);
});Resultado: ~15 drawcalls en lugar de ~30 000. La GPU procesa todos los bloques de un tipo en una sola llamada.
Hidden-block culling#
Antes de instanciar, descarto bloques completamente enterrados. Si los 6 vecinos son sólidos opacos, ese bloque nunca es visible desde fuera — no lo emito. Reduce la cuenta total a ~5 000 instancias visibles en una escena típica.
function isFullyOccluded(world, x, y, z) {
return (
SOLID_OPAQUE(world[x+1][z][y]) &&
SOLID_OPAQUE(world[x-1][z][y]) &&
SOLID_OPAQUE(world[x][z][y+1]) &&
SOLID_OPAQUE(world[x][z][y-1]) &&
SOLID_OPAQUE(world[x][z+1][y]) &&
SOLID_OPAQUE(world[x][z-1][y])
);
}TRANSPARENT_KINDS incluye agua, hielo, hojas, flores, etc. — bloques que dejan ver lo que hay detrás. Esos no se cuentan como oclusores.
Color jitter#
Cubos planos de un solo color tienen un look de juguete. Para romperlo añado un multiplicador de color por instancia:
const k = 1 + (hash3(x, y, z) * 2 - 1) * jitter;
mesh.setColorAt(i, baseColor.clone().multiplyScalar(k));hash3 es determinista — mismo seed, mismas variaciones. La amplitud (jitter) es 0 para agua/hielo/nieve (donde queda mal) y 0.04–0.1 para superficies orgánicas (hierba, hojas, flores).
Mueve el slider y verás el efecto: a 0 todos los cubos son idénticos (look de juguete); a 0.08 hay variación natural; a 0.2+ el ruido empieza a sentirse forzado.
Próximos pasos#
Cosas que tengo pendientes pero no son críticas:
- Greedy meshing para colapsar caras adyacentes del mismo material en una sola geometría. Reduciría aún más drawcalls, pero con
InstancedMeshya rinde bien para el tamaño actual. - Animación de agua con un shader simple (oscilación vertical leve).
- Día/noche rotando la luz direccional según
Date.now().
El código completo está en el repo. Carpetas relevantes: app/components/terrain/world/ (generación) y app/components/terrain/render/ (renderer).