Capítulo 16. Iluminación 2D y post-procesado

CAP 16 · Bevy 0.18/0.19
"La luz no se añade al final. La luz ES el final."

— Probablemente alguien en un estudio de cine, algún día de 1940

¿Sabías que el *lighting* 2D tal y como lo conocemos hoy se popularizó con *Castlevania: Symphony of the Night* (1997, Konami, equipo dirigido por Toru Hagihara)? Antes de eso, los juegos 2D eran una orgía de tiles planos sin sombras. Koji Igarashi y su equipo añadieron antorchas dinámicas al castillo y la gente se volvió loca. Sigues viviendo de esa decisión, 28 años después.

Vamos a iluminar. Y por "iluminar" no me refiero a ponerle un fondo blanco al fondo negro — me refiero a PointLight2d, sombras, viñeta y aberración cromática. Suena a juego AAA, pero en Bevy 0.19 es un plugin, tres componentes y una capa de render.

En este capítulo:


16.1 Por qué la luz no es "decoración"

Cuando un juego indie de 16 colores se siente caro, el 80% del trabajo no es el pixel art — es la luz. La luz hace tres cosas:

  1. Guía al jugador hacia donde debe ir (literalmente: los pathfinding visuales usan luz como breadcrumb).
  2. Cuenta el tiempo: atardecer, medianoche, antorcha parpadeando.
  3. Conecta emocionalmente: pasillo oscuro con una sola luz al fondo = ansiedad.
**PointLight2d**: una fuente de luz puntual en el plano 2D. Tiene posición, color, intensidad y un radio de alcance. Proyecta un círculo de luz sobre los sprites.

**Sombras 2D**: en 2D no hay "sol" que proyecte sombras físicas (porque no hay tercera dimensión). Lo que hacemos es calcular qué zonas están *bloqueadas* por un sprite opaco desde la perspectiva de la luz, y oscurecerlas.

**Post-processing (post-FX)**: efectos que se aplican a la imagen final ya renderizada, antes de enseñarla al jugador. Viñeta, aberración, *bloom*, *grain* (grano de película). No afectan a la lógica, solo a cómo se *ve*.

Si pones una luz para "que quede bonito", estás decorando. Si pones una luz porque el jugador tiene que leer ese cartel a 50 metros, estás diseñando. Esa diferencia vale oro.


16.2 El plugin de iluminación

La iluminación 2D de Bevy vive en el crate comunitario bevy_light_2d (de James Gayfer), que mantiene compatibilidad con cada release de Bevy. Aviso sobre la atribución: algunas fuentes afirman que Bevy 0.19 integró bevy_light_2d de forma nativa en un módulo bevy::render::light_2d, pero la inspección directa de docs.rs/bevy/0.19.0 no lo confirma; el crate sigue siendo externo. Si en el futuro se integrara nativamente, el path de los imports cambiaría, pero la API (componente + plugin) se mantiene.

Cuidado con el plugin: bevy::light::LightPlugin es del pipeline de 3D (gestiona DirectionalLight, PointLight, SpotLight 3D). Para 2D tenés que usar Light2dPlugin del crate bevy_light_2d. No los mezcles.

[dependencies]
bevy = { version = "0.19", features = ["bevy_2d"] }
bevy_light_2d = "0.9"   # crate comunitario de James Gayfer (compat. Bevy 0.18; verificar tag 0.19).
use bevy::prelude::*;
use bevy_light_2d::prelude::*; // ← crate externo (NO bevy::light, que es 3D).

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        // Light2dPlugin habilita PointLight2d / AmbientLight2d sobre sprites 2D.
        .add_plugins(Light2dPlugin)
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    // La ambient se controla con AmbientLight2d (componente sobre la cámara):
    commands.spawn((
        Camera2d,
        AmbientLight2d {
            color: Color::srgb(0.05, 0.05, 0.10),
            brightness: 0.2,
        },
    ));
}
Error clásico: olvidar bajar la `ambient`. Si dejas `brightness = 1.0` por defecto, **tu PointLight2d no se va a ver**, porque el ambiente ya lo inunda todo de luz uniforme. Baja la ambient a 0.1–0.2 y sube la intensidad de tus luces puntuales a 2.0–5.0.

