Machine Learning Avanzado 25 min de lectura

Generación de niveles con redes neuronales (GAN, VAE)

Cuando WFC y BSP se quedan cortos. GANs y VAEs generando layouts a partir de un dataset de niveles existentes: estilo aprendido, no reglas a mano.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

WFC y BSP generan niveles coherentes, pero el “estilo” emerge de reglas que tú diseñas. Si quieres que un nivel se sienta como Mario 1, necesitas codificar tú a mano “los tubos miden esto”, “las nubes están allá arriba”, “los Goombas no aparecen en agua”. Mucho trabajo. Y el resultado es una imitación discreta, no un aprendizaje real.

¿Qué pasa cuando ya tienes un corpus de niveles existentes —los 32 niveles de Super Mario Bros, los calabozos de Zelda, cien runs de Spelunky exportadas— y quieres que el sistema aprenda ese estilo sin reglas explícitas? Ahí entran GANs (Generative Adversarial Networks) y VAEs (Variational Autoencoders): arquitecturas de ML generativo que miran un dataset y producen muestras nuevas que se “sienten” del mismo conjunto.

El campo se llama PCGML (Procedural Content Generation via Machine Learning). El paper de referencia es el Mario GAN de Volz et al. (2018), que entrenó una GAN sobre niveles de Mario codificados como matrices de tiles y combinó la generación con búsqueda evolutiva. Hay trabajo similar para DOOM (Giacomello et al., 2018) y para Sonic, Zelda y juegos custom. En producción comercial todavía es raro, pero la dirección es clara y va creciendo.

En este tutorial vas a ver:

  • Cómo se compara la generación neuronal con WFC clásico.
  • Las arquitecturas GAN y VAE explicadas como cajas con entrada/salida.
  • Cómo representas un nivel para que una red neuronal lo digiera.
  • Pseudocódigo de inferencia y validación de playability.
  • Un snippet en Unity/C# que carga un decoder VAE en Sentis y construye un Tilemap.

1. Demo

GAN/VAE generando niveles Pipeline conceptual: ruido latente z → decoder/generator → matriz de tiles → render. Cambia la seed, observa cómo el mismo modelo produce niveles distintos pero estilísticamente coherentes.

2. Concepto y arquitectura

Las GANs y VAEs son arquitecturas de ML generativo que aprenden la distribución estadística de un dataset (en este caso, niveles existentes) y generan muestras nuevas que pertenecen a esa misma distribución. La diferencia clave: la GAN entrena un generador compitiendo contra un crítico que intenta distinguir reales de falsos; el VAE codifica cada nivel a un espacio latente compacto y aprende a reconstruirlo, generando luego desde puntos cualesquiera de ese espacio.

2.1 ¿Por qué ML para procgen cuando WFC funciona?

WFC es excelente reproduciendo patrones locales: si en el ejemplo dos tiles aparecen juntos, en la salida también pueden. Pero WFC no tiene memoria de la estructura global. No sabe que un nivel de Mario empieza con tierra firme, tiene un climax a mitad de camino y termina con un castillo. Para WFC todos los lugares del mapa son intercambiables siempre que se respeten las adyacencias.

GANs y VAEs aprenden esa estructura macro. El generador internaliza que “los niveles tienen pendientes ascendentes en el segundo tercio”, “los enemigos se densifican cerca del final”, “las plataformas suspendidas aparecen en racimos de 2 a 5”. Y como bonus extra: el espacio latente permite interpolar entre dos referencias. Tomas el z que produce un nivel tipo World 1-1 y el que produce un nivel tipo World 8-3, y los puntos intermedios son híbridos coherentes.

Trade-off honesto: WFC necesita 0 datos de entrenamiento y 0 horas de GPU. GAN/VAE necesitan dataset, pipeline de entrenamiento y compute. Si tu juego ya tiene los niveles que quieres imitar, el ML gana. Si estás creando un estilo nuevo, WFC sigue siendo más práctico.

2.2 ¿Qué es un GAN simplificado?

Una GAN son dos redes que se entrenan a la vez en juego de suma cero:

Arquitectura GAN
z ~ N(0,1)zGenerator Gz → nivelG(z) fakeniveldataset realniveles existentesDiscriminator Dnivel → prob”real” o “fake”loss adversarial → actualiza G y D

