, , , ,

Bases de la Generación Procedural

by

Plataforma: Unity

Lenguaje de programación: C#

Dificultad: Principiante

¡La generación procedural es mucho más sencilla de lo que parece! ¿No me crees? Búscate una excusa de 5 minutos y sigue leyendo 😉

Si prefieres seguir un vídeo, puedes verlo en mi canal:

Índice

¿Qué es la Generación Procedural?

«Generación Procedural» fue una frase que estuvo muy de moda entre el 2009, desde la salida de Minecraft, hasta alrededor del 2016, con la salida de No Man’s Sky. Inspiraba una sensación de complejidad y tecnología punta, capaz de crear escenarios y comportamientos aleatorios de manera dinámica.

Lo cierto es que estas técnicas existen por lo menos desde 1980, y que se han utilizado para crear todo tipo de cosas; texturas, mallas (modelos 3D), terrenos, sonidos, materiales…

La generación procedural es crear algo mediante una serie de pasos (procedural, de procedimiento), y una serie de pasos es en el fondo un programa. Este programa puede ser todo lo complejo que queramos, pero hoy quiero enseñarte lo sencillo que puede ser en realidad.

¿Qué vamos a hacer?

El ejemplo más sencillo y conocido de todos: un terreno. Vamos a ver las bases de la generación procedural, así como pasos más avanzados. Mi objetivo es darte ideas de cómo seguir adentrándote en este mundo, y ayudarte a localizar los problemas a los que podrías enfrentarte en el futuro.

Requisitos previos

  • Unity. En este artículo utilizo Unity 2021.1.12f1, pero cualquiera debería valer.
  • Blender u otro programa de modelado 3D similar.
  • Conocimientos de C#.

Generando la Mesh del terreno

Para generar un terreno necesitaremos una malla que nos haga de base. Podríamos generar nuestra propia malla por código (¡de manera procedural!), pero para simplificarnos la vida vamos a crear una en Blender o en otro programa de tu elección.

Creamos un plano de 10x10u y lo subdividimos 100 veces en los ejes X y Z. Como Unity importa los modelos de Blender rotados 90 grados, he rotado el original 90 grados al contrario para que se importe correctamente.

Esto va a depender un poco del tamaño de terreno que quieras generar, y de cómo exporte tu programa de modelado para Unity.

Lo exportamos como un .fbx y lo guardaremos en una carpeta Modelos dentro de nuestro proyecto de Unity.

Preparando el terreno

En una escena nueva, sacaremos nuestro modelo al espacio 3D.

Lo primero que debemos hacer es colocarlo en el espacio. Lo colocaremos en las coordenadas (0, 0, 0) y nos aseguraremos de que la escala es (1, 1, 1). Es posible que al importarlo, se haya hecho con escala 100. Si utilizas Blender, puedes seleccionar el asset en la ventana de proyecto y desmarcar Convert Units para solucionarlo.

Después, vamos a duplicar el plano del terreno, renombrarlo a Sea (mar) y colocarlo unas cuántas unidades por encima de nuestro terreno. Por ejemplo, en la posición (0, 1.6, 0).

Ponle un material azul transparente para ganar puntos extra 😉

Generación procedural básica

Modificar los vértices

Con todo preparado, ¡sólo nos queda crear el código!

Vamos a crear un script nuevo llamado Simple Terrain Generator; éste será el script encargado de darle forma nuestro terreno.

public class SimpleTerrainGenerator : MonoBehaviour
{
}

Lo primero que vamos a necesitar es acceso a la mesh (la malla) del terreno. Esto lo conseguiremos del componente MeshFilter del objeto, que podemos adquirir desde el inspector.

public class SimpleTerrainGenerator : MonoBehaviour
{
     [SerializeField] protected MeshFilter _filter;
}

Nuestro entorno natural está formado de montes, ríos y valles; diferencias de alturas. Si lo comparáramos con un plano, veríamos que ajustando sus vértices podemos formar casi cualquier accidente natural.

En Unity, la altura del espacio 3D se define por el eje Y. Por tanto, para generar terrenos aleatorios, necesitamos poder modificar la posición Y de los vértices de nuestra malla plana.

Vamos a crear una función que nos permita hacer eso:

public class SimpleTerrainGenerator : MonoBehaviour
{
    [SerializeField] protected MeshFilter _filter;

    private void Awake()
    {
         GenerateTerrain();
    }

    private void GenerateTerrain()
    {
         Mesh mesh = _filter.mesh;
         Vector3[] vertices = mesh.vertices;
         for (int i = 0; i < vertices.Length; ++i)
         {
             Vector3 vertex = vertices[i];
             vertices[i] = vertex;
         }
         mesh.vertices = vertices;
         mesh.RecalculateNormals();
    }
}

