Skip to main content

Cómo construir un motor de raycasting

Este tutorial fue escrito en febrero de 2026, para la v2 del motor.

Doom salió en 1993 y corría en un 486. No era 3D real — era un truco llamado raycasting. Por cada columna de píxeles en pantalla, lanzas un rayo al mundo y mides qué tan lejos llega antes de golpear una pared. Las paredes cercanas producen franjas altas. Las lejanas, franjas cortas. Tu cerebro se encarga del resto.

El búfer de 128x128 píxeles del motor es un lugar divertido para probar esto. No hay una API 3D en la que apoyarse — solo pset, algo de trigonometría y un array plano de colores. Al final tendremos una mazmorra 3D navegable y texturizada con sombreado por distancia, construida desde cero.

El mapa

Todo raycaster empieza con una cuadrícula. Cada celda es pared o espacio vacío. Usaremos un array de 8x8 donde 1 significa pared y 0 significa suelo.

La posición del jugador es un par de flotantes en el espacio del mapa. 1.5, 1.5 lo coloca en el centro de la celda [1][1].

Dibujémoslo como un minimapa visto desde arriba:

// the map: 1 = wall, 0 = empty
const map = [
    [1, 1, 1, 1, 1, 1, 1, 1],
    [1, 0, 0, 0, 0, 0, 0, 1],
    [1, 0, 1, 0, 0, 1, 0, 1],
    [1, 0, 0, 0, 0, 0, 0, 1],
    [1, 0, 0, 0, 0, 0, 0, 1],
    [1, 0, 1, 0, 0, 1, 0, 1],
    [1, 0, 0, 0, 0, 0, 0, 1],
    [1, 1, 1, 1, 1, 1, 1, 1],
];

// player position in map space (floats)
let px = 1.5;
let py = 1.5;

// inside draw()
const cell = 16; // 128 / 8 = 16 pixels per cell

for (let y = 0; y < 8; y++) {
    for (let x = 0; x < 8; x++) {
        const color = map[y][x] === 1 ? 5 : 6;
        rectfill(x * cell, y * cell, (x + 1) * cell, (y + 1) * cell, color);
    }
}

// draw the player dot
rectfill(
    Math.floor(px * cell) - 1,
    Math.floor(py * cell) - 1,
    Math.floor(px * cell) + 2,
    Math.floor(py * cell) + 2,
    8,
);
Raycasting: Map and Minimap
Una vista cenital del mapa 8x8 con el punto del jugador en rojo

Cada celda se dibuja como un bloque de 16x16 píxeles — 8 celdas por 16 píxeles llena exactamente la pantalla de 128 píxeles. El jugador aparece como un pequeño cuadrado rojo. Este minimapa se mantiene durante el resto del tutorial. Es una gran herramienta de depuración.

Lanzando rayos

Ahora viene la parte divertida. Vamos a lanzar un rayo por cada columna de la pantalla, barriendo el campo de visión del jugador. Cada rayo avanza por la cuadrícula hasta golpear una pared.

El algoritmo se llama DDA — Digital Differential Analysis. La idea es simple: un rayo cruza líneas de la cuadrícula en dos direcciones (horizontal y vertical). Determinamos cuál cruce viene primero, avanzamos hasta ahí, verificamos si hay una pared y repetimos.

Las variables clave son deltaDistX y deltaDistY (qué tan lejos viaja el rayo entre cruces de cuadrícula en cada eje) y sideDistX y sideDistY (qué tan lejos hasta el siguiente cruce). En cada paso avanzamos la que sea menor.

Cuando golpeamos una pared, también registramos qué cara impactó el rayo — una cara X (pared vertical) o una cara Y (pared horizontal). Esto importa más adelante para las texturas.

let pa = 0; // player angle in radians
const fov = 0.66; // camera plane length (~66 degree FOV)

// inside update()
if (btn('ArrowLeft')) pa -= 0.05;
if (btn('ArrowRight')) pa += 0.05;

// inside draw() — after drawing the minimap
const dirX = Math.cos(pa);
const dirY = Math.sin(pa);
const planeX = -dirY * fov;
const planeY = dirX * fov;

