tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director

Sincroniza el repo público con el monorepo (estado 2026-06-22):

llimphi-3d — efectos GPU generales subidos desde el renderer 2.5D de supay,
en forma agnóstica (sin tipos de Doom), cada uno con test naga del shader +
example headless:
- PostFx: SSAA + bloom como post-proceso reutilizable (postfx_demo).
- Point-lights en el forward-mesh Renderer3d (normal plana por derivadas;
  mesh_lights_demo).
- SkyBackdrop: cielo cilíndrico fullscreen.
- Billboards: quads instanciados de cara a la cámara (sky_billboard_demo).
- PlanarReflection: plano espejo arbitrario + superficie reflectante
  (Fresnel/ondas; reflection_demo).

llimphi-voxel — refresco de director/worldgen/studio (creador de mundos por
receta, timeline de actores, escenas).

cargo check --workspace OK; 15 tests verdes (incl. naga); examples compilan.

333dca1d5fb473825c9128d402d4cc4e2a5176fc · por Sergio · (hace 4 meses) · padre a3c2ea9ff · ver el árbol en este commit

Delta semántico · 162

llimphi-3d/examples/mesh_lights_demo.rs

llimphi-3d/examples/postfx_demo.rs

llimphi-3d/examples/reflection_demo.rs

llimphi-3d/examples/sky_billboard_demo.rs

llimphi-3d/examples/voxel_dimensiones.rs

llimphi-3d/src/billboard.rs

llimphi-3d/src/lib.rs

llimphi-3d/src/postfx.rs

llimphi-3d/src/reflection.rs

llimphi-3d/src/renderer.rs

llimphi-3d/src/sky.rs

llimphi-3d/src/voxel_renderer.rs

llimphi-voxel/examples/ages_demo.rs

  • cambió por dentro fn main

llimphi-voxel/examples/birth_demo.rs

  • cambió por dentro fn main

llimphi-voxel/examples/desert_demo.rs

  • cambió por dentro fn main

llimphi-voxel/examples/egg_demo.rs

  • cambió por dentro fn main

llimphi-voxel/examples/raycast_edit.rs

  • cambió por dentro fn main

llimphi-voxel/examples/terrain_demo.rs

  • cambió por dentro fn main

llimphi-voxel/examples/terrain_flythrough.rs

  • cambió por dentro fn main

llimphi-voxel/examples/terrain_lod.rs

  • cambió por dentro fn main

llimphi-voxel/examples/terrain_streaming.rs

  • cambió por dentro fn main

llimphi-voxel/examples/terrain_world.rs

  • cambió por dentro fn main

llimphi-voxel/src/director.rs

llimphi-voxel/src/studio.rs

llimphi-voxel/src/worldgen.rs

28 archivos · +3226 −39

diff --git a/Cargo.lock b/Cargo.lock
index 7741427..10a5116 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1746,7 +1746,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
 
 [[package]]
 name = "llimphi"