En GenerateTerrain estamos revisando todos los vértices de nuestra malla, devolviéndolos y pidiéndole que recalcule las normales.

  • Los vértices de las mallas no se pueden cambiar de manera directa; hay que cambiarlos en un array externo que le volvamos a asignar.
  • Recalcular las normales es un paso crítico, ya que afectará a la iluminación del modelo final.
  • Realizamos el cambio de vértices durante la función Awake para que Unity haya creado una instancia nueva del modelo. Cambiar los vértices de una malla en modo Edición llevaría a fugas de memoria.
    • Si generáramos la malla en tiempo real, podríamos hacerlo en modo Edición, ya que no estaríamos modificando un asset en memoria.

Ya sabemos cómo acceder a los vértices, pero ahora debemos determinar la altura de los vértices del plano.

El método más común para conseguir esto es utilizar Ruido Perlin:

Ruido Perlin - Wikipedia, la enciclopedia libre

El Ruido Perlin se genera de manera aleatoria en base a una función, y genera un tipo de patrón que no pierde continuidad, lo cuál es ideal para efectos de todo tipo.

En nuestro caso, vamos a interpretar el blanco; el valor más cercano a 1, como la altura máxima, y el negro; el valor más cercano a 0, como la profundidad máxima.

Por suerte, Unity tiene su propia función Perlin para que no tengamos que programarla:

    private void GenerateTerrain()
    {
        Mesh mesh = _filter.mesh;
        Vector3[] vertices = mesh.vertices;
        for (int i = 0; i < vertices.Length; ++i)
        {
            Vector3 vertex = vertices[i];
            vertex.y = Mathf.PerlinNoise(???);
            vertices[i] = vertex;
        }
        mesh.vertices = vertices;
        mesh.RecalculateNormals();
    }

Necesita que le pasemos dos valores; unas coordenadas XY 2D para devolvernos el valor que tendría el píxel en esa zona de la textura Perlin que genera la función. Podemos pasarle la X y la Y del propio vértice:

        vertex.y = Mathf.PerlinNoise(vertex.x, vertex.z);

Y si ejecutamos, el resultado es bastante convincente:

Generación procedural de un terreno mediante ruido perlin

Felicidades, ¡acabas de generar tu primer terreno procedural! ¿Quieres ir un poquito más allá?

Generación procedural más avanzada

Hay tres cosas que sería estupendo poder controlar:

  • Cuántas y cómo de anchas o grandes son las montañas de nuestro terreno.
  • Cómo de altas son las montañas del terreno.
  • Poder cambiar el patrón generado a placer.

Para hacernos más fácil la vida, vamos a crear una función nueva para determinar la altura de un vértice en base a sus coordenadas X e Y, y asignarle el valor que devuelva al vértice en GenerateTerrain:


private void GenerateTerrain()
{
    ...
    vertex.y = GetHeight(vertex.x, vertex.z);
    ...
}


private float GetHeight(float x, float z)
{
    return Mathf.PerlinNoise(x, z);
}

Controlar el número y tamaño de las montañas

Lo que determina la irregularidad de la superficie de nuestro terreno es la función de Ruido Perlin que le estamos aplicando. Por tanto, el tamaño en XZ de las montañas dependerá del tamaño de la textura que está generando la función de Ruido Perlin:

Cuanto más grande sea la textura; mayor tamaño tendrán nuestras montañas. Y cuanto más pequeña sea, menor tamaño tendrán.

Pero sólo tenemos una función, ¿cómo podemos pedirle que cambie de tamaño?

Mathf.PerlinNoise(x, z);

El truco está en modificar la X y la Z para que de un vértice al siguiente haya más o menos espacio. Al tratarse de una función matemática y no de una textura de verdad, esto hará un efecto zoom sobre los valores que nos devuelva.

Vamos a necesitar una variable nueva, que podemos exponer al inspector:

[SerializeField] [Range(0f, 10f)] protected float _scale = 0.5f;

_scale representará el tamaño de la textura Perlin que estaremos manejando, aunque a la inversa; cuanto más grande sea _scale, más montañas tendremos.

Para poder aplicar este valor, necesitaremos saber el valor relativo de las coordenadas X y Z que le mandamos a la función de Ruido de Perlin. Los vértices no nos dan esta información, pero podemos obtenerla dividiendo por el tamaño del plano en el espacio:

Mathf.PerlinNoise(x / _filter.mesh.bounds.size.x, 
                  z / _filter.mesh.bounds.size.z)

Ahora, todo lo que nos queda es multiplicar estos valores relativos por _scale:

Mathf.PerlinNoise(x / _filter.mesh.bounds.size.x * _scale, 
                  z / _filter.mesh.bounds.size.z * _scale)

Así quedaría GetHeight completa:

private float GetHeight(float x, float z)
{
    return Mathf.PerlinNoise(
                x / _filter.mesh.bounds.size.x * _scale, 
                z / _filter.mesh.bounds.size.z * _scale);
}

No te preocupes por ese efecto espejo que se ve, lo arreglaremos en la sección Más aleatoriedad.