Generator produce niveles a partir de ruido z; el discriminator compara contra reales del dataset y devuelve un veredicto real/fake. El loop adversarial los entrena en paralelo.

El generator G toma un vector de ruido z (típicamente muestreado de una normal) y produce un nivel candidato. El discriminator D recibe niveles —unos reales del dataset, otros producidos por G— y los clasifica como reales o falsos.

El loop de entrenamiento:

  1. D se entrena para distinguir reales de falsos (clasificación binaria).
  2. G se entrena para engañar a D: que clasifique sus outputs como reales.

Si el entrenamiento converge, G produce niveles indistinguibles de los del dataset y D queda confundido al 50%. En la práctica, el equilibrio es frágil: las GANs son famosamente inestables. Pero cuando funcionan, los resultados son nítidos.

2.3 ¿Qué es un VAE simplificado?

Un VAE es un autoencoder con una restricción probabilística. La arquitectura tiene tres piezas:

Arquitectura VAE
nivel xEncoderE(x) → μ, σz~ N(μ,σ)Decoderz → nivelKL( q(z|x) || N(0,1) )regulariza el latenteruntime: solo decoder; sample z ~ N(0,1) y forward pass

El encoder colapsa el nivel a una gaussiana en un latente pequeño; muestreas z y el decoder reconstruye. La pérdida KL empuja el latente hacia N(0,1) para que sea muestreable en runtime.

El encoder mapea un nivel real x a los parámetros μ, σ de una distribución gaussiana sobre el espacio latente. De ahí muestreas un z (truco de la “reparametrización”). El decoder toma z y reconstruye el nivel.

La pérdida combina dos términos:

  • Reconstrucción: que el decoder reproduzca el nivel original.
  • KL divergence: que la distribución del encoder q(z|x) se parezca a una normal estándar N(0,1). Esto comprime el latente y lo hace muestreable de forma sencilla en runtime.

En inferencia: muestreas z ~ N(0,1), lo pasas por el decoder, y obtienes un nivel nuevo. No hay encoder en runtime, solo decoder.

2.4 ¿GAN o VAE para niveles?

Compáralos en lo que importa para un dev indie:

AspectoGANVAE
Calidad visualMás nítidaMás “promediada”
Estabilidad de entrenamientoFrágil (mode collapse, oscilación)Estable
Tamaño de dataset mínimo~50–100 niveles~20–50 niveles
Control del latenteBajo (no estructurado)Alto (latente regularizado)
Interpolación entre muestrasPosible pero ruidosaSuave y coherente

Para tilemaps —donde la salida ya pasa por un argmax discreto— el problema clásico de los VAEs (imágenes borrosas) importa menos. La “borrosidad” se convierte en una distribución de probabilidad por tile que tú colapsas eligiendo el tile más probable. Si tu equipo es pequeño y no tienes meses para domar una GAN, empieza por VAE. Es la recomendación honesta.

2.5 ¿Cómo representas un nivel para una red neuronal?

Una red neuronal espera tensores de números, no “el tubo verde de Mario”. La representación canónica para un tilemap:

Tilemap como tensor one-hot
1) Matriz HxW (tile type por celda)one-hot2) One-hot HxWxCcelda destacada → vector de C bits0cielo0suelo1tubo0item0enemytensor final: H x W x C → entrada de la redcielosuelotuboitemenemy

A la izquierda, fragmento de nivel tipo Mario con cuatro tipos de tile. A la derecha, la misma celda codificada como vector one-hot de C canales que la red ingiere.

La red ingiere y produce tensores HxWxC. La salida del decoder/generator es una distribución (softmax por celda); tomar argmax en el eje de canales te devuelve la matriz HxW de tiles concretos.

Decisiones de diseño que importan:

  • H y W fijos: las redes convolucionales esperan tamaño constante. Si quieres niveles más largos, generas por chunks y los concatenas.
  • C compacto: con 10–20 tipos de tile alcanza para la mayoría de juegos. Más tipos = más datos necesarios para entrenar bien.
  • Tile vacío explícito: no representes el cielo como “ausencia”, trátalo como un tile más. Las redes lo necesitan denso.

2.6 ¿Cuánto dataset necesitas?

