Recapitulando

En la tercera entrega dejamos todo listo, pero en 2D. En realidad dar el salto a 3D no es muy complicado. Ya tenemos la distancia de cada rayo, lo único que falta es determinar la altura de cada columna, y pintarlas.

Al final veremos nuestro mundo virtual en glorioso 3D.

Pintando columnas

Recordemos que en raycasting pintamos el mundo en columnas y no píxeles. Por cada columna tenemos un rayo, y por cada rayo tenemos la distancia del mismo hasta que choca contra una pared.

Entonces por cada rayo vamos a pintar una columna. El color de la columna dependerá de la pared contra la que choque el rayo. Podemos tener un simple mapa que nos diga que número corresponde a qué color:

(def color-map
  {1 [255 184 108]  ;; orange
   2 [255 121 198]  ;; pink
   3 [189 147 249]  ;; purple
   4 [255 85 85]    ;; red
   5 [139 233 253]  ;; cyan
   7 [255 121 198]  ;; pink
   10 [189 147 249] ;; purple
   11 [255 85 85]   ;; red
  })

Actualmente tenemos distancia, es decir que tan lejos está la pared del jugador. A mayor distancia, la pared se verá más pequeña. De alguna manera necesitamos convertir esta distancia en altura de cada columna.

Pero antes de eso veamos como quedaría la función para pintar las columnas. Recordemos que solo necesitamos pintar una columna por rayo. Como tenemos igual número de rayos como píxeles de ancho de nuestro viewport, entonces nuestras columnas tendrán un ancho de 1 píxel:

(defn draw-columns
  [{:keys [pos dir rotation unit world-size projection-dist] :as state}]
  (let [[width height] world-size]
    (doall
     (map-indexed
      (fn [i alpha]
        (let [[distance wall-type] (engine/find-intersect pos alpha unit)
              distance (engine/fishbowl-correction distance (- rotation alpha))
              wall-height (engine/wall-height distance unit projection-dist)]
          (q/with-fill (get g/color-map wall-type [255 184 108])
            (q/with-stroke (get g/color-map wall-type [255 184 108])
              (q/rect i (/ (- height wall-height) 2) 1 wall-height)))))
      (engine/ray-angles state)))))

Ignoremos por un momento engine/fishbowl-correction y engine/wall-height y concentrémonos en engine/find-intersect. Esta es la misma función que ya vimos en Raycasting 3.

Primero engine/ray-angles nos regresa los ángulos, y se lo pasamos a map-indexed. Esta función nos permite aplicar una función (igual que map) con la diferencia de que además nos va a pasar el index, es decir la posición que llevamos en el vector de entrada. Este index es la columna que vamos a pintar.

Finalmente pintamos una raya vertical de 1 píxel de ancho y wall-height de largo (que más adelante veremos cómo calcular).

La posición de la columna (es decir, en donde empezamos a pintar) depende de la altura del jugador. Estamos suponiendo que el jugador tiene una altura de la mitad de la unidad (es decir 32 unidades) por lo que las columnas estarán centradas en nuestro viewport.

De lo anterior que la columna inicie en:

(/ (- height wall-height) 2)

Altura de las paredes

La altura de las paredes está directamente relacionada con la distancia del rayo (a mayor distancia, menor altura) y a la distancia de la proyección. Este es un concepto nuevo que no habíamos necesitado hasta ahora.

La distancia de la proyección es básicamente la distancia entre el jugador y la pantalla en donde vamos a proyectar la imagen en 3D.

¿Qué datos conocemos? (todo esto se vió en Raycasting 2

  • Ancho de la pantalla (512 píxeles)
  • Campo de visión π/3

Veamos la siguiente imagen:

fov

Como podemos observar, el jugador forma un triángulo con la pantalla. Podemos imaginar una línea imaginaria que va del centro del FOV hacia la pantalla, y forma un ángulo de π/2 con la misma.

Esa línea es la distancia de proyección, y es el cateto adyacente de nuestro triángulo imaginario, por lo que conocerlo es muy simple, ya que conocemos la longitud del cateto opuesto (ancho/2) y el ángulo adyacente (FOV/2):

(/ (/ width 2) (Math/tan half-fov))

De hecho esta distancia se mantendrá constante a lo largo de la simulación, siempre y cuando no se altere el FOV y el ancho de la proyección.

Para calcular entonces la altura de las paredes:

(defn wall-height
  [distance unit projection-distance]
  (Math/ceil (* (/ unit distance) projection-distance)))

Anteriormente mencionamos que en nuestro mundo, todas las paredes miden 64 unidades, por lo que la altura de todas las paredes es de 64 (no tenemos paredes de altura variable).

La función anterior obtiene la relación entre el tamaño real de la pared (64) y la distancia del jugador a la pared, y multiplica esta relación por la distancia del jugador al viewport para obtener así el tamaño de la pared percibido desde los ojos del jugador.

Hasta aquí tenemos lo suficiente para comenzar a proyectar nuestro mundo. Solo queda un pequeño detalle...

Corrección del efecto "pecera" (fishbowl effect)

Cuando definimos un punto en un plano 2D con coordenadas (x,y) estamos usando el sistema cartesiano de coordenadas. Cuando definimos un punto por el ángulo que forma de un origen y la distancia al mismo, estamos usando el sistema de coordenadas polares.

Nosotros estamos combinando ambos sistemas de coordenadas y eso causa distorción en la imagen.

Cuando lanzamos los rayos desde la posición del jugador, lo hacemos con cierto ángulo y la distancia la calculamos como ya vimos anteriormente. El problema es que a medida que los rayos se alejan del centro del FOV, la distancia que recorre para chocar contra la pared se vuelve más larga (la ruta más corta es una línea recta). Entre más largo el rayo, más pequeña la columna que dibujamos.

fishbowl-1

El ojo humano corrige este efecto gracias a que es esférico, pero nuestro plano de proyección es, valga la redundancia, plano.

Encontrar la distancia sin distorción es un problema trigonométrico que, afortunadamente, ya hemos resuelto de sobra:

undistort

Conocemos:

  • La magnitud de la hipotenusa (la distancia del rayo).
  • El ángulo (es el ángulo del rayo)

Y necesitamos la magnitud del cateto adyacente:

(defn fishbowl-correction
  [dist beta]
  (* dist (Math/cos beta)))

Es importante aclarar que beta es la diferencia entre el ángulo de rotación (la dirección hacia donde está viendo el jugador) y el ángulo del rayo (la hipotenusa en el diagrama).

Demo

Finalmente llegamos a poner todo en práctica. El movimiento es con las mismas teclas WASD y las flechas de dirección.

Además, pueden mantener presionada la barra de espacio para observar la distorción que sucede si no compensamos el "efecto pecera". Es mucho más evidente a distancias cortas, y bastante trippy.

Y por último, he subido el código completo a mi cuenta de Github. Pásele.

Conclusión

Hemos finalizado el camino desde un simple círculo con ángulos normalizados, hasta una visualización en pseudo-3D.

Obviamente hay muchas cosas que quedaron fuera del alcance de esta serie, por ejemplo mejorar el movimiento del jugador cuando se topa con pared (wall surfing), sprites, enemigos con inteligencia artificial, puertas animadas, cosas que ya entran en el terreno de un juego como tal.

Sin embargo hay una cosa más que quisiera cubrir, que hace toda la diferencia: paredes con texturas. Lo veremos en la quinta y última entrega de esta serie.