0

Cómo programar un motor de Raycasting con Arduino

Saludos, 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 perspectiva 3D a partir de un mapa totalmente 2D, y es la base de videojuegos clásicos como Wolfenstein 3D o DOOM.

El último día vimos cómo construir este motor en lenguaje Processing.

He pensado que sería muy interesante jugar con este algoritmo de raycasting y llevarlo hasta su límite. Dado que vimos cómo programar este algoritmo desde cero, podemos implementarlo en prácticamente cualquier lenguaje y/o pieza de hardware… y cómo no, esto incluye Arduino, el microcontrolador omnipresente en casi cualquier proyecto de electrónica.

Hoy veremos, pués, si somos capaces de implementar este motor de Raycasting para que funcione dentro de una placa Arduino, con menos de 35Kb de memoria.  Por las risas.

Este será el resultado del tutorial de hoy. Los cálculos de perspectiva los hace Arduino, y el escenario se dibuja sobre una pantalla OLED monocromática.


Materiales necesarios

Estos son los componentes y piezas que he utilizado (básicamente, eran los únicos que tenía a mano):

  • Placa Arduino – Cualquier modelo debería servir. Yo he usado una placa Arduino Nano y funciona bien, así que si dispones de alguna versión más potente vas a ir sobrado.
  • Pantalla monocromática – Para mostrar el escenario vas a necesitar una pantalla. Yo he utilizado una monocromática de 0.96” con cuatro pines basada en el chip SSD1306. Si utilizas otra puede que tengas que cambiar las conexiones y adaptar el programa.

Además, necesitarás material para construir algún tipo de mando para mover el jugador por el escenario. Dado que mi joystick decidió morir cuando estaba haciendo este proyecto, opté por construir mi mando utilizando cuatro pulsadores conectados a Arduino. Por tanto, también vas a necesitar:

  • 4 x Pulsadores – uno para cada dirección en la que podrá moverse el jugador: adelante, atrás, derecha e izquierda.
  • 4 x Resistencias de 10K Ohm – para hacer el circuito con los pulsadores.

A no ser que estés utilizando algún modelo de Arduino que tenga un consumo elevado, no deberías necesitar pilas ni baterías para este proyecto; con los 5V del USB bastará.


Conexiones

Las conexiones entre la pantalla y la placa Arduino serán:

Arduino 3.3V ↔ Pantalla VCC
Arduino GND ↔ Pantalla GND
Arduino A5 ↔ OLED SCL
Arduino A4 ↔ OLED SDA

(NOTA: Según la versión de placa Arduino que tengas, los pines SDA y SCL podrían ser otros distintos de A4 y A5. Aquí puedes consultar una lista con los pines de otros modelos).

Los cuatro botones tienen que ir conectados a los pines D2, D3, D4 y D5 de Arduino, siguiendo el esquema habitual. Para aclararnos, supondremos que el botón conectado a D2 es para ir adelante, D3 es para ir atrás, D4 para girar a la derecha y D5 para girar a la izquierda.


Librerías para la pantalla

Aunque me atrae la idea masoquista de escribir todos los programas desde cero, hoy utilizaré las librerías Adafruit GFX, Adafruit SSD1306 y AdafruitBusIO para dibujar gráficos en la pantalla. Si no las tienes instaladas puedes hacerlo siguiendo estos pasos:

  1. Abre el IDE de Arduino y ve a Sketch → Include Library → Manage Libraries. Se abrirá la ventana del gestor de librerías:
  2. En el buscador, escribe “SSD1306” e instala la librería Adafruit SSD1306:
  3. Una vez instalada, escribe “GFX” en el buscador e instala la librería Adafruit GFX:
  4. Por último, escribe “BusIO” en el buscador, instala la librería Adafruit BusIO y reinicia el IDE.

Resumen: ¿cómo funcionaba un motor de Raycasting?

El último día ya expliqué con todo detalle cómo funciona un motor de Raycasting, pero vamos a refrescar brevemente los conceptos clave.

El método que utilizaremos es semejante a la que empleó el primer Wolfenstein para renderizar sus niveles. El mapa se almacenará en una matriz 2D en la que cada casilla sólo puede tener el valor ‘0’ (espacio vacío) ó ‘1’ (pared).