Cifras reales del campo:

  • GAN con resultados decentes en Mario: ~50–100 niveles bien anotados (Volz et al. usaron 173 segmentos).
  • VAE razonable: ~20–50 niveles. Si los tilemaps son pequeños, incluso menos.
  • Augmentación: espejado horizontal cuando aplique (sí en plataformeros simétricos, no si la mecánica fuerza dirección), pequeños recortes/translaciones, rotaciones (solo en juegos top-down).

Si tu dataset es de 5 niveles, ningún ML te va a salvar. Vuelve a WFC y diseña reglas. Si tienes 30+ y crecen, el VAE empieza a ser viable.

2.7 ¿Cómo evitas niveles injugables?

Una red neuronal no entiende físicas ni solvability. Aprende correlaciones estadísticas. Puede generar:

  • Un nivel sin ruta del spawn al goal.
  • Una llave detrás de una pared sin puerta.
  • Plataformas a distancia imposible de saltar.
  • Un enemigo flotando en mitad del cielo sin lógica.

La defensa estándar es un pipeline post-generación con validador de playability:

  1. Genera un nivel con el decoder.
  2. Pasa por un validador que ejecuta checks: BFS de spawn a goal, distancias de salto vs maxJumpDistance del personaje, items alcanzables.
  3. Si pasa, úsalo. Si falla, regenera (sample nuevo z) hasta agotar maxTries.
  4. Si todos fallan, cae a un generador clásico de respaldo (WFC, BSP, plantilla manual).
Nivel generado → validador → ok/reject
decoderz → nivelSGcandidatovalidatorBFS spawn → goaljump checksok → usarejectreject → sample nuevo z (hasta maxTries)si maxTries agotado → fallback a generador clásico (WFC, BSP, plantilla)

El decoder produce un candidato; el validador corre un BFS de spawn a goal y comprueba saltos. Si pasa, sale del loop; si falla, regenera con un nuevo z.

Variante más sofisticada: simulated annealing de reparación sobre el output del decoder. Modificas tiles individuales para arreglar la playability sin volver a samplear. Más caro de implementar, mejor uso del modelo entrenado.

2.8 ¿Cómo se corre todo esto en runtime?

El flujo end-to-end:

  1. Offline (Python): entrenas la GAN/VAE en PyTorch o TensorFlow sobre tu dataset de niveles. Exportas solo el decoder (el encoder y el discriminator no se usan en runtime) a formato ONNX.
  2. In-engine (Unity): importas el .onnx como asset, Sentis lo compila para CPU o GPU. Cada vez que necesitas un nivel: muestreas z, lo conviertes a tensor, corres Worker.Schedule(), lees el output, haces argmax, validas, y construyes el Tilemap de Unity.

El forward pass del decoder en runtime es rapidísimo: un decoder VAE pequeño (8 dims latentes, output 16×16×10) corre en menos de 5 ms en CPU. Generar un nivel no impacta el frame rate si lo haces durante una transición de pantalla.

3. Pseudocódigo

function generateLevelVAE(decoder: Model, latentDim: Int) -> Grid<Int>
    # 1) muestrear punto en el espacio latente
    z = sampleNormal(latentDim)              # vector de tamaño latentDim, N(0,1)

    # 2) forward pass por el decoder
    output = decoder(z)                      # tensor [H, W, C], distribución por celda

    # 3) colapsar la distribución a tiles concretos
    grid = argmaxPerCell(output)             # matriz HxW de ints

    return grid

function generateAndValidate(
    decoder: Model,
    validator: PlayabilityChecker,
    maxTries: Int = 20
) -> Grid<Int>?
    for i in 0..maxTries
        level = generateLevelVAE(decoder, latentDim=8)
        if validator.isPlayable(level):
            return level
    return null                              # fallback al generador clásico

function isPlayable(grid: Grid<Int>) -> Bool
    spawn = findTile(grid, SPAWN)
    goal  = findTile(grid, GOAL)
    if spawn == null or goal == null: return false

    # BFS respetando reglas de salto
    return reachable(grid, spawn, goal, maxJump=4)

El patrón es el mismo para GAN (cambias decoder por generator) y para arquitecturas más complejas (conditional VAEs donde z se concatena con un vector de condición, ej. “dificultad” o “estilo”).

4. Implementación en Unity / C#

El snippet asume que ya entrenaste un decoder VAE afuera, lo exportaste como decoder.onnx con latentDim=8 y output [1, 16, 16, 10] (un batch, 16×16 tiles, 10 tipos), y lo arrastraste al inspector.

