3

Cómo programar un motor de Raycasting con Processing

¿Quién no ha jugado alguna vez al glorioso Wolfenstein 3D, el bisabuelo de todos los shooters modernos? Este fantástico juego apareció a mediados del año 1992 y revolucionó el mundo de los videojuegos ofreciendo acción frenética, muchos disparos, y algunos de los primeros gráficos 3D vistos en un videojuego…

Sí, humano, hubo una época en que los shooters lucían así. Y eran buenos tiempos.

La gran pregunta es: ¿Cómo consiguieron los muchachos de ID Software crear un videojuego como Wolfenstein, con entornos 3D, capaz de correr incluso en un Intel 286 de la época? La respuesta es que Wolfenstein en realidad es un juego totalmente 2D. Utiliza un motor de Raycasting para dibujar sus escenarios, una técnica de renderizado muy ingeniosa que permite crear una perspectiva 3D a partir de un mapa 2D.

En el tutorial de hoy verás cómo implementar un pequeño motor de Raycasting con Processing, programado desde cero y capaz de correr en una patata. Este será el resultado final:

La idea básica

Para crear este motor de Raycasting utilizaremos una estrategia muy parecida a la que empleaba el primer Wolfenstein para renderizar sus niveles.

El mapa del escenario se guardará dentro de una matriz 2D, donde cada casilla tendrá o bien el valor ‘0’ (si es un espacio vacío) o ‘1’ (si hay una pared).

Los motores de raycasting no dibujan toda la escena a la vez, sino que dividen la ventana de juego en columnas y las renderizan una a una. Por cada columna de la ventana de juego se lanzará un rayo desde la posición del jugador. Cuando uno de estos rayos colisione con una pared, se dibujará una línea vertical cuya longitud dependerá de la distancia. Esto dará el efecto de perspectiva 3D.

Como es lógico, cuantos más rayos se lancen más definición tendrá la imagen. Nuestra ventana de juego tendrá un ancho de 640 columnas y por tanto lanzará 640 rayos.

La clave está, pues, en encontrar una forma de calcular la trayectoria de los rayos y determinar su punto de colisión con las paredes. Para una persona es sencillo ver a simple vista el punto en que un rayo impactará contra una pared, pero una máquina lo tiene más difícil y tendrá que ir dibujando la trayectoria del rayo hasta que este colisione.

Como no disponemos de precisión infinita en nuestra máquina, habrá que discretizar la trayectoria del rayo: se define una distancia P y se van calculando “pasos” de este rayo hasta colisionar con una pared.

¿Y cómo puedes saber cuándo se ha colisionado con una pared? Aquí es donde el hecho de guardar el mapa en una matriz será de gran ayuda: cada punto de la trayectoria tiene unas coordenadas X,Y que son números decimales; si al calcular alguno de estos puntos su parte entera se corresponde a una casilla de la matriz ocupada por una pared sabrás que el rayo ha colisionado.

Esto está muy bien, pero ¿cómo se trasladan estas ideas a un algoritmo? Es muy sencillo: recuerda que dado el módulo de un vector y su ángulo puedes calcular su componente vertical y horizontal a partir del seno y el coseno:

Aquí, el módulo del vector sería la distancia de paso P y alpha sería el ángulo inicial con el que se lanza el rayo. Como además las coordenadas X, Y iniciales del rayo son conocidas (pues son las mismas que la posición actual del jugador) puedes calcular la trayectoria del rayo con un pequeño bucle:

 

Es decir, a cada vuelta de bucle se calculan las coordenadas de un nuevo punto. Estas coordenadas serán un valor de tipo float, y si en algún momento su parte entera está dentro de una casilla del mapa que sea una pared, hay una colisión y por tanto se sale del bucle.

A la práctica, al implementar este bucle habrá que hacer un par de cambios para que sea más eficiente, pero la idea será la misma.

¿Cómo calculo la altura de las paredes?

Al salir del bucle tendrás un punto (rayoX, rayoY) que indicará el lugar donde el rayo ha impactado contra un muro. Con el teorema de pitágoras puedes calcular la distancia que hay entre este punto y la posición del jugador, y después ajustar la altura:

Después se dibuja una línea vertical de longitud igual al valor de la variable altura, y cuyo color dependa de la distancia: cuanto más lejos, más oscuro. Esto ayudará a reforzar la sensación de volumen 3D del escenario.

No obstante, si calculas la distancia de esta manera te encontrarás con un problema importante. Imagínate que el jugador está delante de una pared totalmente recta: los rayos que pasan por el centro de la pantalla tienen una distancia más corta que los rayos que pasan por los laterales, y esto hará que la cámara tenga un efecto de ojo de pez:

Para corregirlo habrá que encontrar el ángulo que forman el jugador y el rayo, y buscar la distancia perpendicular al punto de colisión calculando el coseno:

Dibujar los bordes

Para ayudar a resaltar las paredes dibujaremos un borde negro a su alrededor:

Para dibujar los bordes laterales se comparará la altura de la columna actual con la columna anterior. Si su diferencia es mayor que una tolerancia de 10 píxeles, se comprobará cuál de las dos columnas es mayor y se dibujará una línea negra encima.

En cambio, para hacer los bordes superiores e inferiores bastará con pintar algunos píxeles negros encima y debajo de cada columna.

 


Código de Processing

Ya has visto las ideas básicas que hay detrás de un motor de raycasting; ahora verás como implementar uno con el lenguaje Processing en menos de 200 líneas de código. Más abajo está la explicación paso a paso, pero si no quieres seguir las explicaciones y prefieres analizar el programa por tu cuenta, puedes copiar directamente el código acabado:

/* MOTOR DE RAYCASTING BÁSICO
 * Testeado con Processing 3
 *
 * Escrito por Transductor
 * www.robologs.net
 */

//Propiedades de la cámara/pantalla
float FOV = radians(64);
int anchoPantalla = 640;
int altoPantalla = 360;

//Distancia de paso del rayo:
float PASO = 0.01;

//Variables para guardar los parámetros de la columna anterior:
float altura_anterior = 0;
float nTecho_anterior = 0;
float nSuelo_anterior = 0;

//Propiedades del jugador:
float xJugador = 2.0; //Posicion X
float yJugador = 2.0; //Posicion Y
float aJugador = 0.0; //Rotación (ángulo hacia dónde está apuntando)
float velocidad = 0.004; //Velocidad de movimiento
float giro = 0.002; //Velocidad de giro


//Variables para calcular el delta time:
float lastTime = 0;
float delta = 0;

//Propiedades del mapa:
int MAPA_COLS = 10; //Numero de columnas
int MAPA_FILAS = 10; //Numero de filas

//Matriz del mapa:
int[][] mapa = {
  {1,1,1,1,1,1,1,1,1,1},
  {1,0,0,0,0,0,1,0,1,1},
  {1,0,0,0,0,0,0,0,0,1},
  {1,1,0,1,1,1,1,0,0,1},
  {1,0,0,1,0,0,1,0,0,1},
  {1,0,0,0,0,0,1,0,0,1},
  {1,0,0,1,0,0,1,0,0,1},
  {1,0,1,1,1,1,1,0,0,1},
  {1,0,0,0,0,0,0,0,0,1},
  {1,1,1,1,1,1,1,1,1,1}
};

//Configurar la ventana de juego:
void settings()
{
  size(anchoPantalla,altoPantalla); //Establecer tamaño de la pantalla
  smooth(0); //Desactivar el suavizado
}

void setup()
{
  frameRate(30); //Establecer el framerate
}