La ventana de juego (o, en nuestro caso, la pantalla) se divide en columnas. Por cada una de estas columnas se lanza un rayo desde la posición del jugador en el mapa. Cuando uno de estos rayos choca contra una pared, se dibuja una línea vertical cuya longitud dependerá de la distancia. Esto dará la sensación de perspectiva 3D.

Lógicamente, a más columnas, más definición.

 

Como las máquinas no tienen precisión infinita (y Arduino todavía menos…) habrá que discretizar la trayectoria de los rayos para poder calcularlos. Se define una distancia P y se van calculando nuevos “pasos” de la trayectoria hasta alcanzar una pared:

Sabremos que hemos chocado con la pared si al calcular un nuevo punto de la trayectoria del rayo su parte entera se corresponde con una posición del mapa que contenga un muro.

Para calcular la altura de cada columna vertical sólo hay que calcular la distancia entre el jugador y el punto de colisión del rayo y después ajustar la altura de forma proporcional. El último día también expliqué cómo corregir el efecto de ojo de pez buscando la distancia del coseno.

Cálculo de la distancia con corrección.

 


Dibujar los bordes

En el último tutorial también dibujábamos los bordes de las paredes. Hoy, dado que la pantalla que utilizaremos es monocromática,  sólo vamos a dibujar los bordes de las paredes y dejaremos el interior sin color.

Como la pantalla es monocromática, si dibujamos el interior de las paredes será difícil distinguir las esquinas y apreciar la perspectiva.

Para dibujar sólo los bordes, vamos a calcular la distancia y la altura de las paredes de la forma habitual, pero omitiremos el paso de dibujar una línea vertical. En vez de eso, pintaremos un único píxel blanco en el extremo inferior y superior de la supuesta línea vertical: esto nos permitirá dibujar los bordes superiores e inferiores de las paredes.

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 3 píxeles, se comprobará cuál de las dos columnas es mayor y se dibujará una línea blanca encima. Con esto ya tendremos lo que queremos:

 


Programa de Arduino completo

Más abajo está la explicación paso a paso de como construir este programa, que sigue el mismo concepto que el que escribimos con Processing la última vez. Si no quieres seguir las explicaciones, aquí puedes copiar el código completo y analizarlo por tu cuenta:

/******************************************
  RAYCASTING CON ARDUINO
  
  Este ejemplo utiliza una pantalla monocromática basada en el chip SSD1306
  para renderizar una escena 3D mediante Raycasting. El jugador puede moverse
  por el escenario mediante cuatro pulsadores conectados a los pines D2, D3, D4
  y D5.
 
 
  Escrito por Transductor y Nano
  www.robologs.net
 ******************************************/

#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

//Propiedades de la cámara:
#define FOV 1.117 //Field of View (en radianes)
#define ANCHO 128 //Ancho de la pantalla/cámara
#define ALTO 64 //Alto de la pantalla/cámara

//Pines para los botones:
#define ADELANTE 2
#define ATRAS 3
#define DERECHA 4
#define IZQUIERDA 5

//Velocidad de movimiento y giro del jugador:
#define VEL 0.3;
#define GIRO 0.2;


//Dimensiones del mapa:
#define MAPA_COLS 10
#define MAPA_FILAS 10

//Distancia de paso para el rayo:
#define PASO 0.05


//Crear un objeto para la pantalla:
Adafruit_SSD1306 display(ANCHO, ALTO, &Wire, -1);

//Varables para guardar la "columna anterior":
float altura_anterior = 0;
float nTecho_anterior = 0;
float nSuelo_anterior = 0;

//Parámetros del jugador:
float xJugador = 2.0; //Posición X
float yJugador = 2.0; //Posición Y
float aJugador = 0.0; //Ángulo (en radianes)


//Mapa del escenario:
int mapa[MAPA_FILAS][MAPA_COLS] = {
  {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}
};

