Introducción

Este es el quinto y último artículo sobre raycasting. Estrictamente hablando no tiene mucho que ver con raycasting, es solo la culminación del trabajo realizado en las anteriores 4 entregas.

Veremos la manera en la que se pueden implementar texturas y otros objetos interesantes como puertas, cercas, etc.

Texturas

Implementar texturas no es muy distinto a pintar las paredes de colores sólidos como lo estamos haciendo. Ya tenemos el tipo de superficie y el tamaño de la columna, entonces solo hace falta:

  1. Cargar la textura correcta.
  2. Obtener el pedazo de la textura correspondiente.
  3. Escalar la textura al tamaño de la columna.
  4. Pintar la textura en vez del color sólido.

En las implementaciones anteriores estaba usando Quil para manejar todo: el game loop, el render y el manejo del estado de la simulación. Sin embargo cuando intenté usarlo para pintar las texturas, el resultado fue muy lento. Extremadamente lento de hecho, estamos hablando de 2 a 5 cuadros por segundo.

Antes de culpar a Quil, estoy consciente de que puede ser mi propio desconocimiento del funcionamiento de la librería. Pero lo que es realmente frustrante es que sabía que usando el elemento canvas puro, la implementación debería ser extremadamente rápida ya que estaremos pintando secciones de la misma imagen. Secciones distintas y con distintas dimensiones (scaling), pero a fin de cuentas es la misma imagen.

Finalmente decidí hacerlo "a pie", y efectivamente los cuadros por segundo volvieron a la normalidad.

Todas las texturas vienen como una sola imagen (todos los derechos reservados id Software no me demanden por favor):

textures

Como se pueden dar cuenta, cada textura viene en un cuadro de 64x64 en dos versiones: iluminada y no iluminada.

Cargar la textura

Como el motor gráfico se ejecuta en el navegador, necesitamos primero que la imagen con las texturas se encuentre disponible en algún lado de donde lo podamos cargar:

(def quaker-state (atom {:textures nil :canvas nil}))

(defn load-texture
  [src]
  (let [img (js/Image.)]
    (set! (.. img -src) src)
    (swap! quaker-state update-in [:textures] (constantly img))))
    