Controlar la altura de las montañas

La función de Ruido de Perlin controla también la altura de nuestras montañas. Lo hace proporcionándonos un valor entre 0 (negro) y 1 (blanco) que asignamos como componente Y de cada vértice de nuestra malla.

Por suerte, esta es una de las propiedades más explotadas de las funciones que devuelven valores flotantes en rangos binarios. Y es que si lo piensas bien, ¡en el fondo se trata de un porcentaje!

  0 * 1 = 0   //0% de 1 es 0        0 * 2 = 0 //0% de 2 es 0 
0.5 * 1 = 0.5 //50% de 1 es 0.5   0.5 * 2 = 1 //50% de 2 es 1
  1 * 1 = 1   //100% de 1 es 1      1 * 2 = 2 //100% de 2 es 2

Si multiplicamos el valor Perlin por otro número, podremos modificar la altura del terreno generado.

Para esto vamos a necesitar una variable nueva. Podemos exponerla al inspector:

[SerializeField] [Range(0f, 10f)] protected float _height = 0.5f;

_height determinará la altura máxima que podrán tener nuestras montañas en el entorno de Unity.

Todo lo que nos queda por hacer es multiplicar _height por el resultado de la función Perlin. GetHeight quedaría así:

private float GetHeight(float x, float z)
{
    return Mathf.PerlinNoise(
                x / _filter.mesh.bounds.size.x * _scale, 
                z / _filter.mesh.bounds.size.z * _scale)
           ) * _height;
}

Más aleatoriedad

Hemos llegado muy lejos con este generador de terreno simple, pero todavía tiene tres problemas:

  • Hay un efecto espejado raro desde el centro de la malla.
  • No tenemos posibilidad de generar otras orografías. Siempre que ejecutamos es la misma.
  • Tenemos que reiniciar la simulación cada vez que queremos hacer cambios.
Actualización constante

Vamos a empezar por el último punto; necesitamos ser capaces de ver nuestros cambios en tiempo real. La solución es muy sencilla: regenerar el terreno cada fotograma (o cada pocos fotogramas). Podríamos…

  • … hacerlo en el Update.
  • … en el FixedUpdate.
  • … en DrawGizmos.

Personalmente, prefiero la tercera opción porque nos permitirá dejar de regenerar el terreno si minimizamos el componente, y porque no tendrá repercusiones en las builds si se nos olvida quitarlo; DrawGizmos sólo funciona en el inspector.

       private void OnDrawGizmos()
        {
            if (!Application.isPlaying)
            {
                return;
            }

            GenerateTerrain();
        }

Es importante comprobar que sólo se haga cuando la aplicación se esté ejecutando, ya que DrawGizmos se ejecuta en todo momento.

Más orografías y solucionar el efecto espejo

¿Recuerdas cuando dividíamos nuestra malla por su tamaño para encontrar la posición relativa de cada vértice? Pues bien, el problema es que las coordenadas de cada vértice no son en el mundo, sino relativas al propio objeto.

Si quieres saberlo todo sobre las mallas, te dejo aquí un vídeo donde hablo de ellas en detalle.

Para resumir; el pivote del objeto es el centro del objeto y todos los vértices a su alrededor tienen su coordenadas en base a él. De manera que si un vértice estuviera en el pivote, sus coordenadas serían (0, 0, 0).

Como la escala de nuestra malla era la (1, 1, 1) y estaba colocada en el origen del mundo; (0, 0, 0), podíamos confiar en que las coordenadas de cada vértice correspondían con su posición en el mundo. Al escalarlas, hemos exagerado estas propiedades, lo que ha hecho notable el problema.

¡Pero la solución es muy sencilla! Vamos a mover artificialmente el pivote de la malla para fingir que está en otro lugar, de manera que las costuras no sean visibles.

Para ello, vamos a necesitar una nueva variable que podemos exponer en el inspector:

[SerializeField] protected Vector2 _offset = Vector2.one;

Ya que nuestras coordenadas empiezan en (0, 0, 0), podemos fingir que empezaron en _offset sumándoles este vector.

GetHeight quedaría así:

private float GetHeight(float x, float z)
{
    x += _offset.x;
    z += _offset.y;
    return Mathf.PerlinNoise(
               x / _filter.mesh.bounds.size.x * _scale, 
               z / _filter.mesh.bounds.size.z * _scale
           ) * _height;
}

Para terminar…

… sería estupendo poder darle otros colores, ¿verdad? Podríamos hacerlo en base a la altura; que las montañas más altas fueran picos nevados y los puntos más bajos abismos impenetrables.

¡Pues no te preocupes! Veremos cómo hacer eso en el futuro.

En Busca la Excusa no dejamos de buscar cualquier pretexto para seguir aprendiendo. Si te interesan los tutoriales de inteligencia artificial, te sugiero los siguientes artículos de mi blog:

Búscate una excusa para echarle un vistazo y no te lo pierdas 😉

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *