Skip to main content

Cómo Añadir Niebla e Iluminación

Este tutorial fue escrito para la v2 del motor.

El sombreado por distancia del tutorial de raycasting es rudimentario — tres niveles fijos de brillo que hacen que las paredes salten entre claro, tenue y oscuro. No hay gradación, no hay atmósfera. Vamos a arreglar eso.

Cuando terminemos, tendremos niebla difuminada suavizando las paredes hacia la oscuridad, una antorcha que el jugador lleva consigo cortando un cono de luz a través de la bruma, y farolas creando charcos de luz por el mapa. Cada efecto se construye sobre el anterior, así que puedes parar en cualquier momento y tener algo que funciona.

El Punto de Partida

Aquí está el raycaster del tutorial anterior. Usa una tabla de búsqueda darken para sombrear paredes en dos umbrales fijos de distancia — perpDist >= 4 oscurece una vez, perpDist >= 8 oscurece dos veces. Camina por ahí y observa cómo las paredes saltan entre niveles de brillo sin transición.

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

// inside the wall rendering loop:
if (perpDist >= 8) {
    color = darken[darken[color]];
} else if (perpDist >= 4) {
    color = darken[color];
}

Cada entrada mapea un índice de color a su variante más oscura. Aplicarlo dos veces da el nivel más oscuro. ¿El problema? Las paredes saltan entre tres bandas planas de brillo. No hay nada intermedio.

Fog & Lighting: Base Raycaster
Flechas del teclado para moverse. Observa cómo las paredes saltan entre tres niveles de brillo sin transición suave.

Niebla por Distancia con Dithering

Solo tenemos 16 colores y una tabla darken, así que el sombreado con gradientes reales no es una opción. Pero podemos simularlo con ordered dithering — algunos píxeles se oscurecen antes que otros según su posición en pantalla, y el patrón se lee como un gradiente suave a distancia.

El truco es una matriz Bayer de 4x4 — una cuadrícula de valores umbral que determina qué píxeles se oscurecen primero:

const bayer = [
    0 / 16, 8 / 16, 2 / 16, 10 / 16,
    12 / 16, 4 / 16, 14 / 16, 6 / 16,
    3 / 16, 11 / 16, 1 / 16, 9 / 16,
    15 / 16, 7 / 16, 13 / 16, 5 / 16,
];

Cada valor está normalizado de 0 a 1. La matriz se repite por toda la pantalla — cada píxel busca su umbral en (y % 4) * 4 + (x % 4). Cuando el factor de niebla supera ese umbral, oscurecemos el píxel. Diferentes píxeles tienen diferentes umbrales, así que se oscurecen a diferentes distancias. El resultado parece un gradiente suave.

Necesitamos un factor de niebla continuo en lugar de los viejos cortes abruptos. fogAmount devuelve 0 para cualquier cosa más cercana que FOG_START (para que las paredes cercanas se vean nítidas) y sube hasta 1 en MAX_DIST:

const FOG_START = 3;
const MAX_DIST = 6;

function fogAmount(dist) {
    if (dist <= FOG_START) return 0;
    return Math.min(1, (dist - FOG_START) / (MAX_DIST - FOG_START));
}

applyFog compara el factor de niebla contra el umbral Bayer para ese píxel. Si la niebla es suficientemente fuerte, oscurece una vez. Si es muy fuerte (umbral + 0.5), oscurece dos veces para oscurecimiento total:

function applyFog(color, fogFactor, sx, sy) {
    const threshold = bayer[(sy % 4) * 4 + (sx % 4)];
    if (fogFactor > threshold + 0.5) {
        return darken[darken[color]];
    } else if (fogFactor > threshold) {
        return darken[color];
    }
    return color;
}

Para las paredes, calculamos fogAmount(perpDist) una vez por columna y lo pasamos a applyFog para cada píxel de la franja.

El techo y el suelo también necesitan niebla. Ya no podemos usar rectfill porque cada píxel necesita su propia comprobación de dithering. En su lugar, los dibujamos píxel a píxel — la niebla aumenta hacia el horizonte (donde la distancia es conceptualmente infinita) y disminuye hacia la parte superior e inferior de la pantalla:

const halfH = viewH / 2;

for (let sy = 0; sy < viewH; sy++) {
    const distFromHorizon = Math.abs(sy - halfH);
    const floorFog = 1 - distFromHorizon / halfH;
    const baseColor = sy < halfH ? 1 : 5;
    for (let sx = 0; sx < viewW; sx++) {
        pset(sx, sy, applyFog(baseColor, floorFog, sx, sy));
    }
}
Fog & Lighting: Dithered Fog
Las bandas abruptas de brillo desaparecieron. Las paredes ahora se desvanecen suavemente en la oscuridad con un patrón de dithering.

Antorcha del Jugador

La niebla es atmosférica, pero es implacable — todo lo que está más allá de FOG_START se oscurece, incluyendo las paredes justo frente a nosotros. Vamos a darle al jugador una antorcha.