Otro error clásico: poner `bevy::light::LightPlugin` pensando que es "el plugin de luz". Ese es de 3D y **no hace nada** con sprites 2D. Para 2D usás `Light2dPlugin` de `bevy_light_2d`.

16.3 Tu primera PointLight2d

fn setup(mut commands: Commands) {
    commands.spawn(Camera2d);

    // Sprite "bloque" que va a ser iluminado.
    commands.spawn((
        Sprite {
            color: Color::srgb(0.4, 0.4, 0.4),
            custom_size: Some(Vec2::new(200.0, 200.0)),
            ..default()
        },
        Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)),
    ));

    // La luz puntual: cálida, cerca, brillante.
    commands.spawn((
        PointLight2d {
            color: Color::srgb(1.0, 0.7, 0.4), // tono antorcha
            intensity: 3.0,
            radius: 300.0,                    // px de alcance
            falloff: 2.0,                     // curva de caída
            ..default()
        },
        Transform::from_translation(Vec3::new(50.0, 50.0, 1.0)),
    ));
}

Resultado: un cuadrado gris con un círculo cálido encima. Suena cutre. Pero en cuanto metas 5 luces de colores distintos moviéndose con tu personaje, parecerá Hotline Miami.

¿Sabías que *Hotline Miami* (2012, Dennaton Games — Jonatan Söderström + Dennis Wedin) fue el primer juego en hacer de la iluminación 2D casi un personaje? Cada habitación tenía un patrón de luces neón específico. La IA estaba posicionada en función de qué zonas quedaban iluminadas. 13 años después, sigue siendo referencia de iluminación 2D.

16.4 Sombras 2D: contact shadows

Aquí viene la parte interesante. En 2D no hay sombra "real" porque no hay tercera dimensión. Lo que hacemos es:

Aviso importante: el módulo bevy::light2d::contact_shadows y los tipos ContactShadowsPlugin, ShadowCaster2d citados en ediciones previas de este libro NO están verificados como nativos de 2D en Bevy 0.19. Las contact_shadows que sí existen en Bevy 0.19 son parte del pipeline 3D y no afectan a sprites. Para sombras 2D reales hay dos caminos: (a) usar bevy_light_2d, que incluye light occlusion y sombras dinámicas 2D; o (b) raycast manual sobre el tilemap con un crate como bevy_ecs_2d_lighting o bevy_mod_2d_lighting.

// Con bevy_light_2d, los sprites opacos bloquean la luz automáticamente
// (light occlusion). No hay que añadir ningún componente ShadowCaster2d
// ni plugin ContactShadowsPlugin en el sentido de 3D.

use bevy_light_2d::prelude::*;

fn spawn_obstacle(mut commands: Commands) {
    commands.spawn((
        Sprite {
            color: Color::srgb(0.3, 0.3, 0.5),
            custom_size: Some(Vec2::new(120.0, 120.0)),
            ..default()
        },
        Transform::from_translation(Vec3::new(100.0, 0.0, 0.0)),
        // Este sprite, siendo opaco, ya proyecta sombra sobre los que
        // están detrás según la posición de cada PointLight2d.
    ));
}
**Contact shadow**: la sombra dura justo debajo de un objeto. Es la sombra que dice "esto está pegado al suelo". Sin ella, los sprites flotan. Con ella, "pesan".

**Occlusion shadow**: la sombra que se alarga cuando un objeto tapa la luz. Más difícil de hacer bien en 2D; suele requerir tilemap-aware light rays.

Para sombras "raycasted" (la luz pasa por una rendija y crea un abanico), hay crates externos como bevy_ecs_2d_lighting o bevy_mod_2d_lighting que usan raymarching sobre el tilemap. Para empezar, la occlusion de bevy_light_2d es más que suficiente.


16.5 Múltiples luces: rendimiento y cuidado