void draw()
{
  //Calculamos el delta time:
  delta = millis() - lastTime;

  //Pintar el cielo y el suelo:
  noStroke();
  fill(30);
  rect(0,0,anchoPantalla, altoPantalla/2); //Cielo
  fill(120);
  rect(0,altoPantalla/2,anchoPantalla, altoPantalla/2); //Suelo
  
  //Trazar un rayo desde cada una de las columnas de la pantalla:
  for(int x = 0; x < anchoPantalla; x++)
  {
    //La posición inicial del rayo será la del jugador:
    float xRayo = xJugador;
    float yRayo = yJugador;
    
    //Calculamos su ángulo inicial:
    float aRayo = (aJugador - FOV / 2.0) + x*(FOV / anchoPantalla);
    
    //Calculamos el incremento:
    float xIncremento = cos(aRayo)*PASO;
    float yIncremento = sin(aRayo)*PASO;
    
    //Calcular la trayectoria del rayo, paso a paso:
    boolean choque = false;
    while(!choque)
    {
      //Calcular un nuevo punto de la trayectoria:
      xRayo += xIncremento;
      yRayo += yIncremento;
      
      //Si el rayo sale del mapa, o si colisiona con un muro, salimos del bucle while:
      if( xRayo < 0 || xRayo >= MAPA_COLS || yRayo < 0 || yRayo >= MAPA_FILAS || mapa[int(yRayo)][int(xRayo)] != 0)
      {
        choque = true;
      }
    }
    
    //Calcular la distancia corregida del jugador al punto de colisión:
    float distancia = sqrt( pow(xRayo - xJugador, 2) + pow(yRayo - yJugador, 2) );
    distancia = distancia * cos(aRayo - aJugador);
    
    //Calcular la altura del muro:
    float altura = min(altoPantalla, altoPantalla / distancia);
    
    //Calcular el píxel de la pantalla donde hay que empezar a dibujar el muro (nTecho) y donde hay que acabar (nSuelo)
    int nTecho = int(float(altoPantalla) / 2.0 - altura/2);
    int nSuelo = int(float(altoPantalla) / 2.0 + altura/2);
    
    //Calcular una tonalidad para la columna, que dependerá de la distancia (cuanto más lejos, más oscuro)
    float tonalidad = map(min(distancia, 7), 0, 7, 255, 40);
    stroke(int(tonalidad));
    
    //Dibujar la línea vertical:
    line(x, nTecho, x, nSuelo);
    

    //Comprobar si hay que dibujar un borde lateral:
    if( abs(altura - altura_anterior) >= 10)
    {
      stroke(0);
      strokeWeight(2);
      if(altura > altura_anterior) 
        line(x, nTecho, x, nSuelo);
      else 
        line(x-1, nTecho_anterior, x-1, nSuelo_anterior);
    }
    
    //Guardar los parámetros de la columna actual:
    altura_anterior = altura;
    nTecho_anterior = nTecho;
    nSuelo_anterior = nSuelo;
    
    //Dibujar los bordes superiores e inferiores de la pared:
    strokeWeight(1);
    stroke(0);
    line(x, nTecho, x, nTecho+2);
    line(x, nSuelo, x, nSuelo-2);
    
    
  }

  //Actualizar la variable lastTime con el tiempo actual
  lastTime = millis();
}

//Controles del jugador:
void keyPressed() {
  
  //Moverse hacia delante:
  if (keyCode == UP) 
  {
    //Avanzar la posición del jugador hacia delante:
    xJugador += cos(aJugador)*velocidad*delta;
    yJugador += sin(aJugador)*velocidad*delta;
    
    //Si el jugador ha entrado dentro de una pared se deshace el movimiento:
    if(mapa[int(yJugador)][int(xJugador)] != 0)
    {
      xJugador -= cos(aJugador)*velocidad*delta;
      yJugador -= sin(aJugador)*velocidad*delta;
    }
  }
  
  //Giro a la izquierda:
  else if (keyCode == LEFT)
  {
    aJugador -= giro*delta;
  }
  
  //Giro a la derecha:
  else if(keyCode == RIGHT)
  {
    aJugador += giro*delta;
  }
  
  //Moverse hacia atrás:
  else if(keyCode == DOWN)
  {
    xJugador -= cos(aJugador)*velocidad*delta;
    yJugador -= sin(aJugador)*velocidad*delta;
    
    if(mapa[int(yJugador)][int(xJugador)] != 0)
    {
      xJugador += cos(aJugador)*velocidad*delta;
      yJugador += sin(aJugador)*velocidad*delta;
    }
  }
}

 

Veamos ahora la construcción paso a paso:

 

1. Declaraciones:

Empezamos por declarar algunas variables que serán necesarias para construir el motor. FOV es el ángulo de visión del jugador, que será de 64 grados (convertido a radianes). anchoPantalla y altoPantalla son las dimensiones de la ventana de juego:


//Propiedades de la cámara/pantalla
float FOV = radians(64);
int anchoPantalla = 640;
int altoPantalla = 360;

 

Después definimos una distancia para cada paso del rayo. Cuando calculemos la trayectoria del rayo, esta será la distancia que separará cada nuevo punto calculado del anterior:


//Distancia de paso del rayo:
float PASO = 0.01;

 

También hay que crear tres variables para guardar los parámetros de la columna anterior cuando se dibujen los bordes:

//Variables para guardar los parámetros de la columna anterior:
float altura_anterior = 0;
float nTecho_anterior = 0;
float nSuelo_anterior = 0;

 

Después definimos algunas variables para el jugador, que indicarán su posición sobre el mapa (xJugador, yJugador) y la dirección hacia la que está mirando (aJugador). También definimos una velocidad de movimiento y de giro (las variables velocidad y giro). También definimos las variables lastTime y delta que servirán para calcular el delta time (necesario para que el movimiento del jugador sea suave).


//Propiedades del jugador:
float xJugador = 2.0; //Posicion X
float yJugador = 2.0; //Posicion Y
float aJugador = 0.0; //Rotación (ángulo hacia dónde está apuntando)
float velocidad = 0.004; //Velocidad de movimiento
float giro = 0.002; //Velocidad de giro


//Variables para calcular el delta time:
float lastTime = 0;
float delta = 0;

 

2. Matriz del mapa:

Una vez declaradas estas variables, hay que generar un mapa. Creamos dos variables MAPA_COLS y MAPA_FILAS que indicarán las dimensiones del mapa (en este caso 10×10) y después definimos una matriz 2D con ceros y unos:

//Propiedades del mapa:
int MAPA_COLS = 10; //Numero de columnas
int MAPA_FILAS = 10; //Numero de filas

//Matriz del mapa:
int[][] mapa = {
  {1,1,1,1,1,1,1,1,1,1},
  {1,0,0,0,0,0,1,0,1,1},
  {1,0,0,0,0,0,0,0,0,1},
  {1,1,0,1,1,1,1,0,0,1},
  {1,0,0,1,0,0,1,0,0,1},
  {1,0,0,0,0,0,1,0,0,1},
  {1,0,0,1,0,0,1,0,0,1},
  {1,0,1,1,1,1,1,0,0,1},
  {1,0,0,0,0,0,0,0,0,1},
  {1,1,1,1,1,1,1,1,1,1}
};

Recuerda: los ‘0’ se corresponden a espacios vacíos y los ‘1’ a paredes.

3. Funciones settings() y setup()

Dentro de la función settings() creamos una ventana con unas dimensiones que vienen dadas por las variables anchoPantalla y altoPantalla. También desactivamos el suavizado de líneas; no nos hace falta y ganaremos velocidad.

//Configurar la ventana de juego:
void settings()
{
  size(anchoPantalla,altoPantalla); //Establecer tamaño de la pantalla
  smooth(0); //Desactivar el suavizado
}

 

En la función setup() únicamente se cambia el framerate de 60 a 30 para que la máquina no tenga que hacer tantos cómputos por segundo:

void setup()
{
  frameRate(30); //Establecer el framerate
}

4. Función draw()

Dentro de la función draw() se empieza calculando el delta time y dibujando el cielo y el suelo. Para dibujarlos, se pinta la mitad superior de la ventana de juego de color gris oscuro y la otra mitad de color gris claro:

