Percepción Intermedio 13 min de lectura

Line-of-sight: Bresenham y recursive shadowcasting

Crea líneas y dale sombras para agregar visibilidad por octantes.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

Line-of-sight (LOS) y FOV (field of view) son dos preguntas distintas pero relacionadas:

  • LOS: ¿el agente A puede ver el punto B? (boolean entre dos puntos)
  • FOV: ¿qué celdas ve el agente desde su posición, dentro de un radio? (set de celdas)

Ambas se construyen sobre la misma matemática: tirar una “raya” entre dos celdas y ver si toca un muro. Lo que cambia es cómo lo optimizas.

AlgoritmoResuelveComplejidadCasos típicos
BresenhamLOS punto-a-punto, dibujo de líneasO(R) por líneaDisparos, raycasts simples
BFS + Bresenham (FOV simple)FOV por radioO(R³)FOV “fácil”, didáctico
Recursive shadowcastingFOV por radioO(R²)Roguelikes (NetHack, DCSS, Cogmind)
Symmetric shadowcastingFOV simétricoO(R²)Cuando A ve B ⟺ B ve A

1. Bresenham — la base

Bresenham: el algoritmo clásico para dibujar líneas en grid. Cada celda atravesada se ilumina; la línea negra es la conexión geométrica ideal entre A y B. Bresenham aproxima esa línea con celdas enteras.

Figura 1 — La línea negra es la conexión geométrica ideal entre A y B; las celdas iluminadas son las que Bresenham marca al “discretizar” la línea sobre el grid. Arrastra A y B.

1.1 El algoritmo

Bresenham (1962) dibuja líneas en una grilla con solo enteros, sin divisiones ni multiplicaciones. La idea:

function bresenham(x0, y0, x1, y1) -> List<Cell>
    out = []
    dx = abs(x1 - x0)
    dy = abs(y1 - y0)
    sx = x0 < x1 ? 1 : -1
    sy = y0 < y1 ? 1 : -1
    err = dx - dy

    x, y = x0, y0
    loop:
        out.append((x, y))
        if x == x1 and y == y1: break
        e2 = 2 * err
        if e2 > -dy:
            err -= dy
            x += sx
        if e2 < dx:
            err += dx
            y += sy

    return out

err mantiene un “acumulador” del error vertical/horizontal. Cuando supera un umbral, da un paso lateral. Resultado: una secuencia de celdas que aproxima la línea.

1.2 Aplicación a LOS

Para chequear si A ve a B:

function hasLineOfSight(grid, a, b) -> bool
    for each cell on bresenham(a, b):
        if cell != a and cell != b and grid[cell].blocksVision:
            return false
    return true

Excluir A y B de la verificación es importante: una unidad puede estar “dentro” de un muro temporalmente (cubierta) y no querés que se desconecte de sí misma.

1.3 Variantes

  • Supercover Bresenham — incluye todas las celdas que la línea cruza, no solo el camino “thin” típico. Es más estricto: si la línea roza una esquina, esa celda cuenta. Para LOS conservador (no querés que el rayo “salte” entre obstáculos diagonales).
  • DDA (Digital Differential Analyzer) — usa floats, más simple pero menos preciso. Para casos no críticos.

2. Recursive Shadowcasting — el FOV de los roguelikes

Recursive shadowcasting: barre 8 octantes desde el origen. Al tocar un muro, propaga "sombras" angulares que ocluyen las celdas detrás. Es el FOV estándar en roguelikes (NetHack, DCSS, Caves of Qud).

Figura 2 — El algoritmo barre 8 octantes desde el origen, propagando “sombras” angulares cuando un muro corta la línea de visión. Las celdas detrás de un muro quedan ocluidas. Es lo que NetHack, DCSS y Caves of Qud usan.

2.1 La intuición

Mirá el origen como un sol y las celdas walkable como aire. Cuando un muro entra en el campo de visión, proyecta una sombra detrás de sí — una cuña angular donde la luz no llega. Recursive shadowcasting hace exactamente eso:

  1. Divide el plano en 8 octantes (norte, noreste, este, sureste, sur, suroeste, oeste, noroeste).
  2. Por cada octante, recorre celda por celda hacia afuera.
  3. Mantiene un rango angular [startSlope, endSlope] que define qué partes del octante son “visibles”.
  4. Cuando encuentra un muro, divide el rango en sub-rangos y recursea en cada uno. Eso es la “propagación de sombra”.
