refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel

Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "llimphi-3d"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-3d — pase 3D base de Llimphi sobre wgpu: cámara view/proj (glam), depth buffer propio y un pipeline que compone su render dentro del `View` por la misma firma que `gpu_paint_with`. M0 del motor 3D general (ver 01_yachay/dominium/MOTOR-VOXEL.md §11). No mete un segundo motor: va sobre el mismo wgpu que ya usa Llimphi."
[dependencies]
# Sólo los tipos GPU (Device/Queue/Encoder/View/Texture) — mismo wgpu que el
# resto de Llimphi, sin windowing. No agrega un segundo stack gráfico.
wgpu = { workspace = true }
glam = { workspace = true }
[dev-dependencies]
# Volcado headless del render 3D a PNG (llvmpipe en sandbox) para VER el cubo
# sin levantar ventana — mismo patrón que gpu_primitivos_demo.
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
png = { workspace = true }
pollster = { workspace = true }
# Demo interactivo: bucle Elm + ventana + mouse (orbita/zoom) sobre gpu_paint_with.
llimphi-ui = { path = "../llimphi-ui" }
+145
View File
@@ -0,0 +1,145 @@
//! Demo headless de M0: un cubo 3D con depth test, compuesto sobre un fondo
//! vello — el mismo orden que aplica el runtime de Llimphi para
//! `View::gpu_paint_with` (`[vello base] → [GPU 3D]`).
//!
//! No abre ventana: compone sobre una textura intermedia `Rgba8Unorm` (misma
//! mecánica que el frame real) y vuelca a PNG.
//!
//! `cargo run -p llimphi-3d --example cubo_demo --release -- [out.png] [yaw_deg]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, 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 out = std::env::args()
.nth(1)
.unwrap_or_else(|| "cubo_demo.png".to_string());
let yaw_deg: f32 = std::env::args()
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(35.0);
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 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: limpia la intermedia a un azul oscuro (render_to_view
// escribe todos los pixels con base_color).
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: cubo orbitado, depth test propio, LoadOp::Load sobre el fondo.
let camera = Camera3d::orbit(
Vec3::ZERO,
yaw_deg.to_radians(),
25_f32.to_radians(),
4.0,
);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("3d-pass"),
});
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());
write_png(&hal, &inter, &out);
eprintln!("cubo_demo: escrito {out} ({W}x{H}, yaw={yaw_deg}°)");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
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();
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();
}
+177
View File
@@ -0,0 +1,177 @@
//! Demo de **luces puntuales coloreadas** en el ray-march voxel: antorchas/
//! lámparas que tiñen los voxels cercanos con caída por distancia. Útil para
//! mood cinematográfico (la rama machinima) y para juegos (antorchas).
//!
//! Rinde tres PNG para el contraste:
//! - `/tmp/lights_off.png` — sólo sol + ambiente (la escena base).
//! - `/tmp/lights_noshadow.png` — + una luz cálida y una fría (MVP plano, sin sombra).
//! - `/tmp/lights_on.png` — las mismas luces **con sombra dura** (default):
//! los pilares/esfera bloquean la luz puntual y proyectan su sombra en el piso.
//!
//! La diferencia `noshadow` → `on` aísla la sombra de las puntuales (el feature
//! nuevo): se ven los conos oscuros detrás de cada obstáculo respecto de la luz.
//!
//! `cargo run -p llimphi-3d --example lights_demo --release`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, PointLight, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim = [96u32, 96, 96];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut grid = VoxelGrid::demo_scene(dim);
// Losa flotante en una zona despejada del piso: con una luz puntual justo
// ENCIMA, proyecta una sombra rectangular nítida en el piso de abajo — la
// prueba más legible de que las puntuales ya ocluyen.
for z in 58..74 {
for x in 16..34 {
grid.set(x, 20, z, [180, 180, 190]);
grid.set(x, 21, z, [180, 180, 190]);
}
}
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
// Sol bajo y tenue para que las luces puntuales destaquen.
vr.sun_dir = [0.3, 0.35, 0.5];
let camera = Camera3d::orbit(
Vec3::new(0.0, 4.0, 0.0),
40_f32.to_radians(),
24_f32.to_radians(),
dim[0] as f32 * 1.6,
);
// Toma 1: sin luces puntuales.
let off = render(&hal, &mut renderer, &mut vr, &camera);
write_png(&off, "/tmp/lights_off.png");
// Toma 2: una luz cálida (naranja, junto a un pilar) y una fría (cian, junto a
// la esfera). Color > 1.0 = brillo intenso; `range` en voxels.
// Cerca del piso (gris neutro = lee bien el color) y de un pilar, intensas.
vr.lights = vec![
// Cálida JUSTO sobre la losa flotante → sombra rectangular nítida abajo.
PointLight { pos: [25.0, 40.0, 66.0], color: [3.6, 1.7, 0.7], range: 70.0, radius: 0.0 },
// Fría junto a la esfera, a media altura → la esfera corta su luz.
PointLight { pos: [70.0, 30.0, 60.0], color: [0.6, 1.7, 3.6], range: 70.0, radius: 0.0 },
];
// 2a: MVP plano (sin sombra) — para aislar el feature nuevo.
vr.point_shadows = false;
let noshadow = render(&hal, &mut renderer, &mut vr, &camera);
write_png(&noshadow, "/tmp/lights_noshadow.png");
// 2b: con sombra DURA (radius = 0) — los obstáculos cortan la luz de golpe.
vr.point_shadows = true;
let on = render(&hal, &mut renderer, &mut vr, &camera);
write_png(&on, "/tmp/lights_on.png");
// 2c: con sombra BLANDA (radius > 0) — la luz pasa a fuente de área: el borde
// de la sombra se abre en penumbra (más cuanto más lejos el ocluyente).
for l in vr.lights.iter_mut() {
l.radius = 7.0;
}
let soft = render(&hal, &mut renderer, &mut vr, &camera);
write_png(&soft, "/tmp/lights_soft.png");
eprintln!(
"escritos /tmp/lights_off.png (sin luces), /tmp/lights_noshadow.png (sin \
sombra), /tmp/lights_on.png (sombra dura) y /tmp/lights_soft.png (penumbra)"
);
}
fn render(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, 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 view = inter.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(hal, &vello::Scene::new(), &view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("lights") });
vr.render(&hal.device, &hal.queue, &mut enc, &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 wtr = enc.write_header().unwrap();
wtr.write_image_data(pixels).unwrap();
}
+133
View File
@@ -0,0 +1,133 @@
//! Demo headless del **motor 3D general**: voxels + mallas de triángulos en
//! UNA escena con depth compartido ([`Scene3d`]). Prueba de oclusión mutua: un
//! cubo-malla y la esfera voxel se **interpenetran** — la esfera asoma por las
//! caras del cubo. Si el depth NO se compartiera, uno taparía al otro entero;
//! con `Scene3d` se ve una intersección limpia (cada píxel = lo más cercano,
//! sea voxel o triángulo).
//!
//! `cargo run -p llimphi-3d --example scene_mixed --release -- [out.png] [yaw_deg]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::{Mat4, Vec3};
use llimphi_3d::{Camera3d, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 800;
const H: u32 = 600;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const D: u32 = 80;
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "/tmp/scene_mixed.png".to_string());
let yaw_deg: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(40.0);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Voxel: esfera + piso + pilares, centro de la esfera en mundo ≈ (0, 4, 0).
let grid = VoxelGrid::demo_scene([D, D, D]);
let voxel = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
// Malla: cubo coloreado escalado a ~0.45·D, centrado en la esfera → la
// esfera (r≈0.3·D) lo atraviesa y asoma por las caras.
let mut mesh = Renderer3d::new(&hal.device, FMT);
mesh.set_model(Mat4::from_translation(Vec3::new(0.0, 4.0, 0.0)) * Mat4::from_scale(Vec3::splat(0.45 * D as f32)));
let mut scene = Scene3d::new();
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 oscuro.
let base = vello::Scene::new();
renderer
.render_to_view(&hal, &base, &inter_view, W, H, Color::from_rgba8(16, 18, 24, 255))
.expect("render base");
// (2) Escena 3D mixta (voxels + malla, depth compartido).
let camera = Camera3d::orbit(
Vec3::new(0.0, 4.0, 0.0),
yaw_deg.to_radians(),
20_f32.to_radians(),
D as f32 * 1.7,
);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("scene") });
scene.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &camera, Some(&voxel), &[&mesh]);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(&hal, &inter, &out);
eprintln!("scene_mixed: escrito {out} ({W}x{H}, yaw={yaw_deg}°) — voxel ∩ malla con depth compartido");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
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();
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();
}
+144
View File
@@ -0,0 +1,144 @@
//! Demo headless de M1: una grilla de voxels densa renderizada por
//! **ray-marching DDA** (sin meshear), compuesta sobre un fondo vello — el
//! mismo orden que el runtime aplica a `View::gpu_paint_with`.
//!
//! `cargo run -p llimphi-3d --example voxel_demo --release -- [out.png] [yaw_deg] [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
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 out = std::env::args()
.nth(1)
.unwrap_or_else(|| "voxel_demo.png".to_string());
let yaw_deg: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(35.0);
let dim: u32 = std::env::args().nth(3).and_then(|s| s.parse().ok()).unwrap_or(64);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let grid = VoxelGrid::demo_scene([dim, dim, dim]);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
let (used, total) = vr.brick_usage();
let (pool, dense) = vr.memory_bytes();
eprintln!(
"brick pool: {used}/{total} bricks ocupados ({:.1}%) — pool {} KiB vs denso {} KiB ({:.1}× menos)",
used as f32 / total as f32 * 100.0,
pool / 1024,
dense / 1024,
dense as f32 / pool.max(1) as f32,
);
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.
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 voxel ray-march. Cámara orbitando el centro de la grilla (origen).
let d = dim as f32;
let camera = Camera3d::orbit(
Vec3::ZERO,
yaw_deg.to_radians(),
30_f32.to_radians(),
d * 1.7,
);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("voxel-pass"),
});
vr.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());
write_png(&hal, &inter, &out);
eprintln!("voxel_demo: escrito {out} ({W}x{H}, yaw={yaw_deg}°, dim={dim}³)");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
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();
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();
}
+430
View File
@@ -0,0 +1,430 @@
//! Demo de M5 — **dimensiones / mundos paralelos**. Tres mundos voxel
//! independientes (Jardín, Inframundo, Cristal), cada uno con su grid, su cielo,
//! su sol y sus entidades. La cámara ve la dimensión activa; "viajar" = cambiar
//! cuál se renderiza.
//!
//! - **Arrastrar**: orbita. **Rueda**: zoom.
//! - **Tab / N**: siguiente dimensión. **P**: anterior. **1/2/3**: ir a una.
//! - Las entidades de la dimensión activa orbitan solas.
//!
//! `cargo run -p llimphi-3d --example voxel_dimensiones --release`
//! `… --release -- --shot` → vuelca un PNG por dimensión a /tmp/m5_*.png
use std::sync::{Arc, Mutex};
use std::time::Duration;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Dimension, Entity3d, Multiverse, VoxelGrid};
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_layout::LayoutTree;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::{
mount, paint_gpu, App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View,
WheelDelta,
};
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const DIM: u32 = 64;
// ── Construcción de los tres mundos ─────────────────────────────────────────
fn world_jardin(d: u32) -> Dimension {
Dimension::new("Jardín", VoxelGrid::demo_scene([d, d, d]))
.with_sky([20, 30, 26])
.with_sun([0.5, 1.0, 0.35])
.with_atmosphere(Atmosphere {
sky_zenith: [70, 130, 90],
sky_horizon: [196, 222, 188],
fog_density: 0.22 / d as f32,
})
.with_entities(orbit_entities(
d,
&[[235, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]],
))
}
fn world_inframundo(d: u32) -> Dimension {
let mut g = VoxelGrid::new([d, d, d]);
// Piso de lava (damero rojo/naranja).
for z in 0..d {
for x in 0..d {
let chk = ((x / 4 + z / 4) % 2) == 0;
let c = if chk { [150, 45, 22] } else { [185, 70, 28] };
for y in 0..2 {
g.set(x, y, z, c);
}
}
}
// Estalagmitas (columnas que se afinan hacia arriba).
for &(sx, sz, h) in &[(d / 4, d / 4, d * 2 / 5), (d * 3 / 4, d / 3, d / 2), (d / 2, d * 3 / 4, d * 3 / 5), (d / 5, d * 4 / 5, d * 3 / 10)] {
for y in 2..(2 + h).min(d) {
let t = (y - 2) as f32 / h as f32;
let r = ((1.0 - t) * 3.0).round() as i32;
for dx in -r..=r {
for dz in -r..=r {
let x = sx as i32 + dx;
let z = sz as i32 + dz;
if x >= 0 && z >= 0 {
let shade = 60 + (t * 70.0) as u8;
g.set(x as u32, y, z as u32, [120 + shade / 2, 50, 30]);
}
}
}
}
}
Dimension::new("Inframundo", g)
.with_sky([28, 8, 8])
.with_sun([0.35, 0.7, 0.5])
.with_atmosphere(Atmosphere {
sky_zenith: [60, 12, 10],
sky_horizon: [180, 70, 24],
fog_density: 0.4 / d as f32,
})
.with_entities(orbit_entities(d, &[[255, 140, 30], [255, 90, 20], [255, 200, 60]]))
}
fn world_cristal(d: u32) -> Dimension {
let mut g = VoxelGrid::new([d, d, d]);
// Cristales octaédricos flotando en el vacío (sin piso).
let crystals: [(u32, u32, u32, [u8; 3]); 6] = [
(d / 2, d * 3 / 4, d / 2, [120, 220, 255]),
(d / 3, d / 2, d * 2 / 3, [200, 160, 255]),
(d * 2 / 3, d * 3 / 5, d / 3, [160, 255, 220]),
(d / 4, d * 2 / 3, d / 4, [255, 240, 200]),
(d * 3 / 4, d / 2, d * 3 / 4, [180, 200, 255]),
(d / 2, d / 3, d * 4 / 5, [220, 180, 255]),
];
for (cx, cy, cz, col) in crystals {
let r = 4i32;
for dx in -r..=r {
for dy in -r..=r {
for dz in -r..=r {
if dx.abs() + dy.abs() + dz.abs() <= r {
let x = cx as i32 + dx;
let y = cy as i32 + dy;
let z = cz as i32 + dz;
if x >= 0 && y >= 0 && z >= 0 {
g.set(x as u32, y as u32, z as u32, col);
}
}
}
}
}
}
Dimension::new("Cristal", g)
.with_sky([10, 10, 22])
.with_sun([0.4, 0.8, 0.45])
.with_atmosphere(Atmosphere {
sky_zenith: [24, 18, 60],
sky_horizon: [120, 90, 200],
fog_density: 0.28 / d as f32,
})
.with_entities(orbit_entities(d, &[[120, 240, 255], [220, 180, 255]]))
}
/// Entidades distribuidas en una órbita ecuatorial (se animan girando).
fn orbit_entities(d: u32, colors: &[[u8; 3]]) -> Vec<Entity3d> {
let n = colors.len();
let df = d as f32;
(0..n)
.map(|k| {
let a = k as f32 / n as f32 * std::f32::consts::TAU;
Entity3d {
pos: [df * 0.5 + a.cos() * df * 0.42, df * 0.45, df * 0.5 + a.sin() * df * 0.42],
half: [df * 0.05, df * 0.05, df * 0.05],
color: colors[k],
}
})
.collect()
}
fn build_multiverse(d: u32) -> Multiverse {
Multiverse::new(vec![world_jardin(d), world_inframundo(d), world_cristal(d)])
}
fn rotate_y(e: &mut Entity3d, center: [f32; 3], ang: f32) {
let dx = e.pos[0] - center[0];
let dz = e.pos[2] - center[2];
let (s, c) = ang.sin_cos();
e.pos[0] = center[0] + dx * c - dz * s;
e.pos[2] = center[2] + dx * s + dz * c;
}
// ── App interactiva ─────────────────────────────────────────────────────────
#[derive(Clone)]
enum Msg {
Orbit(f32, f32),
Zoom(f32),
Tick,
Next,
Prev,
Go(usize),
}
struct Model {
yaw: f32,
pitch: f32,
dist: f32,
active: usize,
names: Vec<String>,
skies: Vec<[u8; 3]>,
mv: Arc<Mutex<Multiverse>>,
}
struct DimApp;
impl App for DimApp {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi-3d · dimensiones"
}
fn initial_size() -> (u32, u32) {
(1000, 720)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
let mv = build_multiverse(DIM);
Model {
yaw: 35_f32.to_radians(),
pitch: 30_f32.to_radians(),
dist: DIM as f32 * 1.7,
active: mv.active(),
names: mv.names(),
skies: mv.skies(),
mv: Arc::new(Mutex::new(mv)),
}
}
fn window_title(model: &Model) -> Option<String> {
Some(format!(
"llimphi-3d · {} ({}/{}) — Tab=siguiente",
model.names[model.active],
model.active + 1,
model.names.len()
))
}
fn on_key(_model: &Model, ev: &KeyEvent) -> Option<Msg> {
if !matches!(ev.state, KeyState::Pressed) {
return None;
}
match &ev.key {
Key::Named(NamedKey::Tab) => Some(Msg::Next),
Key::Character(c) => match c.as_str() {
"n" | "N" => Some(Msg::Next),
"p" | "P" => Some(Msg::Prev),
"1" => Some(Msg::Go(0)),
"2" => Some(Msg::Go(1)),
"3" => Some(Msg::Go(2)),
_ => None,
},
_ => None,
}
}
fn on_wheel(_m: &Model, delta: WheelDelta, _c: (f32, f32), _mods: Modifiers) -> Option<Msg> {
Some(Msg::Zoom(delta.y))
}
fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
match msg {
Msg::Orbit(dx, dy) => {
model.yaw -= dx * 0.008;
model.pitch += dy * 0.008;
}
Msg::Zoom(dy) => {
let f = (1.0 + dy * 0.1).clamp(0.5, 1.5);
model.dist = (model.dist * f).clamp(DIM as f32 * 0.5, DIM as f32 * 4.0);
}
Msg::Tick => {
// Anima las entidades de la dimensión activa.
let mut mv = model.mv.lock().unwrap();
let c = [DIM as f32 * 0.5, DIM as f32 * 0.45, DIM as f32 * 0.5];
for e in &mut mv.active_dim_mut().entities {
rotate_y(e, c, 0.02);
}
}
Msg::Next => {
model.mv.lock().unwrap().next();
model.active = model.mv.lock().unwrap().active();
}
Msg::Prev => {
model.mv.lock().unwrap().prev();
model.active = model.mv.lock().unwrap().active();
}
Msg::Go(i) => {
let mut mv = model.mv.lock().unwrap();
mv.switch(i);
model.active = mv.active();
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let camera = Camera3d::orbit(Vec3::ZERO, model.yaw, model.pitch, model.dist);
let mv = model.mv.clone();
let canvas = View::new(fill())
.gpu_paint_with(move |device, queue, encoder, target, _rect, vp| {
mv.lock().unwrap().render(device, queue, encoder, target, vp, &camera);
})
.draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Orbit(dx, dy)),
DragPhase::End => None,
});
View::new(fill()).children(vec![canvas])
}
}
fn fill() -> Style {
Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--shot") {
shot();
return;
}
llimphi_ui::run::<DimApp>();
}
/// Vuelca un PNG por dimensión por el compositor real (mount → paint_gpu).
fn shot() {
const W: u32 = 1000;
const H: u32 = 720;
let mv = Arc::new(Mutex::new(build_multiverse(DIM)));
let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), DIM as f32 * 1.7);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let count = mv.lock().unwrap().count();
for i in 0..count {
let (name, sky) = {
let mut g = mv.lock().unwrap();
g.switch(i);
(g.active_name().to_string(), g.active_dim().sky)
};
let model_mv = mv.clone();
let cam = camera;
let canvas: View<Msg> = View::new(fill()).gpu_paint_with(
move |device, queue, encoder, target, _rect, vp| {
model_mv.lock().unwrap().render(device, queue, encoder, target, vp, &cam);
},
);
let view: View<Msg> = View::new(fill()).children(vec![canvas]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, view);
let computed = layout.compute(mounted.root, (W as f32, H as f32)).expect("layout");
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());
renderer
.render_to_view(&hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(sky[0], sky[1], sky[2], 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") });
let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &inter_view, (W, H));
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
assert!(any, "gpu_painter no corrió");
let out = format!("/tmp/m5_{i}_{}.png", name.to_lowercase());
write_png(&hal, &inter, W, H, &out);
eprintln!("dimensión {i} = {name}{out}");
}
}
fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32, path: &str) {
use std::fs::File;
use std::io::BufWriter;
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();
let file = File::create(path).expect("png");
let mut penc = png::Encoder::new(BufWriter::new(file), w, h);
penc.set_color(png::ColorType::Rgba);
penc.set_depth(png::BitDepth::Eight);
let mut wr = penc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}
+173
View File
@@ -0,0 +1,173 @@
//! Demo headless de M3: **mutación incremental** de la grilla en GPU.
//!
//! Renderiza la escena, luego (a) agrega un bloque flotante en aire antes vacío
//! y (b) carva un mordisco en la esfera — cada edición sube SÓLO su sub-caja vía
//! `VoxelRenderer::sync` (no re-sube el grid ni remesha). Vuelca un PNG "antes"
//! y uno "después", e imprime los bytes subidos vs el grid completo.
//!
//! El bloque flotante es el test clave del coarse map: si `sync` no actualizara
//! la ocupación gruesa, el brick seguiría marcado vacío y el bloque sería
//! invisible (lo saltaría el DDA grueso).
//!
//! `cargo run -p llimphi-3d --example voxel_dynamic_demo --release -- [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
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 dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(64);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut grid = VoxelGrid::demo_scene([dim, dim, dim]);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), dim as f32 * 1.7);
// ── Frame ANTES ──────────────────────────────────────────────────────
render_frame(&hal, &mut renderer, &mut vr, &camera, "/tmp/m3_antes.png");
let full = dim * dim * dim * 4;
// ── Edición (a): bloque flotante en aire vacío (arriba, a un costado) ──
let bx = dim / 6;
let by = dim * 4 / 5;
let bz = dim / 6;
for z in 0..8 {
for y in 0..8 {
for x in 0..8 {
grid.set(bx + x, by + y, bz + z, [240, 150, 40]);
}
}
}
let n_a = vr.sync(&hal.queue, &mut grid);
eprintln!("edición (a) bloque flotante: subidos {n_a} B ({:.3}% del grid completo)", n_a as f32 / full as f32 * 100.0);
// ── Edición (b): mordisco cúbico en lo alto de la esfera ──────────────
let cx = dim / 2;
let cy = dim * 7 / 10;
let cz = dim / 2;
for z in 0..(dim / 4) {
for y in 0..(dim / 4) {
for x in 0..(dim / 4) {
grid.clear(cx + x, cy + y, cz - dim / 8 + z);
}
}
}
let n_b = vr.sync(&hal.queue, &mut grid);
eprintln!("edición (b) mordisco esfera: subidos {n_b} B ({:.3}% del grid completo)", n_b as f32 / full as f32 * 100.0);
// ── Frame DESPUÉS ────────────────────────────────────────────────────
render_frame(&hal, &mut renderer, &mut vr, &camera, "/tmp/m3_despues.png");
eprintln!("voxel_dynamic_demo: /tmp/m3_antes.png + /tmp/m3_despues.png (dim={dim}³)");
}
fn render_frame(
hal: &Hal,
renderer: &mut Renderer,
vr: &mut VoxelRenderer,
camera: &Camera3d,
out: &str,
) {
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(18, 22, 32, 255))
.expect("render base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
vr.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());
write_png(hal, &inter, out);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
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();
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();
}
+160
View File
@@ -0,0 +1,160 @@
//! Demo headless de M4: **entidades** (agentes) ray-marcheadas como cajas
//! analíticas en el mismo pase que los voxels. Se mueven con posición sub-voxel
//! (suave, no snapeada a la grilla), ocluyen y son ocluidas por el mundo voxel
//! (esfera/pilares) por comparación de `t`, y proyectan sombras sobre el piso.
//!
//! Genera 3 frames con las entidades en distintas posiciones de una órbita para
//! evidenciar el movimiento + oclusión + sombras.
//!
//! `cargo run -p llimphi-3d --example voxel_entities_demo --release -- [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, Entity3d, VoxelGrid, VoxelRenderer};
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 dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(64);
let d = dim as f32;
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let grid = VoxelGrid::demo_scene([dim, dim, dim]);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), d * 1.7);
let colors = [[235u8, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]];
for (fi, phase) in [0.0_f32, 0.9, 1.8].iter().enumerate() {
// 4 entidades orbitando el centro a media altura, con bobeo vertical.
// Una pasa por delante de la esfera y otra por detrás → oclusión mutua.
vr.entities.clear();
for k in 0..4 {
let a = phase + k as f32 * std::f32::consts::FRAC_PI_2;
let radius = d * 0.42;
let pos = [
d * 0.5 + a.cos() * radius,
d * (0.45 + 0.12 * (a * 1.3).sin()),
d * 0.5 + a.sin() * radius,
];
vr.entities.push(Entity3d {
pos,
half: [d * 0.05, d * 0.05, d * 0.05],
color: colors[k],
});
}
let out = format!("/tmp/m4_frame{fi}.png");
render_frame(&hal, &mut renderer, &mut vr, &camera, &out);
eprintln!("frame {fi}: {} entidades → {out}", vr.entities.len());
}
eprintln!("voxel_entities_demo: /tmp/m4_frame0..2.png (dim={dim}³)");
}
fn render_frame(
hal: &Hal,
renderer: &mut Renderer,
vr: &mut VoxelRenderer,
camera: &Camera3d,
out: &str,
) {
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(18, 22, 32, 255))
.expect("render base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
vr.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());
write_png(hal, &inter, out);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
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();
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();
}
+296
View File
@@ -0,0 +1,296 @@
//! Demo **interactivo** del motor 3D: el mundo voxel (M1-M4) dentro de un
//! `View` vivo de Llimphi, manejado con el mouse.
//!
//! - **Arrastrar** (botón izquierdo): orbita la cámara (yaw/pitch).
//! - **Rueda**: zoom (acerca/aleja).
//! - Las 4 entidades de colores orbitan solas (animación por `spawn_periodic`).
//!
//! Es el cableado real a una app: el `VoxelRenderer` se compone dentro del
//! árbol `View<Msg>` por `View::gpu_paint_with` (corre DESPUÉS de la pasada
//! vello, con `LoadOp::Load`). El renderer se crea perezosamente en la primera
//! llamada GPU (ahí recién hay `Device`/`Queue`) y se cachea en el Model tras
//! un `Arc<Mutex<…>>`.
//!
//! `cargo run -p llimphi-3d --example voxel_interactivo --release -- [dim]`
use std::sync::{Arc, Mutex};
use std::time::Duration;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, Entity3d, VoxelGrid, VoxelRenderer};
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_layout::LayoutTree;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::{mount, paint_gpu, App, DragPhase, Handle, Modifiers, View, WheelDelta};
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
#[derive(Clone)]
enum Msg {
Orbit(f32, f32),
Zoom(f32),
Tick,
}
struct Model {
yaw: f32,
pitch: f32,
dist: f32,
phase: f32,
dim: u32,
grid: Arc<VoxelGrid>,
/// Renderer voxel, creado en la 1ª pintada GPU (necesita el Device).
engine: Arc<Mutex<Option<VoxelRenderer>>>,
}
fn entities_at(phase: f32, dim: u32) -> Vec<Entity3d> {
let d = dim as f32;
let colors = [[235u8, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]];
(0..4)
.map(|k| {
let a = phase + k as f32 * std::f32::consts::FRAC_PI_2;
let radius = d * 0.42;
Entity3d {
pos: [
d * 0.5 + a.cos() * radius,
d * (0.45 + 0.12 * (a * 1.3).sin()),
d * 0.5 + a.sin() * radius,
],
half: [d * 0.05, d * 0.05, d * 0.05],
color: colors[k],
}
})
.collect()
}
struct VoxelApp;
impl App for VoxelApp {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi-3d · motor voxel interactivo"
}
fn initial_size() -> (u32, u32) {
(1000, 720)
}
fn init(handle: &Handle<Msg>) -> Model {
let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(64);
// Anima las entidades a ~30 fps.
handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
Model {
yaw: 35_f32.to_radians(),
pitch: 30_f32.to_radians(),
dist: dim as f32 * 1.7,
phase: 0.0,
dim,
grid: Arc::new(VoxelGrid::demo_scene([dim, dim, dim])),
engine: Arc::new(Mutex::new(None)),
}
}
fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
match msg {
Msg::Orbit(dx, dy) => {
model.yaw -= dx * 0.008;
model.pitch += dy * 0.008;
}
Msg::Zoom(dy) => {
// Rueda hacia adelante = acercar (reduce la distancia). El signo
// va invertido respecto del delta crudo para que sea natural.
let f = (1.0 + dy * 0.1).clamp(0.5, 1.5);
let d = model.dim as f32;
model.dist = (model.dist * f).clamp(d * 0.5, d * 4.0);
}
Msg::Tick => {
model.phase += 0.035;
}
}
model
}
fn on_wheel(
_model: &Model,
delta: WheelDelta,
_cursor: (f32, f32),
_mods: Modifiers,
) -> Option<Msg> {
Some(Msg::Zoom(delta.y))
}
fn view(model: &Model) -> View<Msg> {
let camera = Camera3d::orbit(Vec3::ZERO, model.yaw, model.pitch, model.dist);
let entities = entities_at(model.phase, model.dim);
let engine = model.engine.clone();
let grid = model.grid.clone();
let canvas = View::new(Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
})
.gpu_paint_with(move |device, queue, encoder, target, _rect, vp| {
let mut guard = engine.lock().unwrap();
let er = guard.get_or_insert_with(|| VoxelRenderer::new(device, queue, FMT, &grid));
er.entities = entities.clone();
er.render(device, queue, encoder, target, vp, &camera);
})
.draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Orbit(dx, dy)),
DragPhase::End => None,
});
View::new(Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
})
.children(vec![canvas])
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
// Modo verificación headless: monta el MISMO View por el compositor real
// (mount → compute → paint_gpu) y vuelca un PNG, sin abrir ventana.
if let Some(i) = args.iter().position(|a| a == "--shot") {
let out = args
.get(i + 1)
.cloned()
.unwrap_or_else(|| "/tmp/voxel_interactivo.png".to_string());
shot(&out);
return;
}
llimphi_ui::run::<VoxelApp>();
}
/// Render headless del árbol `View` de la app a través del compositor real.
fn shot(out: &str) {
const W: u32 = 1000;
const H: u32 = 720;
let dim = 64u32;
let model = Model {
yaw: 35_f32.to_radians(),
pitch: 30_f32.to_radians(),
dist: dim as f32 * 1.7,
phase: 0.6,
dim,
grid: Arc::new(VoxelGrid::demo_scene([dim, dim, dim])),
engine: Arc::new(Mutex::new(None)),
};
// Árbol real de la app → mount + layout (igual que el runtime por frame).
let view = VoxelApp::view(&model);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, view);
let computed = layout
.compute(mounted.root, (W as f32, H as f32))
.expect("layout");
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
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());
// Pasada vello base (fondo) — igual que el frame real.
renderer
.render_to_view(&hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(18, 22, 32, 255))
.expect("base");
// Pasada GPU directo: dispara los gpu_painter del árbol (nuestro voxel).
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") });
let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &inter_view, (W, H));
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
assert!(any, "ningún gpu_painter corrió — el cableado no llegó al compositor");
write_png(&hal, &inter, W, H, out);
eprintln!("voxel_interactivo --shot: {out} ({W}x{H}) — gpu_painter del View ejecutado por el compositor");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32, path: &str) {
use std::fs::File;
use std::io::BufWriter;
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();
let file = File::create(path).expect("png");
let mut penc = png::Encoder::new(BufWriter::new(file), w, h);
penc.set_color(png::ColorType::Rgba);
penc.set_depth(png::BitDepth::Eight);
let mut wr = penc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}
+84
View File
@@ -0,0 +1,84 @@
//! Cámara 3D — produce la matriz `view_proj` que el shader aplica a cada
//! vértice. Convención de mano derecha y profundidad `0..1` (la de wgpu/
//! Vulkan/Metal/DX12, **no** la `-1..1` de OpenGL).
use glam::{Mat4, Vec3};
/// Cámara en perspectiva. `eye` mira a `target` con `up` como vertical.
#[derive(Debug, Clone, Copy)]
pub struct Camera3d {
/// Posición del ojo en mundo.
pub eye: Vec3,
/// Punto al que mira.
pub target: Vec3,
/// Vector "arriba" (normalmente `Vec3::Y`).
pub up: Vec3,
/// Campo de visión vertical, en radianes.
pub fovy_rad: f32,
/// Plano cercano (`> 0`).
pub znear: f32,
/// Plano lejano.
pub zfar: f32,
}
impl Default for Camera3d {
fn default() -> Self {
Self {
eye: Vec3::new(2.5, 2.0, 3.5),
target: Vec3::ZERO,
up: Vec3::Y,
fovy_rad: 60_f32.to_radians(),
znear: 0.1,
// Generoso: cubre mundos voxel de cientos de unidades. Importa desde
// que el pase de voxels escribe profundidad (`Scene3d`): un hit más
// allá de `zfar` se clamparía a 1.0 y fallaría el depth test. Float32
// de depth mantiene precisión de sobra en este rango para oclusión.
zfar: 5000.0,
}
}
}
impl Camera3d {
/// Cámara orbitando `target` a `dist`, con `yaw`/`pitch` en radianes.
/// `yaw` gira alrededor del eje Y; `pitch` sube/baja (clamp suave para no
/// cruzar los polos y degenerar el `up`).
pub fn orbit(target: Vec3, yaw: f32, pitch: f32, dist: f32) -> Self {
let lim = std::f32::consts::FRAC_PI_2 - 0.01;
let pitch = pitch.clamp(-lim, lim);
let (sy, cy) = yaw.sin_cos();
let (sp, cp) = pitch.sin_cos();
let offset = Vec3::new(cp * sy, sp, cp * cy) * dist;
Self {
eye: target + offset,
target,
..Self::default()
}
}
/// Cámara **libre / primera persona**: parada en `eye`, mirando según
/// `yaw` (giro alrededor de Y) y `pitch` (cabeceo, clamped para no cruzar el
/// cenit). Complementa a [`orbit`](Self::orbit): `orbit` mira un punto desde
/// afuera (vista de paisaje), `fly` te pone *adentro* del mundo (vuelo / FPS).
/// `yaw=0` mira hacia `+Z`.
pub fn fly(eye: Vec3, yaw: f32, pitch: f32) -> Self {
let lim = std::f32::consts::FRAC_PI_2 - 0.01;
let pitch = pitch.clamp(-lim, lim);
let (sy, cy) = yaw.sin_cos();
let (sp, cp) = pitch.sin_cos();
let dir = Vec3::new(cp * sy, sp, cp * cy);
Self {
eye,
target: eye + dir,
..Self::default()
}
}
/// Matriz `proj * view` lista para `mvp * vec4(pos, 1.0)` en el shader.
/// `aspect` = ancho/alto del viewport en pixels.
pub fn view_proj(&self, aspect: f32) -> Mat4 {
let view = Mat4::look_at_rh(self.eye, self.target, self.up);
// `perspective_rh` (no `_gl`): profundidad 0..1, la que espera wgpu.
let proj = Mat4::perspective_rh(self.fovy_rad, aspect.max(1e-4), self.znear, self.zfar);
proj * view
}
}
+139
View File
@@ -0,0 +1,139 @@
//! `CameraTrack` — interpolación de cámara por **keyframes** en el tiempo, el
//! ingrediente "cine" del motor: en vez de una `Camera3d` fija o atada a input,
//! una secuencia de poses `(t, eye, target, fov)` que se interpolan suave para
//! producir un **movimiento de cámara guionado** (travelling, grúa, dolly,
//! corte). Determinista por construcción → ideal para *filmar* frame a frame.
//!
//! Es genérico del motor 3D (no sabe de voxels ni de juegos): cualquier app que
//! quiera una cámara animada lo usa. La *dirección* de actores/eventos vive en
//! la capa de contenido (la app), no acá.
use glam::Vec3;
use crate::camera::Camera3d;
/// Una pose de cámara anclada a un instante `t` (segundos). Entre keys
/// consecutivas, [`CameraTrack::sample`] interpola `eye`/`target`/`fovy_rad`.
#[derive(Debug, Clone, Copy)]
pub struct CamKey {
/// Instante de la pose, en segundos desde el inicio.
pub t: f32,
/// Posición del ojo.
pub eye: Vec3,
/// Punto al que mira.
pub target: Vec3,
/// Campo de visión vertical (radianes) en esta pose.
pub fovy_rad: f32,
}
impl CamKey {
/// Atajo: una pose mirando de `eye` a `target` con FOV en **grados**.
pub fn look(t: f32, eye: Vec3, target: Vec3, fov_deg: f32) -> Self {
Self { t, eye, target, fovy_rad: fov_deg.to_radians() }
}
}
/// Secuencia de [`CamKey`] ordenada en el tiempo. `sample(t)` devuelve la
/// `Camera3d` interpolada; fuera de rango hace *clamp* a la primera/última pose.
#[derive(Debug, Clone, Default)]
pub struct CameraTrack {
keys: Vec<CamKey>,
}
impl CameraTrack {
/// Crea el track a partir de las keys (se ordenan por `t`). Un track vacío
/// o de una sola key es válido (devuelve siempre esa pose).
pub fn new(mut keys: Vec<CamKey>) -> Self {
keys.sort_by(|a, b| a.t.total_cmp(&b.t));
Self { keys }
}
/// Duración total (el `t` de la última key), o `0.0` si está vacío.
pub fn duration(&self) -> f32 {
self.keys.last().map(|k| k.t).unwrap_or(0.0)
}
/// La cámara interpolada en el instante `t` (segundos). Entre dos keys usa
/// **smoothstep** (acelera/desacelera suave, sin tirones) sobre la fracción
/// del segmento; antes de la primera / después de la última, clampa.
pub fn sample(&self, t: f32) -> Camera3d {
match self.keys.as_slice() {
[] => Camera3d::default(),
[only] => cam_of(only),
keys => {
// Clamp a los extremos.
if t <= keys[0].t {
return cam_of(&keys[0]);
}
if t >= keys[keys.len() - 1].t {
return cam_of(&keys[keys.len() - 1]);
}
// Segmento que contiene a `t`: última key con `t_key <= t`
// (existe y no es la última, por el clamp de arriba).
let i = keys.iter().rposition(|k| k.t <= t).unwrap_or(0).min(keys.len() - 2);
let (a, b) = (&keys[i], &keys[i + 1]);
let span = (b.t - a.t).max(1e-6);
let f = smoothstep((t - a.t) / span);
Camera3d {
eye: a.eye.lerp(b.eye, f),
target: a.target.lerp(b.target, f),
fovy_rad: a.fovy_rad + (b.fovy_rad - a.fovy_rad) * f,
..Camera3d::default()
}
}
}
}
}
/// Construye una `Camera3d` (con `up`/planos por defecto) desde una key.
fn cam_of(k: &CamKey) -> Camera3d {
Camera3d {
eye: k.eye,
target: k.target,
fovy_rad: k.fovy_rad,
..Camera3d::default()
}
}
/// Suavizado Hermite clásico `3t²−2t³` en `[0,1]` (deriva nula en los extremos).
fn smoothstep(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
#[cfg(test)]
mod tests {
use super::*;
fn track() -> CameraTrack {
CameraTrack::new(vec![
CamKey::look(0.0, Vec3::new(0.0, 0.0, 0.0), Vec3::Z, 60.0),
CamKey::look(2.0, Vec3::new(10.0, 0.0, 0.0), Vec3::Z, 40.0),
])
}
#[test]
fn clamp_en_los_extremos() {
let tr = track();
assert_eq!(tr.sample(-1.0).eye, Vec3::ZERO);
assert_eq!(tr.sample(5.0).eye.x, 10.0);
assert_eq!(tr.duration(), 2.0);
}
#[test]
fn interpola_la_mitad_con_smoothstep() {
let tr = track();
// En la mitad temporal, smoothstep(0.5)=0.5 → punto medio exacto.
let c = tr.sample(1.0);
assert!((c.eye.x - 5.0).abs() < 1e-4, "x={}", c.eye.x);
assert!((c.fovy_rad - 50_f32.to_radians()).abs() < 1e-4);
}
#[test]
fn smoothstep_acelera_suave() {
let tr = track();
// A 1/4 del tiempo, smoothstep(0.25)=0.15625 < 0.25 (arranca lento).
let c = tr.sample(0.5);
assert!(c.eye.x < 2.5, "debería ir más lento al principio: x={}", c.eye.x);
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Dimensiones / mundos paralelos (M5) — `MOTOR-VOXEL.md` §3.8.
//!
//! Una **dimensión = un mundo voxel independiente** con su propio grid, su sol,
//! su cielo (color de fondo) y sus entidades. "Viajar" = cambiar qué dimensión
//! renderiza la cámara (un portal = un `switch`). No agrega complejidad de motor
//! (cada dimensión reusa el `VoxelRenderer` sparse tal cual): es contenido.
//!
//! El [`Multiverse`] mantiene N dimensiones y la activa; cada una materializa su
//! `VoxelRenderer` (su brick pool) perezosamente la primera vez que se la pinta,
//! y queda "tibia" en memoria para que el switch sea instantáneo.
use crate::camera::Camera3d;
use crate::voxel::VoxelGrid;
use crate::voxel_renderer::{Atmosphere, Entity3d, VoxelRenderer};
/// Formato de la textura intermedia de Llimphi (target de `gpu_paint_with`).
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Un mundo voxel independiente.
pub struct Dimension {
pub name: String,
pub grid: VoxelGrid,
/// Color de fondo (cielo) sugerido para la pasada vello base.
pub sky: [u8; 3],
/// Dirección hacia el sol de esta dimensión.
pub sun_dir: [f32; 3],
/// Atmósfera (cielo + niebla) de esta dimensión. Default = niebla off, así
/// una dimensión sin configurar se comporta como en M5 (miss → discard).
pub atmosphere: Atmosphere,
/// Entidades (agentes) de esta dimensión; se copian al renderer por frame.
pub entities: Vec<Entity3d>,
renderer: Option<VoxelRenderer>,
}
impl Dimension {
/// Dimensión nueva con cielo/sol por defecto y sin entidades.
pub fn new(name: impl Into<String>, grid: VoxelGrid) -> Self {
Self {
name: name.into(),
grid,
sky: [18, 22, 32],
sun_dir: [0.5, 1.0, 0.35],
atmosphere: Atmosphere::default(),
entities: Vec::new(),
renderer: None,
}
}
pub fn with_sky(mut self, sky: [u8; 3]) -> Self {
self.sky = sky;
self
}
pub fn with_sun(mut self, sun_dir: [f32; 3]) -> Self {
self.sun_dir = sun_dir;
self
}
/// Activa cielo + niebla propios para esta dimensión (el `render` los aplica
/// al renderer). Con `fog_density > 0`, el motor pinta su propio cielo en los
/// misses (ya no se ve el fondo vello).
pub fn with_atmosphere(mut self, atmosphere: Atmosphere) -> Self {
self.atmosphere = atmosphere;
self
}
pub fn with_entities(mut self, entities: Vec<Entity3d>) -> Self {
self.entities = entities;
self
}
}
/// Conjunto de dimensiones con una activa. La cámara siempre ve la activa.
pub struct Multiverse {
dims: Vec<Dimension>,
active: usize,
format: wgpu::TextureFormat,
}
impl Multiverse {
pub fn new(dims: Vec<Dimension>) -> Self {
Self {
dims,
active: 0,
format: FMT,
}
}
/// Cambia el formato de color del target (default `Rgba8Unorm`, la
/// intermedia de Llimphi). Sólo afecta a renderers aún no materializados.
pub fn with_format(mut self, format: wgpu::TextureFormat) -> Self {
self.format = format;
self
}
pub fn count(&self) -> usize {
self.dims.len()
}
pub fn active(&self) -> usize {
self.active
}
pub fn active_name(&self) -> &str {
&self.dims[self.active].name
}
pub fn names(&self) -> Vec<String> {
self.dims.iter().map(|d| d.name.clone()).collect()
}
pub fn skies(&self) -> Vec<[u8; 3]> {
self.dims.iter().map(|d| d.sky).collect()
}
/// Viaja a la dimensión `i` (no-op si fuera de rango).
pub fn switch(&mut self, i: usize) {
if i < self.dims.len() {
self.active = i;
}
}
pub fn next(&mut self) {
self.active = (self.active + 1) % self.dims.len();
}
pub fn prev(&mut self) {
self.active = (self.active + self.dims.len() - 1) % self.dims.len();
}
pub fn active_dim(&self) -> &Dimension {
&self.dims[self.active]
}
pub fn active_dim_mut(&mut self) -> &mut Dimension {
&mut self.dims[self.active]
}
/// Ray-marchea la dimensión activa sobre `target`. Materializa su brick pool
/// la primera vez. Firma compatible con la closure de `gpu_paint_with`.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
viewport: (u32, u32),
camera: &Camera3d,
) {
let fmt = self.format;
let d = &mut self.dims[self.active];
let r = d
.renderer
.get_or_insert_with(|| VoxelRenderer::new(device, queue, fmt, &d.grid));
r.sun_dir = d.sun_dir;
r.atmosphere = d.atmosphere;
r.entities = d.entities.clone();
r.render(device, queue, encoder, target, viewport, camera);
}
/// Acceso al renderer ya materializado de la dimensión activa (para `sync`
/// incremental de mutaciones, stats, etc.). `None` si aún no se pintó.
pub fn active_renderer_mut(&mut self) -> Option<&mut VoxelRenderer> {
self.dims[self.active].renderer.as_mut()
}
}
+306
View File
@@ -0,0 +1,306 @@
//! `Hud` — un pase **screen-space** mínimo: dibuja rectángulos de color plano
//! (con alpha) directamente en NDC, *después* del pase 3D, sobre el mismo
//! target. Es la pieza que faltaba para un **HUD / mira (crosshair)** en primera
//! persona: el contenido vello del árbol Llimphi queda **debajo** del canvas GPU
//! full-screen, así que cualquier overlay que deba ir *encima* del ray-march
//! tiene que pintarse en GPU en la misma closure `gpu_paint_with`, y eso es
//! justo lo que hace [`Hud::render`].
//!
//! Deliberadamente tonto: sin texturas, sin bind groups, sin depth. Geometría
//! en CPU → un vertex buffer dinámico → un draw. Suficiente para miras, barras,
//! marcos y **texto** ([`HudQuad::text`], fuente bitmap 5×7 = un quad por píxel
//! encendido, sin salir del pipeline de quads).
/// Un rectángulo de HUD en **pixels** (origen arriba-izquierda, como la
/// pantalla), color RGBA lineal `0..1`.
#[derive(Debug, Clone, Copy)]
pub struct HudQuad {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub color: [f32; 4],
}
impl HudQuad {
/// Una **mira (crosshair)** centrada en un viewport `(w, h)`: dos barras
/// (horizontal + vertical) de brazo `arm` y grosor `th` pixels.
pub fn crosshair(viewport: (u32, u32), arm: f32, th: f32, color: [f32; 4]) -> [HudQuad; 2] {
let cx = viewport.0 as f32 * 0.5;
let cy = viewport.1 as f32 * 0.5;
[
HudQuad { x: cx - arm, y: cy - th * 0.5, w: arm * 2.0, h: th, color },
HudQuad { x: cx - th * 0.5, y: cy - arm, w: th, h: arm * 2.0, color },
]
}
/// Emite los quads de una cadena con la **fuente bitmap 5×7** embebida
/// ([`glyph`]): origen arriba-izquierda en `(x, y)` pixels, cada píxel de
/// glifo mide `px` pixels de lado y los caracteres avanzan `6·px` (5 de ancho
/// + 1 de espacio). Sólo ASCII; las minúsculas se dibujan en mayúscula y los
/// caracteres desconocidos quedan en blanco. Se mantiene dentro del pipeline
/// tonto del HUD (un quad por píxel encendido, sin texturas).
pub fn text(s: &str, x: f32, y: f32, px: f32, color: [f32; 4]) -> Vec<HudQuad> {
let mut out = Vec::new();
let mut cx = x;
for ch in s.chars() {
if ch != ' ' {
let g = glyph(ch);
for (r, row) in g.iter().enumerate() {
for c in 0..5u32 {
if row & (1 << (4 - c)) != 0 {
out.push(HudQuad {
x: cx + c as f32 * px,
y: y + r as f32 * px,
w: px,
h: px,
color,
});
}
}
}
}
cx += 6.0 * px;
}
out
}
/// Ancho en pixels que ocuparía `s` con [`text`](Self::text) a tamaño `px`
/// (útil para dimensionar un panel de fondo antes de dibujar el texto).
pub fn text_width(s: &str, px: f32) -> f32 {
s.chars().count() as f32 * 6.0 * px
}
}
/// Mapa de un carácter a su bitmap **5×7**: 7 filas, cada `u8` con los 5 bits
/// bajos = columnas de izquierda (bit 4) a derecha (bit 0). Cubre `0-9`, `A-Z`
/// y puntuación común; lo desconocido devuelve un glifo en blanco. Las filas se
/// escriben en binario para que la forma sea legible en el código.
fn glyph(c: char) -> [u8; 7] {
match c.to_ascii_uppercase() {
'0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
'1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
'3' => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
'4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
'5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
'6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
'7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
'8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
'9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
'C' => [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
'D' => [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
'E' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
'F' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
'G' => [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
'H' => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'I' => [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'J' => [0b00111, 0b00010, 0b00010, 0b00010, 0b10010, 0b10010, 0b01100],
'K' => [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
'M' => [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
'N' => [0b10001, 0b11001, 0b10101, 0b10101, 0b10011, 0b10001, 0b10001],
'O' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
'Q' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101],
'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
'S' => [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
'T' => [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
'U' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'V' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
'W' => [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
'X' => [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
'Z' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000],
'.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100],
',' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100, 0b01000],
'-' => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
'+' => [0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000],
'/' => [0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000],
'(' => [0b00110, 0b01000, 0b01000, 0b01000, 0b01000, 0b01000, 0b00110],
')' => [0b01100, 0b00010, 0b00010, 0b00010, 0b00010, 0b00010, 0b01100],
'%' => [0b11001, 0b11001, 0b00010, 0b00100, 0b01000, 0b10011, 0b10011],
_ => [0; 7],
}
}
/// Tamaño de un vértice del HUD: `pos: vec2<f32>` + `color: vec4<f32>`.
const VSIZE: usize = 2 * 4 + 4 * 4;
/// Renderer de overlay screen-space. Cachea pipeline + un vertex buffer
/// dinámico que crece según haga falta.
pub struct Hud {
pipeline: wgpu::RenderPipeline,
vbuf: wgpu::Buffer,
cap: u64,
}
impl Hud {
/// Crea el HUD para el `color_format` del target (el de la intermedia del
/// frame). No toca depth: dibuja siempre encima.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-hud-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-hud-pl"),
bind_group_layouts: &[],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-hud-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: VSIZE as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 8,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
// Sin depth: el HUD va siempre encima del 3D.
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let cap = (64 * 6 * VSIZE) as u64; // ~64 quads sin recrear
let vbuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-hud-vbuf"),
size: cap,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self { pipeline, vbuf, cap }
}
/// Dibuja `quads` sobre `target` (color `LoadOp::Load`, sin depth). Firma
/// compatible con la closure `gpu_paint_with`: llamar *después* del pase 3D.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
quads: &[HudQuad],
) {
if w == 0 || h == 0 || quads.is_empty() {
return;
}
// Geometría en CPU: 2 triángulos (6 vértices) por quad, en NDC. El eje Y
// de pantalla va hacia abajo; NDC hacia arriba → `1 - 2·y/h`.
let (fw, fh) = (w as f32, h as f32);
let mut bytes = Vec::with_capacity(quads.len() * 6 * VSIZE);
let mut vert = |x_px: f32, y_px: f32, c: [f32; 4]| {
let ndc_x = x_px / fw * 2.0 - 1.0;
let ndc_y = 1.0 - y_px / fh * 2.0;
bytes.extend_from_slice(&ndc_x.to_ne_bytes());
bytes.extend_from_slice(&ndc_y.to_ne_bytes());
for ch in c {
bytes.extend_from_slice(&ch.to_ne_bytes());
}
};
for q in quads {
let (x0, y0, x1, y1) = (q.x, q.y, q.x + q.w, q.y + q.h);
vert(x0, y0, q.color);
vert(x1, y0, q.color);
vert(x1, y1, q.color);
vert(x0, y0, q.color);
vert(x1, y1, q.color);
vert(x0, y1, q.color);
}
// Crecer el buffer si hiciera falta (raro: la mira son 2 quads).
if bytes.len() as u64 > self.cap {
self.cap = (bytes.len() as u64).next_power_of_two();
self.vbuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-hud-vbuf"),
size: self.cap,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
queue.write_buffer(&self.vbuf, 0, &bytes);
let count = (quads.len() * 6) as u32;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-3d-hud-pass"),
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,
});
pass.set_pipeline(&self.pipeline);
pass.set_vertex_buffer(0, self.vbuf.slice(..bytes.len() as u64));
pass.draw(0..count, 0..1);
}
}
const WGSL: &str = r#"
struct VIn {
@location(0) pos: vec2<f32>,
@location(1) color: vec4<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vs(in: VIn) -> VOut {
var out: VOut;
out.clip = vec4<f32>(in.pos, 0.0, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
return in.color;
}
"#;
+54
View File
@@ -0,0 +1,54 @@
//! # llimphi-3d — pase 3D base de Llimphi (M0 del motor 3D)
//!
//! Lo mínimo para tener **3D real dentro de un `View` de Llimphi**: una
//! [`Camera3d`] (matrices view/proj con `glam`), un depth buffer propio y un
//! [`Renderer3d`] que dibuja geometría indexada con test de profundidad sobre
//! la textura intermedia del frame.
//!
//! ## Cómo encaja con el bucle Elm + vello + wgpu
//!
//! Llimphi ya rasteriza la UI con vello sobre una textura intermedia y expone
//! [`View::gpu_paint_with`] para inyectar una pasada GPU directa *después* de
//! vello (con `LoadOp::Load`, preservando la UI). [`Renderer3d::render`] tiene
//! **exactamente** la firma que esa closure necesita
//! (`device, queue, encoder, target_view, (w, h), &camera`), así que un nodo 3D
//! es:
//!
//! ```ignore
//! let r3d = Arc::new(Mutex::new(Renderer3d::new(&device, fmt)));
//! View::empty().gpu_paint_with(move |dev, q, enc, view, rect, vp| {
//! r3d.lock().unwrap().render(dev, q, enc, view, vp, &camera);
//! })
//! ```
//!
//! No es un segundo motor: corre sobre el **mismo wgpu** que ya usa Llimphi,
//! que a su vez traduce a Vulkan/Metal/DX12/GL/WebGPU. Ver
//! `01_yachay/dominium/MOTOR-VOXEL.md` §11 para la ruta completa (M0..M4,
//! ray-march de voxels sparse en los hitos siguientes).
//!
//! [`View::gpu_paint_with`]: https://docs/llimphi-compositor
pub use glam;
pub use wgpu;
mod camera;
mod cinema;
mod dimensions;
mod hud;
mod mesh;
mod renderer;
mod scene;
mod voxel;
mod voxel_renderer;
pub use camera::Camera3d;
pub use cinema::{CamKey, CameraTrack};
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 scene::Scene3d;
pub use voxel::{DirtyBox, VoxelGrid};
pub use voxel_renderer::{
Atmosphere, Entity3d, PointLight, VoxelRenderer, VOXEL_BRICK, VOXEL_MAX_LIGHTS,
};
+85
View File
@@ -0,0 +1,85 @@
//! Geometría de mallas: el vértice 3D ([`Vertex3d`]), un cubo de prueba
//! ([`cube`]) y un compositor de cajas transformadas ([`push_cube`]) para armar
//! mallas multi-caja en CPU — p.ej. un **muñeco articulado** (cabeza/torso/
//! miembros como cajas rotadas en sus articulaciones).
//!
//! Sigue el idiom de `llimphi-raster::gpu` (subir a GPU vía `to_ne_bytes`, sin
//! `bytemuck`) para no agregar una dependencia nueva al workspace.
use glam::{Mat4, Vec3};
/// Vértice 3D: posición en mundo + color RGB lineal.
#[derive(Debug, Clone, Copy)]
pub struct Vertex3d {
pub pos: [f32; 3],
pub color: [f32; 3],
}
impl Vertex3d {
/// Tamaño en bytes de un vértice empaquetado (`6 × f32`).
pub const SIZE: usize = 6 * 4;
/// Vuelca este vértice al buffer en orden `pos.xyz, color.rgb` (native
/// endian, como hace `GpuBatch`).
pub fn write_to(&self, out: &mut Vec<u8>) {
for v in self.pos {
out.extend_from_slice(&v.to_ne_bytes());
}
for v in self.color {
out.extend_from_slice(&v.to_ne_bytes());
}
}
}
/// Las 8 esquinas del cubo unitario centrado en el origen (lado 1, `-0.5..0.5`).
const CUBE_CORNERS: [[f32; 3]; 8] = [
[-0.5, -0.5, -0.5],
[0.5, -0.5, -0.5],
[0.5, 0.5, -0.5],
[-0.5, 0.5, -0.5],
[-0.5, -0.5, 0.5],
[0.5, -0.5, 0.5],
[0.5, 0.5, 0.5],
[-0.5, 0.5, 0.5],
];
/// Los 36 índices (12 triángulos) del cubo, winding CCW visto desde afuera.
#[rustfmt::skip]
pub const CUBE_INDICES: [u16; 36] = [
0, 2, 1, 0, 3, 2, // -Z (atrás)
4, 5, 6, 4, 6, 7, // +Z (frente)
0, 4, 7, 0, 7, 3, // -X (izquierda)
1, 2, 6, 1, 6, 5, // +X (derecha)
0, 1, 5, 0, 5, 4, // -Y (abajo)
3, 7, 6, 3, 6, 2, // +Y (arriba)
];
/// Cubo unitario centrado en el origen (lado 1, de `-0.5` a `0.5`). 8 vértices
/// coloreados por su posición (`color = pos + 0.5`) → un degradé que deja ver
/// las tres caras visibles distintas. 36 índices (12 triángulos), winding CCW.
pub fn cube() -> (Vec<Vertex3d>, Vec<u16>) {
let verts = CUBE_CORNERS
.iter()
.map(|&[x, y, z]| Vertex3d {
pos: [x, y, z],
color: [x + 0.5, y + 0.5, z + 0.5],
})
.collect();
(verts, CUBE_INDICES.to_vec())
}
/// Apila un cubo transformado por `m` (mapea el cubo unitario `[-0.5,0.5]³` a su
/// caja en mundo) con color plano `color`, en `verts`/`indices`. Es el ladrillo
/// para componer mallas multi-caja en CPU: cada llamada agrega 8 vértices + 36
/// índices con la base reubicada. Para un miembro articulado, `m` suele ser
/// `T(articulación) · R(ángulo) · T(0,-largo/2,0) · S(tamaño)`.
pub fn push_cube(verts: &mut Vec<Vertex3d>, indices: &mut Vec<u16>, m: Mat4, color: [f32; 3]) {
let base = verts.len() as u16;
for c in CUBE_CORNERS {
let p = m.transform_point3(Vec3::from_array(c));
verts.push(Vertex3d { pos: p.to_array(), color });
}
for i in CUBE_INDICES {
indices.push(base + i);
}
}
+314
View File
@@ -0,0 +1,314 @@
//! `Renderer3d` — pipeline wgpu mínimo que dibuja geometría 3D indexada con
//! test de profundidad sobre la textura intermedia del frame de Llimphi.
//!
//! La firma de [`Renderer3d::render`] es la que pide la closure de
//! `View::gpu_paint_with` (`device, queue, encoder, target_view, (w, h)`), más
//! la cámara — así un nodo 3D entra en el árbol `View<Msg>` sin tocar el
//! runtime. Mantiene su **propio depth buffer** (recreado al cambiar de
//! tamaño); el color se compone con `LoadOp::Load` para preservar la UI vello
//! que ya está debajo.
use glam::Mat4;
use crate::camera::Camera3d;
use crate::mesh::{cube, Vertex3d};
use crate::scene::{ensure_depth, DepthBuffer, DEPTH_FORMAT};
/// Renderer de **mallas** indexadas (por defecto un cubo) visto desde una
/// [`Camera3d`]. Cachea pipeline, buffers de geometría, uniform y (para el
/// camino standalone) un depth propio. En [`Scene3d`](crate::Scene3d) comparte
/// el depth con el pase de voxels para ocluirse mutuamente.
///
/// `model` ubica la malla en el mundo (default identidad): `mvp = view_proj ·
/// model`, así una misma malla se instancia/posiciona sin reconstruir buffers.
pub struct Renderer3d {
pipeline: wgpu::RenderPipeline,
vbuf: wgpu::Buffer,
ibuf: wgpu::Buffer,
index_count: u32,
ubuf: wgpu::Buffer,
bind_group: wgpu::BindGroup,
model: Mat4,
depth: Option<DepthBuffer>,
}
impl Renderer3d {
/// Crea el renderer para un `color_format` dado (el de la textura
/// intermedia del frame — `Rgba8Unorm` en headless, el de la surface en
/// vivo). Arranca con el cubo de prueba cargado.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let (verts, indices) = cube();
Self::with_mesh(device, color_format, &verts, &indices)
}
/// Igual que [`Self::new`] pero con una malla arbitraria.
pub fn with_mesh(
device: &wgpu::Device,
color_format: wgpu::TextureFormat,
verts: &[Vertex3d],
indices: &[u16],
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-3d-bgl"),
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 pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: Vertex3d::SIZE as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 12,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
// M0 sin cull: el depth test ya resuelve la oclusión y nos
// ahorra bugs de winding al sumar mallas. El cull entra en M1+.
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
// Opaco: el cubo reemplaza el fondo vello donde lo cubre.
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
// Geometría → buffers (idiom `to_ne_bytes`, sin bytemuck).
let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
for v in verts {
v.write_to(&mut vbytes);
}
let vbuf = create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
let mut ibytes = Vec::with_capacity(indices.len() * 2);
for &i in indices {
ibytes.extend_from_slice(&i.to_ne_bytes());
}
let ibuf = create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-ubuf"),
size: 64, // una mat4x4<f32>
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-3d-bg"),
layout: &bind_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: ubuf.as_entire_binding(),
}],
});
Self {
pipeline,
vbuf,
ibuf,
index_count: indices.len() as u32,
ubuf,
bind_group,
model: Mat4::IDENTITY,
depth: None,
}
}
/// Ubica la malla en el mundo (`mvp = view_proj · model`). Default identidad.
pub fn set_model(&mut self, model: Mat4) {
self.model = model;
}
/// Reemplaza la geometría (recrea los buffers de vértices/índices). Pensado
/// para mallas que cambian cada frame — p.ej. un **muñeco articulado** cuya
/// pose se rehornea en CPU (limbos rotados) y se vuelve a subir. Las mallas
/// son chicas (decenas-cientos de vértices), así que recrear los buffers por
/// frame es despreciable; el pipeline/uniform/bind-group se conservan.
pub fn set_geometry(&mut self, device: &wgpu::Device, verts: &[Vertex3d], indices: &[u16]) {
let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
for v in verts {
v.write_to(&mut vbytes);
}
self.vbuf =
create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
let mut ibytes = Vec::with_capacity(indices.len() * 2);
for &i in indices {
ibytes.extend_from_slice(&i.to_ne_bytes());
}
self.ibuf =
create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
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.
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());
}
queue.write_buffer(&self.ubuf, 0, &ubytes);
}
/// Dibuja la malla indexada en un pase **ya abierto** (color + depth). Lo usa
/// [`Scene3d`](crate::Scene3d) para compartir el pase con los voxels.
/// Requiere `upload` previo en el mismo frame.
pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, self.vbuf.slice(..));
pass.set_index_buffer(self.ibuf.slice(..), wgpu::IndexFormat::Uint16);
pass.draw_indexed(0..self.index_count, 0, 0..1);
}
/// Dibuja la malla sola sobre `target` (camino standalone, depth propio).
/// Firma compatible con `View::gpu_paint_with`; color preservado
/// (`LoadOp::Load`), depth propio limpiado cada frame.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
camera: &Camera3d,
) {
if w == 0 || h == 0 {
return;
}
self.upload(queue, w as f32 / h as f32, camera);
ensure_depth(&mut self.depth, device, w, h);
let depth_view = &self.depth.as_ref().unwrap().view;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-3d-pass"),
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: 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,
});
self.draw(&mut pass);
}
}
/// Crea un buffer ya inicializado con `data` (sin `wgpu::util::DeviceExt`, para
/// no arrastrar la feature `util`): `mapped_at_creation` + copia + `unmap`.
fn create_buffer_init(
device: &wgpu::Device,
label: &str,
usage: wgpu::BufferUsages,
data: &[u8],
) -> wgpu::Buffer {
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: data.len() as u64,
usage,
mapped_at_creation: true,
});
buf.slice(..).get_mapped_range_mut().copy_from_slice(data);
buf.unmap();
buf
}
const WGSL: &str = r#"
struct Uniforms { mvp: mat4x4<f32> };
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VIn {
@location(0) pos: vec3<f32>,
@location(1) color: vec3<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec3<f32>,
};
@vertex
fn vs(in: VIn) -> VOut {
var out: VOut;
out.clip = u.mvp * vec4<f32>(in.pos, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}
"#;
+186
View File
@@ -0,0 +1,186 @@
//! `Scene3d` — orquestador de una escena 3D **general**: compone, en un único
//! pase con **depth buffer compartido**, el render volumétrico de voxels
//! ([`VoxelRenderer`](crate::VoxelRenderer)) y mallas de triángulos
//! ([`Renderer3d`](crate::Renderer3d)). Es el keystone que vuelve a `llimphi-3d`
//! un motor 3D general y no "sólo voxels": voxels y mallas se **ocluyen
//! correctamente entre sí** porque ambos escriben/testean el mismo depth.
//!
//! La firma de [`Scene3d::render`] es compatible con la closure de
//! `View::gpu_paint_with` (más los renderers a componer): el `Scene3d` posee el
//! depth y abre el pase; cada renderer aporta su `upload` (uniforms) + `draw`
//! (en el pase ya abierto).
use crate::camera::Camera3d;
use crate::renderer::Renderer3d;
use crate::voxel_renderer::VoxelRenderer;
/// Formato del depth buffer de toda la escena 3D (debe coincidir entre el
/// pipeline de voxels, el de mallas y la textura de depth).
pub(crate) const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// Depth attachment cacheado, recreado cuando cambia el tamaño del viewport.
pub(crate) struct DepthBuffer {
pub view: wgpu::TextureView,
w: u32,
h: u32,
}
/// Asegura que `slot` tenga un depth buffer de `w×h` (lo recrea si cambió).
pub(crate) fn ensure_depth(
slot: &mut Option<DepthBuffer>,
device: &wgpu::Device,
w: u32,
h: u32,
) {
if matches!(slot, Some(d) if d.w == w && d.h == h) {
return;
}
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-3d-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 view = tex.create_view(&wgpu::TextureViewDescriptor::default());
*slot = Some(DepthBuffer { view, w, h });
}
/// Escena 3D que comparte un depth buffer entre el pase de voxels y el de
/// mallas. Sólo posee el depth; los renderers los aporta el llamador por
/// referencia en cada frame (así la app conserva la propiedad y los muta).
#[derive(Default)]
pub struct Scene3d {
depth: Option<DepthBuffer>,
}
impl Scene3d {
pub fn new() -> Self {
Self::default()
}
/// Compone la escena sobre `target` (textura intermedia del frame). Primero
/// ray-marchea los voxels (escriben color + profundidad), luego dibuja las
/// mallas (testean contra esa profundidad) — todo en un pase con el depth
/// compartido, limpiado a lejano (`1.0`) al abrirlo. El color se preserva
/// (`LoadOp::Load`) para no pisar la UI vello de abajo.
///
/// Firma compatible con `View::gpu_paint_with` más los renderers a componer.
#[allow(clippy::too_many_arguments)]
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
camera: &Camera3d,
voxel: Option<&VoxelRenderer>,
meshes: &[&Renderer3d],
) {
// El caso por defecto: la escena ocupa todo el target.
self.render_in(
device,
queue,
encoder,
target,
(w, h),
(0.0, 0.0, w as f32, h as f32),
camera,
voxel,
meshes,
);
}
/// Como [`render`](Self::render) pero **confina** la escena a la sub-región
/// `rect = (x, y, w, h)` (en px del target, esquina sup-izq), vía
/// `set_viewport` + `set_scissor_rect`. Es lo que permite montar el 3D en un
/// **panel** de una UI (un canvas que no ocupa toda la ventana) sin pisar el
/// chrome alrededor: la pasada de ray-march/mallas pinta sólo dentro del rect,
/// con el aspect del rect (no el de la ventana). `target`/`viewport` siguen
/// siendo el frame completo (load-preserve del chrome ya rasterizado).
#[allow(clippy::too_many_arguments)]
pub fn render_in(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
rect: (f32, f32, f32, f32),
camera: &Camera3d,
voxel: Option<&VoxelRenderer>,
meshes: &[&Renderer3d],
) {
if w == 0 || h == 0 {
return;
}
let (rx, ry, rw, rh) = rect;
if rw < 1.0 || rh < 1.0 {
return;
}
// El aspect es el del rect (el viewport mapea NDC a esa sub-región).
let aspect = rw / rh;
// Subir uniforms antes de abrir el pase (queue.write_buffer se ordena
// antes del submit).
if let Some(v) = voxel {
v.upload(queue, aspect, camera);
}
for m in meshes {
m.upload(queue, aspect, camera);
}
ensure_depth(&mut self.depth, device, w, h);
let depth_view = &self.depth.as_ref().unwrap().view;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-3d-scene-pass"),
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: 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,
});
// Viewport (mapeo NDC→rect) + scissor (recorte físico al rect, clampeado a
// los límites del attachment).
pass.set_viewport(rx, ry, rw, rh, 0.0, 1.0);
let sx = rx.max(0.0);
let sy = ry.max(0.0);
let sw = (rw.min(w as f32 - sx)).max(0.0) as u32;
let sh = (rh.min(h as f32 - sy)).max(0.0) as u32;
if sw == 0 || sh == 0 {
return;
}
pass.set_scissor_rect(sx as u32, sy as u32, sw, sh);
if let Some(v) = voxel {
v.draw(&mut pass);
}
for m in meshes {
m.draw(&mut pass);
}
}
}
+296
View File
@@ -0,0 +1,296 @@
//! `VoxelGrid` — grid de voxels denso y acotado (CPU side). Cada voxel es
//! RGBA8: `rgb` = color, `a` = ocupación (`0` vacío, `>0` sólido). Se sube a
//! una textura 3D de GPU que el shader ray-march recorre por DDA.
//!
//! M1 es **denso** a propósito (lo más simple que funciona). El salto a sparse
//! (SVO/brickmap, saltar el aire) es M2 — ver `MOTOR-VOXEL.md` §11.2.
//!
//! M3 agrega **dirty tracking**: cada `set`/`clear` expande una caja AABB de la
//! región cambiada. `VoxelRenderer::sync` sube sólo esa sub-caja (fina + bricks
//! gruesos afectados) — la actualización incremental que reemplaza al re-mesh.
/// Caja AABB de voxels cambiados desde el último `take_dirty`: `[xmin, ymin,
/// zmin, xmax, ymax, zmax]` inclusiva.
pub type DirtyBox = [u32; 6];
/// Grid denso de voxels RGBA8. Índice lineal `x + y*dx + z*dx*dy` (x contiguo),
/// que es justo el layout que espera `queue.write_texture` (filas en x, luego y,
/// luego capas en z).
#[derive(Clone)]
pub struct VoxelGrid {
dim: [u32; 3],
data: Vec<[u8; 4]>,
/// AABB de voxels mutados desde el último `take_dirty`. `None` = sin cambios.
dirty: Option<DirtyBox>,
}
impl VoxelGrid {
/// Grid vacío de `dim = [dx, dy, dz]` voxels.
pub fn new(dim: [u32; 3]) -> Self {
let n = (dim[0] * dim[1] * dim[2]) as usize;
Self {
dim,
data: vec![[0, 0, 0, 0]; n],
dirty: None,
}
}
/// Dimensiones `[dx, dy, dz]`.
pub fn dim(&self) -> [u32; 3] {
self.dim
}
#[inline]
fn idx(&self, x: u32, y: u32, z: u32) -> usize {
(x + y * self.dim[0] + z * self.dim[0] * self.dim[1]) as usize
}
#[inline]
fn mark_dirty(&mut self, x: u32, y: u32, z: u32) {
match &mut self.dirty {
None => self.dirty = Some([x, y, z, x, y, z]),
Some(d) => {
d[0] = d[0].min(x);
d[1] = d[1].min(y);
d[2] = d[2].min(z);
d[3] = d[3].max(x);
d[4] = d[4].max(y);
d[5] = d[5].max(z);
}
}
}
/// Toma y limpia la caja de cambios pendientes. `VoxelRenderer::sync` la usa
/// para subir sólo lo mutado. `None` si no hubo cambios desde la última toma.
pub fn take_dirty(&mut self) -> Option<DirtyBox> {
self.dirty.take()
}
/// Descarta los cambios pendientes sin subirlos (tras un upload completo, el
/// estado inicial ya está en GPU).
pub fn reset_dirty(&mut self) {
self.dirty = None;
}
/// Marca un voxel sólido con color `rgb` (alpha = 255). Fuera de rango: no-op.
pub fn set(&mut self, x: u32, y: u32, z: u32, rgb: [u8; 3]) {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.data[i] = [rgb[0], rgb[1], rgb[2], 255];
self.mark_dirty(x, y, z);
}
}
/// Vacía **todos** los voxels y marca el grid entero como dirty (la próxima
/// `VoxelRenderer::sync` re-sube todo). Para regenerar el contenido de una
/// ventana de *streaming* in-place sin reconstruir el renderer.
pub fn clear_all(&mut self) {
for px in &mut self.data {
*px = [0, 0, 0, 0];
}
self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]);
}
/// Vacía un voxel.
pub fn clear(&mut self, x: u32, y: u32, z: u32) {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.data[i] = [0, 0, 0, 0];
self.mark_dirty(x, y, z);
}
}
#[inline]
fn solid(&self, x: u32, y: u32, z: u32) -> bool {
self.data[self.idx(x, y, z)][3] > 0
}
/// `true` si el voxel `(x,y,z)` es sólido. Fuera de rango → `false` (el
/// "afuera" del mundo es aire). Lo usa el raycast de `llimphi-voxel` para
/// picking/edición (mirar → bloque).
#[inline]
pub fn is_solid(&self, x: i32, y: i32, z: i32) -> bool {
if x < 0 || y < 0 || z < 0 {
return false;
}
let (x, y, z) = (x as u32, y as u32, z as u32);
x < self.dim[0] && y < self.dim[1] && z < self.dim[2] && self.solid(x, y, z)
}
/// Color RGBA del voxel `(x,y,z)`, o `None` fuera de rango. `a = 0` = aire.
pub fn get(&self, x: u32, y: u32, z: u32) -> Option<[u8; 4]> {
(x < self.dim[0] && y < self.dim[1] && z < self.dim[2]).then(|| self.data[self.idx(x, y, z)])
}
/// Altura del voxel sólido más alto en la columna `(x, z)` (escaneando de
/// arriba hacia abajo), o `None` si la columna está vacía. Útil para posar
/// una cámara/entidad sobre el terreno sin meterla dentro de la roca.
pub fn height_at(&self, x: u32, z: u32) -> Option<u32> {
if x >= self.dim[0] || z >= self.dim[2] {
return None;
}
(0..self.dim[1]).rev().find(|&y| self.solid(x, y, z))
}
/// Mapa de ocupación grueso por *bricks* de `brick³` voxels (M2): un texel
/// por brick, `255` si el brick contiene algún voxel sólido, `0` si está
/// todo vacío. El shader marcha primero esta grilla gruesa y se salta los
/// bricks vacíos enteros en un paso (empty-space skipping). Devuelve
/// `(dim_grueso, bytes R8)` con índice `cx + cy*cdx + cz*cdx*cdy`.
pub fn coarse_occupancy(&self, brick: u32) -> ([u32; 3], Vec<u8>) {
let b = brick.max(1);
let cdim = [
self.dim[0].div_ceil(b),
self.dim[1].div_ceil(b),
self.dim[2].div_ceil(b),
];
let mut out = vec![0u8; (cdim[0] * cdim[1] * cdim[2]) as usize];
for z in 0..self.dim[2] {
for y in 0..self.dim[1] {
for x in 0..self.dim[0] {
if self.solid(x, y, z) {
let (cx, cy, cz) = (x / b, y / b, z / b);
out[(cx + cy * cdim[0] + cz * cdim[0] * cdim[1]) as usize] = 255;
}
}
}
}
(cdim, out)
}
/// `255` si el brick `(cx,cy,cz)` (tamaño `b`) tiene algún voxel sólido,
/// `0` si está todo vacío. Lo usa el brick pool para decidir si un brick
/// necesita slot.
pub fn brick_occupied(&self, b: u32, cx: u32, cy: u32, cz: u32) -> u8 {
let (x0, y0, z0) = (cx * b, cy * b, cz * b);
for z in z0..(z0 + b).min(self.dim[2]) {
for y in y0..(y0 + b).min(self.dim[1]) {
for x in x0..(x0 + b).min(self.dim[0]) {
if self.solid(x, y, z) {
return 255;
}
}
}
}
0
}
/// Extrae los voxels de un brick `(cx,cy,cz)` de lado `brick` como RGBA
/// plano (`brick³` voxels, x contiguo), padeando con vacío los voxels fuera
/// del grid (bricks de borde cuando `dim` no es múltiplo de `brick`). Es la
/// unidad de subida al *pool* sparse (un slot del atlas = un brick).
pub fn extract_brick(&self, brick: u32, cx: u32, cy: u32, cz: u32) -> Vec<u8> {
let b = brick;
let mut out = vec![0u8; (b * b * b * 4) as usize];
for lz in 0..b {
for ly in 0..b {
for lx in 0..b {
let (x, y, z) = (cx * b + lx, cy * b + ly, cz * b + lz);
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let px = self.data[self.idx(x, y, z)];
let o = ((lx + ly * b + lz * b * b) * 4) as usize;
out[o..o + 4].copy_from_slice(&px);
}
}
}
}
out
}
/// Extrae una sub-caja RGBA contigua `[origin, origin+ext)` para subirla con
/// `queue.write_texture` (M3: upload incremental de la región fina mutada).
pub fn extract_fine(&self, origin: [u32; 3], ext: [u32; 3]) -> Vec<u8> {
let mut out = Vec::with_capacity((ext[0] * ext[1] * ext[2] * 4) as usize);
for z in origin[2]..origin[2] + ext[2] {
for y in origin[1]..origin[1] + ext[1] {
let row = self.idx(origin[0], y, z);
for i in 0..ext[0] as usize {
out.extend_from_slice(&self.data[row + i]);
}
}
}
out
}
/// Recalcula la ocupación gruesa de la caja de bricks `[cmin, cmin+cext)` y
/// la devuelve contigua (R8) para subir sólo esos bricks (M3).
pub fn coarse_region(&self, brick: u32, cmin: [u32; 3], cext: [u32; 3]) -> Vec<u8> {
let b = brick.max(1);
let mut out = Vec::with_capacity((cext[0] * cext[1] * cext[2]) as usize);
for cz in cmin[2]..cmin[2] + cext[2] {
for cy in cmin[1]..cmin[1] + cext[1] {
for cx in cmin[0]..cmin[0] + cext[0] {
out.push(self.brick_occupied(b, cx, cy, cz));
}
}
}
out
}
/// Bytes RGBA planos listos para `queue.write_texture`.
pub fn bytes(&self) -> &[u8] {
// `[u8;4]` es contiguo: reinterpretamos el Vec como bytes planos.
// SAFETY: `[u8;4]` no tiene padding; len*4 bytes válidos.
unsafe {
std::slice::from_raw_parts(self.data.as_ptr() as *const u8, self.data.len() * 4)
}
}
/// Escena de prueba para M1: un piso de 2 capas + una esfera coloreada por
/// posición flotando en el centro. Pone a prueba el DDA (atraviesa aire,
/// pega en piso y en esfera) y el sombreado por normal de cara.
pub fn demo_scene(dim: [u32; 3]) -> Self {
let mut g = Self::new(dim);
let [dx, dy, dz] = dim;
// Piso: 2 capas grises abajo, con un leve damero para leer la perspectiva.
for z in 0..dz {
for x in 0..dx {
let chk = ((x / 4 + z / 4) % 2) == 0;
let base = if chk { 70 } else { 95 };
for y in 0..2 {
g.set(x, y, z, [base, base + 8, base + 16]);
}
}
}
// Esfera centrada, color por posición normalizada.
let cx = dx as f32 / 2.0;
let cy = dy as f32 * 0.55;
let cz = dz as f32 / 2.0;
let r = (dx.min(dy).min(dz) as f32) * 0.3;
for z in 0..dz {
for y in 0..dy {
for x in 0..dx {
let (fx, fy, fz) = (x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5);
let d = ((fx - cx).powi(2) + (fy - cy).powi(2) + (fz - cz).powi(2)).sqrt();
if d <= r {
let rr = (fx / dx as f32 * 255.0) as u8;
let gg = (fy / dy as f32 * 255.0) as u8;
let bb = (fz / dz as f32 * 255.0) as u8;
g.set(x, y, z, [rr, gg, bb]);
}
}
}
}
// Pilares: dan rincones para el AO y proyectan/reciben sombras.
let pillars: [(u32, u32, u32, [u8; 3]); 3] = [
(dx / 5, dz / 4, dy * 7 / 10, [200, 120, 90]),
(dx * 4 / 5, dz / 3, dy / 2, [110, 170, 120]),
(dx / 3, dz * 4 / 5, dy * 3 / 5, [120, 130, 210]),
];
for (px, pz, ph, col) in pillars {
for y in 2..(2 + ph).min(dy) {
for dxx in 0..3u32 {
for dzz in 0..3u32 {
g.set(px + dxx, y, pz + dzz, col);
}
}
}
}
// Estado inicial: el upload completo lo cubre, no es "mutación".
g.reset_dirty();
g
}
}
File diff suppressed because it is too large Load Diff