-version = "0.1.0"
+version = "0.1.1"
 dependencies = [
  "llimphi-3d",
  "llimphi-theme",
@@ -1761,6 +1761,7 @@ dependencies = [
  "llimphi-hal",
  "llimphi-raster",
  "llimphi-ui",
+ "naga 27.0.3",
  "png 0.18.1",
  "pollster",
  "wgpu 27.0.1",
diff --git a/llimphi-3d/Cargo.toml b/llimphi-3d/Cargo.toml
index bb586fa..edd8e86 100644
--- a/llimphi-3d/Cargo.toml
+++ b/llimphi-3d/Cargo.toml
@@ -23,3 +23,7 @@ png = { workspace = true }
 pollster = { workspace = true }
 # Demo interactivo: bucle Elm + ventana + mouse (orbita/zoom) sobre gpu_paint_with.
 llimphi-ui = { path = "../llimphi-ui" }
+# Validación headless de los shaders WGSL de post-proceso (sin GPU): naga
+# parsea + valida los módulos en un test, atajando errores que de otro modo
+# sólo saldrían al crear el pipeline en runtime.
+naga = { version = "27", features = ["wgsl-in"] }
diff --git a/llimphi-3d/examples/mesh_lights_demo.rs b/llimphi-3d/examples/mesh_lights_demo.rs
new file mode 100644
index 0000000..f357cd9
--- /dev/null
+++ b/llimphi-3d/examples/mesh_lights_demo.rs
@@ -0,0 +1,199 @@
+//! Demo headless de las **point-lights en el forward-mesh** ([`Renderer3d`]).
+//!
+//! Pinta un muro plano gris (un quad en el plano XY) con una luz puntual cálida
+//! desplazada al `+X`. Certifica **por texto** (sin mirar el PNG):
+//!  - sin luces + ambiente 1.0 el render es plano (mitad izq ≈ der) →
+//!    compatibilidad hacia atrás;
+//!  - con la luz, la mitad derecha (cerca de la luz) es más brillante que la
+//!    izquierda y el píxel más iluminado queda **cálido** (r > b) = el tinte de
+//!    la luz.
+//!
+//! `cargo run -p llimphi-3d --example mesh_lights_demo --release`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_3d::glam::Vec3;
+use llimphi_3d::{Camera3d, PointLight, Renderer3d, Vertex3d};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+
+const W: u32 = 720;
+const H: u32 = 480;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+fn main() {
+    let hal = pollster::block_on(Hal::new(None)).expect("hal");
+    let mut renderer = Renderer::new(&hal).expect("renderer");
+
+    // Muro: quad en el plano XY (z=0), gris medio, mirando a +Z (hacia la cámara).
+    let g = [0.5, 0.5, 0.5];
+    let verts = vec![
+        Vertex3d { pos: [-2.4, -1.6, 0.0], color: g },
+        Vertex3d { pos: [2.4, -1.6, 0.0], color: g },
+        Vertex3d { pos: [2.4, 1.6, 0.0], color: g },
+        Vertex3d { pos: [-2.4, 1.6, 0.0], color: g },
+    ];
+    let indices = vec![0u16, 1, 2, 0, 2, 3];
+    let mut r3d = Renderer3d::with_mesh(&hal.device, FMT, &verts, &indices);
+
+    let camera = Camera3d {
+        eye: Vec3::new(0.0, 0.0, 5.0),
+        target: Vec3::ZERO,
+        ..Camera3d::default()
+    };
+
+    // (A) Sin luces, ambiente pleno → debe quedar plano (control).
+    r3d.lights.clear();
+    r3d.ambient = [1.0, 1.0, 1.0];
+    let flat = render(&hal, &mut renderer, &mut r3d, &camera);
+    write_png(&flat, "mesh_lights_flat.png");
+
+    // (B) Una luz cálida al +X, ambiente bajo.
+    r3d.ambient = [0.08, 0.08, 0.10];
+    r3d.lights = vec![PointLight {
+        pos: [1.7, 0.0, 1.3],
+        color: [1.7, 1.05, 0.5], // cálida (naranja), > 1 para brillo intenso
+        range: 5.5,
+        radius: 0.0, // sin sombras en el forward-mesh
+    }];
+    let lit = render(&hal, &mut renderer, &mut r3d, &camera);
+    write_png(&lit, "mesh_lights_lit.png");
+
+    let (fl, fr, _, _) = halves(&flat);
+    let (ll, lr, warm_r, warm_b) = halves(&lit);
+    eprintln!("mesh_lights_demo: {W}x{H}");
+    eprintln!("  CONTROL (sin luces): lum izq={fl:.1} der={fr:.1} (Δ={:+.1}, debe ~0)", fr - fl);
+    eprintln!("  CON LUZ +X:          lum izq={ll:.1} der={lr:.1} (Δ={:+.1}, debe >>0)", lr - ll);
+    eprintln!("  píxel más brillante: r={warm_r:.0} b={warm_b:.0} (r>b ⇒ tinte cálido)");
+    assert!((fr - fl).abs() < 2.0, "sin luces el muro debería ser plano");
+    assert!(lr - ll > 8.0, "la luz +X debería iluminar más la mitad derecha");
+    assert!(warm_r > warm_b + 10.0, "la luz cálida debería dar r > b");
+    eprintln!("  OK — point-lights iluminan el forward-mesh con dirección y tinte.");
+}
+
+/// Devuelve (lum_media_izq, lum_media_der, r_del_pixel_mas_brillante, b_idem).
+fn halves(px: &[u8]) -> (f32, f32, f32, f32) {
+    let (mut sl, mut nl, mut sr, mut nr) = (0f64, 0u64, 0f64, 0u64);
+    let (mut best, mut br, mut bb) = (-1f32, 0f32, 0f32);
+    for y in 0..H as usize {
+        for x in 0..W as usize {
+            let i = (y * W as usize + x) * 4;
+            let (r, gg, b) = (px[i] as f32, px[i + 1] as f32, px[i + 2] as f32);
+            let l = 0.2126 * r + 0.7152 * gg + 0.0722 * b;
+            if x < W as usize / 2 {
+                sl += l as f64;
+                nl += 1;
+            } else {
+                sr += l as f64;
+                nr += 1;
+            }
+            if l > best {
+                best = l;
+                br = r;
+                bb = b;
+            }
+        }
+    }
+    (
+        (sl / nl as f64) as f32,
+        (sr / nr as f64) as f32,
+        br,
+        bb,
+    )
+}
+
+fn render(
+    hal: &Hal,
+    renderer: &mut Renderer,
+    r3d: &mut Renderer3d,
+    camera: &Camera3d,
+) -> Vec<u8> {
+    let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
+        label: Some("inter"),
+        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: FMT,
+        usage: wgpu::TextureUsages::STORAGE_BINDING
+            | wgpu::TextureUsages::TEXTURE_BINDING
+            | wgpu::TextureUsages::RENDER_ATTACHMENT
+            | wgpu::TextureUsages::COPY_SRC,
+        view_formats: &[],
+    });
+    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
+
+    let base = vello::Scene::new();
+    renderer
+        .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(8, 8, 12, 255))
+        .expect("render base");
+
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("mesh-lights") });
+    r3d.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
+    hal.queue.submit(std::iter::once(enc.finish()));
+    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+
+    readback(hal, &inter)
+}
+
+fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
+    let unpadded = (W * 4) as usize;
+    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+    let padded = unpadded.div_ceil(align) * align;
+    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
+        label: Some("readback"),
+        size: (padded * H as usize) as u64,
+        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
+        mapped_at_creation: false,
+    });
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+    enc.copy_texture_to_buffer(
+        wgpu::TexelCopyTextureInfo {
+            texture: target,
+            mip_level: 0,
+            origin: wgpu::Origin3d::ZERO,
+            aspect: wgpu::TextureAspect::All,
+        },
+        wgpu::TexelCopyBufferInfo {
+            buffer: &buf,
+            layout: wgpu::TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(padded as u32),
+                rows_per_image: Some(H),
+            },
+        },
+        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+    );
+    hal.queue.submit(std::iter::once(enc.finish()));
+    let slice = buf.slice(..);
+    let (tx, rx) = std::sync::mpsc::channel();
+    slice.map_async(wgpu::MapMode::Read, move |r| {
+        let _ = tx.send(r);
+    });
+    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+    rx.recv().unwrap().unwrap();
+    let data = slice.get_mapped_range();
+    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
+    for row in 0..H as usize {
+        let s = row * padded;
+        pixels.extend_from_slice(&data[s..s + unpadded]);
+    }
+    drop(data);
+    buf.unmap();
+    pixels
+}
+
+fn write_png(pixels: &[u8], path: &str) {
+    let file = File::create(path).expect("png");
+    let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
+    enc.set_color(png::ColorType::Rgba);
+    enc.set_depth(png::BitDepth::Eight);
+    let mut w = enc.write_header().unwrap();
+    w.write_image_data(pixels).unwrap();
+}
diff --git a/llimphi-3d/examples/postfx_demo.rs b/llimphi-3d/examples/postfx_demo.rs
new file mode 100644
index 0000000..4a1f368
--- /dev/null
+++ b/llimphi-3d/examples/postfx_demo.rs
@@ -0,0 +1,172 @@
+//! Demo headless del post-proceso [`PostFx`] (SSAA + bloom) cosechado de supay.
+//!
+//! Rinde el mismo cubo dos veces sobre un fondo vello: una con bloom apagado
+//! (`bloom_strength = 0`) y otra con el bloom por defecto. Vuelca ambos PNG y
+//! certifica **por texto** (sin mirar) que el bloom suma energía luminosa
+//! alrededor de los bordes brillantes: cuenta píxeles cuya luminancia creció y
+//! el delta de luminancia total.
+//!
+//! `cargo run -p llimphi-3d --example postfx_demo --release`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_3d::glam::Vec3;
+use llimphi_3d::{Camera3d, PostFx, PostFxConfig, Renderer3d};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+
+const W: u32 = 720;
+const H: u32 = 480;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+fn main() {
+    let hal = pollster::block_on(Hal::new(None)).expect("hal");
+    let mut renderer = Renderer::new(&hal).expect("renderer");
+    let mut r3d = Renderer3d::new(&hal.device, FMT);
+    let mut fx = PostFx::new(&hal.device, FMT);
+
+    let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 25_f32.to_radians(), 4.0);
+
+    // Sin bloom (sólo SSAA).
+    let off = render(&hal, &mut renderer, &mut r3d, &mut fx, &camera, 0.0);
+    write_png(&off, "postfx_off.png");
+    // Con bloom por defecto.
+    let on = render(&hal, &mut renderer, &mut r3d, &mut fx, &camera, PostFxConfig::default().bloom_strength);
+    write_png(&on, "postfx_on.png");
+
+    // --- Certificación numérica (sin mirar el PNG) ---
+    let (mut grew, mut sum_off, mut sum_on, mut max_gain) = (0u64, 0f64, 0f64, 0f32);
+    for (po, pn) in off.chunks_exact(4).zip(on.chunks_exact(4)) {
+        let lo = lum(po);
+        let ln = lum(pn);
+        sum_off += lo as f64;
+        sum_on += ln as f64;
+        if ln > lo + 0.004 {
+            grew += 1;
+        }
+        max_gain = max_gain.max(ln - lo);
+    }
+    let total = (W * H) as f64;
+    eprintln!("postfx_demo: {W}x{H}, supersample={}", fx.config().supersample);
+    eprintln!("  píxeles que ganaron luz con bloom: {grew} ({:.2}%)", 100.0 * grew as f64 / total);
+    eprintln!("  luminancia media off={:.4} on={:.4} (Δ={:+.4})", sum_off / total, sum_on / total, (sum_on - sum_off) / total);
+    eprintln!("  máxima ganancia en un píxel: {max_gain:.4}");
+    eprintln!("  PNG: postfx_off.png / postfx_on.png");
+    assert!(grew > 0, "el bloom no agregó glow en ningún píxel — algo está mal");
+    assert!(sum_on > sum_off, "el bloom debería sumar luminancia total");
+}
+
+fn lum(p: &[u8]) -> f32 {
+    (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0
+}
+
+fn render(
+    hal: &Hal,
+    renderer: &mut Renderer,
+    r3d: &mut Renderer3d,
+    fx: &mut PostFx,
+    camera: &Camera3d,
+    bloom_strength: f32,
+) -> Vec<u8> {
+    let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
+        label: Some("inter"),
+        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: FMT,
+        usage: wgpu::TextureUsages::STORAGE_BINDING
+            | wgpu::TextureUsages::TEXTURE_BINDING
+            | wgpu::TextureUsages::RENDER_ATTACHMENT
+            | wgpu::TextureUsages::COPY_SRC,
+        view_formats: &[],
+    });
+    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
+
+    // (1) Fondo vello (la base que el blit debe PRESERVAR con LoadOp::Load).
+    let base = vello::Scene::new();
+    renderer
+        .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(18, 22, 32, 255))
+        .expect("render base");
+
+    // (2) Pase 3D envuelto en PostFx (SSAA + bloom) → blit sobre el fondo.
+    let cfg = PostFxConfig { bloom_strength, ..PostFxConfig::default() };
+    fx.set_config(cfg);
+    r3d.upload(&hal.queue, W as f32 / H as f32, camera);
+    fx.prepare(&hal.device, &hal.queue, (W, H));
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("postfx") });
+    {
+        let mut pass = fx.scene_pass(
+            &mut enc,
+            wgpu::Color { r: 0.07, g: 0.086, b: 0.125, a: 1.0 },
+        );
+        r3d.draw(&mut pass);
+    }
+    fx.resolve(&mut enc, &inter_view);
+    hal.queue.submit(std::iter::once(enc.finish()));
+    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+
+    readback(hal, &inter)
+}
+
+fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
+    let unpadded = (W * 4) as usize;
+    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+    let padded = unpadded.div_ceil(align) * align;
+    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
+        label: Some("readback"),
+        size: (padded * H as usize) as u64,
+        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
+        mapped_at_creation: false,
+    });
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+    enc.copy_texture_to_buffer(
+        wgpu::TexelCopyTextureInfo {
+            texture: target,
+            mip_level: 0,
+            origin: wgpu::Origin3d::ZERO,
+            aspect: wgpu::TextureAspect::All,
+        },
+        wgpu::TexelCopyBufferInfo {
+            buffer: &buf,
+            layout: wgpu::TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(padded as u32),
+                rows_per_image: Some(H),
+            },
+        },
+        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+    );
+    hal.queue.submit(std::iter::once(enc.finish()));
+    let slice = buf.slice(..);
+    let (tx, rx) = std::sync::mpsc::channel();
+    slice.map_async(wgpu::MapMode::Read, move |r| {
+        let _ = tx.send(r);
+    });
+    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+    rx.recv().unwrap().unwrap();
+    let data = slice.get_mapped_range();
+    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
+    for row in 0..H as usize {
+        let s = row * padded;
+        pixels.extend_from_slice(&data[s..s + unpadded]);
+    }
+    drop(data);
+    buf.unmap();
+    pixels
+}
+
+fn write_png(pixels: &[u8], path: &str) {
+    let file = File::create(path).expect("png");
+    let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
+    enc.set_color(png::ColorType::Rgba);
+    enc.set_depth(png::BitDepth::Eight);
+    let mut w = enc.write_header().unwrap();
+    w.write_image_data(pixels).unwrap();
+}
diff --git a/llimphi-3d/examples/reflection_demo.rs b/llimphi-3d/examples/reflection_demo.rs
new file mode 100644
index 0000000..5ebc334
--- /dev/null
+++ b/llimphi-3d/examples/reflection_demo.rs
@@ -0,0 +1,249 @@
+//! Demo headless de [`PlanarReflection`].
+//!
+//! Simula un mundo reflejado pintando la textura de reflexión de un color cálido
+//! brillante, y dibuja una superficie de agua horizontal (`y=0`) vista en
+//! ángulo rasante. Certifica **por texto**:
+//!  - la reflexión es **visible** en el agua (hay píxeles cálidos = el color
+//!    reflejado aparece en la superficie);
+//!  - el **Fresnel** funciona: la parte lejana/rasante del agua refleja más
+//!    (más cálida) que la cercana (más tinte azul).
+//!
+//! `cargo run -p llimphi-3d --example reflection_demo --release`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_3d::glam::Vec3;
+use llimphi_3d::{Camera3d, PlanarReflection, ReflectionPlane, SurfaceParams};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+
+const W: u32 = 640;
+const H: u32 = 400;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
+
+fn main() {
+    let hal = pollster::block_on(Hal::new(None)).expect("hal");
+    let mut renderer = Renderer::new(&hal).expect("renderer");
+    let mut refl = PlanarReflection::new(&hal.device, FMT);
+
+    // Agua: quad horizontal en y=0, receding hacia el fondo.
+    refl.set_surface_quad(
+        &hal.device,
+        [
+            [-8.0, 0.0, 2.0],   // cerca-izq
+            [8.0, 0.0, 2.0],    // cerca-der
+            [8.0, 0.0, -16.0],  // lejos-der
+            [-8.0, 0.0, -16.0], // lejos-izq
+        ],
+    );
+
+    // Cámara baja mirando casi a ras del agua (ángulo rasante → Fresnel alto lejos).
+    let camera = Camera3d {
+        eye: Vec3::new(0.0, 0.9, 5.0),
+        target: Vec3::new(0.0, 0.2, -6.0),
+        ..Camera3d::default()
+    };
+    let aspect = W as f32 / H as f32;
+    let plane = ReflectionPlane::horizontal_y(0.0);
+
+    refl.prepare(&hal.device, (W, H));
+    refl.upload_surface(
+        &hal.queue,
+        &camera.view_proj(aspect),
+        &plane,
+        &camera.eye.to_array(),
+        &SurfaceParams::default(),
+        (W, H),
+        0.0,
+    );
+
+    let px = render(&hal, &mut renderer, &mut refl);
+    write_png(&px, "reflection_demo.png");
+
+    // --- Certificación ---
+    // Superficie = píxel no-fondo (el fondo vello es muy oscuro).
+    let mut warm = 0u64; // reflexión cálida visible
+    let mut surf = 0u64;
+    let (mut y_min, mut y_max) = (H as i32, 0i32);
+    for y in 0..H as i32 {
+        for x in 0..W as i32 {
+            let i = ((y * W as i32 + x) * 4) as usize;
+            let (r, g, b) = (px[i] as i32, px[i + 1] as i32, px[i + 2] as i32);
+            if r.max(g).max(b) > 25 {
+                surf += 1;
+                y_min = y_min.min(y);
+                y_max = y_max.max(y);
+                if r > 120 {
+                    warm += 1;
+                }
+            }
+        }
+    }
+    assert!(surf > 0, "no se dibujó superficie");
+    let mid = (y_min + y_max) / 2;
+    // R medio en la mitad lejana (arriba) vs cercana (abajo) de la superficie.
+    let (mut rt, mut nt, mut rb, mut nb) = (0f64, 0u64, 0f64, 0u64);
+    for y in y_min..=y_max {
+        for x in 0..W as i32 {
+            let i = ((y * W as i32 + x) * 4) as usize;
+            let (r, g, b) = (px[i] as i32, px[i + 1] as i32, px[i + 2] as i32);
+            if r.max(g).max(b) <= 25 {
+                continue;
+            }
+            if y < mid {
+                rt += r as f64;
+                nt += 1;
+            } else {
+                rb += r as f64;
+                nb += 1;
+            }
+        }
+    }
+    let r_far = rt / nt.max(1) as f64;
+    let r_near = rb / nb.max(1) as f64;
+
+    eprintln!("reflection_demo: {W}x{H}");
+    eprintln!("  superficie: {surf} px (filas {y_min}..{y_max}); reflexión cálida (r>120): {warm} px");
+    eprintln!("  R medio  lejos/rasante={r_far:.1}  cerca={r_near:.1}  (Fresnel: lejos debe > cerca)");
+    assert!(warm > 500, "la reflexión cálida debería verse en el agua");
+    assert!(r_far > r_near + 8.0, "el Fresnel debería reflejar más en la zona rasante");
+    eprintln!("  OK — reflexión planar visible + Fresnel rasante en la superficie genérica.");
+}
+
+fn render(hal: &Hal, renderer: &mut Renderer, refl: &mut PlanarReflection) -> Vec<u8> {
+    let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
+        label: Some("inter"),
+        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: FMT,
+        usage: wgpu::TextureUsages::STORAGE_BINDING
+            | wgpu::TextureUsages::TEXTURE_BINDING
+            | wgpu::TextureUsages::RENDER_ATTACHMENT
+            | wgpu::TextureUsages::COPY_SRC,
+        view_formats: &[],
+    });
+    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
+    let depth = hal.device.create_texture(&wgpu::TextureDescriptor {
+        label: Some("depth"),
+        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: DEPTH,
+        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+        view_formats: &[],
+    });
+    let depth_view = depth.create_view(&Default::default());
+
+    let base = vello::Scene::new();
+    renderer
+        .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(6, 6, 10, 255))
+        .expect("render base");
+
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("refl") });
+
+    // (1) Pase de reflexión: en vez de rendir un mundo reflejado, lo simulamos
+    //     limpiando a un color cálido brillante (el "cielo/mundo" reflejado).
+    {
+        let _rp = refl.reflection_pass(
+            &mut enc,
+            wgpu::Color { r: 1.0, g: 0.62, b: 0.30, a: 1.0 },
+        );
+    }
+
+    // (2) Pase principal: superficie reflectante sobre el fondo vello.
+    {
+        let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
+            label: Some("refl-main"),
+            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                view: &inter_view,
+                resolve_target: None,
+                depth_slice: None,
+                ops: wgpu::Operations {
+                    load: wgpu::LoadOp::Load,
+                    store: wgpu::StoreOp::Store,
+                },
+            })],
+            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+                view: &depth_view,
+                depth_ops: Some(wgpu::Operations {
+                    load: wgpu::LoadOp::Clear(1.0),
+                    store: wgpu::StoreOp::Store,
+                }),
+                stencil_ops: None,
+            }),
+            timestamp_writes: None,
+            occlusion_query_set: None,
+        });
+        refl.draw_surface(&mut pass);
+    }
+
+    hal.queue.submit(std::iter::once(enc.finish()));
+    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+    readback(hal, &inter)
+}
+
+fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
+    let unpadded = (W * 4) as usize;
+    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+    let padded = unpadded.div_ceil(align) * align;
+    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
+        label: Some("readback"),
+        size: (padded * H as usize) as u64,
+        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
+        mapped_at_creation: false,
+    });
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+    enc.copy_texture_to_buffer(
+        wgpu::TexelCopyTextureInfo {
+            texture: target,
+            mip_level: 0,
+            origin: wgpu::Origin3d::ZERO,
+            aspect: wgpu::TextureAspect::All,
+        },
+        wgpu::TexelCopyBufferInfo {
+            buffer: &buf,
+            layout: wgpu::TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(padded as u32),
+                rows_per_image: Some(H),
+            },
+        },
+        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+    );
+    hal.queue.submit(std::iter::once(enc.finish()));
+    let slice = buf.slice(..);
+    let (tx, rx) = std::sync::mpsc::channel();
+    slice.map_async(wgpu::MapMode::Read, move |r| {
+        let _ = tx.send(r);
+    });
+    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+    rx.recv().unwrap().unwrap();
+    let data = slice.get_mapped_range();
+    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
+    for row in 0..H as usize {
+        let s = row * padded;
+        pixels.extend_from_slice(&data[s..s + unpadded]);
+    }
+    drop(data);
+    buf.unmap();
+    pixels
+}
+
+fn write_png(pixels: &[u8], path: &str) {
+    let file = File::create(path).expect("png");
+    let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
+    enc.set_color(png::ColorType::Rgba);
+    enc.set_depth(png::BitDepth::Eight);
+    let mut w = enc.write_header().unwrap();
+    w.write_image_data(pixels).unwrap();
+}
diff --git a/llimphi-3d/examples/sky_billboard_demo.rs b/llimphi-3d/examples/sky_billboard_demo.rs
new file mode 100644
index 0000000..cfcd8d6
--- /dev/null
+++ b/llimphi-3d/examples/sky_billboard_demo.rs
@@ -0,0 +1,255 @@
+//! Demo headless de [`SkyBackdrop`] + [`Billboards`].
+//!
+//! Compone un cielo cilíndrico (panorama con una barra verde brillante) y un
+//! billboard blanco en el origen, en un pase con depth. Certifica **por texto**:
+//!  - al girar el `yaw`, la barra del cielo se **desplaza horizontalmente**
+//!    (muestreo cilíndrico correcto);
+//!  - el billboard aparece **centrado** y **de cara a la cámara** (su cuadro
+//!    blanco cubre el centro de la imagen en ambos yaws).
+//!
+//! `cargo run -p llimphi-3d --example sky_billboard_demo --release`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_3d::glam::Vec3;
+use llimphi_3d::{Billboard, Billboards, Camera3d, SkyBackdrop, SkyParams};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+
+const W: u32 = 640;
+const H: u32 = 400;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
+
+fn main() {
+    let hal = pollster::block_on(Hal::new(None)).expect("hal");
+    let mut renderer = Renderer::new(&hal).expect("renderer");
+
+    // Cielo: 512×64, fondo azul oscuro + una barra verde brillante en u≈0.
+    let (sw, sh) = (512u32, 64u32);
+    let mut sky_px = vec![0u8; (sw * sh * 4) as usize];
+    for y in 0..sh {
+        for x in 0..sw {
+            let i = ((y * sw + x) * 4) as usize;
+            let bar = x < 6; // barra angosta en el borde izquierdo (u=0)
+            let (r, g, b) = if bar { (40, 255, 40) } else { (12, 14, 30) };
+            sky_px[i] = r;
+            sky_px[i + 1] = g;
+            sky_px[i + 2] = b;
+            sky_px[i + 3] = 255;
+        }
+    }
+    let mut sky = SkyBackdrop::new(&hal.device, FMT);
+    sky.set_texture(&hal.device, &hal.queue, sw, sh, &sky_px);
+
+    // Billboard: atlas 16×16 blanco opaco; un quad en el origen.
+    let atlas = vec![255u8; 16 * 16 * 4];
+    let mut bb = Billboards::new(&hal.device, FMT);
+    bb.set_atlas(&hal.device, &hal.queue, 16, 16, &atlas);
+    bb.set_billboards(
+        &hal.device,
+        &[Billboard {
+            center: [0.0, 0.0, 0.0],
+            size: [1.4, 1.4],
+            uv_min: [0.0, 0.0],
+            uv_max: [1.0, 1.0],
+            tint: [1.0, 1.0, 1.0, 1.0],
+        }],
+    );
+
+    let bar0 = {
+        let px = render(&hal, &mut renderer, &sky, &bb, 0.0);
+        write_png(&px, "sky_billboard_yaw0.png");
+        certify_frame(&px, "yaw=0.0")
+    };
+    let bar1 = {
+        let px = render(&hal, &mut renderer, &sky, &bb, 0.5);
+        write_png(&px, "sky_billboard_yaw05.png");
+        certify_frame(&px, "yaw=0.5")
+    };
+
+    eprintln!("sky_billboard_demo: {W}x{H}");
+    eprintln!("  barra de cielo: x@yaw0={bar0}  x@yaw0.5={bar1}  (Δ={})", (bar1 - bar0).abs());
+    assert!(
+        (bar1 - bar0).abs() > 20,
+        "la barra del cielo debería desplazarse al girar el yaw"
+    );
+    eprintln!("  OK — cielo cilíndrico gira con el yaw + billboard centrado de cara a la cámara.");
+}
+
+/// Mide: (a) que el billboard blanco cubra el centro; (b) la columna x con más
+/// verde (la barra de cielo). Devuelve esa columna x.
+fn certify_frame(px: &[u8], tag: &str) -> i32 {
+    // (a) Centro blanco (el billboard).
+    let ci = ((H / 2 * W + W / 2) * 4) as usize;
+    let (r, g, b) = (px[ci], px[ci + 1], px[ci + 2]);
+    assert!(
+        r > 200 && g > 200 && b > 200,
+        "{tag}: el billboard debería pintar blanco en el centro (vi {r},{g},{b})"
+    );
+    // Esquina (fondo de cielo, no billboard): no debe ser blanca.
+    let cor = 0usize;
+    assert!(
+        !(px[cor] > 200 && px[cor + 1] > 200 && px[cor + 2] > 200),
+        "{tag}: la esquina no debería ser el billboard"
+    );
+
+    // (b) Columna con más verde puro (verde alto, rojo/azul bajos) = la barra.
+    // Muestreamos una banda superior para no contar el billboard.
+    let mut best_x = 0i32;
+    let mut best = -1.0f32;
+    for x in 0..W as usize {
+        let mut acc = 0.0f32;
+        for y in 0..(H as usize / 4) {
+            let i = (y * W as usize + x) * 4;
+            let (r, g, b) = (px[i] as f32, px[i + 1] as f32, px[i + 2] as f32);
+            acc += (g - r).max(0.0) + (g - b).max(0.0);
+        }
+        if acc > best {
+            best = acc;
+            best_x = x as i32;
+        }
+    }
+    best_x
+}
+
+fn render(
+    hal: &Hal,
+    renderer: &mut Renderer,
+    sky: &SkyBackdrop,
+    bb: &Billboards,
+    yaw: f32,
+) -> Vec<u8> {
+    let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
+        label: Some("inter"),
+        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: FMT,
+        usage: wgpu::TextureUsages::STORAGE_BINDING
+            | wgpu::TextureUsages::TEXTURE_BINDING
+            | wgpu::TextureUsages::RENDER_ATTACHMENT
+            | wgpu::TextureUsages::COPY_SRC,
+        view_formats: &[],
+    });
+    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
+    let depth = hal.device.create_texture(&wgpu::TextureDescriptor {
+        label: Some("depth"),
+        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: DEPTH,
+        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+        view_formats: &[],
+    });
+    let depth_view = depth.create_view(&Default::default());
+
+    let base = vello::Scene::new();
+    renderer
+        .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(0, 0, 0, 255))
+        .expect("render base");
+
+    let aspect = W as f32 / H as f32;
+    let camera = Camera3d::orbit(Vec3::ZERO, yaw, 0.0, 4.0);
+    sky.upload(
+        &hal.queue,
+        &SkyParams { yaw, pitch: 0.0, fov_x: std::f32::consts::FRAC_PI_2, aspect, ..SkyParams::default() },
+    );
+    bb.upload(&hal.queue, aspect, &camera);
+
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("sky-bb") });
+    {
+        let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
+            label: Some("sky-bb-pass"),
+            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                view: &inter_view,
+                resolve_target: None,
+                depth_slice: None,
+                ops: wgpu::Operations {
+                    load: wgpu::LoadOp::Load,
+                    store: wgpu::StoreOp::Store,
+                },
+            })],
+            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+                view: &depth_view,
+                depth_ops: Some(wgpu::Operations {
+                    load: wgpu::LoadOp::Clear(1.0),
+                    store: wgpu::StoreOp::Store,
+                }),
+                stencil_ops: None,
+            }),
+            timestamp_writes: None,
+            occlusion_query_set: None,
+        });
+        sky.draw(&mut pass); // fondo primero
+        bb.draw(&mut pass); // billboards encima
+    }
+    hal.queue.submit(std::iter::once(enc.finish()));
+    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+
+    readback(hal, &inter)
+}
+
+fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
+    let unpadded = (W * 4) as usize;
+    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+    let padded = unpadded.div_ceil(align) * align;
+    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
+        label: Some("readback"),
+        size: (padded * H as usize) as u64,
+        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
+        mapped_at_creation: false,
+    });
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+    enc.copy_texture_to_buffer(
+        wgpu::TexelCopyTextureInfo {
+            texture: target,
+            mip_level: 0,
+            origin: wgpu::Origin3d::ZERO,
+            aspect: wgpu::TextureAspect::All,
+        },
+        wgpu::TexelCopyBufferInfo {
+            buffer: &buf,
+            layout: wgpu::TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(padded as u32),
+                rows_per_image: Some(H),
+            },
+        },
+        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
+    );
+    hal.queue.submit(std::iter::once(enc.finish()));
+    let slice = buf.slice(..);
+    let (tx, rx) = std::sync::mpsc::channel();
+    slice.map_async(wgpu::MapMode::Read, move |r| {
+        let _ = tx.send(r);
+    });
+    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+    rx.recv().unwrap().unwrap();
+    let data = slice.get_mapped_range();
+    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
+    for row in 0..H as usize {
+        let s = row * padded;
+        pixels.extend_from_slice(&data[s..s + unpadded]);
+    }
+    drop(data);
+    buf.unmap();
+    pixels
+}
+
+fn write_png(pixels: &[u8], path: &str) {
+    let file = File::create(path).expect("png");
+    let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
+    enc.set_color(png::ColorType::Rgba);
+    enc.set_depth(png::BitDepth::Eight);
+    let mut w = enc.write_header().unwrap();
+    w.write_image_data(pixels).unwrap();
+}
diff --git a/llimphi-3d/examples/voxel_dimensiones.rs b/llimphi-3d/examples/voxel_dimensiones.rs
index 9a1c395..e49e096 100644
--- a/llimphi-3d/examples/voxel_dimensiones.rs
+++ b/llimphi-3d/examples/voxel_dimensiones.rs
@@ -38,6 +38,7 @@ fn world_jardin(d: u32) -> Dimension {
             sky_zenith: [70, 130, 90],
             sky_horizon: [196, 222, 188],
             fog_density: 0.22 / d as f32,
+            god_rays: 0.0,
         })
         .with_entities(orbit_entities(
             d,
@@ -81,6 +82,7 @@ fn world_inframundo(d: u32) -> Dimension {
             sky_zenith: [60, 12, 10],
             sky_horizon: [180, 70, 24],
             fog_density: 0.4 / d as f32,
+            god_rays: 0.0,
         })
         .with_entities(orbit_entities(d, &[[255, 140, 30], [255, 90, 20], [255, 200, 60]]))
 }
@@ -120,6 +122,7 @@ fn world_cristal(d: u32) -> Dimension {
             sky_zenith: [24, 18, 60],
             sky_horizon: [120, 90, 200],
             fog_density: 0.28 / d as f32,
+            god_rays: 0.0,
         })
         .with_entities(orbit_entities(d, &[[120, 240, 255], [220, 180, 255]]))
 }