function castLight(grid, visible, ox, oy, radius, row, startSlope, endSlope, octant):
    if startSlope < endSlope: return
    nextStart = startSlope
    for i = row to radius:
        blocked = false
        for dx = -i to 0:
            lSlope = (dx - 0.5) / (dy + 0.5)
            rSlope = (dx + 0.5) / (dy - 0.5)
            if startSlope < rSlope: continue
            if endSlope > lSlope: break

            sax = ox + dx * octant.xx + dy * octant.xy
            say = oy + dx * octant.yx + dy * octant.yy
            if (dx² + dy²) <= radius²:
                visible[sax, say] = true

            if blocked:
                if grid[sax, say].blocksVision:
                    newStart = rSlope
                else:
                    blocked = false
                    startSlope = newStart
            else:
                if grid[sax, say].blocksVision and i < radius:
                    blocked = true
                    castLight(grid, visible, ox, oy, radius, i + 1, startSlope, lSlope, octant)
                    newStart = rSlope
        if blocked: break

Cada octante tiene su propia OctantTransform que mapea coordenadas locales a globales. El truco es que el algoritmo central solo trabaja con un octante (digamos norte-noreste); las simetrías cubren los otros 7.

2.2 Por qué O(R²) en vez de O(R³)

BFS + Bresenham hace lookups (todas las celdas en el círculo) y para cada una, un Bresenham de O(R) celdas — total O(R³). Shadowcasting toca cada celda a lo sumo una vez porque la “sombra” se propaga sin re-procesar lo ocluido. Para R = 12, eso es 1728 vs 144 ops — un orden de magnitud.

3. Comparativa visual

Mismo mapa, mismo origen, mismo radio. BFS + Bresenham chequea LOS celda por celda (O(R³)). Recursive shadowcasting propaga sombras por octante (O(R²)). Las celdas marcadas son casi idénticas; la diferencia es performance.

Figura 3 — Mismo mapa, mismo origen. Arriba: BFS + Bresenham. Abajo: recursive shadowcasting. Las celdas marcadas son casi idénticas; ambos dan resultados visualmente similares. La diferencia es cuántas operaciones hicieron para llegar.

3.1 Cuándo elegir uno u otro

  • BFS + Bresenham: didáctico, fácil de entender y debuggear. Apropiado para FOV one-shot o radio chico.
  • Shadowcasting: estándar en producción. Para FOV interactivo (recalculado cada turno o cada movimiento), la velocidad importa.
  • Symmetric shadowcasting: si necesitas la simetría dura (A ve B ⟺ B ve A), hay que usar la variante simétrica. Importa en gameplay donde “te tiraron desde un lugar invisible para vos pero visible para el shooter” se siente injusto.

4. FOV dinámico con memoria

Patrulla con FOV dinámico que se recalcula cada paso. Las celdas actualmente visibles brillan amarillo; las vistas antes quedan en gris (fog of war con memoria). El agente recorre 4 waypoints en loop.

Figura 4 — Un agente patrulla 4 waypoints. El FOV se recalcula en cada paso. Las celdas actualmente visibles brillan; las vistas antes quedan en gris (fog of war con memoria). Es lo que vos jugás como jugador en cualquier roguelike.

4.1 Tres estados por celda

estado de cada celda:
  - never_seen  → niebla total (negro)
  - seen_before → terreno conocido pero no visible ahora (gris)
  - visible_now → vista actualmente (color completo, unidades)

Implementación:

class FogOfWar
    seen: Set<Cell>      // todo lo visto alguna vez
    visible: Set<Cell>   // visible este turno

    function updateFov(unit, radius):
        visible = computeFov(grid, unit.position, radius)
        seen = seen ∪ visible    // memoria persistente