Bevy usa un light texture interno: cada frame, renderiza una textura donde se acumulan todas las luces 2D (sumando intensidades), y luego la aplica a la escena como multiplicación. Esto es muy rápido (un fullscreen pass), pero tiene un límite práctico:

Luces activasCoste aproximado
1–8Barato, perfecto
8–32Ok en hardware moderno
32–128Activa frustum culling agresivo
>128Necesitas pooling / desactivar por distancia

Truco: desactiva luces lejanas. Una luz a 2000 px del jugador no se ve; solo consume GPU.

fn cull_distant_lights(
    camera: Query<&Transform, With<Camera2d>>,
    lights: Query<(Entity, &Transform, &mut Visibility), With<PointLight2d>>,
) {
    let cam_pos = camera.single().translation;
    for (_, t, mut vis) in &lights {
        let dist = (t.translation - cam_pos).length();
        *vis = if dist > 1500.0 {
            Visibility::Hidden
        } else {
            Visibility::Visible
        };
    }
}

16.6 Post-processing: viñeta, lens distortion, chromatic aberration

El post-FX se aplica a la imagen final ya renderizada. Bevy 0.18+ usa un sistema de "nodes" en el grafo de render; en 0.19, los siguientes efectos están built-in y se activan con componentes spawneables sobre la cámara:

Path verificado: en Bevy 0.19 estos tres efectos viven en bevy::post_process::effect_stack (NO en bevy::post_processing ni bevy::core_pipeline::vignette, que son rutas erróneas citadas en foros). El ejemplo oficial examples/3d/post_processing.rs los importa así. Funcionan tanto para Camera2d como Camera3d.

Viñeta

use bevy::post_process::effect_stack::Vignette;

commands.spawn((
    Camera2d,
    Vignette {
        intensity: 0.7,        // 0 = sin viñeta, 1 = oscurecimiento total
        smoothness: 0.5,       // transición suave
        ..default()
    },
));

Aberración cromática

use bevy::post_process::effect_stack::ChromaticAberration;

commands.spawn((
    Camera2d,
    ChromaticAberration {
        intensity: 0.005,      // desplazamiento RGB sutil
        ..default()
    },
));
¿Sabías que la aberración cromática digital se hizo popular en juegos con *System Shock 2* (1999, Irrational Games — Ken Levine)? Se usaba para simular "estás drogado o tienes un implante ocular". 26 años después, los indie la usan para "estás en una ensoñación" o "te están dando un flashback". Es la misma herramienta, distinto contexto.

Lens distortion (built-in en 0.19)

use bevy::post_process::effect_stack::LensDistortion;

commands.spawn((
    Camera2d,
    LensDistortion {
        intensity: 0.03,       // curvatura de barril/almohada
        ..default()
    },
));
**Lens distortion**: simula la curvatura de una lente de cámara real. Si el valor es positivo, los bordes se expanden (efecto "ojo de pez"). Si es negativo, se contraen. En juegos, se usa para:*sensación VHS* (positivo bajo), *videollamada* (negativo bajo), *psicodelia* (positivo alto).

Combinando efectos

Todos los efectos se acumulan en la cámara. El orden de aplicación depende del grafo de Bevy; en 0.19 el orden por defecto es:

  1. Viñeta (después del color grading)
  2. Aberración cromática
  3. Lens distortion

Puedes reorganizarlo, pero para el 95% de los juegos no lo necesitas.


16.7 Luces que se mueven con tu personaje

El truco más útil: una luz suave siguiendo al jugador. Crea "túneles de oscuridad" cuando entras en una cueva, y antorchas que aparecen en un cinturón cuando sacas la espada.

#[derive(Component)]
struct PlayerLantern;

fn lantern_follows_player(
    player: Query<&Transform, With<Player>>,
    mut lantern: Query<&mut Transform, (With<PointLight2d>, With<PlayerLantern>)>,
) {
    let player_t = player.single();
    let mut lant_t = lantern.single_mut();
    // Offset: luz a la derecha-arriba del jugador.
    lant_t.translation = player_t.translation + Vec3::new(40.0, 60.0, 1.0);
}