(defn canvas-ref
  [id]
  (swap! quaker-state update-in [:canvas]
         #(-> js/document (.getElementById id) (.getContext "2d"))))

El código anterior crea un atom para guardar el estado del programa, en donde vamos a guardar una referencia a la imagen que contiene las texturas, y el elemento HTML canvas. Simplemente estamos asignando a la propiedad src de la imagen, la URL de donde podemos cargar las texturas. Después en canvas-ref hacemos lo propio, guardando la referencia al contexto 2D del elemento canvas.

Estas dos funciones hay que llamarlas cuando iniciamos nuestro programa:

(defn initial-state
  []
  (let [[width height] world-size
        fov (/ q/PI 3)
        half-fov (* fov 0.5)
        ray-inc (/ fov width)
        rotation (- (* 0.3 q/PI))
        dir (+ rotation (* 0.5 q/PI))
        num-rays width]
    (load-texture "textures/textures.png")
    (canvas-ref "textured")
    {:fov fov :half-fov (* fov 0.5) :world-size world-size :ray-inc ray-inc
     :rotation rotation :dir dir :res (/ q/PI 64) :pos [150 150]
     :num-rays num-rays :unit unit :pressed-keys #{} :accel 5
     :projection-dist (int (/ (/ width 2) (Math/tan half-fov)))}))

Es exáctamente la misma función anterior, solo que aprovechamos el viaje para ejecutar las dos funciones anteriores.

Obtener el pedazo de la textura correspondiente

Las texturas vienen en sets de 6 por fila, pero en realidad son solo 3, cada una con 2 versiones.

Recordando que nuestro mundo está compuesto por bloques de 64 unidades, necesitamos saber exactamente en qué columna del 0 a 63 es donde está pegando el rayo. Basados en esta columna, obtendremos la sección correspondiente de la textura. Esto es muy sencillo, ya que nuestro algoritmo para revisar las intersecciones de los rayos nos dice precisamente en donde está pegando el rayo.

  • Para un cruce horizontal: punto intersección horizontal % 64
  • Para un cruce vertical: punto intersección vertical % 64

A este número le vamos a llamar texture-offset, es decir que tan alejado de la esquina izquierda de la textura se encuentra la columna que nos interesa. Con este valor y la dirección del cruce (horizontal o vertical) podemos crear una función que nos diga las coordenadas (x,y) de donde necesitamos tomar nuestro pedazo de textura:

(defn get-texture-offset
  "Calculate the correct offset to load a texture"
  [texture-number texture-slice direction]
  (let [row (Math/ceil (/ texture-number 3))
        x-offset (condp = (rem texture-number 3)
                   1 0
                   2 128
                   0 256)
        x-offset (cond
                   (= direction :h) x-offset
                   (= direction :v) (+ x-offset 64)
                   :else x-offset)]
    [(+ x-offset texture-slice) (int (* (dec row) 64))]))
  • texture-number es el tipo de pared (el valor contenido en el mapa).
  • texture-slice es el offset que calculamos anteriormente.
  • direction es si el cruce fue horizontal o vertical.

direction no es estríctamente necesario, pero le agrega un toque de "realismo" al usar diferentes iluminaciones dependiendo de la dirección. Es una manera de agregar iluminación sin consumir recursos extra.

Primero determinamos la fila a la que corresponde la textura. La imagen fuente se encuentra organizada en juegos de 3 texturas, asi que dividimos el número de textura entre 3.

Después calculamos el offset desde la esquina izquierda. Nostros tenemos el offset dentro de la textura final (es decir, de 0 a 63) pero necesitamos saber si esto es para la textura en la columna 1, 2 o 3. Dependiendo del resultado el offset será 0 (columna 1), 128 (columna 2) o 256 (columna 3). Esto porque cada textura tiene sus dos versiones.

Por ejemplo, si la pared es del tipo 1, la textura que le corresponde es la que se encuentra en las coordenadas (0,0) o (64,0) dependiendo si el cruce fue horizontal o vertical. La pared del tipo 2 le corresponde la textura en (128,0) o (192,0) y así sucesivamente.

Si el cruce es horizontal, dejamos el offset como está (textura iluminada). Si el cruce es vertical le agregamos 64 (textura no iluminada).

Todas los trozos de texturas serán de 1 píxel de ancho y 64 de alto. La coordenada y es simplemente row * 64.

Escalar la textura al tamaño de la columna

Ya tenemos el trozo de textura que queremos pintar, y el tamaño en el que la queremos pintar (el tamaño de la columna).

Pintarla no podría ser más sencillo. El API de canvas tiene una función drawImage que es capaz de pintar una sección de imagen, en las coordenadas de la pantalla que le indiques, escalada en el tamaño que le indiques.

Ni mandada a hacer.

Lo único que hay que hacer es cambiar esto:

(q/rect i (/ (- height wall-height) 2) 1 wall-height)

Por esto:

(.drawImage canvas textures sx sy 1 64 i y 1 wall-height)
  • canvas es el contexto 2D del elemento canvas que guardamos anteriormente en el estado de la aplicación.
  • textures es la imagen fuente.
  • sx y sy son las coordenadas (x,y) de la esquina superior izquierda de la imagen fuente que vamos a pintar (son las coordenadas que calculamos en get-texture-offset)
  • 1 y 64 es el ancho y largo del trozo que vamos a tomar de la imagen fuente.
  • i y y son las coordenadas (x,y) de la pantalla en donde vamos a pintar la imagen.
  • 1 y wall-height es el ancho y largo de la imagen que vamos a pintar en la pantalla (scaling).

Y es todo en realidad. Lo más complicado fue batallar con Quil hasta que dije

(ノಠ益ಠ)ノ彡┻━┻

y lo hice directamente con .drawImage.

Otros efectos

Una vez que entendemos que raycasting no es más que matemáticas en 2D, podemos hacer muchas cosas más. Por ejemplo en Wolfenstein 3D, las puertas tienen un ancho de 1 píxel (como si fueran una pared muy delgada) y se ven como si estuvieran "metidas" en el medio de dos paredes, justo como una puerta en su marco.

Esto lo podemos lograr modificando el algoritmo que extiende los rayos. Actualmente si el rayo choca con pared regresa las coordenadas, y si no choca lo exitende al siguiente punto de intersección.

Podemos agregar dos tipos nuevos de paredes: paredes delgadas horizontales y verticales:

(defn thin-hor-wall?
  [point unit]
  (let [[x y] (coord->grid point unit)]
    (or (not (inside-map? point unit))
        (= (grid-value x y) \H))))

(defn thin-ver-wall?
  [point unit]
  (let [[x y] (coord->grid point unit)]
    (or (not (inside-map? point unit))
        (= (grid-value x y) \V))))

Entonces nuestro mapa además de números puede contener los caracteres \H y \V para paredes delgadas horizontales y verticales, respectivamente.

Como las paredes horizontales no tienen grosor, los rayos solo pueden topar con ella cuando revisamos cruces horizontales. Lo mismo para las paredes verticales. Es incluso más sencillo que nuestro algoritmo de detección de cruces actual, porque solo tenemos que preocuparnos por un tipo de intersecciones.

En el caso de intersecciones horizontales, podemos usar algo así:

(loop [Ax Ax Ay Ay]
      (cond
        ;; a regular wall
        (wall? [Ax Ay] unit) [(Math/floor Ax) (Math/floor Ay)]
        ;; a thin horizontal wall
        (thin-hor-wall? [Ax Ay] unit) [(Math/floor (+ Ax (* 0.2 Xa))) (Math/floor (+ Ay (* 0.2 Ya)))]
        ;; no wall, extend the ray
        :else (recur (+ Ax Xa) (+ Ay Ya))))

Básicamente si la celda correspondiente contiene un \H regresamos las coordenadas del rayo pero extendiéndolo solo al 20% de lo normal. Visto desde arriba (en 2D) se vería algo como así:

  |        |
  |        |
  |        |
  |--------|
__|        |__

      V

Y si rotamos y lo vemos de norte a sur:

      ^
  |        |
  |--------|
  |        |
  |        |
  |        |
__|        |__

Esto da la ilusión de la "puerta" está más al fondo entre dos paredes que hacen las veces de marco de la puerta. En realidad esta puerta es una pared sin grosor.

Para abrir y cerrar puertas, podemos tener un timer que modifica un valor representando que tan visible debe estar una puerta determinada. Al accionar la puerta, se inicia el timer. Después en thin-hor-wall? verificamos si el cruce del rayo es mayor o menor al límite visible de la puerta. Si es mayor, regresamos false y por consiguiente extendemos el rayo. Tendríamos también que ajustar el valor de la textura para que se mueva junto con la puerta y se logre el efecto deseado.

Para tener cercas (fences) podemos aplicar el mismo principio de una puerta semi-abierta, pero con varias aberturas fijas en vez de una sola movible.

Demo

El demo se maneja igual que el demo de Raycasting 4. Observen como las puertas que están entre los pilares se encuentran "metidas" y no al raz de las columnas que las contienen.

También noten como las paredes en dirección vertical usa texturas menos iluminadas que las paredes en dirección horizontal.

Conclusión

Con esto doy por terminada la serie sobre raycasting. Empecé a programar un raycaster simple como una exploración de las ideas expuestas en el libro Game Engine Black Book: Wolfenstein 3D, y poco a poco el objetivo se fue expandiendo para incluir solo este pequeño detalle más. Por lo general así ha sucedido en todos mis artículos anteriores; el contenido nace de los resultados de un experimento que va creciendo en alcance de manera orgánica, y no es que el experimento nazca de la idea o necesidad de generar cierto contenido en particular.

No me quedo entéramente satisfecho. Aún hay algunos errores que me hubiera gustado corregir (el efecto pecera no está completamente corregido, es más obvio al hacer strafing) y características que me hubiera gustado implementar (como por ejemplo interactuar con las puertas, sprites) pero cada vez puedo justificar menos el tiempo invertido, a la vez que mi interés comienza a enfocarse en otras direcciones.

Aunque me acaba de llegar este otro libro, así que nunca se sabe...