4.2 Detalles que importan

  • Reset de unidades enemigas en seen pero no visible: si vuelvo a una zona, no quiero ver “el enemigo que estaba ahí hace 30 turnos” — ya no está. Solo terreno persiste.
  • Reset on dungeon change: en roguelikes con niveles, la memoria es por nivel. Bajar al siguiente piso = niebla total.
  • Diff visualization: muchos juegos animan el “amarillo apareciendo” cuando una celda nueva se ve por primera vez. Es atención al detalle pero suma sensación.

5. Pseudocódigo — Bresenham + Shadowcasting

// Bresenham: línea entre dos celdas
function bresenhamCells(x0, y0, x1, y1) -> List<Cell>
    cells = []
    x, y = x0, y0
    dx = abs(x1 - x0)
    dy = abs(y1 - y0)
    sx = x0 < x1 ? 1 : -1
    sy = y0 < y1 ? 1 : -1
    err = dx - dy
    while true:
        cells.append((x, y))
        if x == x1 and y == y1: break
        e2 = 2 * err
        if e2 > -dy: err -= dy; x += sx
        if e2 < dx: err += dx; y += sy
    return cells

// LOS check trivial
function hasLineOfSight(grid, a, b) -> bool
    for cell in bresenhamCells(a, b):
        if cell != a and cell != b and grid[cell].blocksVision:
            return false
    return true

// FOV simple (BFS + LOS por celda)
function computeSimpleFov(grid, origin, radius) -> Set<Cell>
    visible = Set()
    for each cell c with chebyshevDist(c, origin) <= radius:
        if hasLineOfSight(grid, origin, c):
            visible.add(c)
    return visible

// FOV con shadowcasting: ver ShadowcastingViz arriba para implementación completa.

6. Implementación en Unity / C#

using UnityEngine;
using System.Collections.Generic;

public static class Los {
    public static List<Vector2Int> BresenhamCells(Vector2Int a, Vector2Int b) {
        var cells = new List<Vector2Int>();
        int x = a.x, y = a.y;
        int dx = Mathf.Abs(b.x - x), dy = Mathf.Abs(b.y - y);
        int sx = x < b.x ? 1 : -1, sy = y < b.y ? 1 : -1;
        int err = dx - dy;
        while (true) {
            cells.Add(new Vector2Int(x, y));
            if (x == b.x && y == b.y) break;
            int e2 = 2 * err;
            if (e2 > -dy) { err -= dy; x += sx; }
            if (e2 < dx) { err += dx; y += sy; }
        }
        return cells;
    }

    public static bool HasLos(bool[,] blocks, Vector2Int a, Vector2Int b) {
        var cells = BresenhamCells(a, b);
        for (int i = 1; i < cells.Count - 1; i++) {
            if (blocks[cells[i].x, cells[i].y]) return false;
        }
        return true;
    }

    // Recursive shadowcasting (8 octantes). Implementación completa en el asset.
    public static bool[,] ComputeFov(bool[,] blocks, Vector2Int origin, int radius) {
        int W = blocks.GetLength(0), H = blocks.GetLength(1);
        var visible = new bool[W, H];
        visible[origin.x, origin.y] = true;
        // ... 8 llamadas a CastLight() con sus OctantTransforms.
        return visible;
    }
}

7. En otros engines

  • Godot: Light2D con occluders y LightOccluder2D para FOV continuo. Para FOV por celdas (gameplay tactical), implementar custom.
  • Unreal: UAISense_Sight componente para perception system. Trae cone-based FOV para AI; para gameplay grid-based, custom.
  • JavaScript / TypeScript: la lib del sitio (~/lib/viz/sim/los) trae bresenhamCells, hasLos, computeFov (recursive shadowcasting) y computeSimpleFov.

8. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Tu juego necesita verificar si un sniper ve al jugador desde el otro lado del mapa. ¿Qué algoritmo es ideal?

  2. Implementás FOV con BFS + Bresenham por celda. En radio 14, parece lento. ¿Causa?

  3. El jugador dispara desde un escondite y mata a un enemigo. El enemigo aparece quejándose porque 'lo mataron desde un lugar invisible'. ¿Qué falta?

  4. El jugador vuelve a una zona que ya había explorado. ¿Cómo manejas la información en el fog of war?

  5. Para tirar disparos en un FPS-2D estilo top-down, ¿qué algoritmo de raycast usás?

9. Siguientes pasos