for (let x = 0; x < 128; x++) {
    const cameraX = (2 * x) / 128 - 1;
    const rdx = dirX + planeX * cameraX;
    const rdy = dirY + planeY * cameraX;

    let mapX = Math.floor(px);
    let mapY = Math.floor(py);

    const deltaDistX = Math.abs(rdx) < 1e-10 ? 1e30 : Math.abs(1 / rdx);
    const deltaDistY = Math.abs(rdy) < 1e-10 ? 1e30 : Math.abs(1 / rdy);

    let stepX, stepY, sideDistX, sideDistY;

    if (rdx < 0) {
        stepX = -1;
        sideDistX = (px - mapX) * deltaDistX;
    } else {
        stepX = 1;
        sideDistX = (mapX + 1 - px) * deltaDistX;
    }

    if (rdy < 0) {
        stepY = -1;
        sideDistY = (py - mapY) * deltaDistY;
    } else {
        stepY = 1;
        sideDistY = (mapY + 1 - py) * deltaDistY;
    }

    let side;

    while (true) {
        if (sideDistX < sideDistY) {
            sideDistX += deltaDistX;
            mapX += stepX;
            side = 0;
        } else {
            sideDistY += deltaDistY;
            mapY += stepY;
            side = 1;
        }
        if (map[mapY]?.[mapX] === 1) break;
    }

    const perpDist =
        side === 0 ? sideDistX - deltaDistX : sideDistY - deltaDistY;

    // draw the ray on the minimap
    const hitX = px + rdx * perpDist;
    const hitY = py + rdy * perpDist;
    line(
        Math.floor(px * cell),
        Math.floor(py * cell),
        Math.floor(hitX * cell),
        Math.floor(hitY * cell),
        10,
    );
}
Raycasting: Casting Rays
Flechas izquierda y derecha para rotar. Observa el abanico de rayos barrer el minimapa

El abanico de rayos muestra exactamente lo que el jugador puede "ver" desde su ángulo actual. Observa cómo los rayos rodean las esquinas y se detienen limpiamente en los bordes de las paredes — eso es DDA haciendo su trabajo.

¿Por qué distancia perpendicular? Si usas la distancia en línea recta (euclidiana) del jugador a la pared, los rayos en los bordes de la pantalla reportarán distancias mayores que el rayo central, incluso para una pared plana. Esto produce un efecto ojo de pez. La distancia perpendicular — el componente de la distancia que es perpendicular al plano de la cámara — corrige esto.

Dibujando paredes

Tenemos las distancias de los rayos. Ahora podemos proyectar paredes. La fórmula cabe en una línea: stripeHeight = viewHeight / perpDist. Una pared a una unidad de distancia llena toda la vista. A dos unidades llena la mitad. Y así sucesivamente.

Dividamos la pantalla: un minimapa pequeño a la izquierda (32 píxeles) y la vista 3D a la derecha (96 píxeles). El techo se rellena con azul marino oscuro, el suelo con gris oscuro:

// inside draw() — split-screen layout
const cell = 4; // minimap: 4px per cell (fits in 32px)
const viewX = 32; // 3D view starts at column 32
const viewW = 96; // 3D view is 96 pixels wide
const viewH = 128;

// draw minimap (left 32 columns)
for (let y = 0; y < 8; y++) {
    for (let x = 0; x < 8; x++) {
        const color = map[y][x] === 1 ? 5 : 6;
        rectfill(x * cell, y * cell, (x + 1) * cell, (y + 1) * cell, color);
    }
}

// draw ceiling and floor
rectfill(viewX, 0, viewX + viewW, viewH / 2, 1);
rectfill(viewX, viewH / 2, viewX + viewW, viewH, 5);

// cast rays — one per 3D view column
for (let x = 0; x < viewW; x++) {
    const cameraX = (2 * x) / viewW - 1;
    // ... DDA raycasting from previous section ...

    const stripeH = Math.floor(viewH / perpDist);
    const drawStart = Math.max(0, Math.floor(viewH / 2 - stripeH / 2));
    const drawEnd = Math.min(viewH, Math.floor(viewH / 2 + stripeH / 2));

    rectfill(viewX + x, drawStart, viewX + x + 1, drawEnd, 6);
}
Raycasting: Drawing Walls
Flechas izquierda y derecha para rotar. La vista 3D aparece a la derecha

Ya parece un pasillo. Todas las paredes son del mismo gris plano, pero la sensación de profundidad es inconfundible. El minimapa a la izquierda muestra la misma escena desde arriba — facilita entender lo que hace la vista 3D.

Moviéndose por el mundo

Una vista estática no es muy divertida. Añadamos movimiento: flechas arriba y abajo para caminar hacia adelante y atrás, izquierda y derecha para rotar.

La detección de colisiones es directa. Antes de confirmar una nueva posición, verificamos si la celda de destino es una pared. Probamos los ejes X e Y por separado para que el jugador se deslice por las paredes en lugar de detenerse en seco:

const moveSpeed = 0.05;
const rotSpeed = 0.05;

// inside update()
if (btn('ArrowLeft')) pa -= rotSpeed;
if (btn('ArrowRight')) pa += rotSpeed;

if (btn('ArrowUp')) {
    const nx = px + Math.cos(pa) * moveSpeed;
    const ny = py + Math.sin(pa) * moveSpeed;

    if (map[Math.floor(py)][Math.floor(nx)] === 0) px = nx;
    if (map[Math.floor(ny)][Math.floor(px)] === 0) py = ny;
}

if (btn('ArrowDown')) {
    const nx = px - Math.cos(pa) * moveSpeed;
    const ny = py - Math.sin(pa) * moveSpeed;

    if (map[Math.floor(py)][Math.floor(nx)] === 0) px = nx;
    if (map[Math.floor(ny)][Math.floor(px)] === 0) py = ny;
}
Raycasting: Player Movement
Flechas para mover y rotar. Camina por los pasillos

Caminar en el espacio de un raycaster es más simple que el movimiento basado en tiles. El jugador siempre está en una posición de punto flotante, y la colisión es una consulta al array del mapa — map[Math.floor(y)][Math.floor(x)]. La verificación por ejes separados funciona igual que la separación moveX/moveY del tutorial de plataformas: prueba un eje, revierte si está bloqueado, luego prueba el otro.

Muestreo de texturas

Las paredes de color plano cumplen su función, pero las paredes texturizadas son donde el raycasting realmente brilla. En lugar de rellenar cada franja con un solo color, leeremos colores de píxel desde un sprite en el objeto sprites.

El sprite de pared es una cuadrícula de 8x8 de índices de color de la paleta — el mismo formato que usa cualquier otro sprite en el motor. Para mapearlo sobre una franja de pared necesitamos dos coordenadas de textura:

  • tx (la columna): calculada a partir de wallX, la posición fraccional donde el rayo golpeó la cara de la pared. Multiplica por 8 para obtener un índice de columna del sprite (0–7).
  • ty (la fila): para cada píxel de pantalla en la franja de pared, interpola desde la parte superior del sprite hasta la inferior. texStep = 8 / stripeHeight nos dice cuánto avanzar por píxel.
const sprites = {
    wall: [
        // 8x8 brick pattern — edit this to change how walls look
        4, 4, 4, 5, 4, 4, 4, 4,
        4, 4, 4, 5, 4, 4, 4, 4,
        5, 5, 5, 5, 5, 5, 5, 5,
        4, 4, 4, 4, 4, 5, 4, 4,
        4, 4, 4, 4, 4, 5, 4, 4,
        5, 5, 5, 5, 5, 5, 5, 5,
        4, 4, 4, 5, 4, 4, 4, 4,
        4, 4, 4, 5, 4, 4, 4, 4,
    ],
};

// after DDA finds a wall hit, compute the texture column
let wallX;
if (side === 0) {
    wallX = py + perpDist * rdy;
} else {
    wallX = px + perpDist * rdx;
}
wallX -= Math.floor(wallX);

const tx = Math.floor(wallX * 8);

// draw the wall stripe pixel by pixel
const texStep = 8 / stripeH;
let texPos = (drawStart - viewH / 2 + stripeH / 2) * texStep;

for (let sy = drawStart; sy < drawEnd; sy++) {
    const ty = Math.min(7, Math.floor(texPos));
    texPos += texStep;

    const color = sprites.wall[ty * 8 + tx];
    if (color >= 0) pset(viewX + x, sy, color);
}
Raycasting: Texture Sampling
Flechas para mover. Las paredes ahora muestran una textura de ladrillo

El salto de gris plano a ladrillos texturizados es bastante dramático, incluso a 128x128. Y como la textura vive en sprites, puedes intercambiar cualquier patrón de 8x8 — piedra, madera, pixel art, lo que sea.

Consejo: Prueba editar el array del sprite wall para dibujar tu propia textura de pared. Cada número es un índice de color de la paleta (0–15), y -1 significa transparente. Las 8 filas de 8 valores se mapean directamente a la cuadrícula de 8x8 píxeles.

Sombreado por distancia

Las paredes texturizadas se ven bien, pero todo tiene el mismo brillo sin importar la distancia. El sombreado por distancia soluciona eso — las paredes cercanas son brillantes, las lejanas se desvanecen en la oscuridad.

La paleta de 16 colores no tiene canal de brillo, así que no podemos multiplicar por 0.5. En su lugar definimos una tabla de búsqueda que mapea cada color a un vecino "más oscuro". Aplícala cero, una o dos veces dependiendo de la distancia.