diff --git a/llimphi-3d/src/billboard.rs b/llimphi-3d/src/billboard.rs
new file mode 100644
index 0000000..0fbeac7
--- /dev/null
+++ b/llimphi-3d/src/billboard.rs
@@ -0,0 +1,431 @@
+//! `Billboards` — quads **siempre de cara a la cámara**, texturizados desde un
+//! atlas, con test de profundidad y alpha-discard (recorte, no blend). El
+//! ladrillo genérico para sprites, partículas, etiquetas flotantes, íconos en
+//! el mundo.
+//!
+//! Cosechado de los sprites 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
+//! los quads se armaban en CPU y estaban atados al `WadAtlas`/tabla de tintes.
+//! Acá la forma es agnóstica: un **atlas** (una textura) + una lista de
+//! [`Billboard`] con su sub-rect UV, tamaño en mundo y tinte. El quad de cara a
+//! la cámara lo arma el vertex shader con los ejes `right`/`up` de la cámara, así
+//! no hay reconstrucción por CPU cada frame.
+//!
+//! ```ignore
+//! let mut bb = Billboards::new(&device, fmt);
+//! bb.set_atlas(&device, &queue, w, h, &rgba);
+//! bb.set_billboards(&device, &[Billboard { center: [0.0,1.0,0.0], size: [1.0,2.0],
+//!     uv_min: [0.0,0.0], uv_max: [1.0,1.0], tint: [1.0,1.0,1.0,1.0] }]);
+//! // en el pase (con depth), después del mundo opaco:
+//! bb.upload(&queue, aspect, &camera);
+//! bb.draw(&mut pass);
+//! ```
+
+use glam::Vec3;
+
+use crate::camera::Camera3d;
+use crate::scene::DEPTH_FORMAT;
+
+/// Un billboard: un quad centrado en `center` (mundo), de `size` (ancho, alto en
+/// unidades de mundo), texturizado con el sub-rect `[uv_min, uv_max]` del atlas y
+/// multiplicado por `tint` (RGBA premultiplica el color; `a` también escala el
+/// alpha del atlas para el discard).
+#[derive(Clone, Copy, Debug)]
+pub struct Billboard {
+    pub center: [f32; 3],
+    pub size: [f32; 2],
+    pub uv_min: [f32; 2],
+    pub uv_max: [f32; 2],
+    pub tint: [f32; 4],
+}
+
+impl Billboard {
+    /// Floats por instancia en el buffer (`center`3 + `size`2 + `uv_min`2 +
+    /// `uv_max`2 + `tint`4).
+    const FLOATS: usize = 3 + 2 + 2 + 2 + 4;
+    const STRIDE: usize = Self::FLOATS * 4;
+
+    fn write_to(&self, out: &mut Vec<u8>) {
+        for v in self.center {
+            out.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in self.size {
+            out.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in self.uv_min {
+            out.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in self.uv_max {
+            out.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in self.tint {
+            out.extend_from_slice(&v.to_ne_bytes());
+        }
+    }
+}
+
+/// Atlas subido + su bind group.
+struct Atlas {
+    bind_group: wgpu::BindGroup,
+}
+
+/// Renderer de billboards reutilizable. Cachea pipeline/sampler/uniform; el
+/// buffer de instancias se recrea en [`Self::set_billboards`].
+pub struct Billboards {
+    pipeline: wgpu::RenderPipeline,
+    uniform_buf: wgpu::Buffer,
+    uniform_bg: wgpu::BindGroup,
+    tex_layout: wgpu::BindGroupLayout,
+    sampler: wgpu::Sampler,
+    atlas: Option<Atlas>,
+    instances: Option<wgpu::Buffer>,
+    count: u32,
+}
+
+impl Billboards {
+    /// Crea el renderer para el `color_format` del target. El pipeline declara
+    /// depth [`DEPTH_FORMAT`] (test `Less`, escribe) → se usa en un pase con
+    /// depth attachment, después de la geometría opaca.
+    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
+        let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("llimphi-3d-bb-uniform-layout"),
+            entries: &[wgpu::BindGroupLayoutEntry {
+                binding: 0,
+                visibility: wgpu::ShaderStages::VERTEX,
+                ty: wgpu::BindingType::Buffer {
+                    ty: wgpu::BufferBindingType::Uniform,
+                    has_dynamic_offset: false,
+                    min_binding_size: None,
+                },
+                count: None,
+            }],
+        });
+        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("llimphi-3d-bb-tex-layout"),
+            entries: &[
+                wgpu::BindGroupLayoutEntry {
+                    binding: 0,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Texture {
+                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                        view_dimension: wgpu::TextureViewDimension::D2,
+                        multisampled: false,
+                    },
+                    count: None,
+                },
+                wgpu::BindGroupLayoutEntry {
+                    binding: 1,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+                    count: None,
+                },
+            ],
+        });
+        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+            label: Some("llimphi-3d-bb-shader"),
+            source: wgpu::ShaderSource::Wgsl(BILLBOARD_WGSL.into()),
+        });
+        let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+            label: Some("llimphi-3d-bb-pl"),
+            bind_group_layouts: &[&uniform_layout, &tex_layout],
+            push_constant_ranges: &[],
+        });
+        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+            label: Some("llimphi-3d-bb-pipeline"),
+            layout: Some(&pl),
+            vertex: wgpu::VertexState {
+                module: &shader,
+                entry_point: Some("vs"),
+                compilation_options: Default::default(),
+                buffers: &[wgpu::VertexBufferLayout {
+                    array_stride: Billboard::STRIDE as u64,
+                    step_mode: wgpu::VertexStepMode::Instance,
+                    attributes: &[
+                        wgpu::VertexAttribute {
+                            format: wgpu::VertexFormat::Float32x3,
+                            offset: 0,
+                            shader_location: 0,
+                        },
+                        wgpu::VertexAttribute {
+                            format: wgpu::VertexFormat::Float32x2,
+                            offset: 12,
+                            shader_location: 1,
+                        },
+                        wgpu::VertexAttribute {
+                            format: wgpu::VertexFormat::Float32x2,
+                            offset: 20,
+                            shader_location: 2,
+                        },
+                        wgpu::VertexAttribute {
+                            format: wgpu::VertexFormat::Float32x2,
+                            offset: 28,
+                            shader_location: 3,
+                        },
+                        wgpu::VertexAttribute {
+                            format: wgpu::VertexFormat::Float32x4,
+                            offset: 36,
+                            shader_location: 4,
+                        },
+                    ],
+                }],
+            },
+            primitive: wgpu::PrimitiveState {
+                cull_mode: None,
+                ..Default::default()
+            },
+            depth_stencil: Some(wgpu::DepthStencilState {
+                format: DEPTH_FORMAT,
+                depth_write_enabled: true,
+                depth_compare: wgpu::CompareFunction::Less,
+                stencil: Default::default(),
+                bias: Default::default(),
+            }),
+            multisample: Default::default(),
+            fragment: Some(wgpu::FragmentState {
+                module: &shader,
+                entry_point: Some("fs"),
+                compilation_options: Default::default(),
+                targets: &[Some(wgpu::ColorTargetState {
+                    format: color_format,
+                    blend: None,
+                    write_mask: wgpu::ColorWrites::ALL,
+                })],
+            }),
+            multiview: None,
+            cache: None,
+        });
+
+        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("llimphi-3d-bb-uniform"),
+            size: 96, // view_proj(64) + right(16) + up(16)
+            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+            mapped_at_creation: false,
+        });
+        let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: Some("llimphi-3d-bb-uniform-bg"),
+            layout: &uniform_layout,
+            entries: &[wgpu::BindGroupEntry {
+                binding: 0,
+                resource: uniform_buf.as_entire_binding(),
+            }],
+        });
+        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+            label: Some("llimphi-3d-bb-sampler"),
+            address_mode_u: wgpu::AddressMode::ClampToEdge,
+            address_mode_v: wgpu::AddressMode::ClampToEdge,
+            address_mode_w: wgpu::AddressMode::ClampToEdge,
+            mag_filter: wgpu::FilterMode::Linear,
+            min_filter: wgpu::FilterMode::Linear,
+            mipmap_filter: wgpu::FilterMode::Nearest,
+            ..Default::default()
+        });
+
+        Self {
+            pipeline,
+            uniform_buf,
+            uniform_bg,
+            tex_layout,
+            sampler,
+            atlas: None,
+            instances: None,
+            count: 0,
+        }
+    }
+
+    /// Sube/reemplaza el atlas (RGBA8, `w×h`, sin padding). `data.len()==w*h*4`.
+    pub fn set_atlas(
+        &mut self,
+        device: &wgpu::Device,
+        queue: &wgpu::Queue,
+        w: u32,
+        h: u32,
+        data: &[u8],
+    ) {
+        assert_eq!(data.len(), (w * h * 4) as usize, "RGBA8 w*h*4 esperado");
+        let tex = device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("llimphi-3d-bb-atlas"),
+            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: wgpu::TextureDimension::D2,
+            format: wgpu::TextureFormat::Rgba8Unorm,
+            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+            view_formats: &[],
+        });
+        queue.write_texture(
+            wgpu::TexelCopyTextureInfo {
+                texture: &tex,
+                mip_level: 0,
+                origin: wgpu::Origin3d::ZERO,
+                aspect: wgpu::TextureAspect::All,
+            },
+            data,
+            wgpu::TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(w * 4),
+                rows_per_image: Some(h),
+            },
+            wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+        );
+        let view = tex.create_view(&Default::default());
+        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: Some("llimphi-3d-bb-atlas-bg"),
+            layout: &self.tex_layout,
+            entries: &[
+                wgpu::BindGroupEntry {
+                    binding: 0,
+                    resource: wgpu::BindingResource::TextureView(&view),
+                },
+                wgpu::BindGroupEntry {
+                    binding: 1,
+                    resource: wgpu::BindingResource::Sampler(&self.sampler),
+                },
+            ],
+        });
+        self.atlas = Some(Atlas { bind_group });
+    }
+
+    /// Reemplaza la lista de billboards (recrea el buffer de instancias). El
+    /// orden importa para el z-fight de alpha-discard: poná los más cercanos
+    /// primero si querés (con depth-write el primero gana). Idealmente el caller
+    /// los ordena back-to-front.
+    pub fn set_billboards(&mut self, device: &wgpu::Device, items: &[Billboard]) {
+        self.count = items.len() as u32;
+        if items.is_empty() {
+            self.instances = None;
+            return;
+        }
+        let mut bytes = Vec::with_capacity(items.len() * Billboard::STRIDE);
+        for b in items {
+            b.write_to(&mut bytes);
+        }
+        let buf = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("llimphi-3d-bb-instances"),
+            size: bytes.len() as u64,
+            usage: wgpu::BufferUsages::VERTEX,
+            mapped_at_creation: true,
+        });
+        buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
+        buf.unmap();
+        self.instances = Some(buf);
+    }
+
+    /// Sube el uniform del frame: `view_proj` + los ejes `right`/`up` de la
+    /// cámara (para orientar los quads). `aspect` = w/h.
+    pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
+        let view_proj = camera.view_proj(aspect);
+        let forward = (camera.target - camera.eye).normalize_or_zero();
+        let right = forward.cross(camera.up).normalize_or_zero();
+        let up = right.cross(forward);
+        let mut b = Vec::with_capacity(96);
+        for v in view_proj.to_cols_array() {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in [right.x, right.y, right.z, 0.0] {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in [up.x, up.y, up.z, 0.0] {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        queue.write_buffer(&self.uniform_buf, 0, &b);
+    }
+
+    /// Dibuja los billboards en un pase **ya abierto** (con depth). Requiere
+    /// atlas + instancias + [`Self::upload`] previo. No-op si falta algo.
+    pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
+        let (Some(atlas), Some(inst)) = (self.atlas.as_ref(), self.instances.as_ref()) else {
+            return;
+        };
+        if self.count == 0 {
+            return;
+        }
+        pass.set_pipeline(&self.pipeline);
+        pass.set_bind_group(0, &self.uniform_bg, &[]);
+        pass.set_bind_group(1, &atlas.bind_group, &[]);
+        pass.set_vertex_buffer(0, inst.slice(..));
+        pass.draw(0..6, 0..self.count);
+    }
+
+    /// Conveniencia para ubicar `up`/`right` desde una cámara sin subir nada (p.
+    /// ej. para ordenar los billboards back-to-front por distancia al ojo).
+    pub fn eye(camera: &Camera3d) -> Vec3 {
+        camera.eye
+    }
+}
+
+/// Quad de cara a la cámara por instancia: el vertex shader lo arma con los ejes
+/// `right`/`up`; el fragment muestrea el atlas y descarta el alpha bajo.
+const BILLBOARD_WGSL: &str = r#"
+struct U {
+    view_proj: mat4x4<f32>,
+    right: vec4<f32>,
+    up: vec4<f32>,
+};
+@group(0) @binding(0) var<uniform> u: U;
+@group(1) @binding(0) var tex: texture_2d<f32>;
+@group(1) @binding(1) var samp: sampler;
+
+struct VIn {
+    @location(0) center: vec3<f32>,
+    @location(1) size: vec2<f32>,
+    @location(2) uv_min: vec2<f32>,
+    @location(3) uv_max: vec2<f32>,
+    @location(4) tint: vec4<f32>,
+};
+struct VOut {
+    @builtin(position) clip: vec4<f32>,
+    @location(0) uv: vec2<f32>,
+    @location(1) tint: vec4<f32>,
+};
+
+@vertex
+fn vs(@builtin(vertex_index) vi: u32, in: VIn) -> VOut {
+    // Dos triángulos: esquinas en {0,1}².
+    var cs = array<vec2<f32>, 6>(
+        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
+        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
+    );
+    let q = cs[vi];
+    let off = q - vec2<f32>(0.5, 0.5);
+    let world = in.center
+        + u.right.xyz * (off.x * in.size.x)
+        + u.up.xyz * (off.y * in.size.y);
+    var o: VOut;
+    o.clip = u.view_proj * vec4<f32>(world, 1.0);
+    // v invertida: q.y=1 (arriba del quad) ↔ uv_min.y (arriba de la imagen).
+    o.uv = vec2<f32>(
+        mix(in.uv_min.x, in.uv_max.x, q.x),
+        mix(in.uv_min.y, in.uv_max.y, 1.0 - q.y),
+    );
+    o.tint = in.tint;
+    return o;
+}
+
+@fragment
+fn fs(in: VOut) -> @location(0) vec4<f32> {
+    let c = textureSample(tex, samp, in.uv) * in.tint;
+    if (c.a < 0.5) { discard; }
+    return vec4<f32>(c.rgb, 1.0);
+}
+"#;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn shader_wgsl_valida() {
+        let module =
+            naga::front::wgsl::parse_str(BILLBOARD_WGSL).expect("BILLBOARD_WGSL no parsea");
+        naga::valid::Validator::new(
+            naga::valid::ValidationFlags::all(),
+            naga::valid::Capabilities::all(),
+        )
+        .validate(&module)
+        .expect("BILLBOARD_WGSL no valida");
+    }
+
+    #[test]
+    fn stride_es_13_floats() {
+        assert_eq!(Billboard::STRIDE, 13 * 4);
+    }
+}
diff --git a/llimphi-3d/src/lib.rs b/llimphi-3d/src/lib.rs
index c2666fa..203a796 100644
--- a/llimphi-3d/src/lib.rs
+++ b/llimphi-3d/src/lib.rs
@@ -31,22 +31,30 @@
 pub use glam;
 pub use wgpu;
 
+mod billboard;
 mod camera;
 mod cinema;
 mod dimensions;
 mod hud;
 mod mesh;
+mod postfx;
+mod reflection;
 mod renderer;
 mod scene;
+mod sky;
 mod voxel;
 mod voxel_renderer;
 
+pub use billboard::{Billboard, Billboards};
 pub use camera::Camera3d;
 pub use cinema::{CamKey, CameraTrack};
+pub use sky::{SkyBackdrop, SkyParams};
 pub use dimensions::{Dimension, Multiverse};
 pub use hud::{Hud, HudQuad};
 pub use mesh::{cube, push_cube, Vertex3d, CUBE_INDICES};
