← CC2018

Raycasting "3D"

Semestre 02, 2026

De un rayo a una estaca

  • En la vista 2D el campo de visión eran unos cuantos rayos dibujados sobre el piso.
  • En la vista en primera persona cada rayo se convierte en una línea vertical de un solo píxel de ancho: una estaca.
  • La distancia que recorrió el rayo decide qué tan alta se ve esa estaca; el carácter de la pared decide su color.
  • Nunca se dibuja geometría tridimensional: es el mismo cálculo 2D repetido una vez por columna.

Los parámetros que uno elige

Todo el motor se construye a partir de estos cuatro números. Cambiarlos no cambia las fórmulas.

\(W\): ancho de la ventana en píxeles. Define cuántos rayos se lanzan.
\(H\): alto de la ventana en píxeles. Define dónde queda el horizonte y dónde se recorta.
\(FOV\): el campo de visión, el ángulo total que abarca la mirada.
\(BLOCK\_SIZE\): la altura de una pared, medida en unidades del mundo.

Los valores derivados

Se calculan una sola vez al arrancar, nunca dentro del bucle de rayos.

Fórmulas generales $$ horizonte = \frac{H}{2} $$ $$ d_{plano} = \frac{W/2}{\tan(FOV/2)} $$ $$ \Delta\beta = \frac{FOV}{W-1} $$
Con \(W = 1300\), \(H = 900\), \(FOV = 60°\)
horizonte = 900 / 2 = 450
d_plano   = 650 / tan(30°)
           = 650 / 0.5773503
           = 1125.83
Δβ        = 60° / 1299 = 0.046189°

La orientación de la pantalla

  • Antes de calcular nada hay que fijar el sistema de coordenadas: de él dependen todos los signos.
  • El origen \((0, 0)\) está en la esquina superior izquierda.
  • La \(x\) crece hacia la derecha y la \(y\) crece hacia abajo.

   (0,0)                        (W-1,0)
      ┌──────────────────────────────┐
      │                              │
   y  │                              │
 crece│- - - - - y = H/2 - - - - - - │ horizonte
 hacia│                              │
 abajo│                              │
      ▼                              │
      └──────────────────────────────┘
   (0,H-1)                    (W-1,H-1)
              x  ──────────►

La convención viene del framebuffer


framebuffer.point(x, y)
// → buffer[y * ancho + x]
Por qué al revés que en matemáticas

La memoria de video guarda los píxeles fila por fila, y la primera fila que se guarda es la que se dibuja hasta arriba.

El mundo del laberinto usa la misma orientación: la fila 0 de maze.txt es la pared de hasta arriba.
Archivo de texto, mundo y pantalla coinciden, así que nunca hay que voltear nada.

Un rayo por cada columna de píxeles

  • Se lanza exactamente un rayo por columna: una pantalla de \(W\) píxeles de ancho pide \(W\) rayos por cuadro.
  • Cada rayo devuelve la distancia que recorrió hasta chocar y el carácter de la pared que golpeó.
  • Con la distancia se calcula la altura; con el carácter se decide el color.
  • El índice del rayo y el índice de la columna son el mismo número \(i\), y eso solo vale porque la relación es uno a uno.
Estacas verticales paradas una junto a la otra

La imagen son W estacas paradas


   y = 0        ┌───┐
                │   │   ← fondo
   y = arriba   ├───┤
                │███│
   y = H/2      │███│   ← horizonte
                │███│
   y = abajo    ├───┤
                │   │   ← fondo
   y = H-1      └───┘
                  ↑
             un píxel de ancho
Idea clave

La estaca es un solo segmento continuo, no dos partes. Arriba y abajo son sus dos extremos, y siempre está centrada en el horizonte.

El desvío de cada columna

  • El jugador mira hacia un ángulo \(a\). El campo de visión se reparte a los dos lados de esa dirección.
  • El rayo de la columna 0 sale en \(a - FOV/2\) y el de la última columna en \(a + FOV/2\).
  • Al desvío de un rayo respecto a la dirección de vista se le llama \(\beta\), y es lo único que hace falta para corregir el ojo de pez.

  columna 0                    columna W-1
      ╲            │            ╱
       ╲     β < 0 │ β > 0     ╱
        ╲          │          ╱
         ╲         │         ╱
          ╲        │        ╱
           ╲   a - FOV/2   ╱
            ╲      │      ╱
             ╲     │     ╱
              ╲    │    ╱
               ╲   │   ╱  ← dirección de vista a
                ╲  │  ╱
                 ╲ │ ╱
                  ╲│╱
                jugador