void draw()
{
  //Calculamos el delta time:
  delta = millis() - lastTime;

  //Pintar el cielo y el suelo:
  noStroke();
  fill(30);
  rect(0,0,anchoPantalla, altoPantalla/2); //Cielo
  fill(120);
  rect(0,altoPantalla/2,anchoPantalla, altoPantalla/2); //Suelo

 

Ahora viene el punto clave del programa: por cada columna de la pantalla de juego vamos a lanzar un rayo. Para hacerlo definimos un bucle for que vaya de 0 a anchoPantalla-1:

  //Trazar un rayo desde cada una de las columnas de la pantalla:
  for(int x = 0; x < anchoPantalla; x++)
  {

 

Dentro del bucle establecemos el primer punto de la trayectoria del rayo, que será la posición actual del jugador, y un ángulo inicial para el rayo:

    //La posición inicial del rayo será la del jugador:
    float xRayo = xJugador;
    float yRayo = yJugador;
    
    //Calculamos su ángulo inicial:
    float aRayo = (aJugador - FOV / 2.0) + x*(FOV / anchoPantalla);

La fórmula con la que se ha calculado el ángulo inicial del rayo (aRayo) quizá no parece obvia a simple vista. Imagínate que el ángulo de visión (que venía dado por la variable FOV) es un arco que se extiende delante del jugador. Si nos fijamos en la dirección en la que apunta el jugador, el arco de visión quedaría dividido en dos mitades:

Calculando (aJugador – FOV / 2.0) obtenemos el ángulo del rayo de más a la izquierda, que pasa a través de la columna x=0 de la pantalla:

Para el resto, sumamos un pequeño valor a este ángulo. Si hay un número de columnas igual a anchoPantalla y el ángulo total es FOV, a cada vuelta del bucle hay que sumar x*(FOV / anchoPantalla) al ángulo inicial (aJugador – FOV / 2.0), donde x es el índice de la columna. De esta forma, al terminar el bucle, aRayo habrá recorrido todo el arco del ángulo de visión.

Una vez definidos los parámetros iniciales del rayo, habrá que definir un incremento X e Y para calcular cada nuevo punto de la trayectoria:

    //Calculamos el incremento:
    float xIncremento = cos(aRayo)*PASO;
    float yIncremento = sin(aRayo)*PASO;

 

Ahora escribimos el bucle que calculará cada punto de la trayectoria del rayo hasta chocar con una pared, tal y como ya te he explicado:

    //Calcular la trayectoria del rayo, paso a paso:
    boolean choque = false;
    while(!choque)
    {
      //Calcular un nuevo punto de la trayectoria:
      xRayo += xIncremento;
      yRayo += yIncremento;
      
      //Si el rayo sale del mapa, o si colisiona con un muro, salimos del bucle while:
      if( xRayo < 0 || xRayo >= MAPA_COLS || yRayo < 0 || yRayo >= MAPA_FILAS || mapa[int(yRayo)][int(xRayo)] != 0)
      {
        choque = true;
      }
    }

 

Al salir de este bucle se tiene un punto de colisión (xRayo, yRayo), y ya se puede calcular la distancia de la pared al jugador, además de la altura que deberá tener la línea de la columna:

    //Calcular la distancia corregida del jugador al punto de colisión:
    float distancia = sqrt( pow(xRayo - xJugador, 2) + pow(yRayo - yJugador, 2) );
    distancia = distancia * cos(aRayo - aJugador);
    
    //Calcular la altura del muro:
    float altura = min(altoPantalla, altoPantalla / distancia);

 

A partir de la variable altura se calculará el píxel inicial y final entre los que deberá dibujarse la línea vertical. Para hacerlo se busca el píxel situado al centro de la columna, y se guardan los dos píxeles que estén a distancia (altura / 2) :

    //Calcular el píxel de la pantalla donde hay que empezar a dibujar el muro (nTecho) y donde hay que acabar (nSuelo)
    int nTecho = int(float(altoPantalla) / 2.0 - altura/2);
    int nSuelo = int(float(altoPantalla) / 2.0 + altura/2);

 

Antes de dibujar la línea vertical hay que elegir una tonalidad de color, que vendrá dada por la distancia. Para ello se utiliza la función map() de processing:

    //Calcular una tonalidad para la columna, que dependerá de la distancia (cuanto más lejos, más oscuro)
    float tonalidad = map(min(distancia, 7), 0, 7, 255, 40);
    stroke(int(tonalidad));

    //Dibujar la línea vertical:
    line(x, nTecho, x, nSuelo);

 

Queda solamente dibujar los bordes de las paredes. Comprobamos la diferencia de altura entre la columna actual y la anterior, y si la diferencia es superior a 10 píxeles se dibuja el borde negro sobre la más alta. También se guardan los parámetros de la columna actual.

    //Comprobar si hay que dibujar un borde lateral:
    if( abs(altura - altura_anterior) >= 10)
    {
      stroke(0);
      strokeWeight(2);
      if(altura > altura_anterior) 
        line(x, nTecho, x, nSuelo);
      else 
        line(x-1, nTecho_anterior, x-1, nSuelo_anterior);
    }
    
    //Guardar los parámetros de la columna actual:
    altura_anterior = altura;
    nTecho_anterior = nTecho;
    nSuelo_anterior = nSuelo;

 

Finalmente se dibujan los bordes superiores e inferiores, se actualiza la variable lastTime con el tiempo actual y se cierra la función draw():

    //Dibujar los bordes superiores e inferiores de la pared:
    strokeWeight(1);
    stroke(0);
    line(x, nTecho, x, nTecho+2);
    line(x, nSuelo, x, nSuelo-2);
  }

  //Actualizar la variable lastTime con el tiempo actual
  lastTime = millis();
}