void setup()
{
 //Encender la pantalla:
 display.begin(SSD1306_SWITCHCAPVCC, 0x3C); //Cambia la dirección 0x3C por la de tu pantalla.

 //Establecer los pines de los botones como INPUT:
 pinMode(ADELANTE,INPUT);
 pinMode(ATRAS,INPUT);
 pinMode(DERECHA, INPUT);
 pinMode(IZQUIERDA, INPUT);
 
}

float trazarRayo(int columna)
{
    //La posición inicial del rayo será la misma que el jugador:
    float xRayo = xJugador;
    float yRayo = yJugador;

    //Calculamos su ángulo inicial:
    float aRayo = (aJugador - FOV / 2.0) + columna*(FOV / ANCHO);

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

    //Calcular la trayectoria, hasta que no se colisione con una pared:
    bool choque = false;
    while(!choque)
    {
      xRayo += xIncremento;
      yRayo += yIncremento;
    

      if(mapa[int(yRayo)][int(xRayo)] != 0)
      {
        choque = true;
      
      }    
    
    }

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

    //Retornar la distancia:
    return distancia;

}

void moverJugador()
{
  if(digitalRead(DERECHA) == HIGH)
  {
    aJugador = aJugador + GIRO;
  }
  if(digitalRead(IZQUIERDA) == HIGH)
  {
    aJugador = aJugador - GIRO;
  }
  if(digitalRead(ADELANTE) == HIGH)
  {
    //Avanzar la posición del jugador hacia adelante:
    float incrementoX = cos(aJugador)*VEL;
    float incrementoY = sin(aJugador)*VEL;
    xJugador += incrementoX;
    yJugador += incrementoY;
     
    //Si el jugador ha entrado dentro de una pared se deshace el movimiento:
    if(mapa[int(yJugador)][int(xJugador)] != 0)
    {
      xJugador -= incrementoX;
      yJugador -= incrementoY;
    }
  }
  if(digitalRead(ATRAS) == HIGH)
  {
    //Avanzar la posición del jugador hacia atrás:
    float incrementoX = cos(aJugador)*VEL;
    float incrementoY = sin(aJugador)*VEL;
    xJugador -= incrementoX;
    yJugador -= incrementoY;
     
    //Si el jugador ha entrado dentro de una pared se deshace el movimiento:
    if(mapa[int(yJugador)][int(xJugador)] != 0)
    {
      xJugador += incrementoX;
      yJugador += incrementoY;
    }
  }
}


void loop()
{
  //Borrar la pantalla:
  display.clearDisplay();

  //Comprobar si el jugador está pulsando algún botón:
  moverJugador();
  

  for(int columna = 0; columna < ANCHO; columna++)
  {
  //Calcular la trayectoria del rayo y guardar la distancia hasta el punto de colisión:
  float distancia = trazarRayo(columna);

  //Calcular la altura del muro a partir de la distancia:
  float altura = min(ALTO, ALTO / distancia);
     
  //Calcular el píxel superior (nTecho) e inferior (nSuelo):
  int nTecho = int(float(ALTO) / 2.0 - altura/2);
  int nSuelo = int(float(ALTO) / 2.0 + altura/2);

  //Si tu pantalla permite colores, aquí podrías trazar una línea de otro color
  //para pintar el interior de las paredes. Descomenta la siguiente línea y cambia '?' por el
  //color que prefieras:
  //display.drawLine(columna, nTecho, columna, nSuelo, ?);

  //Dibujar los dos píxeles de los bordes superiores e inferiores:
  display.drawPixel(columna, nTecho, WHITE);
  display.drawPixel(columna, nSuelo, WHITE);

  //Comprobar si hay que dibujar un borde lateral:
  if( fabs(altura - altura_anterior) >= 3)
    {
      if(altura > altura_anterior)
        display.drawLine(columna, nTecho, columna, nSuelo, WHITE);
      else
        display.drawLine(columna-1, nTecho_anterior, columna-1, nSuelo_anterior,WHITE);
    }
     
    //Guardar los parámetros de la columna actual:
    altura_anterior = altura;
    nTecho_anterior = nTecho;
    nSuelo_anterior = nSuelo;
  }

  //Mostrar la imagen:
  display.display();
}


Explicación paso a paso