Cómo se llega al desvío

Paso 1 — la fracción de pantalla

La columna \(i\) recorre de \(0\) a \(W-1\). Esa fracción va de 0 en la orilla izquierda a 1 en la derecha.

$$ t = \frac{i}{W-1} $$
Paso 2 — repartir el FOV

Se arranca en la orilla izquierda del cono y se avanza \(FOV\) grados a lo largo de toda la pantalla.

$$ \beta_i = -\frac{FOV}{2} + FOV \cdot t $$

El desvío en una sola fórmula

Forma directa $$ \beta_i = FOV\left(\frac{i}{W-1} - \frac{1}{2}\right) = -\frac{FOV}{2} + \Delta\beta \cdot i $$
El ángulo absoluto del rayo es \(a + \beta_i\).
El desvío no depende de hacia dónde mira el jugador: solo de la columna, así que se puede precalcular.

El desvío a lo ancho de la pantalla

Con \(W = 1300\) y \(FOV = 60°\), el paso entre columnas vecinas es \(\Delta\beta = 0.046189°\).


  columna i     t = i / 1299     β = -30° + 0.046189° · i      cos β
          0         0.000000                      -30.0000°   0.866025
        325         0.250192                      -14.9885°   0.965978
        650         0.500385                       +0.0231°   1.000000
        975         0.750577                      +15.0346°   0.965769
       1299         1.000000                      +30.0000°   0.866025
Lectura

El desvío es negativo en la mitad izquierda, cruza por cero cerca del centro y es positivo en la derecha. Con \(W\) par ninguna columna cae exactamente en el eje: la 650 queda a 0.0231 grados del centro.

El plano de proyección

  • Imaginá la pantalla como una ventana de vidrio flotando a cierta distancia enfrente del ojo.
  • Lo que se dibuja es la sombra que el mundo proyecta sobre ese vidrio.
  • La ventana mide \(W\) píxeles de ancho y el cono del FOV tiene que cubrirla exactamente, ni más ni menos.
  • Esa condición es la que fija a qué distancia va la ventana.

        plano de proyección
   ┌──────────────┬──────────────┐
   │              │              │
   │◄──── W/2 ────►              │
   │              │              │
   └──────────────┴──────────────┘
            ╲     │     ╱
             ╲    │    ╱
              ╲ FOV/2 ╱
               ╲  │  ╱   d_plano
                ╲ │ ╱
                 ╲│╱
                  ●  ojo

     tan(FOV/2) = (W/2) / d_plano

Para qué sirve la distancia al plano

El rayo devuelve una distancia en unidades del mundo. La pantalla se mide en píxeles. Hace falta un puente entre las dos.


 ojo         plano                    pared
  │◄─ d_plano ─►│                       │
  │             ┤ ─┐               ┌────┤ ─┐
  │             │  │ altura        │    │  │ BLOCK_SIZE
  ●─────────────┼──┼───────────────┼────┤  │
  │             │  │               │    │  │
  │             ┤ ─┘               └────┤ ─┘
  │                                     │
  │◄─────────── corregida ─────────────►│
Triángulos semejantes $$ \frac{altura}{BLOCK\_SIZE} = \frac{d_{plano}}{corregida} $$

La pared y su imagen forman dos triángulos con el mismo vértice en el ojo, así que sus lados guardan la misma proporción.

Despejando $$ altura = \frac{BLOCK\_SIZE}{corregida} \cdot d_{plano} $$

La distancia al plano es la escala de la cámara

Es el factor que convierte tamaños del mundo en píxeles. Sin él, la fórmula de la altura daría un número sin unidades.
Amarra el alto con el ancho: el FOV horizontal y el tamaño de las paredes salen del mismo número, así que la imagen no se estira ni se aplasta.
Es el zoom. FOV grande deja el plano más cerca del ojo y todo se ve más chico; FOV pequeño lo aleja y todo se ve más grande.
Depende solo de \(W\) y \(FOV\), así que se calcula una vez al arrancar y no \(W\) veces por cuadro.