5. Controles del jugador

La función keyPressed() de Processing se invoca automáticamente cada vez que el usuario pulsa una tecla del teclado. Dentro de esta función se comprueba si la tecla presionada es una flecha y en caso afirmativo se desplaza el jugador:

//Controles del jugador:
void keyPressed() {
  
  //Moverse hacia delante:
  if (keyCode == UP) 
  {
    //Avanzar la posición del jugador hacia delante:
    xJugador += cos(aJugador)*velocidad*delta;
    yJugador += sin(aJugador)*velocidad*delta;
    
    //Si el jugador ha entrado dentro de una pared se deshace el movimiento:
    if(mapa[int(yJugador)][int(xJugador)] != 0)
    {
      xJugador -= cos(aJugador)*velocidad*delta;
      yJugador -= sin(aJugador)*velocidad*delta;
    }
  }
  
  //Giro a la izquierda:
  else if (keyCode == LEFT)
  {
    aJugador -= giro*delta;
  }
  
  //Giro a la derecha:
  else if(keyCode == RIGHT)
  {
    aJugador += giro*delta;
  }
  
  //Moverse hacia atrás:
  else if(keyCode == DOWN)
  {
    xJugador -= cos(aJugador)*velocidad*delta;
    yJugador -= sin(aJugador)*velocidad*delta;
    
    if(mapa[int(yJugador)][int(xJugador)] != 0)
    {
      xJugador += cos(aJugador)*velocidad*delta;
      yJugador += sin(aJugador)*velocidad*delta;
    }
  }
}

Conclusiones

Enhorabuena, humano: hoy has aprendido como crear un motor de Raycasting básico. Esto es sólo el principio: este motor podría mejorarse para que las paredes tengan textura y podríamos añadir sprites 2D con enemigos, armas o munición. Otro día hablaremos de ello, pero por hoy lo dejamos aquí.

En caso de que tengas preguntas sobre el programa o detectes algún error, no dudes en escribir un comentario. Y si te ha gustado el artículo puedes seguirnos en nuestra página de Facebook o Twitter. Es una buena forma de apoyarnos y siempre estarás al tanto de las nuevas publicaciones.

Esto es todo por hoy, humano. Final de línea.

Tr4nsduc7or

Originariamente creado cómo un galvanómetro de bolsillo, Transductor tomó consciencia de si mismo y fue despedido cuando en vez cumplir con su trabajo se dedicó a pensar teorías filosóficas sobre los hilos de cobre, los electrones y el Sentido del efecto Joule en el Universo. Guarda cierto recelo a sus creadores por no comprender la esencia metafísica de las metáforas de su obra. Actualmente trabaja a media jornada cómo antena de radio, y dedica su tiempo libre a la electrónica recreativa y a la filosofía.

guest
3 Comments
más nuevos primero
más antiguos primero
Inline Feedbacks
Ver todos los comentarios
Dario Alcazar
Dario Alcazar
1 mes

Muy interesante, voy a intentar adaptarlo para pygame

trackback

[…] humano. Hace unos días te enseñé cómo programar un motor de Raycasting con Processing. Como recordarás, el Raycasting es una técnica de renderizado muy ingeniosa que permite crear una […]

HenryCavendish
HenryCavendish
2 meses

Muy bueno el tutorial!! espero con ansias el de las texturas 😀