tutoriales.com

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.

Intermedio20 min de lectura13 views
Reportar error

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.
💡 **Consejo:** La generación procedural no solo se aplica a terrenos; también puedes usarla para enemigos, ítems, mazmorras y más.

🧠 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.
Funcionamiento del Ruido Perlin Capa Base SUMA TERRENO FINAL OCTAVAS LACUNARIDAD Más frecuencia en cada capa PERSISTENCIA Menos amplitud en cada capa ESCALA Zoom general del patrón La combinación de frecuencias simples crea la complejidad natural.

🛠️ 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).

📌 **Nota:** Nuestro script generará un `Terrain` o modificará uno existente. No necesitas un terreno creado manualmente para que el script funcione, pero es una buena referencia.

💻 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 de TerrainLayer para aplicar texturas.
    • treePrefabs: Array de GameObject para 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 a GenerateTerrain().

  • 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.Random para que el ruido sea reproducible.
    • Calcula los octaveOffsets para 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 amplitud y frecuencia con persistence y lacunarity.
    • Normaliza el heightMap resultante a un rango de 0 a 1, que es lo que espera TerrainData.SetHeights().
  • ApplyTextures(float[,] heightMap): Se encarga de pintar el terreno.

    • Necesita un array de TerrainLayer (recursos que defines en Unity con texturas).
    • Crea un alphaMap que 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.
  • GenerateTrees(): Añade vegetación al terreno.

    • Importante: Limpia los árboles existentes (DestroyImmediate y treeInstances = new TreeInstance[0]) para evitar duplicados al regenerar.
    • Configura TreePrototype para que el Terrain sepa qué modelos de árboles usar.
    • Itera numberOfTrees veces, 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 TreeInstance con posición, escala y rotación aleatorias y la añade a la lista.
    • terrain.Flush() asegura que los cambios se visualicen.
🔥 **Importante:** La generación de árboles actual usa `DestroyImmediate`. Esto es útil en el editor para ver cambios al instante. En un juego final, para generar árboles dinámicamente en tiempo de ejecución, usarías un sistema de pooling o solo generarías una vez.

🎨 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 free o similar).
  • O, para un ejemplo rápido, crea un Capsule (GameObject -> 3D Object -> Capsule), escala el Y para que parezca un tronco, y añade otro Capsule escalado para la copa, o usa un Sphere. Conviértelos en un prefab arrastrándolos a la carpeta Prefabs.

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 GameObject en tu escena (Haz clic derecho en la jerarquía -> Create Empty). Nómbralo _TerrainGenerator.
  • Arrastra el script TerrainGenerator que creaste a este GameObject en 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: 256 y 256 son buenos valores para empezar.

  • Scale: 20 es un buen punto de partida para colinas suaves.

  • Octaves: 4 o 6 para 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 GrassLayer a Element 0.
    • Arrastra RockLayer a Element 1.
  • 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 Play de Unity. El terreno debería generarse automáticamente al iniciar el juego.
  • Si el botón Play está lento o tienes muchos árboles, puedes añadir un botón de editor para generar fuera del modo de juego. (Ver sección Mejoras y Personalización).
⚠️ **Advertencia:** Generar terrenos grandes con muchos árboles en tiempo de ejecución puede ser intensivo. Para terrenos muy grandes o juegos publicados, considera pre-generar o usar un sistema de chunks.

📈 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!

💡 **Consejo:** Elimina o comenta la llamada a `GenerateTerrain()` en `Start()` si solo quieres generar desde el editor.

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.

Sistema de Chunks Dinámico Destruyendo Área Cargada (3x3) JUGADOR Generando El jugador se mueve hacia la derecha →

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 ApplyTextures debería ser más sofisticada, usando Mathf.InverseLerp para 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

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!