using System.Collections.Generic;
using Unity.Sentis;
using UnityEngine;
using UnityEngine.Tilemaps;

public class NeuralLevelGenerator : MonoBehaviour {
    public ModelAsset decoderAsset;
    public Tilemap targetTilemap;
    public TileBase[] tilePalette;     // index = tile type, value = TileBase
    public int latentDim = 8;
    public int width = 16, height = 16, channels = 10;
    public int maxTries = 20;

    Model decoder;
    Worker worker;

    void Start() {
        decoder = ModelLoader.Load(decoderAsset);
        worker = new Worker(decoder, BackendType.GPUCompute);
        GenerateUntilPlayable();
    }

    void GenerateUntilPlayable() {
        for (int t = 0; t < maxTries; t++) {
            int[,] grid = GenerateOnce();
            if (IsPlayable(grid)) {
                Paint(grid);
                return;
            }
        }
        Debug.LogWarning("No playable level in maxTries; fallback needed.");
    }

    int[,] GenerateOnce() {
        // 1) muestrear z ~ N(0,1)
        float[] z = new float[latentDim];
        for (int i = 0; i < latentDim; i++) z[i] = SampleNormal();
        using var input = new Tensor<float>(new TensorShape(1, latentDim), z);

        // 2) forward pass
        worker.Schedule(input);
        using var output = (worker.PeekOutput() as Tensor<float>).ReadbackAndClone();

        // 3) argmax por celda
        var grid = new int[height, width];
        for (int y = 0; y < height; y++)
            for (int x = 0; x < width; x++) {
                int best = 0; float bestVal = float.NegativeInfinity;
                for (int c = 0; c < channels; c++) {
                    float v = output[0, y, x, c];
                    if (v > bestVal) { bestVal = v; best = c; }
                }
                grid[y, x] = best;
            }
        return grid;
    }

    bool IsPlayable(int[,] grid) {
        // Esquemático: BFS de spawn a goal respetando saltos y obstáculos.
        // En producción, el validador conoce las mecánicas del juego.
        return PlayabilityChecker.Check(grid, maxJump: 4);
    }

    void Paint(int[,] grid) {
        targetTilemap.ClearAllTiles();
        for (int y = 0; y < height; y++)
            for (int x = 0; x < width; x++)
                targetTilemap.SetTile(new Vector3Int(x, y, 0), tilePalette[grid[y, x]]);
    }

    static float SampleNormal() {
        // Box-Muller
        float u1 = 1f - Random.value, u2 = 1f - Random.value;
        return Mathf.Sqrt(-2f * Mathf.Log(u1)) * Mathf.Cos(2f * Mathf.PI * u2);
    }

    void OnDestroy() => worker?.Dispose();
}

5. En otros engines

  • Godot: usa el plugin godot-onnx o expone ONNX Runtime vía GDExtension. La lógica es idéntica: cargas el .onnx, muestreas z, corres inference, pintas un TileMap. La API de Godot 4 para tilemaps es set_cell(layer, coords, source_id, atlas_coords).
  • Unreal: el NNE (Neural Network Engine) plugin reemplaza a NNI. Carga modelos ONNX, expone tensores como FNeuralTensor. El tilemap se construye con UPaperTileMapComponent o, en 3D, instanciando meshes en una grid.
  • JavaScript / Web: TensorFlow.js ejecuta el ONNX (vía tfjs-onnx) o un modelo ya convertido a formato TF.js. Renderizas a <canvas> o a un <svg> de tiles. Ventaja: cero instalación para el usuario; subes el modelo a tu web y la generación corre en su navegador.

6. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Entrenas una GAN y descubres que el generator produce siempre el mismo nivel (o variaciones casi idénticas) sin importar el z. ¿Cómo se llama este problema?

  2. Tu VAE genera tilemaps donde varias celdas tienen colores 'mezclados' entre dos tiles, como si dudara. ¿Qué está pasando y cómo lo arreglas?

  3. Tu decoder VAE genera niveles que se ven 'estilísticamente correctos' pero un 20% son injugables (sin ruta del spawn al goal). ¿Por qué pasa y cómo lo mitigas?

  4. ¿En qué escenario seguirías usando WFC en vez de GAN/VAE para generar niveles?