La misma fórmula en otras pantallas


      W     FOV      d_plano = (W/2) / tan(FOV/2)      Δβ por columna
   1300     60°                          1125.83            0.046189°
    800     60°                           692.82            0.075094°
   1920     60°                          1662.77            0.031266°
   1300     90°                           650.00            0.069284°
   1300     45°                          1569.24            0.034642°
A igual FOV, una pantalla más ancha aleja el plano en la misma proporción: la imagen crece, no se deforma.
A igual ancho, subir el FOV a 90° acerca el plano a 650: entra más mundo y las paredes se ven más pequeñas.

La altura de la estaca

La fórmula $$ altura = \frac{BLOCK\_SIZE}{corregida} \cdot d_{plano} $$
Ejemplo: pared a 300 de distancia corregida
altura = (100 / 300) · 1125.83
altura = 0.3333 · 1125.83
altura = 375.28 px
Al doble de lejos, la mitad de alto
corregida  300 → altura 375.28
corregida  600 → altura 187.64
corregida 1200 → altura  93.82
Esa relación inversa es toda la sensación de profundidad: lo lejano se encoge solo.

Repartir la altura en dos mitades

  • La fórmula da el alto total de la estaca, pero no dice en qué parte de la pantalla va.
  • La estaca tiene que quedar centrada en el horizonte, así que la mitad sube y la mitad baja.
  • Ese es el único motivo por el que aparece el dos.
Las dos orillas $$ arriba = horizonte - \frac{altura}{2} $$ $$ abajo = horizonte + \frac{altura}{2} $$

  y = 0              ┌───┐
                     │   │
  arriba = 262 ──────├───┤ ─┐
                     │███│  │ 187.64
  horizonte = 450 ───┼███┤ ─┤
                     │███│  │ 187.64
  abajo = 637 ───────├───┤ ─┘
                     │   │
  y = 899            └───┘

  altura = 375.28 = 187.64 + 187.64

La estaca centrada en el horizonte

  • El horizonte está en \(H/2\), la mitad exacta de la ventana.
  • La estaca crece media altura hacia arriba y media hacia abajo desde ese punto.
  • Por eso el ojo queda siempre a la mitad de la pared, como si el jugador midiera medio bloque.
  • Mover el horizonte hacia arriba o hacia abajo es lo que da la sensación de agacharse o de subir la mirada.
La estaca centrada en el horizonte del viewport

La corrección de ojo de pez

  • La distancia que devuelve el rayo se mide a lo largo del rayo, no a lo largo de la dirección de vista.
  • Frente a una pared plana, los rayos de las orillas llegan en diagonal y recorren más camino que el del centro.
  • Como la altura depende de la distancia, esas estacas salen más bajas y la pared recta se ve curveada hacia adentro.

              pared plana
    ════════════════════════════
      ╲         │         ╱
       ╲        │        ╱
        ╲       │       ╱
         ╲      │      ╱
          ╲     │     ╱
           ╲    │    ╱
            ╲   │   ╱
             ╲  │  ╱
              ╲ │ ╱
               ╲│╱
             jugador

  rayo diagonal    rayo del centro
  recorre MÁS      recorre MENOS
  → estaca baja    → estaca alta

Proyectar sobre la dirección de vista

La corrección $$ corregida = cruda \cdot \cos\beta $$

El coseno se toma del desvío \(\beta\) de la columna, no del ángulo absoluto del rayo. Por eso conviene guardar \(\beta\) desde que se calcula.

\(\beta = 0°\): \(\cos = 1.000\), la distancia no cambia.
\(\beta = \pm 15°\): \(\cos = 0.966\), quita 3.4%.
\(\beta = \pm 30°\): \(\cos = 0.866\), quita 13.4%.
El efecto crece hacia las orillas, y con un FOV más ancho crece todavía más.

Pregunta de concepto

Pregunta

En las columnas del centro, la distancia cruda y la corregida traen prácticamente el mismo número. ¿Es un error del programa?

Respuesta

No. \(\cos 0° = 1\), así que el rayo que va en la dirección de vista nunca cambia. La corrección solo afecta a los rayos que se desvían, y cada vez más mientras más se desvían.

Un barrido de toda la pantalla

El experimento