También podemos sombrear las caras X de forma diferente a las caras Y. DDA ya registra qué cara fue golpeada, así que esto sale gratis — es el mismo truco que usó Wolfenstein 3D para distinguir visualmente las paredes norte/sur de las este/oeste:

// maps each palette color to a darker variant
const darken = [
    0, 0, 0, 0, 2, 0, 5, 6,
    2, 4, 9, 3, 1, 1, 2, 9,
];

// inside the wall stripe loop, after sampling the texture color
let color = sprites.wall[ty * 8 + tx];
if (color < 0) continue;

// shade by distance: 0, 1, or 2 darkening passes
if (perpDist >= 4) {
    color = darken[darken[color]];
} else if (perpDist >= 2) {
    color = darken[color];
}

// X-face walls get one extra darken pass for orientation shading
if (side === 0) {
    color = darken[color];
}

pset(viewX + x, sy, color);

El array darken es una decisión de diseño, no un algoritmo. Cada entrada mapea un color a un vecino subjetivamente más oscuro. Ajústalo para obtener un sombreado más cálido o más frío — no hay un mapeo "correcto" único.

Juntando todo

Aquí está todo junto: una mazmorra 3D texturizada, sombreada y navegable con un minimapa superpuesto. Unas 150 líneas de aritmética y llamadas a pset. Sin librería 3D. Sin matrices. Una cuadrícula, algo de trigonometría y un bucle:

engine.scope(({ start, cls, rectfill, pset, line, btn, text }) => {
    const map = [
        [1, 1, 1, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 1, 0, 0, 1, 0, 1],
        [1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 1, 0, 0, 1, 0, 1],
        [1, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1],
    ];

    const sprites = {
        wall: [
            4, 4, 4, 5, 4, 4, 4, 4,
            4, 4, 4, 5, 4, 4, 4, 4,
            5, 5, 5, 5, 5, 5, 5, 5,
            4, 4, 4, 4, 4, 5, 4, 4,
            4, 4, 4, 4, 4, 5, 4, 4,
            5, 5, 5, 5, 5, 5, 5, 5,
            4, 4, 4, 5, 4, 4, 4, 4,
            4, 4, 4, 5, 4, 4, 4, 4,
        ],
    };

    const darken = [
        0, 0, 0, 0, 2, 0, 5, 6,
        2, 4, 9, 3, 1, 1, 2, 9,
    ];

    let px = 1.5;
    let py = 1.5;
    let pa = 0;
    const fov = 0.66;
    const moveSpeed = 0.05;
    const rotSpeed = 0.05;
    const cell = 4;
    const viewX = 32;
    const viewW = 96;
    const viewH = 128;

    function update() {
        if (btn('ArrowLeft')) pa -= rotSpeed;
        if (btn('ArrowRight')) pa += rotSpeed;

        if (btn('ArrowUp')) {
            const nx = px + Math.cos(pa) * moveSpeed;
            const ny = py + Math.sin(pa) * moveSpeed;

            if (map[Math.floor(py)][Math.floor(nx)] === 0) px = nx;
            if (map[Math.floor(ny)][Math.floor(px)] === 0) py = ny;
        }

        if (btn('ArrowDown')) {
            const nx = px - Math.cos(pa) * moveSpeed;
            const ny = py - Math.sin(pa) * moveSpeed;

            if (map[Math.floor(py)][Math.floor(nx)] === 0) px = nx;
            if (map[Math.floor(ny)][Math.floor(px)] === 0) py = ny;
        }
    }

    function draw() {
        cls(0);

        for (let y = 0; y < 8; y++) {
            for (let x = 0; x < 8; x++) {
                const color = map[y][x] === 1 ? 5 : 6;
                rectfill(
                    x * cell,
                    y * cell,
                    (x + 1) * cell,
                    (y + 1) * cell,
                    color,
                );
            }
        }

        rectfill(viewX, 0, viewX + viewW, viewH / 2, 1);
        rectfill(viewX, viewH / 2, viewX + viewW, viewH, 5);

        const dirX = Math.cos(pa);
        const dirY = Math.sin(pa);
        const planeX = -dirY * fov;
        const planeY = dirX * fov;

        for (let x = 0; x < viewW; x++) {
            const cameraX = (2 * x) / viewW - 1;
            const rdx = dirX + planeX * cameraX;
            const rdy = dirY + planeY * cameraX;

            let mapX = Math.floor(px);
            let mapY = Math.floor(py);

            const deltaDistX =
                Math.abs(rdx) < 1e-10 ? 1e30 : Math.abs(1 / rdx);
            const deltaDistY =
                Math.abs(rdy) < 1e-10 ? 1e30 : Math.abs(1 / rdy);

            let stepX, stepY, sideDistX, sideDistY;

            if (rdx < 0) {
                stepX = -1;
                sideDistX = (px - mapX) * deltaDistX;
            } else {
                stepX = 1;
                sideDistX = (mapX + 1 - px) * deltaDistX;
            }

            if (rdy < 0) {
                stepY = -1;
                sideDistY = (py - mapY) * deltaDistY;
            } else {
                stepY = 1;
                sideDistY = (mapY + 1 - py) * deltaDistY;
            }

            let side;

            while (true) {
                if (sideDistX < sideDistY) {
                    sideDistX += deltaDistX;
                    mapX += stepX;
                    side = 0;
                } else {
                    sideDistY += deltaDistY;
                    mapY += stepY;
                    side = 1;
                }
                if (map[mapY]?.[mapX] === 1) break;
            }

            const perpDist =
                side === 0
                    ? sideDistX - deltaDistX
                    : sideDistY - deltaDistY;

            const stripeH = Math.floor(viewH / perpDist);
            const drawStart = Math.max(
                0,
                Math.floor(viewH / 2 - stripeH / 2),
            );
            const drawEnd = Math.min(
                viewH,
                Math.floor(viewH / 2 + stripeH / 2),
            );

            let wallHit;
            if (side === 0) {
                wallHit = py + perpDist * rdy;
            } else {
                wallHit = px + perpDist * rdx;
            }
            wallHit -= Math.floor(wallHit);

            const tx = Math.floor(wallHit * 8);
            const texStep = 8 / stripeH;
            let texPos = (drawStart - viewH / 2 + stripeH / 2) * texStep;

            for (let sy = drawStart; sy < drawEnd; sy++) {
                const ty = Math.min(7, Math.floor(texPos));
                texPos += texStep;

                let color = sprites.wall[ty * 8 + tx];
                if (color < 0) continue;

                if (perpDist >= 4) {
                    color = darken[darken[color]];
                } else if (perpDist >= 2) {
                    color = darken[color];
                }

                if (side === 0) {
                    color = darken[color];
                }

                pset(viewX + x, sy, color);
            }

            const hitX = px + rdx * perpDist;
            const hitY = py + rdy * perpDist;
            line(
                Math.floor(px * cell),
                Math.floor(py * cell),
                Math.floor(hitX * cell),
                Math.floor(hitY * cell),
                10,
            );
        }

        rectfill(
            Math.floor(px * cell) - 1,
            Math.floor(py * cell) - 1,
            Math.floor(px * cell) + 2,
            Math.floor(py * cell) + 2,
            8,
        );

        line(
            Math.floor(px * cell),
            Math.floor(py * cell),
            Math.floor(px * cell + Math.cos(pa) * 4),
            Math.floor(py * cell + Math.sin(pa) * 4),
            8,
        );

        text('arrows to move', 34, 2, 7);
    }

    start({ sprites, sounds: {}, update, draw, target });
});
Raycasting: Complete Example
Flechas para mover y rotar. Explora la mazmorra

Tómate un tiempo para editar el array map — abre nuevas habitaciones y pasillos. Cambia el sprite wall para darle a tu mazmorra un aspecto diferente. Al motor no le importa qué hay en el sprite. Lee índices de color y los dibuja.

Para ir más allá

  • Múltiples texturas de pared — usa diferentes valores de celda en el mapa (2 para piedra, 3 para madera) y muestrea un sprite diferente por tipo de pared según la celda golpeada
  • Texturas animadas — intercambia el sprite de pared con un temporizador para antorchas parpadeantes o paredes de portal pulsantes
  • Sprites y enemigos — ordena objetos billboard por distancia y dibújalos como sprites escalados sobre la vista 3D, usando un búfer de profundidad por columna para recortarlos detrás de las paredes
  • Puertas — una celda que se abre cuando el jugador presiona una tecla cerca, deslizando la pared fuera del array del mapa
  • Texturas de suelo y techo — extiende el bucle por fila para lanzar rayos contra el plano del suelo por cada píxel que no sea pared (mucho más costoso, pero posible a 128x128)
  • Un mapa más grande — la cuadrícula de 8x8 puede escalar a 16x16 o 32x32, solo reduce el tamaño de celda del minimapa para que coincida