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
+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();
}