Creando un Sistema de Generación Procedural de Terreno en Unity con Perlin Noise ⛰️
Este tutorial te guiará paso a paso en la creación de un sistema robusto de generación procedural de terreno en Unity. Exploraremos cómo utilizar el algoritmo Perlin Noise para generar alturas realistas y cómo aplicar texturas y árboles de forma dinámica, permitiéndote crear mundos únicos y expansivos.
La generación procedural de contenido es una técnica poderosa en el desarrollo de videojuegos que permite crear mundos vastos y variados sin la necesidad de diseñarlos manualmente. En Unity, podemos aprovechar algoritmos como Perlin Noise para simular la complejidad natural del terreno, ofreciendo experiencias de juego dinámicas e impredecibles.
Este tutorial te sumergirá en el fascinante mundo de la generación de terreno, desde los fundamentos matemáticos de Perlin Noise hasta la implementación práctica en Unity, incluyendo la texturización y la adición de elementos como árboles.
🚀 ¿Por Qué Generación Procedural de Terreno?
La generación procedural ofrece múltiples ventajas, especialmente para juegos de mundo abierto, simuladores o experiencias roguelike:
- Variedad Infinita: Cada partida puede ofrecer un mapa diferente.
- Menor Carga de Trabajo: Reduce la necesidad de diseño manual de niveles.
- Exploración Rejugable: Mantiene el interés del jugador con nuevos entornos.
- Optimización: Puede generar solo las partes del mundo que necesita el jugador.
🧠 Entendiendo Perlin Noise
Perlin Noise es un algoritmo de generación de ruido de gradiente que produce un patrón pseudoaleatorio de valores. A diferencia de un ruido puramente aleatorio, Perlin Noise genera transiciones suaves entre valores, lo que lo hace ideal para simular fenómenos naturales como nubes, texturas de fuego, o en nuestro caso, la elevación del terreno.
¿Cómo funciona?
Imagina una cuadrícula donde cada punto tiene un vector de gradiente aleatorio. Perlin Noise interpola suavemente entre estos gradientes, resultando en un mapa de valores continuos y naturales. Podemos ajustar la escala, octavas, persistencia y lacunaridad para controlar la apariencia del ruido.
- Escala (Scale): Determina el "zoom" del ruido. Valores pequeños producen colinas grandes y suaves; valores grandes, terreno más detallado y fracturado.
- Octavas (Octaves): Número de capas de ruido que se superponen. Más octavas añaden más detalle.
- Persistencia (Persistence): Controla cuánto contribuye cada octava al detalle general. Un valor menor significa que las octavas superiores (más detalladas) tienen menos impacto.
- Lacunaridad (Lacunarity): Determina la frecuencia de las octavas. Un valor mayor significa que cada octava es significativamente más detallada que la anterior.
🛠️ Configurando tu Proyecto Unity
Antes de sumergirnos en el código, asegúrate de tener un proyecto Unity listo.
1. Crear un Nuevo Proyecto
Si aún no lo tienes, crea un nuevo proyecto 3D en Unity Hub. Nómbralo algo como GeneracionTerrenoProcedural.
2. Configurar el Terreno Básico (Opcional, pero útil para comparar)
Aunque vamos a generar el terreno programáticamente, es útil saber cómo se crea un objeto Terrain estándar para entender la estructura. En la jerarquía, haz clic derecho -> 3D Object -> Terrain.
Cuando creamos un Terrain en Unity, este viene con un componente Terrain y un TerrainCollider. El Terrain es esencialmente una malla que podemos modificar. Nosotros manipularemos sus datos de altura (Heightmap).
💻 Implementando el Generador de Terreno con C#
Ahora, vamos a crear el script principal que se encargará de toda la lógica de generación.
1. Crear el Script TerrainGenerator
Crea una nueva carpeta Scripts en tu proyecto. Dentro, haz clic derecho -> Create -> C# Script y nómbralo TerrainGenerator.
using UnityEngine;
using System.Collections.Generic;
public class TerrainGenerator : MonoBehaviour
{
public Terrain terrain; // Referencia al componente Terrain
public int width = 256; // Ancho del terreno
public int height = 256; // Alto del terreno
public float scale = 20f; // Escala del Perlin Noise
[Range(1, 8)]
public int octaves = 4; // Número de capas de ruido
[Range(0f, 1f)]
public float persistence = 0.5f; // Cuánto contribuye cada octava
public float lacunarity = 2f; // Frecuencia de las octavas
public int seed; // Semilla para reproducibilidad
public Vector2 offset; // Desplazamiento del mapa de ruido
// Texturizado
public TerrainLayer[] terrainLayers; // Capas de textura
public float blendFactor = 0.5f; // Factor para mezclar texturas
// Árboles
public GameObject[] treePrefabs; // Prefabs de árboles
public int numberOfTrees = 50; // Cantidad de árboles a generar
[Range(0f, 1f)]
public float treeDensityThreshold = 0.6f; // Altura mínima para colocar árboles
void Start()
{
// Si no hay un terreno asignado, lo creamos
if (terrain == null)
{
GameObject terrainGameObject = new GameObject("Generated Terrain");
terrain = terrainGameObject.AddComponent<Terrain>();
terrainGameObject.AddComponent<TerrainCollider>();
terrain.terrainData = new TerrainData();
terrain.terrainData.heightmapResolution = width + 1;
terrain.terrainData.size = new Vector3(width, 600, height); // Altura máxima del terreno
TerrainCollider terrainCollider = terrainGameObject.GetComponent<TerrainCollider>();
terrainCollider.terrainData = terrain.terrainData;
// Asegurarse de que el objeto tenga un TerrainRenderer
TerrainRenderer terrainRenderer = terrainGameObject.GetComponent<TerrainRenderer>();
if (terrainRenderer == null)
{
terrainRenderer = terrainGameObject.AddComponent<TerrainRenderer>();
}
}
GenerateTerrain();
}
void Update()
{
// Opcional: regenerar en tiempo de ejecución con un botón
// if (Input.GetKeyDown(KeyCode.G))
// {
// GenerateTerrain();
// }
}
public void GenerateTerrain()
{
float[,] heightMap = GenerateHeightMap();
// Actualizar datos de altura
terrain.terrainData.SetHeights(0, 0, heightMap);
// Aplicar texturas
ApplyTextures(heightMap);
// Generar árboles
GenerateTrees();
}
float[,] GenerateHeightMap()
{
float[,] heightMap = new float[width, height];
System.Random prng = new System.Random(seed); // Generador de números pseudoaleatorios con semilla
Vector2[] octaveOffsets = new Vector2[octaves];
for (int i = 0; i < octaves; i++)
{
float offsetX = prng.Next(-100000, 100000) + offset.x;
float offsetY = prng.Next(-100000, 100000) + offset.y;
octaveOffsets[i] = new Vector2(offsetX, offsetY);
}
if (scale <= 0) scale = 0.0001f; // Evitar división por cero
float maxNoiseHeight = float.MinValue;
float minNoiseHeight = float.MaxValue;
float halfWidth = width / 2f;
float halfHeight = height / 2f;
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
float amplitude = 1;
float frequency = 1;
float noiseHeight = 0;
for (int i = 0; i < octaves; i++)
{
float sampleX = (x - halfWidth + octaveOffsets[i].x) / scale * frequency;
float sampleY = (y - halfHeight + octaveOffsets[i].y) / scale * frequency;
float perlinValue = Mathf.PerlinNoise(sampleX, sampleY) * 2 - 1; // Rango de -1 a 1
noiseHeight += perlinValue * amplitude;
amplitude *= persistence;
frequency *= lacunarity;
}
if (noiseHeight > maxNoiseHeight)
{
maxNoiseHeight = noiseHeight;
}
if (noiseHeight < minNoiseHeight)
{
minNoiseHeight = noiseHeight;
}
heightMap[x, y] = noiseHeight;
}
}
// Normalizar el mapa de alturas a un rango de 0 a 1
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
heightMap[x, y] = Mathf.InverseLerp(minNoiseHeight, maxNoiseHeight, heightMap[x, y]);
}
}
return heightMap;
}
void ApplyTextures(float[,] heightMap)
{
if (terrainLayers == null || terrainLayers.Length == 0)
{
Debug.LogWarning("No hay TerrainLayers asignadas para texturizar el terreno.");
return;
}
// Asignar las capas de terreno al TerrainData
terrain.terrainData.terrainLayers = terrainLayers;
float[,,] alphaMap = new float[width, height, terrainLayers.Length];
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
float currentHeight = heightMap[x, y];
// Lógica de blending entre texturas basada en la altura
for (int i = 0; i < terrainLayers.Length; i++)
{
if (i == 0) // Primera textura (base, por ejemplo, hierba)
{
alphaMap[x, y, i] = 1f - currentHeight; // Más hierba en zonas bajas
}
else if (i == 1) // Segunda textura (por ejemplo, roca)
{
alphaMap[x, y, i] = currentHeight; // Más roca en zonas altas
}
else
{ // Para más texturas, necesitarías una lógica más compleja de blending
alphaMap[x, y, i] = 0;
}
}
// Normalizar la suma de los pesos para que sea 1
float totalWeight = 0;
for (int i = 0; i < terrainLayers.Length; i++) totalWeight += alphaMap[x, y, i];
for (int i = 0; i < terrainLayers.Length; i++) alphaMap[x, y, i] /= totalWeight;
}
}
terrain.terrainData.SetAlphamaps(0, 0, alphaMap);
}
void GenerateTrees()
{
// Eliminar árboles existentes para regenerar
List<GameObject> existingTrees = new List<GameObject>();
foreach (Transform child in terrain.transform)
{
if (child.name.Contains("Tree_"))
{
existingTrees.Add(child.gameObject);
}
}
foreach (GameObject tree in existingTrees) { DestroyImmediate(tree); }
// Limpiar TreeInstances del terreno
terrain.terrainData.treeInstances = new TreeInstance[0];
terrain.terrainData.treePrototypes = new TreePrototype[0];
if (treePrefabs == null || treePrefabs.Length == 0)
{
Debug.LogWarning("No hay prefabs de árboles asignados.");
return;
}
// Configurar los prototipos de árbol para el terreno
TreePrototype[] newTreePrototypes = new TreePrototype[treePrefabs.Length];
for (int i = 0; i < treePrefabs.Length; i++)
{
newTreePrototypes[i] = new TreePrototype
{
prefab = treePrefabs[i]
};
}
terrain.terrainData.treePrototypes = newTreePrototypes;
List<TreeInstance> newTreeInstances = new List<TreeInstance>();
for (int i = 0; i < numberOfTrees; i++)
{
float randX = Random.Range(0f, 1f);
float randY = Random.Range(0f, 1f);
// Obtener la altura del terreno en este punto
float terrainHeight = terrain.terrainData.GetHeight(Mathf.FloorToInt(randX * width), Mathf.FloorToInt(randY * height)) / terrain.terrainData.size.y;
if (terrainHeight > treeDensityThreshold) // Colocar árbol solo si la altura es suficiente
{
TreeInstance newTree = new TreeInstance();
newTree.position = new Vector3(randX, terrainHeight, randY);
newTree.widthScale = Random.Range(0.8f, 1.2f);
newTree.heightScale = Random.Range(0.8f, 1.2f);
newTree.rotation = Random.Range(0f, 360f);
newTree.prototypeIndex = Random.Range(0, treePrefabs.Length);
newTreeInstances.Add(newTree);
}
}
terrain.terrainData.treeInstances = newTreeInstances.ToArray();
terrain.Flush(); // Actualizar el terreno para mostrar los árboles
}
}
2. Explicación del Código 📖
Vamos a desglosar las partes clave de este script:
-
Variables Públicas: Permiten configurar el terreno desde el Inspector de Unity.
width,height: Dimensiones del mapa de alturas.scale,octaves,persistence,lacunarity: Parámetros de Perlin Noise.seed,offset: Para reproducibilidad y desplazamiento del terreno.terrainLayers: Array deTerrainLayerpara aplicar texturas.treePrefabs: Array deGameObjectpara los modelos de árboles.numberOfTrees,treeDensityThreshold: Controlan la cantidad y ubicación de los árboles.
-
Start(): Se ejecuta al inicio del juego. Si no hay un terreno asignado, crea uno dinámicamente. Luego, llama aGenerateTerrain(). -
GenerateTerrain(): Orquesta el proceso de generación: genera el mapa de alturas, aplica las texturas y distribuye los árboles. -
GenerateHeightMap(): El corazón de la generación.- Crea una semilla para
System.Randompara que el ruido sea reproducible. - Calcula los
octaveOffsetspara asegurar que cada capa de ruido es ligeramente diferente. - Itera sobre cada punto
(x, y)del terreno. - Para cada punto, calcula el valor de ruido sumando varias octavas de Perlin Noise, ajustando la
amplitudyfrecuenciaconpersistenceylacunarity. - Normaliza el
heightMapresultante a un rango de 0 a 1, que es lo que esperaTerrainData.SetHeights().
- Crea una semilla para
-
ApplyTextures(float[,] heightMap): Se encarga de pintar el terreno.- Necesita un array de
TerrainLayer(recursos que defines en Unity con texturas). - Crea un
alphaMapque define qué textura va en qué punto del terreno. - En este ejemplo básico, la altura (
currentHeight) determina la mezcla entre las dos primeras texturas (por ejemplo, hierba en bajo, roca en alto). terrain.terrainData.SetAlphamaps()aplica el mapa de mezcla.
- Necesita un array de
-
GenerateTrees(): Añade vegetación al terreno.- Importante: Limpia los árboles existentes (
DestroyImmediateytreeInstances = new TreeInstance[0]) para evitar duplicados al regenerar. - Configura
TreePrototypepara que elTerrainsepa qué modelos de árboles usar. - Itera
numberOfTreesveces, eligiendo una posición aleatoria. - Consulta la altura del terreno en esa posición y solo coloca un árbol si supera un
treeDensityThreshold(por ejemplo, para evitar árboles bajo el agua o en valles muy profundos). - Crea una
TreeInstancecon posición, escala y rotación aleatorias y la añade a la lista. terrain.Flush()asegura que los cambios se visualicen.
- Importante: Limpia los árboles existentes (
🎨 Creando Recursos de Terreno (Terrain Layers)
Para que nuestro terreno tenga texturas, necesitamos crear TerrainLayer assets.
1. Crear Terrain Layers
En la carpeta Assets, crea una nueva carpeta TerrainLayers. Dentro, haz clic derecho -> Create -> Terrain Layer.
- Nombra el primero
GrassLayer(Capa de Hierba). - Nombra el segundo
RockLayer(Capa de Roca).
2. Configurar las Terrain Layers
Selecciona GrassLayer.
- Arrastra una textura de hierba (difusa) a la ranura Diffuse. Puedes encontrar texturas gratuitas en asset stores o sitios web de texturas.
- Arrastra una textura normal de hierba (si tienes) a la ranura Normal Map.
- Ajusta Tile Size (por ejemplo, a
5, 5) para controlar cuántas veces se repite la textura en el terreno.
Repite el proceso para RockLayer, usando texturas de roca.
🌲 Preparando los Prefabs de Árboles
Para que el generador coloque árboles, necesitamos prefabs de árboles.
1. Obtener Modelos de Árboles
- Puedes importar modelos de árboles desde el Asset Store de Unity (busca
tree freeo similar). - O, para un ejemplo rápido, crea un
Capsule(GameObject->3D Object->Capsule), escala elYpara que parezca un tronco, y añade otroCapsuleescalado para la copa, o usa unSphere. Conviértelos en un prefab arrastrándolos a la carpetaPrefabs.
2. Convertir a Prefabs
Arrastra tus modelos de árboles (o tu Capsule de ejemplo) desde la jerarquía a una carpeta Prefabs en tus Assets. Asegúrate de que los prefabs estén en el origen (0,0,0) y con escala (1,1,1) cuando los crees, para evitar problemas de posicionamiento.
🎮 Poniéndolo Todo Junto en la Escena
Ahora vamos a ensamblar todos los componentes en tu escena de Unity.
1. Añadir el TerrainGenerator a la Escena
- Crea un
Empty GameObjecten tu escena (Haz clic derecho en la jerarquía ->Create Empty). Nómbralo_TerrainGenerator. - Arrastra el script
TerrainGeneratorque creaste a esteGameObjecten el Inspector.
2. Configurar el TerrainGenerator en el Inspector
Con el _TerrainGenerator seleccionado en la jerarquía, observa el Inspector:
-
Terrain: Deja esto en
None. El script creará uno si no hay. Si ya tienes un terreno en la escena, arrástralo aquí. -
Width, Height:
256y256son buenos valores para empezar. -
Scale:
20es un buen punto de partida para colinas suaves. -
Octaves:
4o6para un buen detalle. -
Persistence:
0.5. -
Lacunarity:
2. -
Seed: Elige un número entero (ej.
12345). Cambia esto para obtener diferentes terrenos. -
Offset:
(0,0)inicialmente. Puedes usarlo para desplazar el "corte" del ruido. -
Terrain Layers:
- Cambia Size a
2. - Arrastra
GrassLayera Element 0. - Arrastra
RockLayera Element 1.
- Cambia Size a
-
Tree Prefabs:
- Cambia Size al número de prefabs de árboles que tengas.
- Arrastra tus prefabs de árboles a los Elements correspondientes.
-
Number of Trees:
50(ajusta según el rendimiento deseado). -
Tree Density Threshold:
0.6(altura mínima para árboles, 0.6 significa el 60% de la altura máxima del terreno).
3. Generar el Terreno
- Asegúrate de que estás en la vista de escena.
- Haz clic en el botón
Playde Unity. El terreno debería generarse automáticamente al iniciar el juego. - Si el botón
Playestá lento o tienes muchos árboles, puedes añadir un botón de editor para generar fuera del modo de juego. (Ver secciónMejoras y Personalización).
📈 Mejoras y Personalización
Nuestro sistema es funcional, pero podemos llevarlo más allá.
1. Editor Script para Generación en Modo Edición
Es tedioso tener que iniciar el modo de juego para regenerar el terreno. Podemos crear un Editor Script para añadir un botón al Inspector.
Crea una carpeta Editor en la raíz de tus Assets (si no existe). Dentro, crea un script TerrainGeneratorEditor:
using UnityEngine;
using UnityEditor; // Necesario para Editor scripts
[CustomEditor(typeof(TerrainGenerator))] // Indica que este es un editor para TerrainGenerator
public class TerrainGeneratorEditor : Editor
{
public override void OnInspectorGUI()
{
// Dibuja el Inspector predeterminado
DrawDefaultInspector();
TerrainGenerator terrainGen = (TerrainGenerator)target; // Referencia al script TerrainGenerator
if (GUILayout.Button("Generate Terrain"))
{
terrainGen.GenerateTerrain();
}
}
}
Ahora, cuando selecciones tu _TerrainGenerator en la jerarquía, verás un botón "Generate Terrain" en la parte inferior del Inspector. ¡Haz clic en él para generar el terreno sin iniciar el juego!
2. Terreno Infinito con Chunks
Para un mundo realmente infinito, implementarías un sistema de "chunks" (trozos de terreno) que se generan y destruyen dinámicamente alrededor del jugador. Cada chunk tendría su propio TerrainGenerator y se calcularían las alturas y texturas usando el offset basado en la posición del chunk.
3. Más Variedad de Texturas y Reglas de Blending
- Puedes añadir más
TerrainLayers para nieve en picos altos, arena en bajas alturas, etc. - La lógica en
ApplyTexturesdebería ser más sofisticada, usandoMathf.InverseLerppara transiciones suaves entre múltiples texturas basadas en rangos de altura y pendiente.
// Ejemplo de lógica de blending más avanzada para ApplyTextures()
// ... dentro del bucle x, y ...
// Suponiendo 4 capas: hierba, roca, arena, nieve
float[] layerWeights = new float[terrainLayers.Length];
// Hierba (0) en alturas bajas a medias
layerWeights[0] = Mathf.InverseLerp(0f, 0.4f, 1f - currentHeight);
// Roca (1) en alturas medias a altas
layerWeights[1] = Mathf.InverseLerp(0.3f, 0.7f, currentHeight);
// Arena (2) en alturas muy bajas (playas)
layerWeights[2] = Mathf.InverseLerp(0f, 0.15f, 1f - currentHeight) * (1f - currentHeight);
// Nieve (3) en alturas muy altas
layerWeights[3] = Mathf.InverseLerp(0.7f, 1f, currentHeight);
// Normalizar pesos
float totalWeight = 0;
for (int i = 0; i < terrainLayers.Length; i++) totalWeight += layerWeights[i];
for (int i = 0; i < terrainLayers.Length; i++) alphaMap[x, y, i] = layerWeights[i] / totalWeight;
// ... fin del bucle x, y ...
4. Detalles Adicionales: Detalles y Hierba
El componente TerrainData también permite añadir DetailPrototypes (pequeñas rocas, hierba baja) y GrassPrototypes (hierba densa) que se pintan con un pincel o se pueden generar programáticamente.
terrain.terrainData.detailPrototypes = ...terrain.terrainData.SetDetailLayer(...)
Esto añadiría aún más realismo a tu terreno.
5. Optimización de Rendimiento
- LOD (Level of Detail): Unity Terrain maneja esto automáticamente si la cámara está lejos, pero puedes ajustarlo.
- Culling: Asegúrate de que los árboles y detalles no visibles se desactiven.
- Batching: Para árboles, Unity usa instanciado GPU, lo cual es eficiente. Para otros objetos, considera Static Batching.
✅ Conclusión
¡Felicidades! Has creado un sistema de generación procedural de terreno en Unity utilizando Perlin Noise. Esta es una habilidad fundamental para el desarrollo de juegos con mundos grandes y dinámicos.
Experimenta con los diferentes parámetros de Perlin Noise, añade más texturas y tipos de árboles, y considera expandir tu generador a un sistema de chunks para un mundo realmente expansivo. Las posibilidades son infinitas cuando dominas la generación procedural.
🎯 Próximos Pasos
- Investiga sobre
Simplex Noise, una alternativa a Perlin Noise con algunas ventajas. - Explora cómo añadir ríos o caminos usando capas de ruido adicionales o modificando el mapa de alturas.
- Implementa un sistema de gestión de chunks para un terreno infinito cargado dinámicamente.
Tutoriales relacionados
- Creando un Sistema de Menús Interactivos y Navegación UI en Unity para tus Juegos 🎮intermediate25 min
- Implementando un Sistema de Detección de Colisiones 2D en Unity: Guía Completa 💥intermediate15 min
- Creando un Sistema de Crafteo y Recetas Dinámico en Unity para Juegos de Supervivencia y RPG 🛠️intermediate20 min
- Optimización Avanzada de Rendimiento en Unity: Sacando el Máximo a tus Juegos 🚀intermediate18 min
- Creando un Sistema de Partículas Personalizado en Unity para Efectos Visuales Impresionantes ✨intermediate15 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!