El jugador mira de frente a una pared plana que está a \(300\) unidades de distancia perpendicular. Al ser plana, todas sus estacas deberían salir de la misma altura.

  • Por geometría, el rayo de la columna \(i\) recorre \(cruda = 300 / \cos\beta_i\) antes de tocarla.
  • Se van a recorrer las 1300 columnas, mostrando cinco repartidas de orilla a orilla.
  • Primero con la corrección aplicada, después sin ella, para comparar.

Con corrección la pared sale plana


  columna       β      d cruda    cos β    corregida    altura   arriba   abajo
        0    -30.000°   346.410  0.866025    300.000    375.28      262     637
      325    -14.988°   310.566  0.965978    300.000    375.28      262     637
      650     +0.023°   300.000  1.000000    300.000    375.28      262     637
      975    +15.035°   310.633  0.965769    300.000    375.28      262     637
     1299    +30.000°   346.410  0.866025    300.000    375.28      262     637
Resultado

La corrección deshace exactamente lo que agregó la diagonal: \((300/\cos\beta)\cdot\cos\beta = 300\). Las 1300 estacas salen idénticas y la pared se ve recta.

Sin corrección la pared se curvea


  columna       β      d cruda    altura sin corregir    contra el centro
        0    -30.000°   346.410                 325.00             -13.4%
      325    -14.988°   310.566                 362.51              -3.4%
      650     +0.023°   300.000                 375.28               0.0%
      975    +15.035°   310.633                 362.43              -3.4%
     1299    +30.000°   346.410                 325.00             -13.4%
La estaca del borde pierde 50 píxeles de alto contra la del centro: la pared se hincha hacia el observador.
La caída es exactamente el factor \(\cos\beta\), así que solo depende del FOV, no de la distancia.

Las dos siluetas, lado a lado


   columna:   0        325       650       975      1299

   corregida (pared recta)        sin corregir (curveada)
   ┌────────────────────────┐     ┌────────────────────────┐
   │                        │     │                        │
   │  ┌──┬───┬───┬───┬──┐   │     │       ┌───┬───┐        │
   │  │  │   │   │   │  │   │     │   ┌───┤   │   ├───┐    │
   │  │  │   │   │   │  │   │     │ ┌─┤   │   │   │   ├─┐  │
   │──┼──┼───┼───┼───┼──┼───│     │─┼─┼───┼───┼───┼───┼─┼──│ horizonte
   │  │  │   │   │   │  │   │     │ └─┤   │   │   │   ├─┘  │
   │  │  │   │   │   │  │   │     │   └───┤   │   ├───┘    │
   │  └──┴───┴───┴───┴──┘   │     │       └───┴───┘        │
   │                        │     │                        │
   └────────────────────────┘     └────────────────────────┘
        375 px parejos              325 en las orillas,
                                    375 en el centro

Dos columnas, de principio a fin

Ahora en una escena real, donde cada rayo choca a una distancia distinta.

Columna 325, el rayo choca a \(415.0\) unidades. Una pared a media distancia.
Columna 975, el rayo choca a \(70.0\) unidades. Una pared pegada a la cara.
El mismo procedimiento en los dos

Columna → desvío → distancia cruda → corregida → altura → orillas → recorte. Nada de esto cambia si cambia la pantalla, el FOV o la escena.

La distancia cruda la busca el rayo

Es la etapa anterior del motor: aquí solo se usa el resultado.

  • El rayo arranca en el jugador con \(d = 0\), apuntando hacia \(\theta = a + \beta\).
  • Se va aumentando \(d\) de poquito en poquito.
  • En cada paso se mira qué celda del laberinto toca ese punto.
  • Al caer en una pared se detiene, y el \(d\) de ese momento es la distancia cruda.
El punto que toca el rayo tras avanzar d $$ x = jugador_x + d\cos\theta $$ $$ y = jugador_y + d\sin\theta $$
\(jugador_x, jugador_y\): dónde está parado el jugador.
\(d\): lo que lleva avanzado el rayo.
\(\theta\): hacia dónde apunta, o sea \(a + \beta\).

  jugador
     ●·····●·····●·····●·····█ pared
     0    100   200   300   415
                             ↑
                       cruda = 415.0

Punto 1: la columna 325