Truco de iluminación narrativa: la luz NO sigue exactamente al jugador. Sigue con un lag de 0.2s. Cuando el jugador gira bruscamente, la luz se queda atrás un instante, y eso genera tensión. Es uno de los trucos de Limbo (2010, Playdead — Arnt Jensen).


16.8 Luces dinámicas con eventos

¿Quieres que las antorchas parpadeen? ¿Que una explosión ilumine toda la pantalla? Usa eventos.

#[derive(Event)]
struct ExplosionLight {
    position: Vec2,
    max_intensity: f32,
}

fn handle_explosion_light(
    mut events: EventReader<ExplosionLight>,
    mut commands: Commands,
) {
    for ev in events.read() {
        let entity = commands.spawn((
            PointLight2d {
                color: Color::srgb(1.0, 0.6, 0.2),
                intensity: ev.max_intensity,
                radius: 600.0,
                ..default()
            },
            Transform::from_translation(ev.position.extend(1.0)),
            ExplosionLightTimer(Timer::from_seconds(0.3, TimerMode::Once)),
        )).id();

        commands.entity(entity).insert(ExplosionLightEntity);
    }
}

#[derive(Component)]
struct ExplosionLightEntity;

#[derive(Component)]
struct ExplosionLightTimer(Timer);

fn fade_explosion(
    mut commands: Commands,
    time: Res<Time>,
    mut query: Query<(Entity, &mut PointLight2d, &mut ExplosionLightTimer)>,
) {
    for (e, mut light, mut timer) in &mut query {
        timer.0.tick(time.delta());
        let t = timer.0.fraction();
        light.intensity *= 1.0 - t; // fade-out lineal
        if timer.0.finished() {
            commands.entity(e).despawn();
        }
    }
}

Esto da un efecto muy cinematográfico: cada enemigo que muere lanza un fogonazo naranja que decae en 0.3 segundos. Cero coste en CPU cuando no hay explosiones, y un boost brutal cuando las hay.


🎯 16.9 Patrón del capítulo: luz = información, no decoración

El patrón es este:

Si quitas la luz y el jugador deja de entender el juego, la luz estaba bien puesta. Si solo "queda bonito", quítala.

Preguntas de prueba que te debes hacer:

  1. ¿El jugador ve hacia dónde ir sin leer texto? → Falta luz direccional.
  2. ¿El jugador distingue enemigo de fondo? → Falta contraste / rim light.
  3. ¿El jugador siente urgencia temporal? → Falta oscilación de luz (antorcha parpadeando, luz roja de alarma).
  4. ¿El jugador reconoce un objeto a 50 metros? → Falta luz de "spotlight" sobre el item clave.

Si la respuesta a alguna es "no", la luz no estaba haciendo su trabajo. Decórala, dale viñeta, ponle aberración... pero el diseño base de luz tiene que responder a esas preguntas.

// ❌ MAL: luz "porque queda guay"
commands.spawn(PointLight2d {
    color: Color::srgb(0.7, 0.3, 0.9), // morado random
    intensity: 5.0,
    radius: 800.0,
    ..default()
});

// ✅ BIEN: luz que informa
commands.spawn((
    PointLight2d {
        color: Color::srgb(1.0, 0.4, 0.2), // rojo = peligro
        intensity: 3.0,
        radius: 400.0,
        ..default()
    },
    AlarmLight,           // marca semántica, no solo visual
    PulsingLight(0.8),    // oscila → el jugador sabe "esto es una alarma"
));
**Marcador semántico**: una etiqueta (`AlarmLight`, `SafeZoneLight`, `TreasureLight`) que describe *para qué* sirve la luz. Cuando hagas balance de tu juego, podrás contar cuántas luces "informativas" tienes. Si el 80% no tienen marcador, tienes un problema.

16.10 Trucos avanzados rápidos


Lo que vimos

En el siguiente

En el capítulo 17 vamos a hacer que las cosas choquen. Entramos en física 2D: Avian2d vs bevy_rapier2d, rigid bodies, colliders, sensores y el patrón del character controller con coyote time y jump buffering. Spoiler: vamos a hacer que tu personaje salte como en Celeste.