Este es el truco: la antorcha no añade brillo. Reduce la distancia efectiva usada para el cálculo de niebla. Las columnas cerca del centro de la vista reciben una mayor reducción, creando forma de cono. La caída es parabólica — más fuerte justo al frente, disminuyendo hacia los bordes:

const TORCH_STRENGTH = 4;

// per column:
const columnOffset = Math.abs(x - viewW / 2) / (viewW / 2);
const torchReduction = TORCH_STRENGTH * (1 - columnOffset * columnOffset);
const effectiveDist = Math.max(0, perpDist - torchReduction);
const fogFactor = fogAmount(effectiveDist);

columnOffset es 0 en el centro de la pantalla y 1 en los bordes. Elevarlo al cuadrado da la curva parabólica — la luz cae suavemente cerca del centro y bruscamente en los lados. TORCH_STRENGTH controla cuántas unidades de distancia la antorcha "empuja hacia atrás" la niebla en el centro.

Alimentamos effectiveDist en fogAmount en lugar del perpDist crudo. Una pared a 5 unidades justo al frente se trata como a 1 unidad (5 - 4 = 1) — por debajo de FOG_START, así que se renderiza completamente iluminada. La misma pared en el borde de la pantalla no recibe reducción y se cubre de niebla normalmente.

El techo y el suelo necesitan el mismo tratamiento — para cada píxel, calculamos la reducción de la antorcha según su posición horizontal y la restamos de la niebla del suelo:

for (let sx = 0; sx < viewW; sx++) {
    const columnOffset = Math.abs(sx - viewW / 2) / (viewW / 2);
    const torchReduction = TORCH_STRENGTH * (1 - columnOffset * columnOffset);
    const effectiveFog = Math.max(0, floorFog - torchReduction / MAX_DIST);
    pset(sx, sy, applyFog(baseColor, effectiveFog, sx, sy));
}
Fog & Lighting: Player Torch
La antorcha corta un cono de luz a través de la niebla. Gira para ver cómo los bordes del cono se oscurecen.

Farolas

La antorcha se mueve con el jugador, pero el mundo en sí sigue uniformemente oscuro. Vamos a colocar algunas fuentes de luz fijas por el mapa — farolas. Cada una tiene una posición y un radio:

const lamps = [
    { x: 8, y: 2.5, radius: 4 },
    { x: 2.5, y: 8, radius: 4 },
    { x: 13.5, y: 8, radius: 4 },
    { x: 8, y: 13.5, radius: 4 },
];

Cuando un rayo golpea una pared, conocemos las coordenadas exactas del punto de impacto en el espacio del mundo. Recorremos cada farola y comprobamos qué tan lejos está el punto de impacto. Si está dentro del alcance, reducimos la niebla proporcionalmente:

function lampLight(hitX, hitY) {
    let light = 0;
    for (const lamp of lamps) {
        const dx = hitX - lamp.x;
        const dy = hitY - lamp.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        if (dist < lamp.radius) {
            light += 1 - dist / lamp.radius;
        }
    }
    return Math.min(1, light);
}

lampLight devuelve 0 cuando no hay ninguna farola cerca y hasta 1 cuando estás justo encima de una. Múltiples farolas pueden solaparse — su luz se acumula, con tope en 1. La combinamos con la reducción de la antorcha al calcular la distancia efectiva:

const light = lampLight(hitX, hitY);
const effectiveDist = Math.max(0, perpDist - torchReduction - light * MAX_DIST);
const fogFactor = fogAmount(effectiveDist);

Esto se calcula por rayo, no por píxel, así que es económico — la luz de la farola se aplica uniformemente a lo largo de cada franja de pared. Camina hacia las posiciones de las farolas (puntos amarillos en el minimapa) y verás las paredes iluminarse al entrar en el radio de una farola.

Fog & Lighting: Street Lamps
Las farolas crean charcos de luz por el mapa. Busca las posiciones de las farolas marcadas en amarillo en el minimapa.

Para Seguir Explorando

  • Antorcha parpadeante — aleatoriza TORCH_STRENGTH ligeramente cada fotograma con rnd() para una sensación orgánica e inestable
  • Iluminación con color — usa pal() para remapear colores de la paleta cerca de una fuente de luz, cambiando los colores de las paredes hacia tonos cálidos cerca de las farolas
  • Luces dinámicas — fuentes de luz que se mueven por el mundo, como una antorcha lanzada o un proyectil brillante
  • Ciclo día/noche — cambia gradualmente MAX_DIST con el tiempo para transicionar entre escenas brillantes al aire libre y niebla densa
  • Color de niebla — en lugar de oscurecer hacia negro, oscurece hacia azul oscuro para la noche o gris para la bruma ajustando la tabla darken
  • Parpadeo de farolas — anima el radio de cada farola con una onda sinusoidal o función de ruido para un pulso atmosférico