El cálculo
β        = -30 + 0.046189 · 325
         = -14.9885°
cos β    = 0.96598
cruda    = 415.0   ← la devolvió el rayo
corregida = 415.0 · 0.96598 = 400.88
altura    = (100 / 400.88) · 1125.83
         = 280.84
mitad    = 140.42
arriba   = 450 - 140.42 = 309.58
abajo    = 450 + 140.42 = 590.42
Lo que se pinta

La columna 325 se pinta de \(y = 309\) a \(y = 590\). Arriba y abajo queda el fondo.

Punto 2: la columna 975

El cálculo
β        = -30 + 0.046189 · 975
         = +15.0346°
cos β    = 0.96577
cruda    = 70.0    ← la devolvió el rayo
corregida = 70.0 · 0.96577 = 67.60
altura    = (100 / 67.60) · 1125.83
         = 1665.34
mitad    = 832.67
arriba   = 450 - 832.67 = -382.67
abajo    = 450 + 832.67 = 1282.67
Lo que se pinta
arriba = max(-382.67, 0)   = 0
abajo  = min(1282.67, 900) = 900

La columna 975 se pinta de \(y = 0\) a \(y = 900\). La pared está tan cerca que llena la columna entera.

Cuándo la estaca deja de caber

El umbral, en general

La estaca desborda cuando su altura pasa de \(H\):

$$ \frac{BLOCK\_SIZE}{corregida} \cdot d_{plano} > H $$ $$ corregida < \frac{BLOCK\_SIZE \cdot d_{plano}}{H} $$
Con nuestros números
umbral = 100 · 1125.83 / 900
umbral = 125.09

Toda columna cuya distancia corregida baje de 125.09 llena la pantalla completa.

El umbral se calcula una vez y sirve para saber de antemano cuántas columnas van a salir recortadas.

El recorte también protege la memoria

Sin recorte, arriba se vuelve negativo al acercarse mucho a una pared.
Un número negativo convertido a un tipo sin signo se vuelve un número enorme.
El programa intentaría escribir fuera de la memoria del framebuffer.
Recortar contra \(0\) y \(H\) cierra el problema antes de que llegue al bucle de pintado.

El procedimiento, para cualquier pantalla

Antes del bucle, una sola vez.

1Fijar los parámetros
  • \(W\), \(H\), \(FOV\) y \(BLOCK\_SIZE\).
2Derivar las constantes
  • Horizonte \(= H/2\).
  • \(d_{plano} = (W/2) / \tan(FOV/2)\).
  • \(\Delta\beta = FOV/(W-1)\).

Lo que se repite en cada columna

3Sacar el desvío
  • \(\beta_i = -FOV/2 + \Delta\beta \cdot i\).
  • El rayo va hacia \(a + \beta_i\).
4Lanzar el rayo
  • Devuelve distancia y carácter de pared.
  • Si no chocó, se salta la columna.
5Corregir y medir
  • \(corregida = cruda \cdot \cos\beta_i\).
  • \(altura = (BLOCK\_SIZE / corregida) \cdot d_{plano}\).
6Recortar y pintar
  • Orillas en \(horizonte \pm altura/2\).
  • Acotadas a \([0, H]\), se pinta la columna \(i\).

Errores comunes

1Usar la distancia sin corregir
  • Las paredes rectas se ven curveadas.
  • La curvatura desaparece si se apunta directo a la pared y se nota más al voltear.
2Corregir con el ángulo equivocado
  • El coseno va del desvío \(\beta\), no del ángulo absoluto del rayo.
  • Con el absoluto la imagen se deforma al girar el jugador.
3Olvidar el recorte
  • Al acercarse mucho, arriba se vuelve negativo.
  • Convertido a un tipo sin signo, ese número se vuelve enorme.

Errores comunes

4Fijar la distancia al plano a mano
  • Si no sale de \(W\) y \(FOV\), el ancho y el alto dejan de concordar.
  • La imagen se ve estirada y cambiar el FOV ya no hace lo que debería.
5Voltear el eje vertical
  • Con el origen arriba a la izquierda, arriba es la \(y\) más pequeña.
  • Aquí la estaca es simétrica y no se nota, pero en texturas se dibujan de cabeza.
6Dividir entre cero
  • Si el jugador queda dentro de una pared, la distancia puede ser 0.