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
+169
View File
@@ -0,0 +1,169 @@
//! Demo headless de las **edades cuantizadas** del personaje: los 5 estadios
//! (bebé/niño/joven/adulto/viejo) parados en fila sobre arena, para ver la
//! progresión de proporciones (el bebé cabezón → el adulto alto). El corto arranca
//! mostrando al **niño** recién nacido.
//!
//! `cargo run -p llimphi-voxel --example ages_demo --release` → `/tmp/ages.png`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{Actor, Age, Material};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Piso plano de arena (aísla los cuerpos; sin relieve que distraiga).
let dim = [44u32, 16, 20];
let mut grid = VoxelGrid::new(dim);
for z in 0..dim[2] {
for x in 0..dim[0] {
grid.set(x, 0, z, Material::Sand.color());
grid.set(x, 1, z, Material::Sand.color());
}
}
grid.reset_dirty();
let floor_top = 2.0; // y del suelo (sobre las 2 capas)
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.5, 0.7, 0.4];
vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0 };
// 5 actores, uno por edad, espaciados en X. El grid se centra en el origen, así
// la coord de mundo del actor = local dim/2.
let ages = [Age::Baby, Age::Child, Age::Teen, Age::Adult, Age::Elder];
let palettes: [([f32; 3], [f32; 3]); 5] = [
([0.90, 0.74, 0.60], [0.86, 0.40, 0.42]), // bebé
([0.88, 0.70, 0.56], [0.36, 0.62, 0.82]), // niño
([0.86, 0.68, 0.54], [0.40, 0.74, 0.46]), // joven
([0.84, 0.66, 0.52], [0.82, 0.66, 0.30]), // adulto
([0.82, 0.64, 0.50], [0.62, 0.52, 0.74]), // viejo
];
let mut actor_r = Vec::new();
for (k, (age, (skin, shirt))) in ages.iter().zip(palettes).enumerate() {
let lx = 12.0 + k as f32 * 5.0; // fila apretada centrada en el grid
let wx = lx - dim[0] as f32 / 2.0;
let wz = 0.0; // centro en z (mundo)
let mut a = Actor::new(Vec3::new(wx, floor_top - dim[1] as f32 / 2.0, wz), std::f32::consts::PI)
.with_age(*age)
.with_colors(skin, shirt, [0.20, 0.22, 0.30]);
a.look_at(None);
let (v, i) = a.mesh();
let mut r = Renderer3d::new(&hal.device, FMT);
r.set_geometry(&hal.device, &v, &i);
r.set_model(a.model());
actor_r.push(r);
}
// Cámara frontal baja y CERCA, encuadrando la fila a la altura del pecho.
let feet_y = floor_top - dim[1] as f32 / 2.0;
let camera = Camera3d::orbit(Vec3::new(0.0, feet_y + 1.0, 0.0), 0_f32.to_radians(), 8_f32.to_radians(), 17.0);
let refs: Vec<&Renderer3d> = actor_r.iter().collect();
let mut scene = Scene3d::new();
let pixels = render(&hal, &mut renderer, &mut scene, &mut vr, &refs, &camera);
write_png(&pixels, "/tmp/ages.png");
eprintln!("escrito /tmp/ages.png (bebé · niño · joven · adulto · viejo)");
}
fn render(
hal: &Hal,
renderer: &mut Renderer,
scene: &mut Scene3d,
vr: &mut VoxelRenderer,
meshes: &[&Renderer3d],
camera: &Camera3d,
) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let 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("ages") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), camera, Some(vr), meshes);
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();
}
+196
View File
@@ -0,0 +1,196 @@
//! Demo headless de la **secuencia de nacimiento** (modos de cámara): un montaje
//! 2×2 de cuatro momentos —
//! 1. la cámara cae del cielo mirando abajo (ve el huevo),
//! 2. casi tocando suelo (el huevo se raja),
//! 3. recién nacido: la cámara sale del sujeto,
//! 4. plano de seguimiento detrás del niño.
//!
//! `cargo run -p llimphi-voxel --example birth_demo --release` → `/tmp/birth.png`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{Age, BirthSequence, Egg, Hatchling, Material};
// Cada cuadro del montaje (mitad de un lienzo 960×540 → 2×2).
const TW: u32 = 480;
const TH: u32 = 270;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Piso plano de arena, centrado en el origen.
let dim = [48u32, 28, 48];
let mut grid = VoxelGrid::new(dim);
for z in 0..dim[2] {
for x in 0..dim[0] {
grid.set(x, 0, z, Material::Sand.color());
grid.set(x, 1, z, Material::Sand.color());
}
}
grid.reset_dirty();
let feet_y = 2.0 - dim[1] as f32 / 2.0; // suelo en mundo
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.5, 0.7, 0.4];
vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0 };
// Huevo en el centro, sobre el suelo. La secuencia hace caer la cámara sobre él.
let egg = Egg::new(Vec3::new(0.0, feet_y, 0.0), 1.4, Hatchling::human(Age::Baby));
let seq = BirthSequence::new(egg);
// Cuatro instantes clave de la secuencia.
let ts = [
seq.t_land * 0.35, // cayendo, alto
seq.t_land * 0.93, // casi en el suelo, el huevo se raja
seq.t_land + seq.t_pull * 0.5, // saliendo del sujeto
seq.duration(), // seguimiento detrás del niño
];
// Lienzo final 2×2.
let fw = TW * 2;
let fh = TH * 2;
let mut canvas = vec![0u8; (fw * fh * 4) as usize];
for (idx, &t) in ts.iter().enumerate() {
let mut egg_t = seq.egg;
egg_t.hatch = seq.hatch(t);
let camera = seq.camera(t);
let mut meshes: Vec<Renderer3d> = Vec::new();
let (ev, ei) = egg_t.mesh();
let mut er = Renderer3d::new(&hal.device, FMT);
er.set_geometry(&hal.device, &ev, &ei);
er.set_model(egg_t.model());
meshes.push(er);
// El recién nacido aparece una vez que el huevo está bien abierto.
if egg_t.hatch > 0.5 {
let baby = seq.newborn();
let (bv, bi) = baby.mesh();
let mut br = Renderer3d::new(&hal.device, FMT);
br.set_geometry(&hal.device, &bv, &bi);
br.set_model(baby.model());
meshes.push(br);
}
let refs: Vec<&Renderer3d> = meshes.iter().collect();
let mut scene = Scene3d::new();
let cam = {
let mut c = camera;
c.fovy_rad = 55_f32.to_radians();
c
};
let tile = render(&hal, &mut renderer, &mut scene, &mut vr, &refs, &cam);
// Pegar el cuadro en su celda del 2×2.
let (cx, cy) = ((idx as u32 % 2) * TW, (idx as u32 / 2) * TH);
for row in 0..TH {
let src = (row * TW * 4) as usize;
let dst = (((cy + row) * fw + cx) * 4) as usize;
canvas[dst..dst + (TW * 4) as usize].copy_from_slice(&tile[src..src + (TW * 4) as usize]);
}
}
write_png(&canvas, fw, fh, "/tmp/birth.png");
eprintln!("escrito /tmp/birth.png (caída · rajadura · nace · seguimiento)");
}
fn render(
hal: &Hal,
renderer: &mut Renderer,
scene: &mut Scene3d,
vr: &mut VoxelRenderer,
meshes: &[&Renderer3d],
camera: &llimphi_3d::Camera3d,
) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: TW, height: TH, 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());
// Fondo cielo (sin niebla los misses del voxel descartan a este color base).
renderer
.render_to_view(hal, &vello::Scene::new(), &view, TW, TH, Color::from_rgba8(150, 184, 224, 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("birth") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (TW, TH), camera, Some(vr), meshes);
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 = (TW * 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 * TH 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(TH),
},
},
wgpu::Extent3d { width: TW, height: TH, 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((TW * TH * 4) as usize);
for row in 0..TH as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], w: u32, h: u32, 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();
}
+145
View File
@@ -0,0 +1,145 @@
//! Demo headless del **creador de mundos**: rinde la receta del **desierto**
//! ([`WorldRecipe::desert`]) — llano de arena, pocas montañas, pocos ríos, cactus.
//! Es el mundo de apertura del corto.
//!
//! `cargo run -p llimphi-voxel --example desert_demo --release -- [dim_xz] [seed]`
//! → `/tmp/desert.png`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Scene3d, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::WorldRecipe;
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(160);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(7);
let dy: u32 = (dim_xz * 4 / 10).max(48);
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let recipe = WorldRecipe::desert(seed);
let grid = recipe.generate(dim);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.62, 0.42, 0.28]; // sol más bajo → relieve/sombras de las dunas y cactus
vr.atmosphere = Atmosphere {
sky_zenith: [92, 146, 208],
sky_horizon: [228, 206, 162], // horizonte arenoso/caluroso
fog_density: 0.22 / dim_xz as f32, // niebla suave: no lavar el llano
};
// Cámara baja, en 3/4, para leer el llano + los cactus recortados contra el cielo.
let camera = Camera3d::orbit(
Vec3::new(0.0, dy as f32 * -0.18, 0.0),
38_f32.to_radians(),
14_f32.to_radians(),
dim_xz as f32 * 1.5,
);
let mut scene = Scene3d::new();
let pixels = render(&hal, &mut renderer, &mut scene, &mut vr, &camera);
write_png(&pixels, "/tmp/desert.png");
eprintln!("escrito /tmp/desert.png (desierto {dim_xz}³ seed {seed})");
}
fn render(
hal: &Hal,
renderer: &mut Renderer,
scene: &mut Scene3d,
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("desert") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), camera, Some(vr), &[]);
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();
}
+165
View File
@@ -0,0 +1,165 @@
//! Demo headless del **objeto potencial**: el huevo en tres momentos — intacto,
//! rajándose, y abierto con el **bebé recién nacido** al lado. Es el corazón de la
//! apertura del corto (el huevo nace en el desierto).
//!
//! `cargo run -p llimphi-voxel --example egg_demo --release` → `/tmp/egg.png`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{Age, Egg, Hatchling, Material};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Piso plano de arena.
let dim = [44u32, 16, 20];
let mut grid = VoxelGrid::new(dim);
for z in 0..dim[2] {
for x in 0..dim[0] {
grid.set(x, 0, z, Material::Sand.color());
grid.set(x, 1, z, Material::Sand.color());
}
}
grid.reset_dirty();
let feet_y = 2.0 - dim[1] as f32 / 2.0; // y del suelo en mundo
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.5, 0.7, 0.4];
vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0 };
let mut meshes: Vec<Renderer3d> = Vec::new();
let push = |meshes: &mut Vec<Renderer3d>, hal: &Hal, v: &[_], i: &[u16], model| {
let mut r = Renderer3d::new(&hal.device, FMT);
r.set_geometry(&hal.device, v, i);
r.set_model(model);
meshes.push(r);
};
// Tres huevos en fila: intacto, rajándose, abierto.
let xs = [-11.0_f32, 0.0, 11.0];
let hatches = [0.0_f32, 0.55, 1.0];
for (x, h) in xs.iter().zip(hatches) {
let mut egg = Egg::new(Vec3::new(*x, feet_y, 0.0), 1.6, Hatchling::human(Age::Baby));
egg.hatch = h;
let (v, i) = egg.mesh();
push(&mut meshes, &hal, &v, &i, egg.model());
// En el abierto, el bebé recién nacido sale, un paso al frente.
if egg.is_open() {
let mut baby = egg.newborn();
baby.pos = egg.pos + Vec3::new(0.0, 0.0, 1.4); // un paso hacia la cámara
baby.facing = std::f32::consts::PI;
let (bv, bi) = baby.mesh();
push(&mut meshes, &hal, &bv, &bi, baby.model());
}
}
let camera = Camera3d::orbit(Vec3::new(0.0, feet_y + 1.0, 0.0), 0_f32.to_radians(), 10_f32.to_radians(), 19.0);
let refs: Vec<&Renderer3d> = meshes.iter().collect();
let mut scene = Scene3d::new();
let pixels = render(&hal, &mut renderer, &mut scene, &mut vr, &refs, &camera);
write_png(&pixels, "/tmp/egg.png");
eprintln!("escrito /tmp/egg.png (intacto · rajándose · abierto + bebé)");
}
fn render(
hal: &Hal,
renderer: &mut Renderer,
scene: &mut Scene3d,
vr: &mut VoxelRenderer,
meshes: &[&Renderer3d],
camera: &Camera3d,
) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let 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("egg") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), camera, Some(vr), meshes);
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();
}
+186
View File
@@ -0,0 +1,186 @@
//! Demo headless de la mecánica núcleo de juego voxel: **mirar → romper**.
//!
//! Renderiza el terreno, tira un [`raycast`] desde la cámara hacia el centro de
//! la vista hasta el primer voxel sólido, y **cava un cráter** (vacía los voxels
//! en un radio del impacto). Cada edición sube SÓLO su sub-caja vía
//! `VoxelRenderer::sync` (no re-sube el mundo). Vuelca PNG antes/después e
//! imprime los bytes subidos vs el grid completo.
//!
//! `cargo run -p llimphi-voxel --example raycast_edit --release -- [dim_xz] [seed]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{raycast, terrain};
const W: u32 = 880;
const H: u32 = 560;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(160);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(7);
let dy = (dim_xz * 4 / 10).max(48);
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut grid = terrain(dim, seed);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.55, 0.6, 0.3];
vr.atmosphere = Atmosphere {
sky_zenith: [64, 118, 196],
sky_horizon: [202, 218, 236],
fog_density: 0.5 / dim_xz as f32,
};
let inter = make_target(&hal);
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let camera = Camera3d::orbit(
Vec3::new(0.0, dy as f32 * 0.30, 0.0),
45_f32.to_radians(),
14_f32.to_radians(),
dim_xz as f32 * 0.78,
);
// (antes)
draw(&hal, &mut renderer, &mut vr, &inter, &inter_view, &camera, "/tmp/edit_before.png");
// Rayo desde la cámara hacia el centro de la vista. La grilla está centrada
// en el origen → origen del rayo en grilla = eye_mundo + dim/2.
let dimv = Vec3::new(dim[0] as f32, dim[1] as f32, dim[2] as f32);
let ro = camera.eye + dimv * 0.5;
let rd = (camera.target - camera.eye).normalize();
match raycast(&grid, [ro.x, ro.y, ro.z], [rd.x, rd.y, rd.z], dim_xz as f32 * 3.0) {
Some(hit) => {
eprintln!("impacto en {:?} (cara {:?}, dist {:.1})", hit.cell, hit.normal, hit.dist);
// Cavar un cráter esférico alrededor del impacto.
let r = 12i32;
let [cx, cy, cz] = hit.cell;
for dz in -r..=r {
for dyy in -r..=r {
for dx in -r..=r {
if dx * dx + dyy * dyy + dz * dz <= r * r {
let (x, y, z) = (cx + dx, cy + dyy, cz + dz);
if x >= 0 && y >= 0 && z >= 0 {
grid.clear(x as u32, y as u32, z as u32);
}
}
}
}
}
let uploaded = vr.sync(&hal.queue, &mut grid);
let full = dim[0] * dim[1] * dim[2] * 4;
eprintln!(
"cráter r={r} → sync subió {} KiB ({:.3}% del grid completo de {} KiB) — incremental",
uploaded / 1024,
uploaded as f32 / full as f32 * 100.0,
full / 1024,
);
}
None => eprintln!("el rayo no pegó terreno (ajustá cámara)"),
}
// (después)
draw(&hal, &mut renderer, &mut vr, &inter, &inter_view, &camera, "/tmp/edit_after.png");
eprintln!("escrito /tmp/edit_before.png y /tmp/edit_after.png ({W}x{H})");
}
fn make_target(hal: &Hal) -> wgpu::Texture {
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: &[],
})
}
#[allow(clippy::too_many_arguments)]
fn draw(
hal: &Hal,
renderer: &mut Renderer,
vr: &mut VoxelRenderer,
target: &wgpu::Texture,
target_view: &wgpu::TextureView,
camera: &Camera3d,
out: &str,
) {
renderer
.render_to_view(hal, &vello::Scene::new(), target_view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel") });
vr.render(&hal.device, &hal.queue, &mut enc, target_view, (W, H), camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(hal, target, 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();
}
+155
View File
@@ -0,0 +1,155 @@
//! Demo headless de M6 (primera rebanada): **world-gen procedural + atmósfera**.
//!
//! Genera un paisaje voxel grande por ruido fractal ([`llimphi_3d::terrain`]) y
//! lo ray-marchea con **cielo gradiente + niebla por distancia** ([`Atmosphere`])
//! — lo que hace legible el borde lejano de un mundo grande (sin niebla, el
//! horizonte del terreno se ve como un muro recortado). Imprime además el ahorro
//! de memoria del brick pool sparse: un mundo es casi todo aire, así que el pool
//! ocupa una fracción del grid denso.
//!
//! `cargo run -p llimphi-3d --example terrain_demo --release -- [dim_xz] [seed]`
//! → escribe /tmp/m6_terrain_{a,b,c}.png (tres ángulos de órbita).
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_voxel::terrain;
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_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(192);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(1337);
let dy: u32 = (dim_xz * 4 / 10).max(48); // mundo "ancho y bajo": continente, no torre.
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let grid = terrain(dim, seed);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
let (used, total) = vr.brick_usage();
let (pool, dense) = vr.memory_bytes();
eprintln!(
"terreno {}×{}×{} (seed {seed}) — brick pool {used}/{total} bricks ({:.1}%) → {} KiB vs denso {} KiB ({:.1}× menos)",
dim[0], dim[1], dim[2],
used as f32 / total as f32 * 100.0,
pool / 1024,
dense / 1024,
dense as f32 / pool.max(1) as f32,
);
// Atmósfera diurna: sol bajo (luz rasante = relieve marcado), niebla suave
// escalada al tamaño del mundo (lo lejano desvanece, lo cercano queda nítido).
vr.sun_dir = [0.55, 0.55, 0.35];
vr.atmosphere = Atmosphere {
sky_zenith: [64, 118, 196],
sky_horizon: [200, 216, 234],
fog_density: 0.5 / dim_xz 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());
let d = dim_xz as f32;
for (tag, yaw) in [("a", 35.0_f32), ("b", 120.0), ("c", 230.0)] {
// (1) Fondo vello (lo tapa la atmósfera del pase voxel, pero dejamos el
// mismo orden que el runtime: [vello base] → [GPU 3D]).
let base = vello::Scene::new();
renderer
.render_to_view(&hal, &base, &inter_view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("render base");
// (2) Pase voxel. Órbita mirando un poco por encima del centro para que
// entre cielo en cuadro; pitch bajo = vista de paisaje.
let camera = Camera3d::orbit(
Vec3::new(0.0, dy as f32 * 0.12, 0.0),
yaw.to_radians(),
22_f32.to_radians(),
d * 1.45,
);
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());
let out = format!("/tmp/m6_terrain_{tag}.png");
write_png(&hal, &inter, &out);
eprintln!("escrito {out} ({W}x{H}, yaw={yaw}°)");
}
}
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();
}
@@ -0,0 +1,158 @@
//! Demo headless de M6 — **vuelo por dentro del mundo** (cámara libre).
//!
//! Complementa a `terrain_demo` (órbita desde afuera): acá la [`Camera3d::fly`]
//! recorre el paisaje procedural a baja altura, siguiendo el relieve
//! ([`VoxelGrid::height_at`]) para no meterse dentro de la roca, con la misma
//! atmósfera (cielo + niebla). Es el plano "showreel" del motor y ejercita el
//! ray-march DDA **desde adentro** de la grilla (no sólo orbitándola).
//!
//! `cargo run -p llimphi-3d --example terrain_flythrough --release -- [dim_xz] [seed] [frames]`
//! → escribe /tmp/m6_fly_##.png
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_voxel::terrain;
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_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(192);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(1337);
let frames: u32 = std::env::args().nth(3).and_then(|s| s.parse().ok()).unwrap_or(6);
let dy: u32 = (dim_xz * 4 / 10).max(48);
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let grid = terrain(dim, seed);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.55, 0.5, 0.32];
vr.atmosphere = Atmosphere {
sky_zenith: [64, 118, 196],
sky_horizon: [202, 218, 236],
fog_density: 0.9 / dim_xz as f32, // un poco más densa: estamos adentro
};
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 (dxf, dyf, dzf) = (dim[0] as f32, dim[1] as f32, dim[2] as f32);
let half_z = dzf * 0.5;
// Altura del terreno (en y de MUNDO) sobre una ventana hacia adelante, para
// que el vuelo suba por encima de los picos que vienen.
let ground_world_y = |gx: f32, gz: f32| -> f32 {
let mut hmax = 0u32;
let gxi = gx.clamp(0.0, dxf - 1.0) as u32;
for dz in 0..16u32 {
let gzi = (gz as i32 + dz as i32).clamp(0, dim[2] as i32 - 1) as u32;
if let Some(h) = grid.height_at(gxi, gzi) {
hmax = hmax.max(h);
}
}
hmax as f32 - dyf * 0.5 // grid y → mundo y (grilla centrada en origen)
};
for i in 0..frames {
let t = if frames > 1 { i as f32 / (frames - 1) as f32 } else { 0.0 };
// Avanza en +Z (yaw=0 mira +Z); curva suave en X.
let pz = (-0.8 + 1.5 * t) * half_z;
let px = (t * std::f32::consts::PI).sin() * dxf * 0.18;
let gx = px + dxf * 0.5;
let gz = pz + dzf * 0.5;
let py = ground_world_y(gx, gz) + dyf * 0.16 + 6.0; // despejado sobre el relieve
let yaw = (px / dxf) * -0.6; // mira hacia donde curva
let camera = Camera3d::fly(Vec3::new(px, py, pz), yaw, -0.12);
let base = vello::Scene::new();
renderer
.render_to_view(&hal, &base, &inter_view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("render base");
let 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());
let out = format!("/tmp/m6_fly_{i:02}.png");
write_png(&hal, &inter, &out);
eprintln!("escrito {out} (eye=[{px:.0},{py:.0},{pz:.0}], yaw={:.2})", yaw);
}
}
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();
}
+237
View File
@@ -0,0 +1,237 @@
//! Demo headless de M6 — **LOD del horizonte**: más allá de la ventana voxel fina,
//! una **malla gruesa** del terreno circundante ([`lod_skirt`]) muestra colinas
//! lejanas en vez de un muro de niebla. Voxel cerca / malla-LOD lejos, compuestos
//! por el depth compartido de [`Scene3d`].
//!
//! Rinde dos PNG para el contraste:
//! - `/tmp/m6_lod_off.png` — sólo voxels (el terreno se corta en el borde de la
//! ventana; la niebla tapa el vacío = "muro").
//! - `/tmp/m6_lod_on.png` — voxels + falda LOD (el horizonte sigue con relieve).
//!
//! `cargo run -p llimphi-voxel --example terrain_lod --release -- [dim_xz] [seed]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Renderer3d, Scene3d, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{lod_skirt, lod_skirt_pyramid, terrain, LodParams, LodRing};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(128);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(1337);
let dy: u32 = (dim_xz * 4 / 10).max(48);
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Ventana voxel fina en mundo [0, dim_xz); su centro de mundo es (dim/2, dim/2)
// y se renderiza centrada en el origen (rendered = local dim/2).
let grid = terrain(dim, seed);
let sun = [0.5, 0.45, 0.32];
let atmo = Atmosphere {
sky_zenith: [70, 120, 196],
sky_horizon: [200, 216, 234],
fog_density: 1.1 / dim_xz as f32,
};
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = sun;
vr.atmosphere = atmo;
// Falda LOD alrededor: centro = (dim/2, dim/2) en mundo, hueco = la ventana.
let center = [dim_xz as i32 / 2, dim_xz as i32 / 2];
let p = LodParams {
center_xz: center,
window_xz: dim_xz,
span: dim_xz as i32 * 3, // horizonte a ~3 ventanas de distancia
stride: 6,
sky_horizon: atmo.sky_horizon,
fog_density: atmo.fog_density,
sun_dir: sun,
};
let (verts, indices) = lod_skirt(&p, dim, seed);
eprintln!("falda LOD: {} vértices, {} triángulos", verts.len(), indices.len() / 3);
let mut skirt = Renderer3d::new(&hal.device, FMT);
skirt.set_geometry(&hal.device, &verts, &indices);
let mut scene = Scene3d::new();
// Cámara elevada cerca del borde -Z mirando hacia +Z (el horizonte): ve la
// ventana fina cerca y, detrás, la falda lejana.
let mut hmax = 0u32;
for z in (0..dim[2]).step_by(4) {
for x in (0..dim[0]).step_by(4) {
if let Some(h) = grid.height_at(x, z) {
hmax = hmax.max(h);
}
}
}
let eye_y = (hmax as f32 - dy as f32 * 0.5) + dy as f32 * 0.30 + 6.0;
let camera = Camera3d::fly(Vec3::new(0.0, eye_y, -(dim[2] as f32) * 0.46), 0.0, -0.13);
// Toma 1: sólo voxels (sin falda) — horizonte = niebla/vacío.
let off = render(&hal, &mut renderer, &mut scene, &mut vr, &[], &camera);
write_png(&off, "/tmp/m6_lod_off.png");
// Toma 2: voxels + falda LOD (un nivel) — horizonte con relieve.
let on = render(&hal, &mut renderer, &mut scene, &mut vr, &[&skirt], &camera);
write_png(&on, "/tmp/m6_lod_on.png");
eprintln!("escritos /tmp/m6_lod_off.png (sin LOD) y /tmp/m6_lod_on.png (con LOD)");
// --- Un nivel vs PIRÁMIDE multi-nivel, con niebla baja para que se vea hasta
// dónde llega cada uno (con la niebla normal el horizonte se taparía igual). El
// único nivel se corta a ~3 ventanas; la pirámide llega a ~16.
let low_fog = 0.30 / dim_xz as f32;
vr.atmosphere = Atmosphere { fog_density: low_fog, ..atmo };
// Cámara aérea (alta, mirando hacia abajo) para esta comparación: así el terreno
// lejano se despliega en el suelo en vez de apretarse contra la línea del horizonte
// — se ve **hasta dónde** llega cada falda.
let cam_high = Camera3d::fly(
Vec3::new(0.0, eye_y + dy as f32 * 2.2, -(dim[2] as f32) * 0.5),
0.0,
-0.62,
);
let p_single = LodParams { fog_density: low_fog, ..clone_params(&p) };
let (sv, si) = lod_skirt(&p_single, dim, seed);
let mut single = Renderer3d::new(&hal.device, FMT);
single.set_geometry(&hal.device, &sv, &si);
let single_shot = render(&hal, &mut renderer, &mut scene, &mut vr, &[&single], &cam_high);
write_png(&single_shot, "/tmp/m6_lod_single.png");
let rings = [
LodRing { stride: 6, span: dim_xz as i32 * 3 },
LodRing { stride: 16, span: dim_xz as i32 * 8 },
LodRing { stride: 40, span: dim_xz as i32 * 16 },
];
let p_pyr = LodParams { fog_density: low_fog, ..clone_params(&p) };
let meshes = lod_skirt_pyramid(&p_pyr, dim, seed, &rings);
let total_tris: usize = meshes.iter().map(|(_, i)| i.len() / 3).sum();
eprintln!("pirámide LOD: {} anillos, {} triángulos a {} voxels de alcance", meshes.len(), total_tris, dim_xz as i32 * 16);
let renderers: Vec<Renderer3d> = meshes
.iter()
.map(|(v, i)| {
let mut r = Renderer3d::new(&hal.device, FMT);
r.set_geometry(&hal.device, v, i);
r
})
.collect();
let refs: Vec<&Renderer3d> = renderers.iter().collect();
let pyr_shot = render(&hal, &mut renderer, &mut scene, &mut vr, &refs, &cam_high);
write_png(&pyr_shot, "/tmp/m6_lod_pyramid.png");
eprintln!("escritos /tmp/m6_lod_single.png (1 nivel) y /tmp/m6_lod_pyramid.png (multi-nivel)");
}
/// Copia los campos de un [`LodParams`] (no deriva `Clone` a propósito por el
/// `sun_dir`; acá lo replicamos para variar sólo la niebla).
fn clone_params(p: &LodParams) -> LodParams {
LodParams {
center_xz: p.center_xz,
window_xz: p.window_xz,
span: p.span,
stride: p.stride,
sky_horizon: p.sky_horizon,
fog_density: p.fog_density,
sun_dir: p.sun_dir,
}
}
#[allow(clippy::too_many_arguments)]
fn render(
hal: &Hal,
renderer: &mut Renderer,
scene: &mut Scene3d,
vr: &mut VoxelRenderer,
meshes: &[&Renderer3d],
camera: &Camera3d,
) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let 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("lod") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), camera, Some(vr), meshes);
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();
}
+351
View File
@@ -0,0 +1,351 @@
//! Demo headless de M6 — **streaming toroidal**: una ventana voxel acotada que
//! se desliza por un mundo procedural **ilimitado** ([`WorldStream`]) re-subiendo
//! a la GPU **sólo la franja de bricks que entra** (no la ventana entera, ni
//! reconstruyendo el renderer): la textura del brick pool es un **ring buffer**
//! (`world_brick mod cdim`) y el shader envuelve la celda lógica con un offset de
//! origen ([`VoxelRenderer::scroll_to`]).
//!
//! La cámara se queda quieta en el **centro** de la ventana mirando adelante; lo
//! que avanza es el **foco de mundo** (`focus_z`), que marcha mucho más allá del
//! tamaño de la ventana. Cada cuadro [`WorldStream::follow`] reubica la ventana y
//! `scroll_to` sube sólo la franja → cada PNG es **paisaje nuevo y distinto** sin
//! "muro" ni repetición, y el reporte muestra que se suben **KiB**, no MiB.
//!
//! **Prueba de paridad**: en el último cuadro se compara el render scrolleado
//! contra un renderer **reconstruido de cero** en ese mismo origen — deben dar
//! la **misma imagen** (el toroidal no degrada el contenido). Falla con assert si
//! divergen.
//!
//! `cargo run -p llimphi-voxel --example terrain_streaming --release -- [dim_xz] [seed] [frames]`
//! → escribe /tmp/m6_stream_##.png
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{fill_terrain_window, WorldStream};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(160);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(1337);
let frames: u32 = std::env::args().nth(3).and_then(|s| s.parse().ok()).unwrap_or(6);
let dy: u32 = (dim_xz * 4 / 10).max(48);
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Ventana de mundo (paso = lado de brick). Centro inicial en mundo (0,0).
let step = llimphi_3d::VOXEL_BRICK;
let mut stream = WorldStream::new(dim, seed, 0, 0, step);
// El renderer se construye UNA vez, desde un grid en **mundo (0,0)** (donde
// `brick_origin = 0` es consistente con el ring buffer: celda física P ⟺
// world_brick ≡ P mod cdim). El primer `scroll_to` lo lleva al origen real
// del stream; de ahí en más, sólo franjas.
let mut zero = VoxelGrid::new(dim);
fill_terrain_window(&mut zero, [0, 0], seed);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &zero);
vr.sun_dir = [0.55, 0.5, 0.32];
vr.atmosphere = Atmosphere {
sky_zenith: [64, 118, 196],
sky_horizon: [202, 218, 236],
fog_density: 0.7 / dim_xz as f32,
};
let (_, total_bricks) = vr.brick_usage();
let full_pool_kib = vr.memory_bytes().0 / 1024;
let inter = make_target(&hal);
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let mut last_pixels: Vec<u8> = Vec::new();
for i in 0..frames {
// El foco de mundo marcha en +Z; en pocos cuadros recorre varias ventanas
// (cada PNG es mundo nuevo) pero cada paso entra sólo ~¼ de ventana, así
// se ve que el scroll sube una **franja**, no el mundo entero.
let focus_z = i as i32 * (dim_xz as i32 / 4);
stream.follow(0, focus_z);
// Streaming toroidal: sube sólo la franja de bricks que entró.
let uploaded = vr.scroll_to(&hal.device, &hal.queue, stream.origin_voxel(), stream.grid());
// Cámara: sobre los picos de la ventana, atrás, mirando +Z hacia abajo.
let camera = camera_for(stream.grid(), dim);
last_pixels = render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut vr, &camera);
let out = format!("/tmp/m6_stream_{i:02}.png");
encode_png(&last_pixels, W, H, &out);
let [ox, oz] = stream.origin();
let (used, _) = vr.brick_usage();
eprintln!(
"{out} — foco_z={focus_z}, origen=({ox},{oz}), subido {} KiB de {} KiB de ventana ({}/{} bricks vivos)",
uploaded / 1024,
full_pool_kib,
used,
total_bricks,
);
}
// --- Paridad: el render scrolleado del último cuadro debe coincidir con un
// renderer RECONSTRUIDO de cero en ese mismo origen (mismo contenido lógico).
let camera = camera_for(stream.grid(), dim);
let mut fresh = VoxelRenderer::new(&hal.device, &hal.queue, FMT, stream.grid());
fresh.sun_dir = vr.sun_dir;
fresh.atmosphere = vr.atmosphere;
let fresh_pixels = render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut fresh, &camera);
let (max_d, mean_d) = diff(&last_pixels, &fresh_pixels);
encode_png(&fresh_pixels, W, H, "/tmp/m6_stream_fresh.png");
eprintln!(
"PARIDAD scroll-vs-rebuild: max |Δ|={max_d}, media |Δ|={mean_d:.3} (0 = idéntico) → /tmp/m6_stream_fresh.png"
);
assert!(
max_d <= 2,
"el streaming toroidal divergió del rebuild (max |Δ|={max_d})"
);
eprintln!("PARIDAD OK — el toroidal rinde idéntico al rebuild, subiendo sólo la franja.");
// --- Pool-grow: arrancar con un pool minúsculo (grid vacío) y scrollear a una
// ventana densa **lejana** (sin solape → todo entra, sin bulk stale) fuerza al
// brick pool a crecer. La paridad vs un rebuild prueba que creció sin huecos.
let far = [dim_xz as i32 * 2, 0, dim_xz as i32 * 2]; // brick-aligned, lejos del origen
let mut far_grid = VoxelGrid::new(dim);
fill_terrain_window(&mut far_grid, [far[0], far[2]], seed);
let mut tiny = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &VoxelGrid::new(dim));
tiny.sun_dir = vr.sun_dir;
tiny.atmosphere = vr.atmosphere;
let cap0 = tiny.pool_capacity();
tiny.scroll_to(&hal.device, &hal.queue, far, &far_grid);
let cap1 = tiny.pool_capacity();
let cam = camera_for(&far_grid, dim);
let tiny_px = render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut tiny, &cam);
let mut far_fresh = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &far_grid);
far_fresh.sun_dir = vr.sun_dir;
far_fresh.atmosphere = vr.atmosphere;
let far_fresh_px = render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut far_fresh, &cam);
let (gmax, _) = diff(&tiny_px, &far_fresh_px);
encode_png(&tiny_px, W, H, "/tmp/m6_stream_grow.png");
eprintln!("POOL-GROW: pool {cap0}{cap1} slots, render vs rebuild max|Δ|={gmax}");
assert!(cap1 > cap0, "el pool no creció (arrancó con capacidad suficiente)");
assert!(gmax <= 2, "el pool creció con huecos (max|Δ|={gmax})");
eprintln!("POOL-GROW OK — el pool creció y la ventana densa quedó completa, sin huecos.");
// --- Persistencia de ediciones: un pilar magenta editado sobre la columna de
// mundo (0,0) debe seguir ahí tras alejarse miles de voxels y volver (el
// terreno se regenera desde la semilla, el overlay de `edits` lo re-aplica).
let mut s = WorldStream::new(dim, seed, 0, 0, llimphi_3d::VOXEL_BRICK);
let (lx0, lz0) = s.world_to_local(0, 0).unwrap();
let gh = s.grid().height_at(lx0, lz0).unwrap_or(dim[1] / 2) as i32;
// Torre magenta gruesa (5×5) y alta sobre la columna de mundo (0,0): el
// terreno nunca pone magenta, así el píxel es inequívocamente la edición.
let top = (gh + 22).min(dim[1] as i32 - 1);
for wy in (gh + 1)..top {
for dx in -2..=2 {
for dz in -2..=2 {
s.edit(dx, wy, dz, Some([240, 40, 220]));
}
}
}
// Cámara apuntada a la torre (mundo centrado en el origen → columna (0,0) cae
// en el centro; `y` de mundo = `y` de grilla dim_y/2).
let half_y = dim[1] as f32 * 0.5;
let tower_mid = (gh + 11) as f32 - half_y;
let pcam = Camera3d {
eye: Vec3::new(14.0, tower_mid + 10.0, -(dim[2] as f32) * 0.32),
target: Vec3::new(0.0, tower_mid, 0.0),
..Camera3d::default()
};
// Se reconstruye el renderer desde `s.grid()` en cada toma: acá probamos la
// PERSISTENCIA (la edición sobrevive el regen), no el upload incremental (ya
// verificado arriba). Subir la torre por scroll caería en bricks "bulk" que el
// toroidal no re-sube (asume terreno determinista; la edición rompe eso).
let mut render_grid = |g: &VoxelGrid| {
let mut r = VoxelRenderer::new(&hal.device, &hal.queue, FMT, g);
r.sun_dir = vr.sun_dir;
r.atmosphere = vr.atmosphere;
render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut r, &pcam)
};
let before = render_grid(s.grid());
encode_png(&before, W, H, "/tmp/m6_persist_before.png");
// Alejarse miles de voxels (el terreno se regenera, la torre sale de ventana)
// y volver al origen (se regenera de nuevo + overlay re-aplica la torre).
s.follow(4000, 4000);
s.follow(0, 0);
let after = render_grid(s.grid());
encode_png(&after, W, H, "/tmp/m6_persist_after.png");
// Conteo de píxeles magenta (la torre) en ambas tomas: deben ser ~iguales.
// Magenta = rojo y azul ambos claramente por encima del verde (robusto a la
// luz coloreada/AO, que baja los valores absolutos pero no esa relación).
let magenta = |px: &[u8]| -> u32 {
px.chunks_exact(4)
.filter(|c| {
let (r, g, b) = (c[0] as i32, c[1] as i32, c[2] as i32);
r > g + 40 && b > g + 40
})
.count() as u32
};
let (mb, ma) = (magenta(&before), magenta(&after));
eprintln!("PERSISTENCIA: pilar magenta = {mb} px antes / {ma} px tras alejarse+volver ({} ediciones)", s.edit_count());
assert!(mb > 200, "el pilar debería verse antes ({mb} px)");
assert!(ma * 100 >= mb * 90, "el pilar se perdió al volver ({mb}→{ma} px)");
eprintln!("PERSISTENCIA OK — la edición sobrevivió el regen del streaming.");
// --- CAS a disco: guardar el blob de ediciones en un archivo nombrado por su
// BLAKE3 y recargarlo en un mundo FRESCO (simula reiniciar el programa).
let blob = s.export_edits();
let addr = blake3::hash(&blob).to_hex();
let dir = std::path::Path::new("/tmp/m6_cas");
std::fs::create_dir_all(dir).ok();
let path = dir.join(format!("{addr}.edits"));
std::fs::write(&path, &blob).expect("escribir blob");
let mut loaded = WorldStream::new(dim, seed, 0, 0, llimphi_3d::VOXEL_BRICK);
let from_disk = std::fs::read(&path).expect("leer blob");
let n = loaded.import_edits(&from_disk).expect("blob válido");
let cas = magenta(&render_grid(loaded.grid()));
encode_png(&render_grid(loaded.grid()), W, H, "/tmp/m6_persist_cas.png");
eprintln!("CAS A DISCO: {n} ediciones desde {} ({} bytes), torre = {cas} px", path.display(), blob.len());
assert!(cas * 100 >= mb * 90, "la torre no se restauró desde disco ({mb}→{cas} px)");
eprintln!("CAS OK — las ediciones se restauraron desde un archivo direccionado por BLAKE3.");
}
/// Cámara de la ventana: posada sobre los **picos** del terreno (muestreo de
/// alturas), atrás del centro y mirando hacia adelante y abajo — encuadra el
/// relieve sin importar si el centro cae en agua o en una cima.
fn camera_for(grid: &VoxelGrid, dim: [u32; 3]) -> Camera3d {
let (dx, dy, dz) = (dim[0], dim[1], dim[2]);
let mut hmax = 0u32;
for z in (0..dz).step_by(4) {
for x in (0..dx).step_by(4) {
if let Some(h) = grid.height_at(x, z) {
hmax = hmax.max(h);
}
}
}
let (dyf, dzf) = (dy as f32, dz as f32);
let eye_y = (hmax as f32 - dyf * 0.5) + dyf * 0.28 + 8.0;
Camera3d::fly(Vec3::new(0.0, eye_y, -dzf * 0.42), 0.0, -0.30)
}
/// Diferencia entre dos buffers RGBA: `(max abs por canal, media abs)`.
fn diff(a: &[u8], b: &[u8]) -> (u8, f64) {
let mut max = 0u8;
let mut sum = 0u64;
for (x, y) in a.iter().zip(b.iter()) {
let d = x.abs_diff(*y);
max = max.max(d);
sum += d as u64;
}
(max, sum as f64 / a.len().max(1) as f64)
}
fn make_target(hal: &Hal) -> wgpu::Texture {
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: &[],
})
}
/// Rinde el voxel renderer a `inter` (sobre un fondo negro de vello) y devuelve
/// los píxeles RGBA planos.
fn render_to_pixels(
hal: &Hal,
renderer: &mut Renderer,
inter: &wgpu::Texture,
inter_view: &wgpu::TextureView,
vr: &mut VoxelRenderer,
camera: &Camera3d,
) -> Vec<u8> {
let base = vello::Scene::new();
renderer
.render_to_view(hal, &base, inter_view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("render base");
let 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());
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 encode_png(pixels: &[u8], w: u32, h: u32, 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();
}
+189
View File
@@ -0,0 +1,189 @@
//! Demo "hero" de M6 — **el mundo completo**: un vuelo por un mundo procedural
//! **ilimitado** que combina las dos mitades del frente de streaming:
//!
//! - **Streaming toroidal** ([`WorldStream`] + `VoxelRenderer::scroll_to`): la
//! ventana voxel fina se desliza siguiendo a la cámara, re-subiendo sólo la
//! franja que entra (mundo sin fin, sin muro ni repetición).
//! - **LOD del horizonte** ([`lod_skirt`]): una malla gruesa del terreno
//! circundante, regenerada al recentrar, hace que más allá de la ventana fina
//! se vean colinas lejanas (compuesta con los voxels por el depth de
//! [`Scene3d`]).
//!
//! La cámara queda en el centro de la ventana mirando hacia el relieve que viene;
//! lo que avanza es el foco de mundo. Cada PNG es terreno nuevo, siempre con
//! horizonte.
//!
//! `cargo run -p llimphi-voxel --example terrain_world --release -- [dim_xz] [seed] [frames]`
//! → /tmp/m6_world_##.png
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{fill_terrain_window, lod_skirt, LodParams, WorldStream};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(160);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(1337);
let frames: u32 = std::env::args().nth(3).and_then(|s| s.parse().ok()).unwrap_or(6);
let dy: u32 = (dim_xz * 4 / 10).max(48);
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let step = llimphi_3d::VOXEL_BRICK;
let mut stream = WorldStream::new(dim, seed, 0, 0, step);
let sun = [0.55, 0.5, 0.32];
let atmo = Atmosphere {
sky_zenith: [66, 120, 198],
sky_horizon: [202, 218, 236],
fog_density: 1.0 / dim_xz as f32,
};
// Renderer voxel construido UNA vez desde mundo (0,0) (invariante del ring
// buffer); el 1er scroll lo lleva al origen del stream.
let mut zero = VoxelGrid::new(dim);
fill_terrain_window(&mut zero, [0, 0], seed);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &zero);
vr.sun_dir = sun;
vr.atmosphere = atmo;
let mut skirt = Renderer3d::new(&hal.device, FMT);
let mut scene = Scene3d::new();
let inter = make_target(&hal);
let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
for i in 0..frames {
// El foco marcha en +Z; cada cuadro entra a mundo nuevo.
let focus_z = i as i32 * (dim_xz as i32 / 2);
stream.follow(0, focus_z);
vr.scroll_to(&hal.device, &hal.queue, stream.origin_voxel(), stream.grid());
// Falda LOD recentrada en la ventana actual (su hueco = la ventana fina).
let [ox, oz] = stream.origin();
let center = [ox + dim_xz as i32 / 2, oz + dim_xz as i32 / 2];
let p = LodParams {
center_xz: center,
window_xz: dim_xz,
span: dim_xz as i32 * 3,
stride: 6,
sky_horizon: atmo.sky_horizon,
fog_density: atmo.fog_density,
sun_dir: sun,
};
let (verts, indices) = lod_skirt(&p, dim, seed);
skirt.set_geometry(&hal.device, &verts, &indices);
// Cámara: en el centro de la ventana, atrás, mirando +Z hacia el horizonte.
let mut hmax = 0u32;
for z in (0..dim[2]).step_by(4) {
for x in (0..dim[0]).step_by(4) {
if let Some(h) = stream.grid().height_at(x, z) {
hmax = hmax.max(h);
}
}
}
let eye_y = (hmax as f32 - dy as f32 * 0.5) + dy as f32 * 0.22 + 7.0;
let camera = Camera3d::fly(Vec3::new(0.0, eye_y, -(dim[2] as f32) * 0.42), 0.0, -0.12);
// Render base (vello) + escena 3D (voxel fino + falda LOD, depth compartido).
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("world") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), &camera, Some(&vr), &[&skirt]);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
let out = format!("/tmp/m6_world_{i:02}.png");
write_png(&readback(&hal, &inter), &out);
eprintln!("{out} — foco_z={focus_z}, origen=({ox},{oz}), {} tris de horizonte", indices.len() / 3);
}
}
fn make_target(hal: &Hal) -> wgpu::Texture {
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: &[],
})
}
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();
}