-pub use renderer::Renderer3d;
+pub use postfx::{PostFx, PostFxConfig};
+pub use reflection::{PlanarReflection, ReflectionPlane, SurfaceParams};
+pub use renderer::{Renderer3d, MESH_MAX_LIGHTS};
 pub use scene::Scene3d;
 pub use voxel::{DirtyBox, VoxelGrid};
 pub use voxel_renderer::{
diff --git a/llimphi-3d/src/postfx.rs b/llimphi-3d/src/postfx.rs
new file mode 100644
index 0000000..5a1664f
--- /dev/null
+++ b/llimphi-3d/src/postfx.rs
@@ -0,0 +1,606 @@
+//! `PostFx` — **antialiasing por supersampling (SSAA) + bloom** genéricos como
+//! pase de post-proceso reutilizable sobre cualquier render 3D wgpu.
+//!
+//! Cosechado del renderer 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
+//! estos efectos nacieron pegados a tipos del juego. Acá viven en su forma
+//! agnóstica: nada sabe de paredes, sprites ni WAD. Cualquier escena que se
+//! pueda dibujar en un pase wgpu (las mallas de [`Renderer3d`](crate::Renderer3d),
+//! los voxels de [`VoxelRenderer`](crate::VoxelRenderer), una carta 3D de cosmos,
+//! una galería de nahual) obtiene AA + glow «gratis» envolviendo su dibujo en
+//! [`PostFx::render_with`].
+//!
+//! ## Flujo (igual que el de supay, sin sus tipos)
+//!
+//! 1. La escena se rinde a un color intermedio a `supersample`× la resolución
+//!    de salida (4× de fragmentos con factor 2) con su propio depth.
+//! 2. **Bright-pass + blur 5×5** de ese color → textura de bloom a media res
+//!    (extrae lo que supera el umbral de luminancia y lo suaviza).
+//! 3. **Blit final**: baja la escena al target real con filtro lineal (eso es
+//!    el antialiasing) y le suma el bloom (eso es el glow). Compone con
+//!    `LoadOp::Load`, así no pisa la UI vello que ya esté debajo.
+//!
+//! ## Uso
+//!
+//! ```ignore
+//! let mut fx = PostFx::new(&device, color_format);
+//! // ... en gpu_paint_with(dev, q, enc, view, rect, _vp):
+//! fx.render_with(dev, q, enc, view, (w, h), wgpu::Color::BLACK, |pass| {
+//!     // `pass` ya está a supersample× con color+depth; sólo dibujá.
+//!     renderer3d.upload(q, w as f32 / h as f32, &camera); // antes del pase
+//!     renderer3d.draw(pass);
+//! });
+//! ```
+
+use crate::scene::DEPTH_FORMAT;
+
+/// Parámetros del post-proceso. Los defaults replican el «look» de supay
+/// (supersample 2×, glow suave). Ajustables por escena: una carta astral quiere
+/// poco bloom, un FPS infernal mucho.
+#[derive(Clone, Copy, Debug)]
+pub struct PostFxConfig {
+    /// Factor de supersampling por eje. 2 = rinde a 2× y promedia al bajar
+    /// (antialiasing 4×). 1 = sin SSAA (sólo bloom). Útil bajarlo en GPUs flojas.
+    pub supersample: u32,
+    /// Cuánto se suma el bloom al blit final (0 = sin glow).
+    pub bloom_strength: f32,
+    /// Umbral de luminancia del bright-pass: por encima de esto un píxel sangra.
+    pub bloom_threshold: f32,
+    /// Rampa del bright-pass (knee): ancho de la transición sobre el umbral.
+    pub bloom_knee: f32,
+    /// Radio del blur del bright-pass, en téxeles de la textura de bloom.
+    pub bloom_radius: f32,
+}
+
+impl Default for PostFxConfig {
+    fn default() -> Self {
+        Self {
+            supersample: 2,
+            bloom_strength: 0.85,
+            bloom_threshold: 0.62,
+            bloom_knee: 0.38,
+            bloom_radius: 2.5,
+        }
+    }
+}
+
+/// Target intermedio de la escena (color supersampleado + depth) más el bind
+/// group que expone su color como textura para el bright-pass y el blit.
+struct SceneTarget {
+    w: u32,
+    h: u32,
+    color_view: wgpu::TextureView,
+    depth_view: wgpu::TextureView,
+    tex_bg: wgpu::BindGroup,
+}
+
+/// Target de bloom (media resolución): color + bind group para sumarlo en el blit.
+struct BloomTarget {
+    w: u32,
+    h: u32,
+    color_view: wgpu::TextureView,
+    tex_bg: wgpu::BindGroup,
+}
+
+/// Pase de post-proceso reutilizable: SSAA + bloom sobre cualquier render wgpu.
+/// Cachea pipelines/sampler/layouts y recrea los targets al cambiar de tamaño.
+pub struct PostFx {
+    cfg: PostFxConfig,
+    output_format: wgpu::TextureFormat,
+    tex_layout: wgpu::BindGroupLayout,
+    sampler: wgpu::Sampler,
+    /// Bright-pass + blur: escena → bloom. group0 = escena, group1 = uniform.
+    bright_pipeline: wgpu::RenderPipeline,
+    /// Blit final: escena + bloom → target. group0 = escena, 1 = bloom, 2 = uniform.
+    blit_pipeline: wgpu::RenderPipeline,
+    bright_uniform: wgpu::Buffer,
+    bright_uniform_bg: wgpu::BindGroup,
+    blit_uniform: wgpu::Buffer,
+    blit_uniform_bg: wgpu::BindGroup,
+    scene: Option<SceneTarget>,
+    bloom: Option<BloomTarget>,
+}
+
+impl PostFx {
+    /// Crea el post-proceso para el `output_format` del target final (el de la
+    /// textura intermedia del frame: `Rgba8Unorm` headless, el de la surface en
+    /// vivo). Config por defecto.
+    pub fn new(device: &wgpu::Device, output_format: wgpu::TextureFormat) -> Self {
+        Self::with_config(device, output_format, PostFxConfig::default())
+    }
+
+    /// Igual que [`Self::new`] con una [`PostFxConfig`] explícita.
+    pub fn with_config(
+        device: &wgpu::Device,
+        output_format: wgpu::TextureFormat,
+        cfg: PostFxConfig,
+    ) -> Self {
+        // Layout textura+sampler (compartido por escena y bloom como entradas).
+        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("llimphi-3d-postfx-tex-layout"),
+            entries: &[
+                wgpu::BindGroupLayoutEntry {
+                    binding: 0,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Texture {
+                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                        view_dimension: wgpu::TextureViewDimension::D2,
+                        multisampled: false,
+                    },
+                    count: None,
+                },
+                wgpu::BindGroupLayoutEntry {
+                    binding: 1,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+                    count: None,
+                },
+            ],
+        });
+
+        let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("llimphi-3d-postfx-uniform-layout"),
+            entries: &[wgpu::BindGroupLayoutEntry {
+                binding: 0,
+                visibility: wgpu::ShaderStages::FRAGMENT,
+                ty: wgpu::BindingType::Buffer {
+                    ty: wgpu::BufferBindingType::Uniform,
+                    has_dynamic_offset: false,
+                    min_binding_size: None,
+                },
+                count: None,
+            }],
+        });
+
+        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+            label: Some("llimphi-3d-postfx-sampler"),
+            address_mode_u: wgpu::AddressMode::ClampToEdge,
+            address_mode_v: wgpu::AddressMode::ClampToEdge,
+            address_mode_w: wgpu::AddressMode::ClampToEdge,
+            mag_filter: wgpu::FilterMode::Linear,
+            min_filter: wgpu::FilterMode::Linear,
+            mipmap_filter: wgpu::FilterMode::Linear,
+            ..Default::default()
+        });
+
+        // Helper para pipelines fullscreen (triángulo cubre-pantalla, sin VBO).
+        let make_fullscreen = |layouts: &[&wgpu::BindGroupLayout],
+                               wgsl: &str,
+                               label: &str| {
+            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+                label: Some(label),
+                source: wgpu::ShaderSource::Wgsl(wgsl.into()),
+            });
+            let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+                label: Some(label),
+                bind_group_layouts: layouts,
+                push_constant_ranges: &[],
+            });
+            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+                label: Some(label),
+                layout: Some(&pl),
+                vertex: wgpu::VertexState {
+                    module: &shader,
+                    entry_point: Some("vs"),
+                    compilation_options: Default::default(),
+                    buffers: &[],
+                },
+                primitive: wgpu::PrimitiveState {
+                    cull_mode: None,
+                    ..Default::default()
+                },
+                depth_stencil: None,
+                multisample: Default::default(),
+                fragment: Some(wgpu::FragmentState {
+                    module: &shader,
+                    entry_point: Some("fs"),
+                    compilation_options: Default::default(),
+                    targets: &[Some(wgpu::ColorTargetState {
+                        format: output_format,
+                        blend: None,
+                        write_mask: wgpu::ColorWrites::ALL,
+                    })],
+                }),
+                multiview: None,
+                cache: None,
+            })
+        };
+
+        let bright_pipeline = make_fullscreen(
+            &[&tex_layout, &uniform_layout],
+            BRIGHT_WGSL,
+            "llimphi-3d-postfx-bright",
+        );
+        let blit_pipeline = make_fullscreen(
+            &[&tex_layout, &tex_layout, &uniform_layout],
+            BLIT_WGSL,
+            "llimphi-3d-postfx-blit",
+        );
+
+        let mk_uniform = |label: &str| {
+            device.create_buffer(&wgpu::BufferDescriptor {
+                label: Some(label),
+                size: 16, // un vec4<f32>
+                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+                mapped_at_creation: false,
+            })
+        };
+        let bright_uniform = mk_uniform("llimphi-3d-postfx-bright-u");
+        let blit_uniform = mk_uniform("llimphi-3d-postfx-blit-u");
+        let mk_uniform_bg = |buf: &wgpu::Buffer, label: &str| {
+            device.create_bind_group(&wgpu::BindGroupDescriptor {
+                label: Some(label),
+                layout: &uniform_layout,
+                entries: &[wgpu::BindGroupEntry {
+                    binding: 0,
+                    resource: buf.as_entire_binding(),
+                }],
+            })
+        };
+        let bright_uniform_bg = mk_uniform_bg(&bright_uniform, "llimphi-3d-postfx-bright-u-bg");
+        let blit_uniform_bg = mk_uniform_bg(&blit_uniform, "llimphi-3d-postfx-blit-u-bg");
+
+        Self {
+            cfg,
+            output_format,
+            tex_layout,
+            sampler,
+            bright_pipeline,
+            blit_pipeline,
+            bright_uniform,
+            bright_uniform_bg,
+            blit_uniform,
+            blit_uniform_bg,
+            scene: None,
+            bloom: None,
+        }
+    }
+
+    /// Config actual (lectura).
+    pub fn config(&self) -> PostFxConfig {
+        self.cfg
+    }
+
+    /// Cambia la config (toma efecto el próximo frame). Si cambia `supersample`
+    /// los targets se recrean solos al verificar el tamaño.
+    pub fn set_config(&mut self, cfg: PostFxConfig) {
+        self.cfg = cfg;
+    }
+
+    /// Formato del target final con el que se creó.
+    pub fn output_format(&self) -> wgpu::TextureFormat {
+        self.output_format
+    }
+
+    /// Tamaño supersampleado al que el llamador debe rendir su escena, dado el
+    /// `output_size` final. (= `output × supersample`.) Útil para el aspect/UI.
+    pub fn scene_size(&self, output_size: (u32, u32)) -> (u32, u32) {
+        let ss = self.cfg.supersample.max(1);
+        (output_size.0 * ss, output_size.1 * ss)
+    }
+
+    /// Asegura los targets internos para una salida `output_size` y sube los
+    /// uniforms del frame. **Llamar antes** de [`Self::scene_pass`] y
+    /// [`Self::resolve`] con el mismo `output_size`.
+    pub fn prepare(
+        &mut self,
+        device: &wgpu::Device,
+        queue: &wgpu::Queue,
+        output_size: (u32, u32),
+    ) {
+        let (ow, oh) = output_size;
+        if ow == 0 || oh == 0 {
+            return;
+        }
+        let (rw, rh) = self.scene_size(output_size);
+        self.ensure_scene(device, rw, rh);
+        self.ensure_bloom(device, (ow / 2).max(1), (oh / 2).max(1));
+        self.upload_uniforms(queue);
+    }
+
+    /// Abre el pase de escena a resolución supersampleada (color limpiado a
+    /// `clear` + depth limpiado a lejano) y devuelve el [`wgpu::RenderPass`] para
+    /// que el llamador setee pipeline(s) y dibuje. Requiere [`Self::prepare`]
+    /// previo en el mismo frame. Al soltar el pase, llamar [`Self::resolve`].
+    ///
+    /// El depth es `Depth32Float` (igual que [`Renderer3d`](crate::Renderer3d) y
+    /// [`Scene3d`](crate::Scene3d)), así sus pipelines encajan sin cambios.
+    pub fn scene_pass<'p>(
+        &'p self,
+        encoder: &'p mut wgpu::CommandEncoder,
+        clear: wgpu::Color,
+    ) -> wgpu::RenderPass<'p> {
+        let scene = self
+            .scene
+            .as_ref()
+            .expect("PostFx::scene_pass sin prepare() previo");
+        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+            label: Some("llimphi-3d-postfx-scene"),
+            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                view: &scene.color_view,
+                resolve_target: None,
+                depth_slice: None,
+                ops: wgpu::Operations {
+                    load: wgpu::LoadOp::Clear(clear),
+                    store: wgpu::StoreOp::Store,
+                },
+            })],
+            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+                view: &scene.depth_view,
+                depth_ops: Some(wgpu::Operations {
+                    load: wgpu::LoadOp::Clear(1.0),
+                    store: wgpu::StoreOp::Store,
+                }),
+                stencil_ops: None,
+            }),
+            timestamp_writes: None,
+            occlusion_query_set: None,
+        })
+    }
+
+    /// Resuelve la escena ya dibujada: bright-pass + blur → bloom, luego blit
+    /// (bajada SSAA + suma de bloom) sobre `target`, preservando lo que haya
+    /// debajo (`LoadOp::Load`). Requiere [`Self::prepare`] + el pase de
+    /// [`Self::scene_pass`] ya soltado.
+    pub fn resolve(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView) {
+        let scene_bg = match self.scene.as_ref() {
+            Some(s) => &s.tex_bg,
+            None => return,
+        };
+        let bloom = self.bloom.as_ref().unwrap();
+
+        // --- Bright-pass + blur: escena → textura de bloom (media res). ---
+        {
+            let mut bp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                label: Some("llimphi-3d-postfx-bright"),
+                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                    view: &bloom.color_view,
+                    resolve_target: None,
+                    depth_slice: None,
+                    ops: wgpu::Operations {
+                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+                        store: wgpu::StoreOp::Store,
+                    },
+                })],
+                depth_stencil_attachment: None,
+                timestamp_writes: None,
+                occlusion_query_set: None,
+            });
+            bp.set_pipeline(&self.bright_pipeline);
+            bp.set_bind_group(0, scene_bg, &[]);
+            bp.set_bind_group(1, &self.bright_uniform_bg, &[]);
+            bp.draw(0..3, 0..1);
+        }
+
+        // --- Blit final: escena (SSAA) + bloom → target real (preserva debajo). ---
+        {
+            let mut blit = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                label: Some("llimphi-3d-postfx-blit"),
+                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                    view: target,
+                    resolve_target: None,
+                    depth_slice: None,
+                    ops: wgpu::Operations {
+                        load: wgpu::LoadOp::Load,
+                        store: wgpu::StoreOp::Store,
+                    },
+                })],
+                depth_stencil_attachment: None,
+                timestamp_writes: None,
+                occlusion_query_set: None,
+            });
+            blit.set_pipeline(&self.blit_pipeline);
+            blit.set_bind_group(0, scene_bg, &[]);
+            blit.set_bind_group(1, &bloom.tex_bg, &[]);
+            blit.set_bind_group(2, &self.blit_uniform_bg, &[]);
+            blit.draw(0..3, 0..1);
+        }
+    }
+
+    fn upload_uniforms(&self, queue: &wgpu::Queue) {
+        // bright: (threshold, knee, radius, _)
+        let mut b = Vec::with_capacity(16);
+        for v in [
+            self.cfg.bloom_threshold,
+            self.cfg.bloom_knee.max(1e-4),
+            self.cfg.bloom_radius,
+            0.0,
+        ] {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        queue.write_buffer(&self.bright_uniform, 0, &b);
+        // blit: (bloom_strength, _, _, _)
+        let mut s = Vec::with_capacity(16);
+        for v in [self.cfg.bloom_strength, 0.0, 0.0, 0.0] {
+            s.extend_from_slice(&v.to_ne_bytes());
+        }
+        queue.write_buffer(&self.blit_uniform, 0, &s);
+    }
+
+    fn ensure_scene(&mut self, device: &wgpu::Device, w: u32, h: u32) {
+        if matches!(&self.scene, Some(s) if s.w == w && s.h == h) {
+            return;
+        }
+        let color = device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("llimphi-3d-postfx-scene-color"),
+            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: wgpu::TextureDimension::D2,
+            format: wgpu::TextureFormat::Rgba8Unorm,
+            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
+            view_formats: &[],
+        });
+        let color_view = color.create_view(&Default::default());
+        let depth = device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("llimphi-3d-postfx-scene-depth"),
+            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: wgpu::TextureDimension::D2,
+            format: DEPTH_FORMAT,
+            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+            view_formats: &[],
+        });
+        let depth_view = depth.create_view(&Default::default());
+        let tex_bg = self.make_tex_bg(device, &color_view, "llimphi-3d-postfx-scene-bg");
+        self.scene = Some(SceneTarget { w, h, color_view, depth_view, tex_bg });
+    }
+
+    fn ensure_bloom(&mut self, device: &wgpu::Device, w: u32, h: u32) {
+        if matches!(&self.bloom, Some(b) if b.w == w && b.h == h) {
+            return;
+        }
+        let color = device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("llimphi-3d-postfx-bloom-color"),
+            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: wgpu::TextureDimension::D2,
+            format: wgpu::TextureFormat::Rgba8Unorm,
+            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
+            view_formats: &[],
+        });
+        let color_view = color.create_view(&Default::default());
+        let tex_bg = self.make_tex_bg(device, &color_view, "llimphi-3d-postfx-bloom-bg");
+        self.bloom = Some(BloomTarget { w, h, color_view, tex_bg });
+    }
+
+    fn make_tex_bg(
+        &self,
+        device: &wgpu::Device,
+        view: &wgpu::TextureView,
+        label: &str,
+    ) -> wgpu::BindGroup {
+        device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: Some(label),
+            layout: &self.tex_layout,
+            entries: &[
+                wgpu::BindGroupEntry {
+                    binding: 0,
+                    resource: wgpu::BindingResource::TextureView(view),
+                },
+                wgpu::BindGroupEntry {
+                    binding: 1,
+                    resource: wgpu::BindingResource::Sampler(&self.sampler),
+                },
+            ],
+        })
+    }
+}
+
+/// Bright-pass + blur 5×5 de la escena → textura de bloom (media res). En una
+/// pasada: downsample bilinear + extracción de lo brillante + suavizado.
+/// `u.params = (threshold, knee, radius, _)`.
+pub const BRIGHT_WGSL: &str = r#"
+@group(0) @binding(0) var tex: texture_2d<f32>;
+@group(0) @binding(1) var samp: sampler;
+struct U { params: vec4<f32> };
+@group(1) @binding(0) var<uniform> u: U;
+
+struct VOut {
+    @builtin(position) clip: vec4<f32>,
+    @location(0) uv: vec2<f32>,
+};
+
+@vertex
+fn vs(@builtin(vertex_index) vi: u32) -> VOut {
+    var p = array<vec2<f32>, 3>(
+        vec2<f32>(-1.0, -1.0),
+        vec2<f32>( 3.0, -1.0),
+        vec2<f32>(-1.0,  3.0),
+    );
+    var o: VOut;
+    let c = p[vi];
+    o.clip = vec4<f32>(c, 0.0, 1.0);
+    o.uv = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
+    return o;
+}
+
+@fragment
+fn fs(in: VOut) -> @location(0) vec4<f32> {
+    let dim = vec2<f32>(textureDimensions(tex, 0));
+    let texel = 1.0 / dim;
+    let thresh = u.params.x;
+    let knee = u.params.y;
+    let radius = u.params.z;
+    var acc = vec3<f32>(0.0);
+    for (var j = -2; j <= 2; j = j + 1) {
+        for (var i = -2; i <= 2; i = i + 1) {
+            let o = vec2<f32>(f32(i), f32(j)) * texel * radius;
+            let c = textureSample(tex, samp, in.uv + o).rgb;
+            let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
+            // Bright-pass suave: lo que supera el umbral sangra según el knee.
+            let k = clamp((l - thresh) / knee, 0.0, 1.0);
+            acc += c * k;
+        }
+    }
+    return vec4<f32>(acc / 25.0, 1.0);
+}
+"#;
+
+/// Blit de bajada SSAA + suma de bloom: triángulo fullscreen que muestrea la
+/// escena supersampleada (filtro lineal = antialiasing) y le suma el bloom.
+/// `u.params.x = bloom_strength`.
+pub const BLIT_WGSL: &str = r#"
+@group(0) @binding(0) var tex: texture_2d<f32>;
+@group(0) @binding(1) var samp: sampler;
+@group(1) @binding(0) var bloom: texture_2d<f32>;
+@group(1) @binding(1) var bsamp: sampler;
+struct U { params: vec4<f32> };
+@group(2) @binding(0) var<uniform> u: U;
+
+struct VOut {
+    @builtin(position) clip: vec4<f32>,
+    @location(0) uv: vec2<f32>,
+};
+
+@vertex
+fn vs(@builtin(vertex_index) vi: u32) -> VOut {
+    var p = array<vec2<f32>, 3>(
+        vec2<f32>(-1.0, -1.0),
+        vec2<f32>( 3.0, -1.0),
+        vec2<f32>(-1.0,  3.0),
+    );
+    var o: VOut;
+    let c = p[vi];
+    o.clip = vec4<f32>(c, 0.0, 1.0);
+    o.uv = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
+    return o;
+}
+
+@fragment
+fn fs(in: VOut) -> @location(0) vec4<f32> {
+    let s = textureSample(tex, samp, in.uv).rgb;
+    let b = textureSample(bloom, bsamp, in.uv).rgb;
+    return vec4<f32>(s + b * u.params.x, 1.0);
+}
+"#;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Los shaders WGSL del post-proceso parsean y validan sin GPU (naga).
+    /// Ataja errores que de otro modo sólo saldrían al crear el pipeline.
+    #[test]
+    fn shaders_wgsl_validan() {
+        for (nombre, src) in [("BRIGHT_WGSL", BRIGHT_WGSL), ("BLIT_WGSL", BLIT_WGSL)] {
+            let module = naga::front::wgsl::parse_str(src)
+                .unwrap_or_else(|e| panic!("{nombre} no parsea: {e:?}"));
+            naga::valid::Validator::new(
+                naga::valid::ValidationFlags::all(),
+                naga::valid::Capabilities::all(),
+            )
+            .validate(&module)
+            .unwrap_or_else(|e| panic!("{nombre} no valida: {e:?}"));
+        }
+    }
+
+    #[test]
+    fn config_default_replica_supay() {
+        let c = PostFxConfig::default();
+        assert_eq!(c.supersample, 2);
+        assert!((c.bloom_strength - 0.85).abs() < 1e-6);
+        assert!((c.bloom_threshold - 0.62).abs() < 1e-6);
+    }
+}
diff --git a/llimphi-3d/src/reflection.rs b/llimphi-3d/src/reflection.rs
new file mode 100644
index 0000000..4eaf681
--- /dev/null
+++ b/llimphi-3d/src/reflection.rs
@@ -0,0 +1,509 @@
+//! `PlanarReflection` — reflexión planar genérica: una superficie espejada
+//! (agua, piso pulido, vidrio) que refleja el mundo a través de un plano.
+//!
+//! Cosechado del agua 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde la
+//! reflexión estaba cableada al plano horizontal de Doom (`reflect_across_z`) y
+//! al shader del agua. Acá:
+//!
+//!  - [`ReflectionPlane`] modela un plano **arbitrario** (`n·x + d = 0`) y da su
+//!    matriz espejo ([`ReflectionPlane::mirror`]) por reflexión de Householder.
+//!  - [`PlanarReflection`] administra el **render target de reflexión**
+//!    (color+depth) — el caller rinde su mundo con la `view_proj · mirror` ahí —
+//!    y trae una **superficie reflectante lista**: un quad sobre el plano que
+//!    muestrea esa textura en screen-space, con ondas procedurales + Fresnel +
+//!    tinte. El caller puede usar esa superficie o sólo la textura para su
+//!    propio shader.
+//!
+//! Flujo:
+//! ```ignore
+//! let m = plane.mirror();
+//! refl.prepare(&device, (w, h));
+//! { let mut rp = refl.reflection_pass(&mut enc, clear);
+//!   // rendir el mundo con view_proj * m (clip bajo el plano en tu shader) }
+//! // ... pase principal: mundo normal ...
+//! refl.set_surface_quad(&device, corners);
+//! refl.upload_surface(&queue, &view_proj, &plane, &eye, &SurfaceParams::default(), (w,h), time);
+//! refl.draw_surface(&mut pass); // en el pase principal (con depth)
+//! ```
+
+use glam::{Mat4, Vec3};
+
+use crate::scene::DEPTH_FORMAT;
+
+/// Plano `n·x + d = 0` con `n` unitaria. La reflexión espeja a través de él.
+#[derive(Clone, Copy, Debug)]
+pub struct ReflectionPlane {
+    /// Normal del plano (se normaliza en [`Self::mirror`]).
+    pub normal: [f32; 3],
+    /// Término independiente: distancia con signo del origen, en unidades de `n`.
+    pub d: f32,
+}
+
+impl ReflectionPlane {
+    /// Plano horizontal a altura `y` con normal `+Y` (piso/agua, mundo Y-up).
+    pub fn horizontal_y(y: f32) -> Self {
+        Self { normal: [0.0, 1.0, 0.0], d: -y }
+    }
+
+    /// Plano horizontal a altura `z` con normal `+Z` (mundo Z-up, como Doom).
+    pub fn horizontal_z(z: f32) -> Self {
+        Self { normal: [0.0, 0.0, 1.0], d: -z }
+    }
+
+    /// Matriz de **reflexión de Householder** a través del plano: espeja
+    /// cualquier punto a su imagen del otro lado. `mvp_reflejada = view_proj ·
+    /// mirror`. Equivale, para un plano horizontal, al `T·S(-1)·T⁻¹` de Doom.
+    pub fn mirror(&self) -> Mat4 {
+        let n = Vec3::from_array(self.normal).normalize_or_zero();
+        let (nx, ny, nz) = (n.x, n.y, n.z);
+        // Renormalizamos d al `n` unitario.
+        let len = Vec3::from_array(self.normal).length().max(1e-8);
+        let d = self.d / len;
+        // Column-major (glam): cada `Vec4` es una columna.
+        Mat4::from_cols(
+            [1.0 - 2.0 * nx * nx, -2.0 * nx * ny, -2.0 * nx * nz, 0.0].into(),
+            [-2.0 * nx * ny, 1.0 - 2.0 * ny * ny, -2.0 * ny * nz, 0.0].into(),
+            [-2.0 * nx * nz, -2.0 * ny * nz, 1.0 - 2.0 * nz * nz, 0.0].into(),
+            [-2.0 * nx * d, -2.0 * ny * d, -2.0 * nz * d, 1.0].into(),
+        )
+    }
+}
+
+/// Parámetros de la superficie reflectante.
+#[derive(Clone, Copy, Debug)]
+pub struct SurfaceParams {
+    /// Amplitud de la distorsión por ondas (en uv de pantalla). `0` = espejo liso.
+    pub ripple_strength: f32,
+    /// Frecuencia espacial de las ondas (en unidades de mundo).
+    pub ripple_scale: f32,
+    /// Exponente Fresnel: más alto = la reflexión sólo aparece muy rasante.
+    pub fresnel_power: f32,
+    /// Reflectividad máxima (a incidencia rasante). `1` = espejo; `<1` mezcla tinte.
+    pub reflectivity: f32,
+    /// Reflexión mínima mirando de frente (`0`..`reflectivity`).
+    pub base_reflect: f32,
+    /// Color/tinte de la superficie donde no refleja (rgb) + opacidad (a).
+    pub tint: [f32; 4],
+}
+
+impl Default for SurfaceParams {
+    fn default() -> Self {
+        Self {
+            ripple_strength: 0.012,
+            ripple_scale: 1.6,
+            fresnel_power: 4.0,
+            reflectivity: 0.9,
+            base_reflect: 0.08,
+            tint: [0.04, 0.10, 0.16, 1.0],
+        }
+    }
+}
+
+/// Render target de reflexión (color+depth) + su bind group para muestrearlo.
+struct ReflTarget {
+    w: u32,
+    h: u32,
+    color_view: wgpu::TextureView,
+    depth_view: wgpu::TextureView,
+    tex_bg: wgpu::BindGroup,
+}
+
+/// Reflexión planar reutilizable: target de reflexión + superficie reflectante.
+pub struct PlanarReflection {
+    tex_layout: wgpu::BindGroupLayout,
+    sampler: wgpu::Sampler,
+    target: Option<ReflTarget>,
+    // Superficie:
+    surface_pipeline: wgpu::RenderPipeline,
+    surface_uniform: wgpu::Buffer,
+    surface_uniform_bg: wgpu::BindGroup,
+    surface_quad: Option<wgpu::Buffer>,
+}
+
+impl PlanarReflection {
+    /// Crea el sistema para el `color_format` del target final.
+    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
+        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("llimphi-3d-refl-tex-layout"),
+            entries: &[
+                wgpu::BindGroupLayoutEntry {
+                    binding: 0,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Texture {
+                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                        view_dimension: wgpu::TextureViewDimension::D2,
+                        multisampled: false,
+                    },
+                    count: None,
+                },
+                wgpu::BindGroupLayoutEntry {
+                    binding: 1,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+                    count: None,
+                },
+            ],
+        });
+        let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("llimphi-3d-refl-uniform-layout"),
+            entries: &[wgpu::BindGroupLayoutEntry {
+                binding: 0,
+                visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
+                ty: wgpu::BindingType::Buffer {
+                    ty: wgpu::BufferBindingType::Uniform,
+                    has_dynamic_offset: false,
+                    min_binding_size: None,
+                },
+                count: None,
+            }],
+        });
+        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+            label: Some("llimphi-3d-refl-sampler"),
+            address_mode_u: wgpu::AddressMode::ClampToEdge,
+            address_mode_v: wgpu::AddressMode::ClampToEdge,
+            address_mode_w: wgpu::AddressMode::ClampToEdge,
+            mag_filter: wgpu::FilterMode::Linear,
+            min_filter: wgpu::FilterMode::Linear,
+            mipmap_filter: wgpu::FilterMode::Linear,
+            ..Default::default()
+        });
+
+        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+            label: Some("llimphi-3d-refl-surface-shader"),
+            source: wgpu::ShaderSource::Wgsl(SURFACE_WGSL.into()),
+        });
+        let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+            label: Some("llimphi-3d-refl-surface-pl"),
+            bind_group_layouts: &[&uniform_layout, &tex_layout],
+            push_constant_ranges: &[],
+        });
+        let surface_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+            label: Some("llimphi-3d-refl-surface-pipeline"),
+            layout: Some(&pl),
+            vertex: wgpu::VertexState {
+                module: &shader,
+                entry_point: Some("vs"),
+                compilation_options: Default::default(),
+                buffers: &[wgpu::VertexBufferLayout {
+                    array_stride: 12,
+                    step_mode: wgpu::VertexStepMode::Vertex,
+                    attributes: &[wgpu::VertexAttribute {
+                        format: wgpu::VertexFormat::Float32x3,
+                        offset: 0,
+                        shader_location: 0,
+                    }],
+                }],
+            },
+            primitive: wgpu::PrimitiveState {
+                cull_mode: None,
+                ..Default::default()
+            },
+            depth_stencil: Some(wgpu::DepthStencilState {
+                format: DEPTH_FORMAT,
+                depth_write_enabled: true,
+                depth_compare: wgpu::CompareFunction::Less,
+                stencil: Default::default(),
+                bias: Default::default(),
+            }),
+            multisample: Default::default(),
+            fragment: Some(wgpu::FragmentState {
+                module: &shader,
+                entry_point: Some("fs"),
+                compilation_options: Default::default(),
+                targets: &[Some(wgpu::ColorTargetState {
+                    format: color_format,
+                    blend: None,
+                    write_mask: wgpu::ColorWrites::ALL,
+                })],
+            }),
+            multiview: None,
+            cache: None,
+        });
+
+        let surface_uniform = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("llimphi-3d-refl-surface-uniform"),
+            size: SURFACE_UNIFORM_SIZE as u64,
+            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+            mapped_at_creation: false,
+        });
+        let surface_uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: Some("llimphi-3d-refl-surface-uniform-bg"),
+            layout: &uniform_layout,
+            entries: &[wgpu::BindGroupEntry {
+                binding: 0,
+                resource: surface_uniform.as_entire_binding(),
+            }],
+        });
+
+        Self {
+            tex_layout,
+            sampler,
+            target: None,
+            surface_pipeline,
+            surface_uniform,
+            surface_uniform_bg,
+            surface_quad: None,
+        }
+    }
+
+    /// Asegura el render target de reflexión a `(w,h)` (lo recrea al cambiar).
+    pub fn prepare(&mut self, device: &wgpu::Device, (w, h): (u32, u32)) {
+        if w == 0 || h == 0 {
+            return;
+        }
+        if matches!(&self.target, Some(t) if t.w == w && t.h == h) {
+            return;
+        }
+        let color = device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("llimphi-3d-refl-color"),
+            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: wgpu::TextureDimension::D2,
+            format: wgpu::TextureFormat::Rgba8Unorm,
+            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
+            view_formats: &[],
+        });
+        let color_view = color.create_view(&Default::default());
+        let depth = device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("llimphi-3d-refl-depth"),
+            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: wgpu::TextureDimension::D2,
+            format: DEPTH_FORMAT,
+            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+            view_formats: &[],
+        });
+        let depth_view = depth.create_view(&Default::default());
+        let tex_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: Some("llimphi-3d-refl-tex-bg"),
+            layout: &self.tex_layout,
+            entries: &[
+                wgpu::BindGroupEntry {
+                    binding: 0,
+                    resource: wgpu::BindingResource::TextureView(&color_view),
+                },
+                wgpu::BindGroupEntry {
+                    binding: 1,
+                    resource: wgpu::BindingResource::Sampler(&self.sampler),
+                },
+            ],
+        });
+        self.target = Some(ReflTarget { w, h, color_view, depth_view, tex_bg });
+    }
+
+    /// Abre el pase de reflexión (color limpiado a `clear` + depth limpio) y
+    /// devuelve el [`wgpu::RenderPass`] para que el caller rinda el mundo con la
+    /// `view_proj · plane.mirror()`. Requiere [`Self::prepare`]. Soltar el pase
+    /// antes de muestrear la textura.
+    pub fn reflection_pass<'p>(
+        &'p self,
+        encoder: &'p mut wgpu::CommandEncoder,
+        clear: wgpu::Color,
+    ) -> wgpu::RenderPass<'p> {
+        let t = self
+            .target
+            .as_ref()
+            .expect("PlanarReflection::reflection_pass sin prepare()");
+        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+            label: Some("llimphi-3d-refl-pass"),
+            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                view: &t.color_view,
+                resolve_target: None,
+                depth_slice: None,
+                ops: wgpu::Operations {
+                    load: wgpu::LoadOp::Clear(clear),
+                    store: wgpu::StoreOp::Store,
+                },
+            })],
+            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+                view: &t.depth_view,
+                depth_ops: Some(wgpu::Operations {
+                    load: wgpu::LoadOp::Clear(1.0),
+                    store: wgpu::StoreOp::Store,
+                }),
+                stencil_ops: None,
+            }),
+            timestamp_writes: None,
+            occlusion_query_set: None,
+        })
+    }
+
+    /// Define el quad de la superficie reflectante por sus 4 esquinas en mundo
+    /// (orden CCW: por ejemplo `[bl, br, tr, tl]`). Se dibuja como 2 triángulos.
+    pub fn set_surface_quad(&mut self, device: &wgpu::Device, corners: [[f32; 3]; 4]) {
+        let idx = [0usize, 1, 2, 0, 2, 3];
+        let mut bytes = Vec::with_capacity(6 * 12);
+        for &i in &idx {
+            for v in corners[i] {
+                bytes.extend_from_slice(&v.to_ne_bytes());
+            }
+        }
+        let buf = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("llimphi-3d-refl-surface-quad"),
+            size: bytes.len() as u64,
+            usage: wgpu::BufferUsages::VERTEX,
+            mapped_at_creation: true,
+        });
+        buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
+        buf.unmap();
+        self.surface_quad = Some(buf);
+    }
+
+    /// Sube el uniform de la superficie. `view_proj` es la cámara real (no la
+    /// reflejada), `eye` la posición del ojo, `time` para animar las ondas,
+    /// `screen` el tamaño del target final en px.
+    #[allow(clippy::too_many_arguments)]
+    pub fn upload_surface(
+        &self,
+        queue: &wgpu::Queue,
+        view_proj: &Mat4,
+        plane: &ReflectionPlane,
+        eye: &[f32; 3],
+        params: &SurfaceParams,
+        screen: (u32, u32),
+        time: f32,
+    ) {
+        let n = Vec3::from_array(plane.normal).normalize_or_zero();
+        let len = Vec3::from_array(plane.normal).length().max(1e-8);
+        let d = plane.d / len;
+        let mut b = Vec::with_capacity(SURFACE_UNIFORM_SIZE);
+        for v in view_proj.to_cols_array() {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in [eye[0], eye[1], eye[2], time] {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in [n.x, n.y, n.z, d] {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in [
+            params.ripple_strength,
+            params.ripple_scale,
+            params.fresnel_power,
+            params.reflectivity,
+        ] {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in params.tint {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in [screen.0 as f32, screen.1 as f32, params.base_reflect, 0.0] {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        debug_assert_eq!(b.len(), SURFACE_UNIFORM_SIZE);
+        queue.write_buffer(&self.surface_uniform, 0, &b);
+    }
+
+    /// Dibuja la superficie reflectante en un pase **ya abierto** (con depth),
+    /// muestreando la textura de reflexión. Requiere `prepare` + `set_surface_quad`
+    /// + `upload_surface`. No-op si falta algo.
+    pub fn draw_surface<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
+        let (Some(t), Some(quad)) = (self.target.as_ref(), self.surface_quad.as_ref()) else {
+            return;
+        };
+        pass.set_pipeline(&self.surface_pipeline);
+        pass.set_bind_group(0, &self.surface_uniform_bg, &[]);
+        pass.set_bind_group(1, &t.tex_bg, &[]);
+        pass.set_vertex_buffer(0, quad.slice(..));
+        pass.draw(0..6, 0..1);
+    }
+}
+
+/// Uniform de la superficie: `view_proj`(64) + `eye_time`(16) + `plane`(16) +
+/// `params`(16) + `tint`(16) + `screen_basereflect`(16).
+const SURFACE_UNIFORM_SIZE: usize = 64 + 16 * 5;
+
+/// Superficie reflectante: muestrea la textura de reflexión en screen-space,
+/// con ondas procedurales + Fresnel + tinte.
+const SURFACE_WGSL: &str = r#"
+struct U {
+    view_proj: mat4x4<f32>,
+    eye_time: vec4<f32>,   // xyz=ojo, w=time
+    plane: vec4<f32>,      // xyz=normal, w=d
+    params: vec4<f32>,     // ripple_strength, ripple_scale, fresnel_power, reflectivity
+    tint: vec4<f32>,       // rgb=color base, a=opacidad
+    screen: vec4<f32>,     // w, h, base_reflect, _
+};
+@group(0) @binding(0) var<uniform> u: U;
+@group(1) @binding(0) var refl: texture_2d<f32>;
+@group(1) @binding(1) var samp: sampler;
+
+struct VOut {
+    @builtin(position) clip: vec4<f32>,
+    @location(0) world: vec3<f32>,
+};
+
+@vertex
+fn vs(@location(0) pos: vec3<f32>) -> VOut {
+    var o: VOut;
+    o.clip = u.view_proj * vec4<f32>(pos, 1.0);
+    o.world = pos;
+    return o;
+}
+
+@fragment
+fn fs(in: VOut) -> @location(0) vec4<f32> {
+    // uv de pantalla a partir del builtin position (px, origen arriba-izq).
+    var suv = in.clip.xy / vec2<f32>(u.screen.x, u.screen.y);
+    // Ondas: distorsión sinusoidal cruzada en función de la posición de mundo.
+    let t = u.eye_time.w;
+    let sc = u.params.y;
+    let ripple = vec2<f32>(
+        sin(in.world.x * sc + t) + cos(in.world.z * sc * 1.3 - t * 0.7),
+        cos(in.world.z * sc + t * 1.1) + sin(in.world.x * sc * 0.9 + t),
+    ) * u.params.x;
+    suv = clamp(suv + ripple, vec2<f32>(0.0), vec2<f32>(1.0));
+    let reflected = textureSample(refl, samp, suv).rgb;
+
+    // Fresnel: más reflexión a incidencia rasante.
+    let n = normalize(u.plane.xyz);
+    let view_dir = normalize(u.eye_time.xyz - in.world);
+    let f = pow(1.0 - clamp(dot(view_dir, n), 0.0, 1.0), u.params.z);
+    let amount = clamp(u.screen.z + f * u.params.w, 0.0, 1.0);
+
+    let col = mix(u.tint.rgb, reflected, amount);
+    return vec4<f32>(col, 1.0);
+}
+"#;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn shader_wgsl_valida() {
+        let module = naga::front::wgsl::parse_str(SURFACE_WGSL).expect("SURFACE_WGSL no parsea");
+        naga::valid::Validator::new(
+            naga::valid::ValidationFlags::all(),
+            naga::valid::Capabilities::all(),
+        )
+        .validate(&module)
+        .expect("SURFACE_WGSL no valida");
+    }
+
+    #[test]
+    fn mirror_espeja_punto_sobre_plano() {
+        // Plano horizontal y=2: (0,5,0) → (0,-1,0).
+        let m = ReflectionPlane::horizontal_y(2.0).mirror();
+        let p = m.transform_point3(Vec3::new(0.0, 5.0, 0.0));
+        assert!((p - Vec3::new(0.0, -1.0, 0.0)).length() < 1e-4, "fue {p:?}");
+        // Un punto en el plano queda fijo.
+        let q = m.transform_point3(Vec3::new(3.0, 2.0, -1.0));
+        assert!((q - Vec3::new(3.0, 2.0, -1.0)).length() < 1e-4, "fue {q:?}");
+    }
+
+    #[test]
+    fn mirror_z_equivale_a_doom() {
+        // El reflect_across_z de Doom: T(z)·S(-1z)·T(-z). Comparamos en z=3.
+        let m = ReflectionPlane::horizontal_z(3.0).mirror();
+        let p = m.transform_point3(Vec3::new(1.0, 2.0, 7.0));
+        assert!((p - Vec3::new(1.0, 2.0, -1.0)).length() < 1e-4, "fue {p:?}");
+    }
+
+    #[test]
+    fn uniform_size_alineado() {
+        assert_eq!(SURFACE_UNIFORM_SIZE % 16, 0);
+    }
+}
diff --git a/llimphi-3d/src/renderer.rs b/llimphi-3d/src/renderer.rs
index 137e48c..5f5fed2 100644
--- a/llimphi-3d/src/renderer.rs
+++ b/llimphi-3d/src/renderer.rs
@@ -13,6 +13,16 @@ use glam::Mat4;
 use crate::camera::Camera3d;
 use crate::mesh::{cube, Vertex3d};
 use crate::scene::{ensure_depth, DepthBuffer, DEPTH_FORMAT};
+use crate::voxel_renderer::PointLight;
+
+/// Tope de luces puntuales por frame en el forward-mesh (caben en el uniform).
+/// El caller puede agregar más a [`Renderer3d::lights`]; las que excedan se
+/// ignoran (las primeras `MESH_MAX_LIGHTS`).
+pub const MESH_MAX_LIGHTS: usize = 8;
+
+/// Tamaño del uniform en bytes: `view_proj`(64) + `model`(64) + `eye_nlights`(16)
+/// + `ambient`(16) + `lpos`(MAX·16) + `lcol`(MAX·16).
+const UNIFORM_SIZE: usize = 64 + 64 + 16 + 16 + MESH_MAX_LIGHTS * 16 * 2;
 
 /// Renderer de **mallas** indexadas (por defecto un cubo) visto desde una
 /// [`Camera3d`]. Cachea pipeline, buffers de geometría, uniform y (para el
@@ -30,6 +40,18 @@ pub struct Renderer3d {
     bind_group: wgpu::BindGroup,
     model: Mat4,
     depth: Option<DepthBuffer>,
+    /// Luces puntuales coloreadas que iluminan la malla (≤ [`MESH_MAX_LIGHTS`];
+    /// las que sobren se ignoran). `pos`/`range` en **coordenadas de mundo** (no
+    /// de voxel: ojo con el doc de [`PointLight`], que está redactado para el
+    /// ray-marcher); `range` = radio de caída, `radius` se ignora (no hay
+    /// sombras en el forward-mesh). Editable antes de `upload`/`render`. Vacío =
+    /// sin luces → sólo el término ambiente.
+    pub lights: Vec<PointLight>,
+    /// Luz ambiente RGB que multiplica el color base donde no llega ninguna luz.
+    /// Default `[1, 1, 1]`: sin luces, el render es **idéntico** al plano de
+    /// antes (color base tal cual). Para que las luces se noten, bajalo
+    /// (p.ej. `[0.12, 0.12, 0.14]`).
+    pub ambient: [f32; 3],
 }
 
 impl Renderer3d {
@@ -57,7 +79,8 @@ impl Renderer3d {
             label: Some("llimphi-3d-bgl"),
             entries: &[wgpu::BindGroupLayoutEntry {
                 binding: 0,
-                visibility: wgpu::ShaderStages::VERTEX,
+                // El fragment también lee el uniform (luces + ambiente + eye).
+                visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
                 ty: wgpu::BindingType::Buffer {
                     ty: wgpu::BufferBindingType::Uniform,
                     has_dynamic_offset: false,
@@ -146,7 +169,7 @@ impl Renderer3d {
 
         let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
             label: Some("llimphi-3d-ubuf"),
-            size: 64, // una mat4x4<f32>
+            size: UNIFORM_SIZE as u64,
             usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
             mapped_at_creation: false,
         });
@@ -169,6 +192,8 @@ impl Renderer3d {
             bind_group,
             model: Mat4::IDENTITY,
             depth: None,
+            lights: Vec::new(),
+            ambient: [1.0, 1.0, 1.0],
         }
     }
 
@@ -199,16 +224,44 @@ impl Renderer3d {
         self.index_count = indices.len() as u32;
     }
 
-    /// Sube el uniform del frame (`mvp = view_proj · model`). Lo llama
-    /// [`Self::render`] y [`Scene3d`](crate::Scene3d). `aspect` = w/h.
+    /// Sube el uniform del frame: `view_proj` y `model` por separado (el shader
+    /// necesita la posición de mundo `model · pos` para iluminar), `eye`, número
+    /// de luces, ambiente y los arrays de luces. Lo llama [`Self::render`] y
+    /// [`Scene3d`](crate::Scene3d). `aspect` = w/h. glam es column-major, igual
+    /// que WGSL → se sube tal cual.
     pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
-        let mvp = camera.view_proj(aspect) * self.model;
-        // glam es column-major; el shader WGSL espera column-major → upload tal cual.
-        let mut ubytes = Vec::with_capacity(64);
-        for v in mvp.to_cols_array() {
-            ubytes.extend_from_slice(&v.to_ne_bytes());
+        let view_proj = camera.view_proj(aspect);
+        let mut b = Vec::with_capacity(UNIFORM_SIZE);
+        for v in view_proj.to_cols_array() {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in self.model.to_cols_array() {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        let n = self.lights.len().min(MESH_MAX_LIGHTS);
+        for v in [camera.eye.x, camera.eye.y, camera.eye.z, n as f32] {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        for v in [self.ambient[0], self.ambient[1], self.ambient[2], 0.0] {
+            b.extend_from_slice(&v.to_ne_bytes());
         }
-        queue.write_buffer(&self.ubuf, 0, &ubytes);
+        // Posiciones (xyz) + range (w).
+        for i in 0..MESH_MAX_LIGHTS {
+            let l = self.lights.get(i).copied();
+            let (p, r) = l.map_or(([0.0; 3], 1.0), |l| (l.pos, l.range));
+            for v in [p[0], p[1], p[2], r] {
+                b.extend_from_slice(&v.to_ne_bytes());
+            }
+        }
+        // Colores (rgb) + relleno.
+        for i in 0..MESH_MAX_LIGHTS {
+            let c = self.lights.get(i).copied().map_or([0.0; 3], |l| l.color);
+            for v in [c[0], c[1], c[2], 0.0] {
+                b.extend_from_slice(&v.to_ne_bytes());
+            }
+        }
+        debug_assert_eq!(b.len(), UNIFORM_SIZE);
+        queue.write_buffer(&self.ubuf, 0, &b);
     }
 
     /// Dibuja la malla indexada en un pase **ya abierto** (color + depth). Lo usa
@@ -287,7 +340,15 @@ fn create_buffer_init(
 }
 
 const WGSL: &str = r#"
-struct Uniforms { mvp: mat4x4<f32> };
+const MAXL: u32 = 8u;
+struct Uniforms {
+    view_proj: mat4x4<f32>,
+    model: mat4x4<f32>,
+    eye_n: vec4<f32>,      // xyz = ojo en mundo, w = nº de luces
+    ambient: vec4<f32>,    // rgb = luz ambiente
+    lpos: array<vec4<f32>, MAXL>,  // xyz = posición mundo, w = range (radio caída)
+    lcol: array<vec4<f32>, MAXL>,  // rgb = color de la luz
+};
 @group(0) @binding(0) var<uniform> u: Uniforms;
 
 struct VIn {
@@ -297,18 +358,77 @@ struct VIn {
 struct VOut {
     @builtin(position) clip: vec4<f32>,
     @location(0) color: vec3<f32>,
+    @location(1) world: vec3<f32>,
 };
 
 @vertex
 fn vs(in: VIn) -> VOut {
     var out: VOut;
-    out.clip = u.mvp * vec4<f32>(in.pos, 1.0);
+    let world = (u.model * vec4<f32>(in.pos, 1.0)).xyz;
+    out.clip = u.view_proj * vec4<f32>(world, 1.0);
     out.color = in.color;
+    out.world = world;
     return out;
 }
 
 @fragment
 fn fs(in: VOut) -> @location(0) vec4<f32> {
-    return vec4<f32>(in.color, 1.0);
+    // Normal geométrica plana (faceteada) por derivadas screen-space: no exige
+    // normales por vértice y sirve para cualquier malla (cubos, muñecos…).
+    let dx = dpdx(in.world);
+    let dy = dpdy(in.world);
+    let ncross = cross(dx, dy);
+    let nlen = length(ncross);
+    if (nlen < 1e-8) {
+        // Triángulo degenerado / visto de canto: sólo ambiente.
+        return vec4<f32>(in.color * u.ambient.rgb, 1.0);
+    }
+    var n = ncross / nlen;
+    // Orientar la normal hacia la cámara (independiente del winding/signo de las
+    // derivadas) para que la cara visible siempre se ilumine bien.
+    let view_dir = u.eye_n.xyz - in.world;
+    if (dot(n, view_dir) < 0.0) {
+        n = -n;
+    }
+
+    var lit = u.ambient.rgb;
+    let count = u32(u.eye_n.w);
+    for (var i: u32 = 0u; i < count; i = i + 1u) {
+        let lp = u.lpos[i];
+        let d = lp.xyz - in.world;
+        let dist = length(d);
+        let range = max(lp.w, 1e-3);
+        // Caída suave: (1 - d/range)^2, recortada a 0 fuera del radio.
+        let a = clamp(1.0 - dist / range, 0.0, 1.0);
+        let atten = a * a;
+        let ndotl = max(dot(n, d / max(dist, 1e-4)), 0.0);
+        lit = lit + u.lcol[i].rgb * (ndotl * atten);
+    }
+    return vec4<f32>(in.color * lit, 1.0);
 }
 "#;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// El shader del forward-mesh (con luces + derivadas) parsea y valida sin
+    /// GPU. Ataja errores que sólo saldrían al crear el pipeline en runtime.
+    #[test]
+    fn shader_wgsl_valida() {
+        let module = naga::front::wgsl::parse_str(WGSL).expect("WGSL no parsea");
+        naga::valid::Validator::new(
+            naga::valid::ValidationFlags::all(),
+            naga::valid::Capabilities::all(),
+        )
+        .validate(&module)
+        .expect("WGSL no valida");
+    }
+
+    /// El uniform empaquetado mide exactamente lo que declara el struct WGSL.
+    #[test]
+    fn uniform_size_coincide() {
+        assert_eq!(UNIFORM_SIZE, 64 + 64 + 16 + 16 + 8 * 16 * 2);
+        assert_eq!(UNIFORM_SIZE % 16, 0);
+    }
+}
diff --git a/llimphi-3d/src/sky.rs b/llimphi-3d/src/sky.rs
new file mode 100644
index 0000000..eba7150
--- /dev/null
+++ b/llimphi-3d/src/sky.rs
@@ -0,0 +1,349 @@
+//! `SkyBackdrop` — fondo de cielo **cilíndrico** genérico: un triángulo
+//! fullscreen que muestrea una textura de panorama por el azimut de la cámara,
+//! dibujado detrás de todo (depth `Always`, sin escribir depth) para que la
+//! geometría del mundo lo tape.
+//!
+//! Cosechado del cielo 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
+//! estaba pegado a las constantes de Doom (tileo 4×/360°, estiramiento vertical
+//! 1.8×). Acá esos números son **parámetros** ([`SkyParams`]), así un panorama
+//! normal usa los defaults y Doom reproduce su look exacto pasando los suyos.
+//!
+//! Se dibuja **primero** en un pase que ya tiene depth attachment (el de
+//! [`Scene3d`](crate::Scene3d) o el de [`PostFx::scene_pass`](crate::PostFx)):
+//!
+//! ```ignore
+//! sky.upload(queue, &SkyParams { yaw, pitch, fov_x, aspect, ..Default::default() });
+//! // dentro del pase, ANTES de la geometría:
+//! sky.draw(&mut pass);
+//! ```
+
+use crate::scene::DEPTH_FORMAT;
+
+/// Parámetros del cielo por frame. `yaw`/`pitch`/`fov_x` salen de la cámara;
+/// el resto modela cómo se mapea la textura.
+#[derive(Clone, Copy, Debug)]
+pub struct SkyParams {
+    /// Azimut de la cámara (rad). El centro de pantalla muestrea esta columna.
+    pub yaw: f32,
+    /// Cabeceo de la cámara (rad). Desplaza el cielo en vertical.
+    pub pitch: f32,
+    /// Campo de visión horizontal (rad). Define cuánto panorama abarca el ancho.
+    pub fov_x: f32,
+    /// Aspect ratio del viewport (w/h). Reservado para usos futuros.
+    pub aspect: f32,
+    /// Cuántas veces se repite la textura al dar la vuelta 360°. `1.0` = un
+    /// panorama completo; Doom usa `4.0` (el cielo tilea 4×).
+    pub wraps: f32,
+    /// Escala vertical de la textura sobre la pantalla. `1.0` = la textura ocupa
+    /// toda la altura; Doom usa `1.8` (franja superior + horizonte al medio).
+    pub v_scale: f32,
+    /// Cuánto desplaza el `pitch` la textura en vertical. Doom usa `0.6`.
+    pub pitch_scale: f32,
+    /// Offset vertical fijo añadido a la coordenada de textura.
+    pub v_offset: f32,
+}
+
+impl Default for SkyParams {
+    fn default() -> Self {
+        Self {
+            yaw: 0.0,
+            pitch: 0.0,
+            fov_x: std::f32::consts::FRAC_PI_2,
+            aspect: 1.0,
+            wraps: 1.0,
+            v_scale: 1.0,
+            pitch_scale: 1.0,
+            v_offset: 0.0,
+        }
+    }
+}
+
+/// Textura de cielo subida + su bind group.
+struct SkyTexture {
+    bind_group: wgpu::BindGroup,
+}
+
+/// Fondo de cielo cilíndrico reutilizable. Sin textura, [`Self::draw`] es no-op.
+pub struct SkyBackdrop {
+    pipeline: wgpu::RenderPipeline,
+    uniform_buf: wgpu::Buffer,
+    uniform_bg: wgpu::BindGroup,
+    tex_layout: wgpu::BindGroupLayout,
+    sampler: wgpu::Sampler,
+    tex: Option<SkyTexture>,
+}
+
+impl SkyBackdrop {
+    /// Crea el backdrop para el `color_format` del target. El pipeline declara
+    /// depth [`DEPTH_FORMAT`] (`Always`, sin escribir) → debe dibujarse en un
+    /// pase con depth attachment, como los de [`Scene3d`](crate::Scene3d)/
+    /// [`PostFx`](crate::PostFx).
+    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
+        let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("llimphi-3d-sky-uniform-layout"),
+            entries: &[wgpu::BindGroupLayoutEntry {
+                binding: 0,
+                visibility: wgpu::ShaderStages::FRAGMENT,
+                ty: wgpu::BindingType::Buffer {
+                    ty: wgpu::BufferBindingType::Uniform,
+                    has_dynamic_offset: false,
+                    min_binding_size: None,
+                },
+                count: None,
+            }],
+        });
+        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("llimphi-3d-sky-tex-layout"),
+            entries: &[
+                wgpu::BindGroupLayoutEntry {
+                    binding: 0,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Texture {
+                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                        view_dimension: wgpu::TextureViewDimension::D2,
+                        multisampled: false,
+                    },
+                    count: None,
+                },
+                wgpu::BindGroupLayoutEntry {
+                    binding: 1,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+                    count: None,
+                },
+            ],
+        });
+        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+            label: Some("llimphi-3d-sky-shader"),
+            source: wgpu::ShaderSource::Wgsl(SKY_WGSL.into()),
+        });
+        let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+            label: Some("llimphi-3d-sky-pl"),
+            bind_group_layouts: &[&uniform_layout, &tex_layout],
+            push_constant_ranges: &[],
+        });
+        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+            label: Some("llimphi-3d-sky-pipeline"),
+            layout: Some(&pl),
+            vertex: wgpu::VertexState {
+                module: &shader,
+                entry_point: Some("vs"),
+                compilation_options: Default::default(),
+                buffers: &[],
+            },
+            primitive: wgpu::PrimitiveState {
+                cull_mode: None,
+                ..Default::default()
+            },
+            depth_stencil: Some(wgpu::DepthStencilState {
+                format: DEPTH_FORMAT,
+                depth_write_enabled: false,
+                depth_compare: wgpu::CompareFunction::Always,
+                stencil: Default::default(),
+                bias: Default::default(),
+            }),
+            multisample: Default::default(),
+            fragment: Some(wgpu::FragmentState {
+                module: &shader,
+                entry_point: Some("fs"),
+                compilation_options: Default::default(),
+                targets: &[Some(wgpu::ColorTargetState {
+                    format: color_format,
+                    blend: None,
+                    write_mask: wgpu::ColorWrites::ALL,
+                })],
+            }),
+            multiview: None,
+            cache: None,
+        });
+
+        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("llimphi-3d-sky-uniform"),
+            size: 32, // 8 × f32
+            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+            mapped_at_creation: false,
+        });
+        let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: Some("llimphi-3d-sky-uniform-bg"),
+            layout: &uniform_layout,
+            entries: &[wgpu::BindGroupEntry {
+                binding: 0,
+                resource: uniform_buf.as_entire_binding(),
+            }],
+        });
+        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+            label: Some("llimphi-3d-sky-sampler"),
+            // Horizontal envuelve (panorama 360°); vertical clampea (no repite
+            // el cielo hacia abajo).
+            address_mode_u: wgpu::AddressMode::Repeat,
+            address_mode_v: wgpu::AddressMode::ClampToEdge,
+            address_mode_w: wgpu::AddressMode::ClampToEdge,
+            mag_filter: wgpu::FilterMode::Linear,
+            min_filter: wgpu::FilterMode::Linear,
+            mipmap_filter: wgpu::FilterMode::Linear,
+            ..Default::default()
+        });
+
+        Self {
+            pipeline,
+            uniform_buf,
+            uniform_bg,
+            tex_layout,
+            sampler,
+            tex: None,
+        }
+    }
+
+    /// ¿Ya tiene textura cargada? Útil para subirla una sola vez.
+    pub fn has_texture(&self) -> bool {
+        self.tex.is_some()
+    }
+
+    /// Sube/reemplaza la textura del cielo (RGBA8, `w×h`, fila contigua sin
+    /// padding). `data.len()` debe ser `w*h*4`.
+    pub fn set_texture(
+        &mut self,
+        device: &wgpu::Device,
+        queue: &wgpu::Queue,
+        w: u32,
+        h: u32,
+        data: &[u8],
+    ) {
+        assert_eq!(data.len(), (w * h * 4) as usize, "RGBA8 w*h*4 esperado");
+        let tex = device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("llimphi-3d-sky-tex"),
+            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: wgpu::TextureDimension::D2,
+            format: wgpu::TextureFormat::Rgba8Unorm,
+            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+            view_formats: &[],
+        });
+        queue.write_texture(
+            wgpu::TexelCopyTextureInfo {
+                texture: &tex,
+                mip_level: 0,
+                origin: wgpu::Origin3d::ZERO,
+                aspect: wgpu::TextureAspect::All,
+            },
+            data,
+            wgpu::TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(w * 4),
+                rows_per_image: Some(h),
+            },
+            wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+        );
+        let view = tex.create_view(&Default::default());
+        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: Some("llimphi-3d-sky-tex-bg"),
+            layout: &self.tex_layout,
+            entries: &[
+                wgpu::BindGroupEntry {
+                    binding: 0,
+                    resource: wgpu::BindingResource::TextureView(&view),
+                },
+                wgpu::BindGroupEntry {
+                    binding: 1,
+                    resource: wgpu::BindingResource::Sampler(&self.sampler),
+                },
+            ],
+        });
+        self.tex = Some(SkyTexture { bind_group });
+    }
+
+    /// Sube los parámetros del frame. Llamar antes de [`Self::draw`].
+    pub fn upload(&self, queue: &wgpu::Queue, p: &SkyParams) {
+        let mut b = Vec::with_capacity(32);
+        for v in [
+            p.yaw,
+            p.pitch,
+            p.fov_x,
+            p.aspect,
+            p.wraps,
+            p.v_scale,
+            p.pitch_scale,
+            p.v_offset,
+        ] {
+            b.extend_from_slice(&v.to_ne_bytes());
+        }
+        queue.write_buffer(&self.uniform_buf, 0, &b);
+    }
+
+    /// Dibuja el cielo en un pase **ya abierto** (con depth attachment), como
+    /// **primer** draw (el mundo lo tapa después). No-op si no hay textura.
+    pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
+        let Some(tex) = self.tex.as_ref() else {
+            return;
+        };
+        pass.set_pipeline(&self.pipeline);
+        pass.set_bind_group(0, &self.uniform_bg, &[]);
+        pass.set_bind_group(1, &tex.bind_group, &[]);
+        pass.draw(0..3, 0..1);
+    }
+}
+
+/// Fondo cilíndrico: muestreo de la textura por el azimut de cada columna.
+const SKY_WGSL: &str = r#"
+struct SkyU {
+    yaw: f32, pitch: f32, fov_x: f32, aspect: f32,
+    wraps: f32, v_scale: f32, pitch_scale: f32, v_offset: f32,
+};
+@group(0) @binding(0) var<uniform> s: SkyU;
+@group(1) @binding(0) var sky: texture_2d<f32>;
+@group(1) @binding(1) var samp: sampler;
+
+const PI: f32 = 3.14159265;
+
+struct SOut {
+    @builtin(position) clip: vec4<f32>,
+    @location(0) scr: vec2<f32>,   // uv de pantalla, (0,0) = arriba-izquierda
+};
+
+@vertex
+fn vs(@builtin(vertex_index) vi: u32) -> SOut {
+    var p = array<vec2<f32>, 3>(
+        vec2<f32>(-1.0, -1.0),
+        vec2<f32>( 3.0, -1.0),
+        vec2<f32>(-1.0,  3.0),
+    );
+    var o: SOut;
+    let c = p[vi];
+    o.clip = vec4<f32>(c, 0.0, 1.0);
+    o.scr = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
+    return o;
+}
+
+@fragment
+fn fs(in: SOut) -> @location(0) vec4<f32> {
+    // Azimut de la columna: centro = yaw, bordes ± fov/2.
+    let colang = s.yaw - (in.scr.x - 0.5) * s.fov_x;
+    let su = fract(colang / (2.0 * PI) * s.wraps);
+    let sv = clamp(in.scr.y * s.v_scale - s.pitch * s.pitch_scale + s.v_offset, 0.0, 1.0);
+    return textureSample(sky, samp, vec2<f32>(su, sv));
+}
+"#;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn shader_wgsl_valida() {
+        let module = naga::front::wgsl::parse_str(SKY_WGSL).expect("SKY_WGSL no parsea");
+        naga::valid::Validator::new(
+            naga::valid::ValidationFlags::all(),
+            naga::valid::Capabilities::all(),
+        )
+        .validate(&module)
+        .expect("SKY_WGSL no valida");
+    }
+
+    #[test]
+    fn defaults_panorama_simple() {
+        let p = SkyParams::default();
+        assert_eq!(p.wraps, 1.0);
+        assert_eq!(p.v_scale, 1.0);
+    }
+}
diff --git a/llimphi-3d/src/voxel_renderer.rs b/llimphi-3d/src/voxel_renderer.rs
index f64156b..1489598 100644
--- a/llimphi-3d/src/voxel_renderer.rs
+++ b/llimphi-3d/src/voxel_renderer.rs
@@ -86,6 +86,15 @@ pub struct Atmosphere {
     /// Densidad de niebla por unidad de voxel. `0.0` = desactivada (miss →
     /// `discard`, sin niebla); valores típicos `0.002..0.02`.
     pub fog_density: f32,
+    /// **God rays** (luz volumétrica / *in-scattering*): `0.0` = off; `~1.0` =
+    /// haces de sol marcados cruzando la niebla. El shader marcha unas pocas
+    /// muestras a lo largo del rayo de cámara, prueba la visibilidad del sol en
+    /// cada una y acumula luz dispersada hacia el ojo (más fuerte mirando hacia el
+    /// sol, fase tipo Henyey-Greenstein). Sólo actúa con `fog_density > 0` (la
+    /// niebla es el medio que dispersa). Es el sello anti-Minecraft: la malla de
+    /// cubos no puede hacer esto; el ray-march sí. Coste: shadow rays extra por
+    /// píxel — subilo con criterio.
+    pub god_rays: f32,
 }
 
 impl Default for Atmosphere {
@@ -94,6 +103,7 @@ impl Default for Atmosphere {
             sky_zenith: [70, 120, 200],
             sky_horizon: [188, 208, 230],
             fog_density: 0.0,
+            god_rays: 0.0,
         }
     }
 }
