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 · ruwaq Sergio · 2026-06-22T23:35:00Z (4 killa ñawpaq) · tayta a3c2ea9ff · kay commitpi sach'ata qhaway
Semántico sayk'uy · 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/reflection.rs
llimphi-3d/src/renderer.rs
llimphi-3d/src/voxel_renderer.rs
silq'uyninpi t'ikrakurqan struct Atmosphere pub silq'uyninpi t'ikrakurqan def WGSL ukhunpi t'ikrakurqan fn default ukhunpi t'ikrakurqan fn upload pub
llimphi-voxel/examples/ages_demo.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/examples/birth_demo.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/examples/desert_demo.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/examples/egg_demo.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/examples/raycast_edit.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/examples/terrain_demo.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/examples/terrain_flythrough.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/examples/terrain_lod.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/examples/terrain_streaming.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/examples/terrain_world.rs
ukhunpi t'ikrakurqan fn main
llimphi-voxel/src/director.rs
llimphi-voxel/src/studio.rs
llimphi-voxel/src/worldgen.rs
28 qillqakuna · +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];