Empezaremos cargando todas las librerías necesarias para la comunicación entre Arduino y la pantalla:

#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

 

Después definimos constantes que utilizaremos durante todo el programa:

//Propiedades de la cámara:
#define FOV 1.117 //Field of View (en radianes)
#define ANCHO 128 //Ancho de la pantalla/cámara
#define ALTO 64 //Alto de la pantalla/cámara

//Pines para los botones:
#define ADELANTE 2
#define ATRAS 3
#define DERECHA 4
#define IZQUIERDA 5

//Velocidad de movimiento y giro del jugador:
#define VEL 0.3;
#define GIRO 0.2;


//Dimensiones del mapa:
#define MAPA_COLS 10
#define MAPA_FILAS 10

//Distancia de paso para el rayo:
#define PASO 0.05

 

Creamos un objeto de clase “Adafruit_SSD1306” con el nombre display, que nos permitirá utilizar todas las funciones de la librería de Adafruit para controlar la pantalla:

//Crear un objeto para la pantalla:
Adafruit_SSD1306 display(ANCHO, ALTO, &Wire, -1);

 

Creamos variables para almacenar las propiedades una columna. Cuando calculemos la altura de cada columna con un bucle for, estas variables guardarán la columna de la iteración inmediatamente anterior. Nos servirán para calcular los bordes de las paredes:

//Varables para guardar la "columna anterior":
float altura_anterior = 0;
float nTecho_anterior = 0;
float nSuelo_anterior = 0;

 

También hay que definir variables que almacenarán la posición (X,Y) del jugador y el ángulo hacia el que está apuntando:

//Parámetros del jugador:
float xJugador = 2.0; //Posición X
float yJugador = 2.0; //Posición Y
float aJugador = 0.0; //Ángulo (en radianes)

 

El mapa se guardará, como hemos dicho antes, en una matriz 2D de ceros y unos. Las casillas con valor 1 serán las paredes.

//Mapa del escenario:
int mapa[MAPA_FILAS][MAPA_COLS] = {
  {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}
};

 

Dentro de la función setup() encendemos la pantalla y establecemos los pines de los botones como entradas. No te olvides de cambiar el número 0x3C por la dirección de tu pantalla.

void setup()
{
 //Encender la pantalla:
 display.begin(SSD1306_SWITCHCAPVCC, 0x3C); //Cambia la dirección 0x3C por la de tu pantalla.

 //Establecer los pines de los botones como INPUT:
 pinMode(ADELANTE,INPUT);
 pinMode(ATRAS,INPUT);
 pinMode(DERECHA, INPUT);
 pinMode(IZQUIERDA, INPUT);
 
}

 

Para no poner todo el resto del programa dentro de la función loop() vamos a separar el código que se ocupa del cálculo de la trayectoria de los rayos y el código del movimiento del jugador en dos funciones distintas.

La función trazarRayo() recibirá como parámetro el índice de una columna de la pantalla, calculará la trayectoria del rayo correspondiente hasta chocar con una pared y devolverá la distancia del jugador hasta el punto de colisión:

float trazarRayo(int columna)
{
    //La posición inicial del rayo será la misma que el jugador:
    float xRayo = xJugador;
    float yRayo = yJugador;

    //Calculamos su ángulo inicial:
    float aRayo = (aJugador - FOV / 2.0) + columna*(FOV / ANCHO);

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

    //Calcular la trayectoria, hasta que no se colisione con una pared:
    bool choque = false;
    while(!choque)
    {
      xRayo += xIncremento;
      yRayo += yIncremento;
    

      if(mapa[int(yRayo)][int(xRayo)] != 0)
      {
        choque = true;
      
      }    
    
    }

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

    //Retornar la distancia:
    return distancia;

}

 

La función moverJugador() modificará la posición y el ángulo del jugador según el botón que se esté pulsando en ese momento:

void moverJugador()
{
  if(digitalRead(DERECHA) == HIGH)
  {
    aJugador = aJugador + GIRO;
  }
  if(digitalRead(IZQUIERDA) == HIGH)
  {
    aJugador = aJugador - GIRO;
  }
  if(digitalRead(ADELANTE) == HIGH)
  {
    //Avanzar la posición del jugador hacia adelante:
    float incrementoX = cos(aJugador)*VEL;
    float incrementoY = sin(aJugador)*VEL;
    xJugador += incrementoX;
    yJugador += incrementoY;
     
    //Si el jugador ha entrado dentro de una pared se deshace el movimiento:
    if(mapa[int(yJugador)][int(xJugador)] != 0)
    {
      xJugador -= incrementoX;
      yJugador -= incrementoY;
    }
  }
  if(digitalRead(ATRAS) == HIGH)
  {
    //Avanzar la posición del jugador hacia atrás:
    float incrementoX = cos(aJugador)*VEL;
    float incrementoY = sin(aJugador)*VEL;
    xJugador -= incrementoX;
    yJugador -= incrementoY;
     
    //Si el jugador ha entrado dentro de una pared se deshace el movimiento:
    if(mapa[int(yJugador)][int(xJugador)] != 0)
    {
      xJugador += incrementoX;
      yJugador += incrementoY;
    }
  }
}

 

Tan sólo nos queda escribir la función loop(), que será el bucle principal del “juego”. Empezamos esta función limpiando la pantalla y comprobando los botones:

void loop()
{
  //Borrar la pantalla:
  display.clearDisplay();

  //Comprobar si el jugador está pulsando algún botón:
  moverJugador();

 

Después se define un bucle que recorrerá todas las columnas de la pantalla, se llama a la función trazarRayo() (pasando como parámetro la columna actual) y se guarda la distancia en una variable:

  for(int columna = 0; columna < ANCHO; columna++)
  {
  //Calcular la trayectoria del rayo y guardar la distancia hasta el punto de colisión:
  float distancia = trazarRayo(columna);

 

Después se calcula la altura a partir de la distancia, y se busca el índice de los píxeles superior e inferior de la columna vertical:

  //Calcular la altura del muro a partir de la distancia:
  float altura = min(ALTO, ALTO / distancia);
     
  //Calcular el píxel superior (nTecho) e inferior (nSuelo):
  int nTecho = int(float(ALTO) / 2.0 - altura/2);
  int nSuelo = int(float(ALTO) / 2.0 + altura/2);

 

En caso de que tu pantalla permita color (y sólo en este caso), podrías añadir esta línea para pintar el interior de las paredes (descomentándola y cambiando ‘?’ por el color que quieras):

    //display.drawLine(columna, nTecho, columna, nSuelo, ?);

 

Ahora se dibujan los píxeles de los bordes superior e inferior de las paredes:

  //Dibujar los dos píxeles de los bordes superiores e inferiores:
  display.drawPixel(columna, nTecho, WHITE);
  display.drawPixel(columna, nSuelo, WHITE);

 

Se comprueba si también hay que dibujar el borde lateral:

  //Comprobar si hay que dibujar un borde lateral:
  if( fabs(altura - altura_anterior) >= 3)
    {
      if(altura > altura_anterior)
        display.drawLine(columna, nTecho, columna, nSuelo, WHITE);
      else
        display.drawLine(columna-1, nTecho_anterior, columna-1, nSuelo_anterior,WHITE);
    }

 

Para acabar, se guardan los parámetros de la columna actual y se dibuja toda la escena sobre la pantalla:

    //Guardar los parámetros de la columna actual:
    altura_anterior = altura;
    nTecho_anterior = nTecho;
    nSuelo_anterior = nSuelo;
  }

  //Mostrar la imagen:
  display.display();
}

 

Y con esto ya tienes tu propio motor de Raycasting dentro de una placa Arduino. Si cargas el programa verás el escenario en la pantallita, y podrás desplazarte pulsando los cuatro botones.

Si tienes alguna pregunta o problema con el tutorial no dudes en escribir un comentario; te responderé cuando pueda. Y si te ha gustado este artículo puedes seguirnos en nuestra página de Facebook o Twitter. Es una buena forma de darnos apoyo y, a la vez, estar al corriente de nuestras nuevas publicaciones.

Esto es todo por hoy. 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
0 Comments
Inline Feedbacks
Ver todos los comentarios