@@ -681,7 +691,8 @@ impl VoxelRenderer {
             u.extend_from_slice(&v.to_ne_bytes());
         }
         let s = normalize3(self.sun_dir);
-        for v in [s[0], s[1], s[2], 0.0] {
+        // sun_dir.w lleva la fuerza de god rays (in-scattering volumétrico).
+        for v in [s[0], s[1], s[2], self.atmosphere.god_rays.max(0.0)] {
             u.extend_from_slice(&v.to_ne_bytes());
         }
         for v in [self.cdim[0] as f32, self.cdim[1] as f32, self.cdim[2] as f32, 0.0] {
@@ -897,7 +908,7 @@ struct U {
     inv_vp: mat4x4<f32>,
     cam_eye: vec4<f32>,
     grid_dim: vec4<f32>,   // xyz = dim fino, w = brick size
-    sun_dir: vec4<f32>,    // xyz = dirección hacia el sol (normalizada)
+    sun_dir: vec4<f32>,    // xyz = dirección hacia el sol (normalizada), w = fuerza de god rays
     cdim: vec4<f32>,       // xyz = dim grueso (celdas de brick)
     atlas: vec4<f32>,      // xyz = slots por eje en el atlas del pool
     sky_zenith: vec4<f32>, // xyz = color cenit, w = densidad de niebla (0 = off)
@@ -1181,6 +1192,41 @@ struct FOut {
     @builtin(frag_depth) depth: f32,
 };
 
+// **God rays** (in-scattering volumétrico): marcha el rayo de cámara `ro+rd·t`
+// hasta `max_t` tomando muestras; en cada una tira un shadow ray hacia el sol y
+// acumula "cuánta luz solar llega a ese punto de la niebla". El resultado, pesado
+// por una fase hacia-adelante (brilla mirando al sol) y por la densidad de niebla,
+// es la luz dispersada hacia el ojo → haces visibles cruzando el aire. Devuelve un
+// escalar [0,1+] que se multiplica por el color cálido del sol. Coste: N shadow
+// rays por píxel — sólo se llama si `u.sun_dir.w > 0` y hay niebla.
+fn god_rays(ro: vec3<f32>, rd: vec3<f32>, max_t: f32, dim: vec3<f32>, B: f32) -> f32 {
+    let strength = u.sun_dir.w;
+    let fogd = u.sky_zenith.w;
+    if (strength <= 0.0 || fogd <= 0.0) { return 0.0; }
+    // Fase hacia-adelante PRIMERO: los haces sólo se ven mirando hacia el sol. Los
+    // píxeles que miran lejos aportan ~0 → cortamos ANTES de la marcha (la marcha es
+    // lo caro: N shadow rays/píxel). God rays queda casi gratis salvo en la cuña que
+    // enfrenta al sol, que es justo donde el efecto importa.
+    let phase = pow(max(dot(rd, u.sun_dir.xyz), 0.0), 6.0);
+    if (phase < 0.003) { return 0.0; }
+    let march_len = min(max_t, 220.0);
+    let steps = 8; // pocas muestras: el efecto es atmosférico, no necesita más (y cuesta)
+    let dt = march_len / f32(steps);
+    // Dither del paso inicial para romper el bandeo de pocas muestras.
+    let jitter = fract(sin(dot(rd.xy, vec2<f32>(12.9898, 78.233))) * 43758.5453);
+    var acc = 0.0;
+    for (var i = 0; i < steps; i = i + 1) {
+        let tt = (f32(i) + jitter) * dt;
+        if (tt >= max_t) { break; }
+        let sp = ro + rd * tt;
+        let sh = trace(sp, u.sun_dir.xyz, dim, B);
+        acc = acc + select(1.0, 0.0, sh.hit); // 1 si el sol llega, 0 si está en sombra
+    }
+    acc = acc / f32(steps);
+    let depth_fac = 1.0 - exp(-march_len * fogd); // más aire, más dispersión
+    return acc * strength * phase * depth_fac;
+}
+
 @fragment
 fn fs(in: VOut) -> FOut {
     let p_near = u.inv_vp * vec4<f32>(in.ndc, 0.0, 1.0);
@@ -1220,8 +1266,11 @@ fn fs(in: VOut) -> FOut {
         // lejana, así una malla por delante igual se dibuja); sin niebla,
         // descartamos para dejar ver el fondo vello (comportamiento clásico).
         if (fog_density > 0.0) {
+            // Cielo + god rays: los haces se ven sobre todo contra el fondo abierto.
+            let gr = god_rays(ro, rd, 1e30, dim, B);
+            let glow = vec3<f32>(1.0, 0.95, 0.82) * gr;
             var sky: FOut;
-            sky.color = vec4<f32>(sky_color(rd), 1.0);
+            sky.color = vec4<f32>(sky_color(rd) + glow, 1.0);
             sky.depth = 1.0;
             return sky;
         }
@@ -1310,6 +1359,9 @@ fn fs(in: VOut) -> FOut {
     if (fog_density > 0.0) {
         let f = 1.0 - exp(-t_hit * fog_density);
         color = mix(color, sky_color(rd), f);
+        // God rays: haz volumétrico en el aire DELANTE de la superficie golpeada.
+        let gr = god_rays(ro, rd, t_hit, dim, B);
+        color = color + vec3<f32>(1.0, 0.95, 0.82) * gr;
     }
 
     var out: FOut;
diff --git a/llimphi-voxel/examples/ages_demo.rs b/llimphi-voxel/examples/ages_demo.rs
index df1e8cc..a65df7d 100644
--- a/llimphi-voxel/examples/ages_demo.rs
+++ b/llimphi-voxel/examples/ages_demo.rs
@@ -37,7 +37,7 @@ fn main() {
 
     let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
     vr.sun_dir = [0.5, 0.7, 0.4];
-    vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0 };
+    vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0, god_rays: 0.0 };
 
     // 5 actores, uno por edad, espaciados en X. El grid se centra en el origen, así
     // la coord de mundo del actor = local − dim/2.
diff --git a/llimphi-voxel/examples/birth_demo.rs b/llimphi-voxel/examples/birth_demo.rs
index 8d2d92f..034ff8c 100644
--- a/llimphi-voxel/examples/birth_demo.rs
+++ b/llimphi-voxel/examples/birth_demo.rs
@@ -40,7 +40,7 @@ fn main() {
 
     let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
     vr.sun_dir = [0.5, 0.7, 0.4];
-    vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0 };
+    vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0, god_rays: 0.0 };
 
     // Huevo en el centro, sobre el suelo. La secuencia hace caer la cámara sobre él.
     let egg = Egg::new(Vec3::new(0.0, feet_y, 0.0), 1.4, Hatchling::human(Age::Baby));
diff --git a/llimphi-voxel/examples/desert_demo.rs b/llimphi-voxel/examples/desert_demo.rs
index 275cfd4..4c738d5 100644
--- a/llimphi-voxel/examples/desert_demo.rs
+++ b/llimphi-voxel/examples/desert_demo.rs
@@ -37,6 +37,7 @@ fn main() {
         sky_zenith: [92, 146, 208],
         sky_horizon: [228, 206, 162], // horizonte arenoso/caluroso
         fog_density: 0.22 / dim_xz as f32, // niebla suave: no lavar el llano
+        god_rays: 0.0,
     };
 
     // Cámara baja, en 3/4, para leer el llano + los cactus recortados contra el cielo.
diff --git a/llimphi-voxel/examples/egg_demo.rs b/llimphi-voxel/examples/egg_demo.rs
index 811d05e..fc8e4ac 100644
--- a/llimphi-voxel/examples/egg_demo.rs
+++ b/llimphi-voxel/examples/egg_demo.rs
@@ -36,7 +36,7 @@ fn main() {
 
     let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
     vr.sun_dir = [0.5, 0.7, 0.4];
-    vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0 };
+    vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0, god_rays: 0.0 };
 
     let mut meshes: Vec<Renderer3d> = Vec::new();
     let push = |meshes: &mut Vec<Renderer3d>, hal: &Hal, v: &[_], i: &[u16], model| {
diff --git a/llimphi-voxel/examples/raycast_edit.rs b/llimphi-voxel/examples/raycast_edit.rs
index ff22448..5777ef4 100644
--- a/llimphi-voxel/examples/raycast_edit.rs
+++ b/llimphi-voxel/examples/raycast_edit.rs
@@ -38,6 +38,7 @@ fn main() {
         sky_zenith: [64, 118, 196],
         sky_horizon: [202, 218, 236],
         fog_density: 0.5 / dim_xz as f32,
+        god_rays: 0.0,
     };
 
     let inter = make_target(&hal);
diff --git a/llimphi-voxel/examples/terrain_demo.rs b/llimphi-voxel/examples/terrain_demo.rs
index 45f15f6..637a98a 100644
--- a/llimphi-voxel/examples/terrain_demo.rs
+++ b/llimphi-voxel/examples/terrain_demo.rs
@@ -53,6 +53,7 @@ fn main() {
         sky_zenith: [64, 118, 196],
         sky_horizon: [200, 216, 234],
         fog_density: 0.5 / dim_xz as f32,
+        god_rays: 0.0,
     };
 
     let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
diff --git a/llimphi-voxel/examples/terrain_flythrough.rs b/llimphi-voxel/examples/terrain_flythrough.rs
index c8818a1..3a5a6b7 100644
--- a/llimphi-voxel/examples/terrain_flythrough.rs
+++ b/llimphi-voxel/examples/terrain_flythrough.rs
@@ -40,6 +40,7 @@ fn main() {
         sky_zenith: [64, 118, 196],
         sky_horizon: [202, 218, 236],
         fog_density: 0.9 / dim_xz as f32, // un poco más densa: estamos adentro
+        god_rays: 0.0,
     };
 
     let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
diff --git a/llimphi-voxel/examples/terrain_lod.rs b/llimphi-voxel/examples/terrain_lod.rs
index 73b6ba0..444739c 100644
--- a/llimphi-voxel/examples/terrain_lod.rs
+++ b/llimphi-voxel/examples/terrain_lod.rs
@@ -41,6 +41,7 @@ fn main() {
         sky_zenith: [70, 120, 196],
         sky_horizon: [200, 216, 234],
         fog_density: 1.1 / dim_xz as f32,
+        god_rays: 0.0,
     };
     let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
     vr.sun_dir = sun;
diff --git a/llimphi-voxel/examples/terrain_streaming.rs b/llimphi-voxel/examples/terrain_streaming.rs
index 9981a7a..2c1d60f 100644
--- a/llimphi-voxel/examples/terrain_streaming.rs
+++ b/llimphi-voxel/examples/terrain_streaming.rs
@@ -59,6 +59,7 @@ fn main() {
         sky_zenith: [64, 118, 196],
         sky_horizon: [202, 218, 236],
         fog_density: 0.7 / dim_xz as f32,
+        god_rays: 0.0,
     };
     let (_, total_bricks) = vr.brick_usage();
     let full_pool_kib = vr.memory_bytes().0 / 1024;
diff --git a/llimphi-voxel/examples/terrain_world.rs b/llimphi-voxel/examples/terrain_world.rs
index 1e2b87e..b0cf08e 100644
--- a/llimphi-voxel/examples/terrain_world.rs
+++ b/llimphi-voxel/examples/terrain_world.rs
@@ -48,6 +48,7 @@ fn main() {
         sky_zenith: [66, 120, 198],
         sky_horizon: [202, 218, 236],
         fog_density: 1.0 / dim_xz as f32,
+        god_rays: 0.0,
     };
 
     // Renderer voxel construido UNA vez desde mundo (0,0) (invariante del ring
diff --git a/llimphi-voxel/src/director.rs b/llimphi-voxel/src/director.rs
index aa28997..18d744c 100644
--- a/llimphi-voxel/src/director.rs
+++ b/llimphi-voxel/src/director.rs
@@ -86,13 +86,44 @@ pub struct ActorSample {
 #[derive(Debug, Clone, Default)]
 pub struct ActorScript {
     keys: Vec<ActorKey>,
+    /// **Tasa de cuadros propia** de este actor (`None` = la nativa de la peli,
+    /// fluida). Si está, el tiempo se *cuantiza* a esa rejilla antes de muestrear
+    /// posición **y** pose: el actor se mueve "a tirones" (animación *en doses*,
+    /// estilo stop-motion/Spider-Verse) aunque el render salga a 30/60. Es el
+    /// **sello de animación**: el Héroe a 12 fps contra un Avatar fluido a 60 da
+    /// una extrañeza que nadie obtiene usando el motor por defecto.
+    frame_rate: Option<u32>,
 }
 
 impl ActorScript {
     /// Crea el guion (ordena las keys por `t`).
     pub fn new(mut keys: Vec<ActorKey>) -> Self {
         keys.sort_by(|a, b| a.t.total_cmp(&b.t));
-        Self { keys }
+        Self { keys, frame_rate: None }
+    }
+
+    /// Fija la **tasa de cuadros propia** del actor (animación en doses). `0` o
+    /// quitarla = fluido (nativo). Ver [`quantize`](Self::quantize).
+    pub fn with_frame_rate(mut self, fps: Option<u32>) -> Self {
+        self.frame_rate = fps.filter(|&f| f > 0);
+        self
+    }
+
+    /// La tasa de cuadros propia del actor (`None` = fluido).
+    pub fn frame_rate(&self) -> Option<u32> {
+        self.frame_rate
+    }
+
+    /// **Cuantiza** un instante a la rejilla de cuadros del actor: si tiene
+    /// `frame_rate = Some(fps)`, devuelve el inicio del cuadro que contiene a `t`
+    /// (`floor(t·fps)/fps`); si no, devuelve `t` tal cual. El reproductor debe
+    /// muestrear **posición y pose** con este tiempo cuantizado para que el cuerpo
+    /// avance en escalones (no sólo se teletransporte la posición).
+    pub fn quantize(&self, t: f32) -> f32 {
+        match self.frame_rate {
+            Some(fps) => (t * fps as f32).floor() / fps as f32,
+            None => t,
+        }
     }
 
     /// Tiempo de la última key (duración del guion del actor).
@@ -289,6 +320,27 @@ mod tests {
         assert!((e.facing - FRAC_PI_2).abs() < 1e-4);
     }
 
+    #[test]
+    fn frame_rate_cuantiza_el_tiempo_en_doses() {
+        // Sin tasa propia: fluido (t pasa tal cual).
+        let fluido = ActorScript::new(vec![ActorKey::at(0.0, 0.0, 0.0)]);
+        assert_eq!(fluido.quantize(0.137), 0.137);
+        assert_eq!(fluido.frame_rate(), None);
+
+        // A 12 fps: el tiempo cae al inicio del cuadro (1/12 ≈ 0.0833).
+        let heroe = fluido.clone().with_frame_rate(Some(12));
+        assert_eq!(heroe.frame_rate(), Some(12));
+        // t=0.13 → cuadro 1 (0.0833); t=0.17 → cuadro 2 (0.1667).
+        assert!((heroe.quantize(0.13) - 1.0 / 12.0).abs() < 1e-6);
+        assert!((heroe.quantize(0.17) - 2.0 / 12.0).abs() < 1e-6);
+        // Dentro del mismo cuadro, dos tiempos distintos dan EL MISMO sample → "doses".
+        assert_eq!(heroe.quantize(0.090), heroe.quantize(0.160));
+        assert_eq!(heroe.sample(heroe.quantize(0.090)).gx, heroe.sample(heroe.quantize(0.160)).gx);
+
+        // 0 fps se trata como sin tasa (fluido).
+        assert_eq!(fluido.clone().with_frame_rate(Some(0)).frame_rate(), None);
+    }
+
     #[test]
     fn beats_del_guion_son_cortes_y_gestos() {
         use llimphi_3d::CamKey;
diff --git a/llimphi-voxel/src/lib.rs b/llimphi-voxel/src/lib.rs
index bdc5cf7..62f4ae2 100644
--- a/llimphi-voxel/src/lib.rs
+++ b/llimphi-voxel/src/lib.rs
@@ -36,7 +36,7 @@ pub use potential::{Egg, Hatchling};
 pub use raycast::{raycast, VoxelHit};
 pub use studio::{
     world_dim, ActorKeySpec, ActorSpec, CharSpec, NamedWorld, Project, SceneSpec, ShotKind,
-    ShotSpec, PREVIEW_DIM_XZ,
+    ShotSpec, PREVIEW_DIM_XZ, SCENE_SUN,
 };
 pub use terrain::{column_height, fill_terrain_window, terrain};
 pub use world_stream::WorldStream;
diff --git a/llimphi-voxel/src/studio.rs b/llimphi-voxel/src/studio.rs
index 0838936..316b3a4 100644
--- a/llimphi-voxel/src/studio.rs
+++ b/llimphi-voxel/src/studio.rs
@@ -20,6 +20,12 @@ use llimphi_3d::Camera3d;
 /// (cúbica en XZ, alto = 0.4·lado, mínimo 48) — el mismo criterio que la app.
 pub const PREVIEW_DIM_XZ: u32 = 128;
 
+/// **Dirección del sol** de la escena (hacia el sol, sin normalizar). Una sola
+/// fuente de verdad: la usa el preview/export para iluminar (`voxel.sun_dir`) y el
+/// plano [`ShotKind::Backlight`] para saber adónde mirar. Sol algo bajo → luz
+/// rasante (claroscuro) y, en contraluz, **god rays** legibles.
+pub const SCENE_SUN: [f32; 3] = [0.5, 0.36, 0.45];
+
 /// Calcula el `dim` `[x, y, z]` de un mundo de lado `xz` (alto derivado).
 pub fn world_dim(xz: u32) -> [u32; 3] {
     let dy = (xz * 4 / 10).max(48);
@@ -99,12 +105,19 @@ impl ActorKeySpec {
 pub struct ActorSpec {
     pub character: usize,
     pub keys: Vec<ActorKeySpec>,
+    /// **Tasa de cuadros propia** del actor (`None` = fluido/nativo). Con un valor
+    /// bajo (12–15) el actor se anima *en doses* (stop-motion): es el sello que
+    /// separa al Héroe del Avatar. Ver [`ActorScript::quantize`].
+    #[serde(default)]
+    pub frame_rate: Option<u32>,
 }
 
 impl ActorSpec {
-    /// Compila los keyframes a un [`ActorScript`] reproducible.
+    /// Compila los keyframes a un [`ActorScript`] reproducible (con su tasa de
+    /// cuadros propia, si la tiene).
     pub fn to_script(&self) -> ActorScript {
         ActorScript::new(self.keys.iter().map(|k| k.to_key()).collect())
+            .with_frame_rate(self.frame_rate)
     }
 }
 
@@ -122,12 +135,21 @@ pub enum ShotKind {
     Side,
     /// Órbita: gira lento alrededor del reparto.
     Orbit,
+    /// **Contraluz**: cámara del lado opuesto al sol, mirando hacia él con el reparto
+    /// a contraluz (silueta en el tercio bajo) y el disco solar en lo alto del cuadro
+    /// → rim light + **god rays** (haces volumétricos). El plano "héroe" del motor.
+    Backlight,
 }
 
 impl ShotKind {
     /// Todos los planos (para ciclar en un editor).
-    pub const ALL: [ShotKind; 4] =
-        [ShotKind::Establishing, ShotKind::CloseUp, ShotKind::Side, ShotKind::Orbit];
+    pub const ALL: [ShotKind; 5] = [
+        ShotKind::Establishing,
+        ShotKind::CloseUp,
+        ShotKind::Side,
+        ShotKind::Orbit,
+        ShotKind::Backlight,
+    ];
 
     /// Nombre legible (español).
     pub fn label(self) -> &'static str {
@@ -136,6 +158,7 @@ impl ShotKind {
             ShotKind::CloseUp => "primer plano",
             ShotKind::Side => "lateral",
             ShotKind::Orbit => "órbita",
+            ShotKind::Backlight => "contraluz",
         }
     }
 
@@ -149,18 +172,30 @@ impl ShotKind {
     /// elevado a la altura del pecho), con el ojo según el tipo, a distancia base
     /// `d` (escala con el tamaño del reparto). `t` (seg) anima la órbita.
     pub fn resolve(self, look: Vec3, d: f32, t: f32) -> Camera3d {
-        let (eye, fov) = match self {
+        // `(eye, fov, target)` por tipo. Casi todos miran al reparto (`look`); el
+        // contraluz sube el objetivo hacia el sol para meterlo en el cuadro.
+        let (eye, fov, target) = match self {
             ShotKind::Establishing => {
-                (look + Vec3::new(-0.5 * d, 0.9 * d, -1.6 * d), 50.0)
+                (look + Vec3::new(-0.5 * d, 0.9 * d, -1.6 * d), 50.0, look)
             }
-            ShotKind::CloseUp => (look + Vec3::new(0.25 * d, 0.45 * d, -0.85 * d), 40.0),
-            ShotKind::Side => (look + Vec3::new(1.35 * d, 0.4 * d, 0.15 * d), 46.0),
+            ShotKind::CloseUp => (look + Vec3::new(0.25 * d, 0.45 * d, -0.85 * d), 40.0, look),
+            ShotKind::Side => (look + Vec3::new(1.35 * d, 0.4 * d, 0.15 * d), 46.0, look),
             ShotKind::Orbit => {
                 let a = t * 0.6;
-                (look + Vec3::new(a.cos() * 1.3 * d, 0.6 * d, a.sin() * 1.3 * d), 48.0)
+                (look + Vec3::new(a.cos() * 1.3 * d, 0.6 * d, a.sin() * 1.3 * d), 48.0, look)
+            }
+            ShotKind::Backlight => {
+                // Detrás del reparto respecto al sol (horizontal anti-sol), apenas
+                // elevado; el objetivo sube hacia el cielo → la cámara mira hacia el
+                // sol con el reparto a contraluz en el tercio bajo.
+                let s = Vec3::new(SCENE_SUN[0], SCENE_SUN[1], SCENE_SUN[2]).normalize();
+                let back = Vec3::new(-s.x, 0.0, -s.z).normalize();
+                let eye = look + back * (1.2 * d) + Vec3::new(0.0, 0.15 * d, 0.0);
+                let target = look + Vec3::new(0.0, 0.55 * d, 0.0);
+                (eye, 55.0, target)
             }
         };
-        Camera3d { eye, target: look, fovy_rad: fov_f32_to_rad(fov), ..Camera3d::default() }
+        Camera3d { eye, target, fovy_rad: fov_f32_to_rad(fov), ..Camera3d::default() }
     }
 }
 
@@ -191,6 +226,39 @@ pub struct SceneSpec {
     pub actors: Vec<ActorSpec>,
     #[serde(default)]
     pub shots: Vec<ShotSpec>,
+    /// **Cámara en mano**: intensidad del temblor orgánico (`0` = trípode fijo,
+    /// look de dron; `~1` = respiración/pulso de camarógrafo). Ensucia la cámara
+    /// matemáticamente perfecta del motor — es el sello que mete al espectador en
+    /// el "barro" de la escena. Ver [`handheld_shake`].
+    #[serde(default)]
+    pub handheld: f32,
+}
+
+/// **Temblor de cámara en mano**, determinista (función pura de `t` → la peli sale
+/// reproducible cuadro a cuadro). Suma de senos en frecuencias inconmensurables:
+/// una **respiración** lenta (bob vertical) + un **micro-pulso** rápido en los tres
+/// ejes para el ojo, y una **deriva** aún más lenta para el objetivo (el encuadre
+/// flota, no sólo tiembla). `amt ≤ 0` → sin offset (trípode). Devuelve
+/// `(offset_ojo, offset_objetivo)` en unidades de mundo, escalado un poco con la
+/// distancia `d` del plano para que también respire en planos lejanos.
+pub fn handheld_shake(t: f32, amt: f32, d: f32) -> (Vec3, Vec3) {
+    if amt <= 0.0 {
+        return (Vec3::ZERO, Vec3::ZERO);
+    }
+    let scale = amt * (1.0 + d * 0.03);
+    // Respiración (bob) + micro-pulso por eje (fases y frecuencias dispares).
+    let breath = (t * 1.7).sin() * 0.6 + (t * 0.9).sin() * 0.4;
+    let jx = (t * 9.3).sin() * 0.5 + (t * 4.7 + 1.3).sin() * 0.5;
+    let jy = (t * 8.1 + 2.1).sin() * 0.5 + (t * 5.3 + 0.7).sin() * 0.5;
+    let jz = (t * 7.4 + 0.4).sin() * 0.5 + (t * 3.9 + 2.7).sin() * 0.5;
+    let eye = Vec3::new(jx * 0.10, breath * 0.12 + jy * 0.08, jz * 0.10) * scale;
+    // Deriva del objetivo: más lenta y desfasada → el cuadro "busca" al sujeto.
+    let tgt = Vec3::new(
+        (t * 1.3 + 0.5).sin() * 0.06,
+        (t * 1.1 + 1.9).sin() * 0.05,
+        0.0,
+    ) * scale;
+    (eye, tgt)
 }
 
 impl SceneSpec {
@@ -223,6 +291,19 @@ impl SceneSpec {
         ts
     }
 
+    /// La **cámara de la escena** en `t`: resuelve el plano vigente mirando a
+    /// `look` (centroide del reparto) a distancia `d`, y le suma el temblor de
+    /// **cámara en mano** ([`handheld_shake`]) según [`Self::handheld`]. Es el
+    /// único punto por el que deberían pasar el preview y el export para que el
+    /// sello de cámara salga igual en ambos.
+    pub fn camera_at(&self, look: Vec3, d: f32, t: f32) -> Camera3d {
+        let mut cam = self.active_shot(t).resolve(look, d, t);
+        let (eo, to) = handheld_shake(t, self.handheld, d);
+        cam.eye += eo;
+        cam.target += to;
+        cam
+    }
+
     /// El plano vigente en `t` (el último con `start ≤ t`); `Establishing` si no
     /// hay planos definidos.
     pub fn active_shot(&self, t: f32) -> ShotKind {
@@ -265,14 +346,21 @@ impl SceneSpec {
                     ActorKeySpec { t: t_turn, gx: gx1, gz, clip: Some(gesture), face: Some(PI) },
                     ActorKeySpec { t: dur, gx: gx1, gz, clip: Some(gesture), face: Some(PI) },
                 ],
+                // El **Héroe** (primer actor) se anima en doses (12 fps): se mueve a
+                // tirones, pesado, contra los demás (Avatares) que van fluidos. Es el
+                // sello de animación, visible ya en la escena de arranque.
+                frame_rate: if i == 0 { Some(12) } else { None },
             });
         }
-        // Dos planos: establecedor durante la caminata, primer plano en el gesto.
+        // Tres planos: establecedor, **contraluz** a mitad de la caminata (el reparto
+        // entra al sol → luce los god rays), y primer plano en el gesto.
         let shots = vec![
             ShotSpec { start: 0.0, kind: ShotKind::Establishing },
+            ShotSpec { start: t_walk * 0.5, kind: ShotKind::Backlight },
             ShotSpec { start: t_turn, kind: ShotKind::CloseUp },
         ];
-        Self { name: name.into(), world, duration: dur, actors, shots }
+        // Cámara en mano suave por defecto: el sello se ve sin tener que pedirlo.
+        Self { name: name.into(), world, duration: dur, actors, shots, handheld: 0.7 }
     }
 }
 
@@ -398,6 +486,36 @@ mod tests {
         assert!(beats.iter().any(|&t| (t - 3.0).abs() < 0.2), "acento cerca del gesto/corte");
     }
 
+    #[test]
+    fn camara_en_mano_es_determinista_y_apagable() {
+        // amt=0 → trípode: sin offset, exactamente cero.
+        let (e0, t0) = handheld_shake(1.234, 0.0, 30.0);
+        assert_eq!(e0, Vec3::ZERO);
+        assert_eq!(t0, Vec3::ZERO);
+
+        // amt>0 → tiembla (offset no nulo) y es función pura de t (reproducible).
+        let (e1, _) = handheld_shake(1.234, 0.7, 30.0);
+        let (e2, _) = handheld_shake(1.234, 0.7, 30.0);
+        assert_eq!(e1, e2, "mismo t → mismo temblor (peli reproducible)");
+        assert!(e1.length() > 0.0, "con intensidad la cámara se mueve");
+        // Instantes distintos → temblor distinto (no está congelado).
+        let (e3, _) = handheld_shake(1.235, 0.7, 30.0);
+        assert!((e1 - e3).length() > 0.0, "el temblor evoluciona en el tiempo");
+    }
+
+    #[test]
+    fn frame_rate_del_heroe_viaja_al_guion() {
+        let dim = world_dim(128);
+        let s = SceneSpec::walk_and_emote("demo", 0, 3, Clip::Wave, dim);
+        // El primer actor (Héroe) anima en doses; los demás, fluidos.
+        assert_eq!(s.actors[0].frame_rate, Some(12));
+        assert_eq!(s.actors[1].frame_rate, None);
+        // Y sobrevive la compilación a guion.
+        assert_eq!(s.scripts()[0].frame_rate(), Some(12));
+        // Cámara en mano por defecto encendida.
+        assert!(s.handheld > 0.0);
+    }
+
     #[test]
     fn starter_trae_escena_y_personajes() {
         let p = Project::starter();
diff --git a/llimphi-voxel/src/worldgen.rs b/llimphi-voxel/src/worldgen.rs
index 2826810..1d33e4c 100644
--- a/llimphi-voxel/src/worldgen.rs
+++ b/llimphi-voxel/src/worldgen.rs
@@ -151,6 +151,31 @@ pub struct WorldRecipe {
     /// Flora y su densidad `[0,1]`.
     pub flora: Flora,
     pub flora_density: f32,
+    /// **Grano de materia** `[0,1]`: jitter de brillo *por vóxel* sobre el color del
+    /// material. `0` = caras planas de un color liso (el look "textura de Minecraft
+    /// estirada"); `~1` = superficie granular, densa, que parece materia y no bloque.
+    /// Es el **sello del motor**: lo que distancia del cubo plano sin tocar el shader
+    /// (la textura es el propio vóxel). Determinista por `(wx, y, wz, seed)`.
+    #[serde(default)]
+    pub grain: f32,
+}
+
+/// Aplica el **grano de materia** al color base de un vóxel: perturba el brillo
+/// (±`grain`·18%) y mete una pizca de variación por canal, deterministamente por
+/// la posición de mundo. `grain = 0` → color intacto. Función pura → seamless entre
+/// ventanas de streaming.
+fn grained(base: [u8; 3], grain: f32, wx: i32, y: u32, wz: i32, seed: u32) -> [u8; 3] {
+    if grain <= 0.0 {
+        return base;
+    }
+    let g = grain.clamp(0.0, 1.0);
+    // Hash por vóxel (mete `y` en la coordenada para que la columna no sea uniforme).
+    let h = hash2(wx, wz.wrapping_mul(31).wrapping_add(y as i32 * 7), seed ^ 0x6817);
+    let bright = 1.0 + (h - 0.5) * 2.0 * g * 0.18; // ±18% a grano pleno
+    let speck = hash2(wx.wrapping_mul(13).wrapping_add(y as i32), wz, seed ^ 0x51A3);
+    let tilt = (speck - 0.5) * 2.0 * g * 12.0; // ±12 niveles, rompe el liso plano
+    let ch = |c: u8, extra: f32| (c as f32 * bright + extra).clamp(0.0, 255.0) as u8;
+    [ch(base[0], tilt), ch(base[1], tilt * 0.7), ch(base[2], -tilt * 0.5)]
 }
 
 impl WorldRecipe {
@@ -171,6 +196,7 @@ impl WorldRecipe {
             peak_at: 1.0,
             flora: Flora::Cactus,
             flora_density: 0.010,
+            grain: 0.55, // arena granular, no slab plano
         }
     }
 
@@ -192,6 +218,7 @@ impl WorldRecipe {
             peak_at: 0.80,
             flora: Flora::None,
             flora_density: 0.0,
+            grain: 0.45, // pasto/roca con textura, no color liso
         }
     }
 
@@ -294,10 +321,10 @@ impl WorldRecipe {
                     .abs()
                     .max((h as i32 - h_at(lx, lz - 1) as i32).abs()) as f32;
 
-                // Columna sólida.
+                // Columna sólida (con grano de materia por vóxel — sello del motor).
                 for y in 0..=h.min(dy - 1) {
                     let m = self.ground_material(wx, wz, y, h, slope, dim);
-                    g.set(lx as u32, y, lz as u32, m.color());
+                    g.set(lx as u32, y, lz as u32, grained(m.color(), self.grain, wx, y, wz, self.seed));
                 }
                 // Agua: llena por encima del terreno hasta el nivel del agua.
                 if h < water_y {
@@ -398,6 +425,13 @@ mod tests {
         let r = WorldRecipe::desert(3);
         let g = r.generate(dim);
         let mut seen = [false; 3]; // [arena, agua, cactus]
+        // La arena lleva grano (perturbación por vóxel) → se detecta por cercanía,
+        // no por igualdad exacta. Agua y cactus van sin grano (color exacto).
+        let near = |a: [u8; 3], b: [u8; 3]| {
+            (a[0] as i32 - b[0] as i32).abs() <= 60
+                && (a[1] as i32 - b[1] as i32).abs() <= 60
+                && (a[2] as i32 - b[2] as i32).abs() <= 60
+        };
         for z in 0..dim[2] {
             for x in 0..dim[0] {
                 for y in 0..dim[1] {
@@ -406,12 +440,12 @@ mod tests {
                             continue;
                         }
                         let rgb = [c[0], c[1], c[2]];
-                        if rgb == Material::Sand.color() {
-                            seen[0] = true;
-                        } else if rgb == Material::Water.color() {
+                        if rgb == Material::Water.color() {
                             seen[1] = true;
                         } else if rgb == Material::Cactus.color() {
                             seen[2] = true;
+                        } else if near(rgb, Material::Sand.color()) {
+                            seen[0] = true;
                         }
                     }
                 }
@@ -422,6 +456,24 @@ mod tests {
         assert!(seen[2], "hay cactus");
     }
 
+    #[test]
+    fn grano_de_materia_perturba_pero_es_determinista() {
+        let base = Material::Sand.color();
+        // grain=0 → color intacto (slab plano).
+        assert_eq!(grained(base, 0.0, 5, 3, 9, 1337), base);
+        // grain>0 → el mismo vóxel no es exactamente el color base (tiene grano)…
+        let g1 = grained(base, 0.6, 5, 3, 9, 1337);
+        assert_ne!(g1, base, "con grano el vóxel se aparta del color liso");
+        // …pero es función pura: mismo (pos, seed) → mismo color (seamless/streaming).
+        let g2 = grained(base, 0.6, 5, 3, 9, 1337);
+        assert_eq!(g1, g2);
+        // Vóxeles vecinos difieren → la superficie no es un color uniforme.
+        let gn = grained(base, 0.6, 6, 3, 9, 1337);
+        assert_ne!(g1, gn, "vóxeles contiguos varían → materia granular, no bloque liso");
+        // El desierto trae grano por defecto.
+        assert!(WorldRecipe::desert(1).grain > 0.0);
+    }
+
     #[test]
     fn mismo_seed_mismo_mundo() {
         let dim = [64, 40, 64];