— 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:
PointLight2d vía el crate comunitario bevy_light_2d (de James Gayfer). Aviso: algunas fuentes afirman que se integró de forma nativa en Bevy 0.19, pero la verificación directa de docs.rs/bevy/0.19.0 no muestra un módulo bevy::render::light_2d ni bevy_light_2d dentro del workspace de Bevy; tratad el tipo como crate externo hasta nueva orden.contact_shadows nativas de Bevy son solo 3D; para 2D se usa bevy_light_2d o raycasting sobre el tilemap).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:
**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.
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::LightPlugines del pipeline de 3D (gestionaDirectionalLight,PointLight,SpotLight3D). Para 2D tenés que usarLight2dPlugindel cratebevy_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`.
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.
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_shadowsy los tiposContactShadowsPlugin,ShadowCaster2dcitados en ediciones previas de este libro NO están verificados como nativos de 2D en Bevy 0.19. Lascontact_shadowsque sí existen en Bevy 0.19 son parte del pipeline 3D y no afectan a sprites. Para sombras 2D reales hay dos caminos: (a) usarbevy_light_2d, que incluye light occlusion y sombras dinámicas 2D; o (b) raycast manual sobre el tilemap con un crate comobevy_ecs_2d_lightingobevy_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.
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 activas | Coste aproximado |
|---|---|
| 1–8 | Barato, perfecto |
| 8–32 | Ok en hardware moderno |
| 32–128 | Activa frustum culling agresivo |
| >128 | Necesitas 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
};
}
}
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 enbevy::post_processingnibevy::core_pipeline::vignette, que son rutas erróneas citadas en foros). El ejemplo oficialexamples/3d/post_processing.rslos importa así. Funcionan tanto paraCamera2dcomoCamera3d.
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()
},
));
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.
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).
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:
Puedes reorganizarlo, pero para el 95% de los juegos no lo necesitas.
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).
¿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.
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:
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.
BlendMode::Additive. Parece niebla iluminada.Material2d con un shader que emita luz. El sprite mismo es la luz.PointLight2d se obtiene del crate comunitario bevy_light_2d (no es built-in del core de Bevy; algunas fuentes lo citan como nativo en 0.19, pero docs.rs/bevy/0.19.0 no lo confirma).bevy_light_2d o raycasting sobre el tilemap. Las contact_shadows nativas de Bevy son solo 3D.bevy::post_process::effect_stack.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.