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:
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "llimphi-voxel"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-voxel — capa de dinámica voxel/juego (estilo Minecraft) sobre el motor 3D general llimphi-3d: world-gen procedural (terreno por ruido fractal) y la casa de bloques/biomas/streaming. Reusable por cualquier juego con orientación voxel. NO renderiza: delega en llimphi-3d (Scene3d + VoxelRenderer)."
|
||||
|
||||
[dependencies]
|
||||
# El motor 3D general; esta capa aporta CONTENIDO/dinámica, no render.
|
||||
llimphi-3d = { path = "../llimphi-3d" }
|
||||
# Puente al formato MagicaVoxel (.vox): importar sets/personajes a VoxelGrid.
|
||||
foreign-vox = { path = "../shared/foreign-vox" }
|
||||
# (de)serialización de las ediciones persistidas para la CAS (mundo→postcard) y de
|
||||
# los artefactos del studio (Project: mundos/personajes con nombre).
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
postcard = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Volcado headless a PNG de los demos de mundo (mismo patrón que llimphi-3d).
|
||||
llimphi-hal = { path = "../llimphi-hal" }
|
||||
llimphi-raster = { path = "../llimphi-raster" }
|
||||
png = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
# Direccionamiento por contenido (BLAKE3) de las ediciones en el demo de CAS.
|
||||
blake3 = { workspace = true }
|
||||
# Round-trip RON de los artefactos del studio (Project) en los tests.
|
||||
ron = { workspace = true }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
//! `Actor` — un **muñeco de cajas articuladas** (humanoide voxel estilo
|
||||
//! Minecraft/MagicaVoxel) para *actuar* en una escena filmada, con una pequeña
|
||||
//! **librería de clips de animación** ([`Clip`]: quieto/caminar/correr/saludar/
|
||||
//! señalar/festejar). Es el tercer ingrediente de la rama de juego (tras
|
||||
//! [`Player`](crate::Player) y [`raycast`](crate::raycast)): un personaje
|
||||
//! **posable y animable**.
|
||||
//!
|
||||
//! El cuerpo son 6 cajas (cabeza/torso/2 brazos/2 piernas); cada miembro rota en
|
||||
//! su articulación (cadera/hombro). Un [`Clip`] es una función `fase → `[`Pose`]
|
||||
//! (los ángulos de todas las articulaciones), así agregar una animación nueva es
|
||||
//! escribir una pose, no tocar el render. No toca la GPU: produce una **malla**
|
||||
//! (`Vec<Vertex3d>` + índices) en espacio local (pies en el origen, mirando a
|
||||
//! `+Z`) que la app sube a un [`Renderer3d`](llimphi_3d::Renderer3d) por frame
|
||||
//! (`set_geometry`) y compone con los voxels en [`Scene3d`](llimphi_3d::Scene3d).
|
||||
|
||||
use llimphi_3d::glam::{Mat4, Vec3};
|
||||
use llimphi_3d::{push_cube, Vertex3d};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Amplitud base de balanceo de miembros al caminar (rad).
|
||||
const SWING: f32 = 0.7;
|
||||
/// Duración del **cross-fade** al cambiar de clip (seg): el cuerpo mezcla la pose
|
||||
/// saliente con la entrante en este lapso, en vez de saltar en seco.
|
||||
const BLEND_DUR: f32 = 0.22;
|
||||
|
||||
/// Suavizado Hermite `3t²−2t³` (deriva nula en los extremos) para el cross-fade.
|
||||
fn smoothstep(x: f32) -> f32 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
x * x * (3.0 - 2.0 * x)
|
||||
}
|
||||
|
||||
/// Ángulos de todas las articulaciones del muñeco en un instante. Una animación
|
||||
/// ([`Clip`]) produce una `Pose`; [`Actor::mesh`] la hornea a cajas. Ángulos en
|
||||
/// radianes; `0` = postura neutra (de pie, brazos colgando).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Pose {
|
||||
/// Balanceo de la pierna izquierda/derecha en la cadera (eje X, adelante+).
|
||||
pub leg_l: f32,
|
||||
pub leg_r: f32,
|
||||
/// Balanceo del brazo izquierdo/derecho en el hombro (eje X, adelante+).
|
||||
pub arm_l: f32,
|
||||
pub arm_r: f32,
|
||||
/// Apertura del brazo izquierdo/derecho hacia el costado/arriba (eje Z). El
|
||||
/// signo se espeja por lado dentro de [`Actor::mesh`]; positivo = levantar.
|
||||
pub arm_l_out: f32,
|
||||
pub arm_r_out: f32,
|
||||
/// Cabeceo de la cabeza (eje X).
|
||||
pub head_pitch: f32,
|
||||
/// Desplazamiento vertical del cuerpo (rebote/respiración), en unidades.
|
||||
pub bob: f32,
|
||||
/// Inclinación del torso hacia adelante (eje X, alrededor de los pies).
|
||||
pub lean: f32,
|
||||
}
|
||||
|
||||
impl Pose {
|
||||
/// Interpola campo a campo entre dos poses (`t=0`→`a`, `t=1`→`b`). Lo usa el
|
||||
/// cross-fade entre clips.
|
||||
pub fn lerp(a: &Pose, b: &Pose, t: f32) -> Pose {
|
||||
let l = |x: f32, y: f32| x + (y - x) * t;
|
||||
Pose {
|
||||
leg_l: l(a.leg_l, b.leg_l),
|
||||
leg_r: l(a.leg_r, b.leg_r),
|
||||
arm_l: l(a.arm_l, b.arm_l),
|
||||
arm_r: l(a.arm_r, b.arm_r),
|
||||
arm_l_out: l(a.arm_l_out, b.arm_l_out),
|
||||
arm_r_out: l(a.arm_r_out, b.arm_r_out),
|
||||
head_pitch: l(a.head_pitch, b.head_pitch),
|
||||
bob: l(a.bob, b.bob),
|
||||
lean: l(a.lean, b.lean),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Animación: una función determinista `fase → `[`Pose`]. La fase la acumula
|
||||
/// [`Actor::advance`] a la [`cadence`](Clip::cadence) del clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Clip {
|
||||
/// De pie, respirando apenas.
|
||||
Idle,
|
||||
/// Caminata: piernas/brazos en oposición.
|
||||
Walk,
|
||||
/// Trote: balanceo amplio + inclinación hacia adelante.
|
||||
Run,
|
||||
/// Saludo: un brazo levantado al costado, oscilando.
|
||||
Wave,
|
||||
/// Señalar: un brazo extendido hacia adelante, firme.
|
||||
Point,
|
||||
/// Festejo: ambos brazos arriba, rebotando.
|
||||
Cheer,
|
||||
}
|
||||
|
||||
impl Clip {
|
||||
/// `true` si el clip es un **gesto** (no locomoción) — un momento expresivo que
|
||||
/// merece un acento musical. Lo usa el director para derivar los "beats del guion".
|
||||
pub fn is_emote(self) -> bool {
|
||||
matches!(self, Clip::Wave | Clip::Point | Clip::Cheer)
|
||||
}
|
||||
|
||||
/// Velocidad de avance de la fase (rad/seg): pasos más rápidos = más cadencia.
|
||||
pub fn cadence(self) -> f32 {
|
||||
match self {
|
||||
Clip::Idle => 2.0,
|
||||
Clip::Walk => 8.0,
|
||||
Clip::Run => 13.0,
|
||||
Clip::Wave => 9.0,
|
||||
Clip::Point => 3.0,
|
||||
Clip::Cheer => 7.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// La pose de este clip en la `fase` dada.
|
||||
pub fn pose(self, phase: f32) -> Pose {
|
||||
let s = phase.sin();
|
||||
match self {
|
||||
Clip::Idle => Pose {
|
||||
bob: 0.02 * s,
|
||||
head_pitch: 0.03 * s,
|
||||
arm_l_out: 0.07,
|
||||
arm_r_out: 0.07,
|
||||
..Pose::default()
|
||||
},
|
||||
Clip::Walk => Pose {
|
||||
leg_l: s * SWING,
|
||||
leg_r: -s * SWING,
|
||||
arm_l: -s * SWING,
|
||||
arm_r: s * SWING,
|
||||
bob: (phase * 2.0).sin().abs() * 0.03,
|
||||
..Pose::default()
|
||||
},
|
||||
Clip::Run => Pose {
|
||||
leg_l: s,
|
||||
leg_r: -s,
|
||||
arm_l: -s * 1.1,
|
||||
arm_r: s * 1.1,
|
||||
lean: 0.38,
|
||||
bob: (phase * 2.0).sin().abs() * 0.06,
|
||||
..Pose::default()
|
||||
},
|
||||
Clip::Wave => Pose {
|
||||
arm_r_out: 2.35 + 0.18 * s, // levantado al costado, saludando
|
||||
arm_l_out: 0.08,
|
||||
head_pitch: -0.05,
|
||||
..Pose::default()
|
||||
},
|
||||
Clip::Point => Pose {
|
||||
arm_r: -1.5, // extendido hacia adelante (+Z)
|
||||
arm_l_out: 0.07,
|
||||
head_pitch: 0.08,
|
||||
..Pose::default()
|
||||
},
|
||||
Clip::Cheer => Pose {
|
||||
arm_l_out: 2.6,
|
||||
arm_r_out: 2.6,
|
||||
bob: (phase * 2.0).sin().abs() * 0.08,
|
||||
head_pitch: -0.1,
|
||||
..Pose::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **Edad cuantizada** del personaje: estadios discretos que cambian las
|
||||
/// proporciones del cuerpo (un bebé es cabezón y de miembros cortos; un adulto es
|
||||
/// alto y proporcionado). Sirve para *mostrar al niño primero* (el corto: nace en el
|
||||
/// desierto) y envejecerlo por etapas. Cada edad deriva un [`Build`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Age {
|
||||
/// Bebé/recién nacido: chiquito, cabezón, miembros cortos.
|
||||
Baby,
|
||||
/// Niño.
|
||||
Child,
|
||||
/// Joven/adolescente.
|
||||
Teen,
|
||||
/// Adulto (proporciones de referencia).
|
||||
Adult,
|
||||
/// Anciano (apenas más bajo que el adulto).
|
||||
Elder,
|
||||
}
|
||||
|
||||
impl Age {
|
||||
/// Todas las edades, de menor a mayor (para que un editor cicle entre ellas).
|
||||
pub const ALL: [Age; 5] = [Age::Baby, Age::Child, Age::Teen, Age::Adult, Age::Elder];
|
||||
|
||||
/// Nombre legible (español) para la UI.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Age::Baby => "bebé",
|
||||
Age::Child => "niño",
|
||||
Age::Teen => "joven",
|
||||
Age::Adult => "adulto",
|
||||
Age::Elder => "anciano",
|
||||
}
|
||||
}
|
||||
|
||||
/// La edad siguiente (cicla) — para botones de ciclo.
|
||||
pub fn next(self) -> Age {
|
||||
let i = Age::ALL.iter().position(|&a| a == self).unwrap_or(0);
|
||||
Age::ALL[(i + 1) % Age::ALL.len()]
|
||||
}
|
||||
|
||||
/// `(escala_total, refuerzo_cabeza, escala_miembros)` por edad. Más joven =
|
||||
/// más chico, cabeza proporcionalmente más grande y miembros más cortos.
|
||||
fn params(self) -> (f32, f32, f32) {
|
||||
match self {
|
||||
Age::Baby => (0.50, 1.55, 0.70),
|
||||
Age::Child => (0.66, 1.28, 0.82),
|
||||
Age::Teen => (0.84, 1.08, 0.93),
|
||||
Age::Adult => (1.00, 1.00, 1.00),
|
||||
Age::Elder => (0.96, 1.00, 0.97),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **Constitución** del muñeco: las medidas de cada parte (posiciones de
|
||||
/// articulación y tamaños de caja), en el espacio local del actor (pies en el
|
||||
/// origen, mirando a `+Z`). Es el "esqueleto + modelado" configurable: cambiarla
|
||||
/// hace personajes distintos (alto/bajo, cabezón, etc.). Se construye por
|
||||
/// [`Age`](Age) ([`Build::for_age`]) y los pies quedan **siempre en `y=0`** por
|
||||
/// construcción (`hip_y == leg_len`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Build {
|
||||
/// Altura aproximada total (referencia).
|
||||
pub height: f32,
|
||||
/// Centro y tamaño del torso.
|
||||
pub torso_y: f32,
|
||||
pub torso: Vec3,
|
||||
/// Centro y tamaño de la cabeza.
|
||||
pub head_y: f32,
|
||||
pub head: Vec3,
|
||||
/// Altura del hombro y separación lateral; largo y tamaño del brazo.
|
||||
pub shoulder_y: f32,
|
||||
pub shoulder_x: f32,
|
||||
pub arm_len: f32,
|
||||
pub arm: Vec3,
|
||||
/// Altura de la cadera (= `leg_len`, pies en el piso), separación; largo/tamaño
|
||||
/// de la pierna.
|
||||
pub hip_y: f32,
|
||||
pub hip_x: f32,
|
||||
pub leg_len: f32,
|
||||
pub leg: Vec3,
|
||||
/// Tamaño de la mano.
|
||||
pub hand: Vec3,
|
||||
}
|
||||
|
||||
impl Build {
|
||||
/// Construye la constitución de una [`Age`]. Bottom-up desde los pies (`y=0`),
|
||||
/// así cualquier edad queda con los pies en el piso. `Adult` reproduce las
|
||||
/// proporciones históricas del muñeco (los 11 cubos de siempre).
|
||||
pub fn for_age(age: Age) -> Build {
|
||||
let (s, head_boost, limb) = age.params();
|
||||
let neck = 0.02 * s;
|
||||
let leg_len = 0.80 * s * limb;
|
||||
let hip_y = leg_len; // pies en el piso
|
||||
let torso = Vec3::new(0.55 * s, 0.60 * s, 0.30 * s);
|
||||
let torso_y = hip_y + torso.y * 0.5;
|
||||
let shoulder_y = hip_y + torso.y;
|
||||
let head = Vec3::new(0.42 * s * head_boost, 0.40 * s * head_boost, 0.42 * s * head_boost);
|
||||
let head_y = shoulder_y + head.y * 0.5 + neck;
|
||||
Build {
|
||||
height: head_y + head.y * 0.5,
|
||||
torso_y,
|
||||
torso,
|
||||
head_y,
|
||||
head,
|
||||
shoulder_y,
|
||||
shoulder_x: 0.36 * s,
|
||||
arm_len: 0.60 * s * limb,
|
||||
arm: Vec3::new(0.18 * s, 0.60 * s * limb, 0.18 * s),
|
||||
hip_y,
|
||||
hip_x: 0.14 * s,
|
||||
leg_len,
|
||||
leg: Vec3::new(0.22 * s, leg_len, 0.22 * s),
|
||||
hand: Vec3::new(0.20 * s, 0.18 * s, 0.20 * s),
|
||||
}
|
||||
}
|
||||
|
||||
/// Constitución adulta de referencia.
|
||||
pub fn adult() -> Build {
|
||||
Build::for_age(Age::Adult)
|
||||
}
|
||||
}
|
||||
|
||||
/// Personaje articulado. `pos` es el **centro de los pies** en espacio de mundo
|
||||
/// (las mismas coordenadas del terreno/grid); `facing` el rumbo (yaw, `0`=`+Z`).
|
||||
/// `clip`/`phase` definen la animación actual. Colores por zona (piel/remera/
|
||||
/// pantalón). La [`Build`] define las proporciones (edad/personaje).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Actor {
|
||||
/// Centro de los pies, en mundo.
|
||||
pub pos: Vec3,
|
||||
/// Rumbo (yaw, radianes; `0` mira a `+Z`).
|
||||
pub facing: f32,
|
||||
/// Animación actual.
|
||||
pub clip: Clip,
|
||||
/// Fase del clip (acumulada por [`advance`](Self::advance)).
|
||||
pub phase: f32,
|
||||
/// Clip saliente durante un cross-fade (`None` si no hay transición en curso).
|
||||
prev_clip: Option<Clip>,
|
||||
/// Fase del clip saliente (sigue avanzando durante la mezcla).
|
||||
prev_phase: f32,
|
||||
/// Progreso del cross-fade `0..1` (a `1` se descarta el clip saliente).
|
||||
blend: f32,
|
||||
/// Color de la piel (cabeza).
|
||||
pub skin: [f32; 3],
|
||||
/// Color de la remera (torso + brazos).
|
||||
pub shirt: [f32; 3],
|
||||
/// Color del pantalón (piernas).
|
||||
pub pants: [f32; 3],
|
||||
/// **IK de mirada** (look-at constraint): si está, la cabeza gira (yaw+pitch,
|
||||
/// dentro de un rango creíble) para **mirar ese punto de mundo**, por encima del
|
||||
/// cabeceo del clip — los ojos siguen al objetivo. `None` = cabeza alineada al
|
||||
/// cuerpo. La fija [`look_at`](Self::look_at).
|
||||
look_target: Option<Vec3>,
|
||||
/// Constitución (proporciones por edad/personaje). La fija
|
||||
/// [`with_age`](Self::with_age) / [`with_build`](Self::with_build).
|
||||
pub build: Build,
|
||||
/// Edad actual (estadio cuantizado) — informativo; el cuerpo lo da `build`.
|
||||
pub age: Age,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
/// Actor parado en `pos` (centro de pies, mundo) mirando a `facing`, en
|
||||
/// [`Clip::Idle`], con una paleta por defecto (piel clara, remera teal,
|
||||
/// pantalón azul).
|
||||
pub fn new(pos: Vec3, facing: f32) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
facing,
|
||||
clip: Clip::Idle,
|
||||
phase: 0.0,
|
||||
prev_clip: None,
|
||||
prev_phase: 0.0,
|
||||
blend: 1.0,
|
||||
skin: [0.86, 0.68, 0.54],
|
||||
shirt: [0.20, 0.62, 0.55],
|
||||
pants: [0.18, 0.22, 0.34],
|
||||
look_target: None,
|
||||
build: Build::adult(),
|
||||
age: Age::Adult,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fija la **edad** (estadio cuantizado) → recalcula la constitución del cuerpo.
|
||||
/// Encadenable: `Actor::new(pos, yaw).with_age(Age::Baby)`. Para *mostrar al
|
||||
/// niño primero* y envejecerlo por etapas.
|
||||
pub fn with_age(mut self, age: Age) -> Self {
|
||||
self.set_age(age);
|
||||
self
|
||||
}
|
||||
|
||||
/// Cambia la edad en caliente (recalcula `build`).
|
||||
pub fn set_age(&mut self, age: Age) {
|
||||
self.age = age;
|
||||
self.build = Build::for_age(age);
|
||||
}
|
||||
|
||||
/// Fija una constitución arbitraria (personaje a medida, no atado a una edad).
|
||||
pub fn with_build(mut self, build: Build) -> Self {
|
||||
self.build = build;
|
||||
self
|
||||
}
|
||||
|
||||
/// Fija (o limpia con `None`) el **objetivo de mirada** (IK de cabeza): la cabeza
|
||||
/// y los ojos se orientan hacia ese punto de mundo, dentro de un rango creíble,
|
||||
/// sin mover el cuerpo. Útil para que un actor "mire a cámara" o siga algo.
|
||||
pub fn look_at(&mut self, target: Option<Vec3>) {
|
||||
self.look_target = target;
|
||||
}
|
||||
|
||||
/// Tinta el actor (piel/remera/pantalón) — encadenable tras [`new`](Self::new).
|
||||
pub fn with_colors(mut self, skin: [f32; 3], shirt: [f32; 3], pants: [f32; 3]) -> Self {
|
||||
self.skin = skin;
|
||||
self.shirt = shirt;
|
||||
self.pants = pants;
|
||||
self
|
||||
}
|
||||
|
||||
/// Cambia la animación. Si es un clip distinto, arranca un **cross-fade**: la
|
||||
/// pose saliente se mezcla con la nueva durante [`BLEND_DUR`] segundos (sin
|
||||
/// saltos). Repetir el mismo clip no corta nada.
|
||||
pub fn set_clip(&mut self, clip: Clip) {
|
||||
if self.clip != clip {
|
||||
self.prev_clip = Some(self.clip);
|
||||
self.prev_phase = self.phase;
|
||||
self.clip = clip;
|
||||
self.phase = 0.0;
|
||||
self.blend = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Avanza la animación `dt` segundos: la fase a la cadencia del clip, y —si hay
|
||||
/// transición— la fase saliente y el progreso del cross-fade. El movimiento de
|
||||
/// `pos`/`facing` lo maneja el llamador (la dirección).
|
||||
pub fn advance(&mut self, dt: f32) {
|
||||
self.phase += dt * self.clip.cadence();
|
||||
if let Some(pc) = self.prev_clip {
|
||||
self.prev_phase += dt * pc.cadence();
|
||||
self.blend += dt / BLEND_DUR;
|
||||
if self.blend >= 1.0 {
|
||||
self.prev_clip = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// La pose actual del cuerpo: la del clip vigente, o —durante un cambio— la
|
||||
/// **mezcla** suave entre el clip saliente y el entrante.
|
||||
pub fn pose(&self) -> Pose {
|
||||
let target = self.clip.pose(self.phase);
|
||||
match self.prev_clip {
|
||||
Some(pc) => Pose::lerp(&pc.pose(self.prev_phase), &target, smoothstep(self.blend)),
|
||||
None => target,
|
||||
}
|
||||
}
|
||||
|
||||
/// Orienta al actor para mirar hacia `target` (sólo el plano horizontal).
|
||||
pub fn face_towards(&mut self, target: Vec3) {
|
||||
let d = target - self.pos;
|
||||
if d.x.abs() + d.z.abs() > 1e-4 {
|
||||
self.facing = d.x.atan2(d.z); // yaw=0 → +Z, consistente con forward_h
|
||||
}
|
||||
}
|
||||
|
||||
/// Matriz de ubicación en mundo: traslada a `pos` y rota por `facing`. La
|
||||
/// malla de [`mesh`](Self::mesh) está en espacio local; este es el `model`
|
||||
/// del [`Renderer3d`](llimphi_3d::Renderer3d).
|
||||
pub fn model(&self) -> Mat4 {
|
||||
Mat4::from_translation(self.pos) * Mat4::from_rotation_y(self.facing)
|
||||
}
|
||||
|
||||
/// Construye la **malla del cuerpo** en espacio local (pies en el origen,
|
||||
/// mirando a `+Z`) para la pose del clip/fase actuales. 6 cajas. El cuerpo
|
||||
/// superior (torso/cabeza/brazos) lleva el `bob`+`lean` de la pose; las
|
||||
/// piernas quedan plantadas (sólo su balanceo de cadera) para no levantar los
|
||||
/// pies del suelo. Subir con `Renderer3d::set_geometry` y ubicar con
|
||||
/// [`model`](Self::model).
|
||||
pub fn mesh(&self) -> (Vec<Vertex3d>, Vec<u16>) {
|
||||
let p = self.pose();
|
||||
let b = &self.build;
|
||||
let mut v = Vec::with_capacity(8 * 11);
|
||||
let mut i = Vec::with_capacity(36 * 11);
|
||||
|
||||
// Transform del cuerpo superior: rebote vertical + inclinación adelante
|
||||
// (rotación en X alrededor de los pies/origen).
|
||||
let body = Mat4::from_translation(Vec3::new(0.0, p.bob, 0.0)) * Mat4::from_rotation_x(p.lean);
|
||||
|
||||
// Torso.
|
||||
push_cube(&mut v, &mut i, body * trs(Vec3::new(0.0, b.torso_y, 0.0), Mat4::IDENTITY, b.torso), self.shirt);
|
||||
|
||||
// Cabeza: cabeceo del clip + IK de mirada (yaw/pitch hacia el objetivo). El
|
||||
// `head_anchor` (sin escala) ancla cabeza, ojos y boca para que giren juntos.
|
||||
let (look_yaw, look_pitch) = self.look_angles();
|
||||
let head_rot = Mat4::from_rotation_y(look_yaw) * Mat4::from_rotation_x(p.head_pitch + look_pitch);
|
||||
let head_anchor = body * Mat4::from_translation(Vec3::new(0.0, b.head_y, 0.0)) * head_rot;
|
||||
push_cube(&mut v, &mut i, head_anchor * Mat4::from_scale(b.head), self.skin);
|
||||
|
||||
// Cara: dos ojos + boca en la cara `+Z` de la cabeza. Las posiciones/tamaños
|
||||
// van como **fracción del tamaño de la cabeza** → escalan con la edad (un bebé
|
||||
// cabezón tiene ojos más grandes). Decales finos (apenas sobresalen, estilo
|
||||
// Minecraft). Parpadeo determinista + boca que se abre con los gestos.
|
||||
let (hw, hh, hd) = (b.head.x, b.head.y, b.head.z);
|
||||
let blink = self.blink(); // 1 = abierto, ~0 = cerrado
|
||||
let eye_sz = Vec3::new(0.095 * hw, (0.15 * hh * blink).max(0.012), 0.05 * hd);
|
||||
let face_z = hd * 0.49; // casi en la cara +Z
|
||||
for sx in [0.26_f32, -0.26] {
|
||||
push_cube(
|
||||
&mut v,
|
||||
&mut i,
|
||||
head_anchor * trs(Vec3::new(sx * hw, 0.12 * hh, face_z), Mat4::IDENTITY, eye_sz),
|
||||
EYE_COLOR,
|
||||
);
|
||||
}
|
||||
let mouth_open = self.mouth_open();
|
||||
push_cube(
|
||||
&mut v,
|
||||
&mut i,
|
||||
head_anchor * trs(Vec3::new(0.0, -0.25 * hh, face_z), Mat4::IDENTITY, Vec3::new(0.38 * hw, 0.05 * hh + mouth_open, 0.05 * hd)),
|
||||
MOUTH_COLOR,
|
||||
);
|
||||
|
||||
// Piernas (sin `body`: pies plantados). Articulación en la cadera.
|
||||
let leg_rot_r = Mat4::from_rotation_x(p.leg_r);
|
||||
let leg_rot_l = Mat4::from_rotation_x(p.leg_l);
|
||||
limb(&mut v, &mut i, Mat4::IDENTITY, Vec3::new(b.hip_x, b.hip_y, 0.0), b.leg_len, b.leg, leg_rot_r, self.pants);
|
||||
limb(&mut v, &mut i, Mat4::IDENTITY, Vec3::new(-b.hip_x, b.hip_y, 0.0), b.leg_len, b.leg, leg_rot_l, self.pants);
|
||||
|
||||
// Brazos (con `body`). Rotación = apertura(Z)·balanceo(X); apertura espejada.
|
||||
let arm_r_rot = Mat4::from_rotation_z(p.arm_r_out) * Mat4::from_rotation_x(p.arm_r);
|
||||
let arm_l_rot = Mat4::from_rotation_z(-p.arm_l_out) * Mat4::from_rotation_x(p.arm_l);
|
||||
let (sh_r, sh_l) = (Vec3::new(b.shoulder_x, b.shoulder_y, 0.0), Vec3::new(-b.shoulder_x, b.shoulder_y, 0.0));
|
||||
limb(&mut v, &mut i, body, sh_r, b.arm_len, b.arm, arm_r_rot, self.shirt);
|
||||
limb(&mut v, &mut i, body, sh_l, b.arm_len, b.arm, arm_l_rot, self.shirt);
|
||||
|
||||
// Manos: una caja de piel en la punta de cada brazo (a `arm_len` del hombro).
|
||||
hand_at(&mut v, &mut i, body, sh_r, b.arm_len, arm_r_rot, b.hand, self.skin);
|
||||
hand_at(&mut v, &mut i, body, sh_l, b.arm_len, arm_l_rot, b.hand, self.skin);
|
||||
|
||||
(v, i)
|
||||
}
|
||||
|
||||
/// Ángulos `(yaw, pitch)` de la cabeza para el IK de mirada: dirección al objetivo
|
||||
/// llevada al espacio local del actor (deshaciendo `facing`) y acotada a un rango
|
||||
/// creíble (±70° yaw, ±50° pitch) para que el cuello no se quiebre. `(0,0)` si no
|
||||
/// hay objetivo.
|
||||
fn look_angles(&self) -> (f32, f32) {
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
let Some(target) = self.look_target else { return (0.0, 0.0) };
|
||||
// Posición aproximada de la cabeza en mundo (pies + 1.62 de altura).
|
||||
let head_pos = self.pos + Vec3::new(0.0, 1.62, 0.0);
|
||||
let d = target - head_pos;
|
||||
if d.length_squared() < 1e-6 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
// A espacio local: deshacer el yaw del cuerpo.
|
||||
let local = Mat4::from_rotation_y(-self.facing).transform_vector3(d).normalize();
|
||||
let yaw = local.x.atan2(local.z).clamp(-1.22, 1.22); // ±70°
|
||||
let pitch = (-local.y).asin().clamp(-0.87, 0.87); // ±50°, mirar arriba/abajo
|
||||
let _ = FRAC_PI_2;
|
||||
(yaw, pitch)
|
||||
}
|
||||
|
||||
/// Apertura de párpados `0..1` (1 = abierto). Parpadeo determinista por la fase:
|
||||
/// un cierre breve cada ~3 unidades de fase. Idle no necesita objetivo.
|
||||
fn blink(&self) -> f32 {
|
||||
let ph = self.phase * 0.33; // ciclos lentos
|
||||
let f = ph - ph.floor();
|
||||
// Cerrado sólo en una ventana corta (~6% del ciclo).
|
||||
if f > 0.94 {
|
||||
// Sube y baja rápido dentro de la ventana (triángulo invertido).
|
||||
let w = (f - 0.94) / 0.06; // 0..1
|
||||
(1.0 - (1.0 - (2.0 * w - 1.0).abs())).clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Apertura de la boca (unidades): abierta en los gestos expresivos (festejar más
|
||||
/// que saludar/señalar), cerrada el resto → la cara "reacciona" al clip.
|
||||
fn mouth_open(&self) -> f32 {
|
||||
match self.clip {
|
||||
Clip::Cheer => 0.10 + 0.04 * self.phase.sin().abs(),
|
||||
Clip::Wave | Clip::Point => 0.05,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Color de los ojos (casi negro).
|
||||
const EYE_COLOR: [f32; 3] = [0.08, 0.07, 0.09];
|
||||
/// Color de la boca (marrón oscuro).
|
||||
const MOUTH_COLOR: [f32; 3] = [0.30, 0.12, 0.12];
|
||||
|
||||
/// Apila la **mano** en la punta de un miembro: una caja de tamaño `hand` centrada a
|
||||
/// `len` del pivote `joint` (donde termina la caja del brazo), con la misma rotación
|
||||
/// `rot` del brazo y el mismo prefijo `pre`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn hand_at(
|
||||
v: &mut Vec<Vertex3d>,
|
||||
i: &mut Vec<u16>,
|
||||
pre: Mat4,
|
||||
joint: Vec3,
|
||||
len: f32,
|
||||
rot: Mat4,
|
||||
hand: Vec3,
|
||||
color: [f32; 3],
|
||||
) {
|
||||
let m = pre
|
||||
* Mat4::from_translation(joint)
|
||||
* rot
|
||||
* Mat4::from_translation(Vec3::new(0.0, -len, 0.0))
|
||||
* Mat4::from_scale(hand);
|
||||
push_cube(v, i, m, color);
|
||||
}
|
||||
|
||||
/// `T(center) · R · S(size)` — caja centrada en `center`, rotada por `rot`,
|
||||
/// escalada a `size` (un cubo unitario → su caja en el cuerpo).
|
||||
fn trs(center: Vec3, rot: Mat4, size: Vec3) -> Mat4 {
|
||||
Mat4::from_translation(center) * rot * Mat4::from_scale(size)
|
||||
}
|
||||
|
||||
/// Apila un **miembro articulado**: caja de tamaño `size` y largo `len` que
|
||||
/// cuelga del pivote `joint` (su extremo superior) y rota por `rot` en torno a
|
||||
/// ese pivote; todo prefijado por `pre` (el transform del cuerpo, o identidad
|
||||
/// para las piernas). El centro de la caja queda a `len/2` por debajo del pivote
|
||||
/// antes de rotar.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn limb(
|
||||
v: &mut Vec<Vertex3d>,
|
||||
i: &mut Vec<u16>,
|
||||
pre: Mat4,
|
||||
joint: Vec3,
|
||||
len: f32,
|
||||
size: Vec3,
|
||||
rot: Mat4,
|
||||
color: [f32; 3],
|
||||
) {
|
||||
let m = pre
|
||||
* Mat4::from_translation(joint)
|
||||
* rot
|
||||
* Mat4::from_translation(Vec3::new(0.0, -len / 2.0, 0.0))
|
||||
* Mat4::from_scale(size);
|
||||
push_cube(v, i, m, color);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
|
||||
/// Rango en Z de los vértices de la malla (cuánto adelantan/atrasan miembros).
|
||||
fn z_span(a: &Actor) -> f32 {
|
||||
let z: Vec<f32> = a.mesh().0.iter().map(|v| v.pos[2]).collect();
|
||||
z.iter().cloned().fold(f32::MIN, f32::max) - z.iter().cloned().fold(f32::MAX, f32::min)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malla_tiene_once_cajas() {
|
||||
// 6 del cuerpo (torso/cabeza/2 piernas/2 brazos) + 2 manos + 2 ojos + boca.
|
||||
let a = Actor::new(Vec3::ZERO, 0.0);
|
||||
let (v, idx) = a.mesh();
|
||||
assert_eq!(v.len(), 8 * 11, "11 cajas × 8 vértices");
|
||||
assert_eq!(idx.len(), 36 * 11, "11 cajas × 36 índices");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edades_cambian_proporciones_y_dejan_los_pies_en_el_piso() {
|
||||
let baby = Build::for_age(Age::Baby);
|
||||
let adult = Build::for_age(Age::Adult);
|
||||
// El bebé es más bajo que el adulto.
|
||||
assert!(baby.height < adult.height, "bebé {} < adulto {}", baby.height, adult.height);
|
||||
// ...y CABEZÓN: la cabeza ocupa una fracción mayor de su altura.
|
||||
let head_frac = |b: &Build| b.head.y / b.height;
|
||||
assert!(head_frac(&baby) > head_frac(&adult) + 0.05, "bebé cabezón: {} vs {}", head_frac(&baby), head_frac(&adult));
|
||||
// Toda edad apoya los pies en y=0 (el voxel más bajo de la malla ≈ 0).
|
||||
for age in [Age::Baby, Age::Child, Age::Teen, Age::Adult, Age::Elder] {
|
||||
let a = Actor::new(Vec3::ZERO, 0.0).with_age(age);
|
||||
let ymin = a.mesh().0.iter().map(|v| v.pos[1]).fold(f32::MAX, f32::min);
|
||||
assert!(ymin.abs() < 1e-3, "pies en el piso para {age:?}: ymin={ymin}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adulto_conserva_los_once_cubos_y_altura_historica() {
|
||||
// El refactor a Build no cambió al adulto: 11 cajas y altura ~1.82.
|
||||
let a = Actor::new(Vec3::ZERO, 0.0);
|
||||
assert_eq!(a.mesh().0.len(), 8 * 11);
|
||||
assert!((a.build.height - 1.82).abs() < 0.05, "altura adulta {}", a.build.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ik_de_mirada_gira_la_cabeza_hacia_el_objetivo() {
|
||||
// Mirar a la derecha (mundo +X) vs a la izquierda (−X): los ojos (vértices más
|
||||
// adelantados en la cara) deben desplazarse en X en sentidos opuestos.
|
||||
let eye_centroid_x = |a: &Actor| {
|
||||
// Los ojos están en la cara +Z, son los vértices con mayor z y |x|≈0.11.
|
||||
let verts = a.mesh().0;
|
||||
let zmax = verts.iter().map(|v| v.pos[2]).fold(f32::MIN, f32::max);
|
||||
let front: Vec<f32> =
|
||||
verts.iter().filter(|v| v.pos[2] > zmax - 0.05).map(|v| v.pos[0]).collect();
|
||||
front.iter().sum::<f32>() / front.len().max(1) as f32
|
||||
};
|
||||
let mut right = Actor::new(Vec3::ZERO, 0.0);
|
||||
right.look_at(Some(Vec3::new(10.0, 1.62, 1.0)));
|
||||
let mut left = Actor::new(Vec3::ZERO, 0.0);
|
||||
left.look_at(Some(Vec3::new(-10.0, 1.62, 1.0)));
|
||||
// Mirar a +X adelanta la cara hacia +X; a −X, hacia −X.
|
||||
assert!(eye_centroid_x(&right) > eye_centroid_x(&left) + 0.05, "la cabeza sigue al objetivo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caminar_balancea_las_piernas() {
|
||||
// A fase π/2 el seno es máximo → las piernas separan al máximo.
|
||||
let mut a = Actor::new(Vec3::ZERO, 0.0);
|
||||
a.set_clip(Clip::Walk);
|
||||
a.advance(FRAC_PI_2 / Clip::Walk.cadence());
|
||||
assert!(z_span(&a) > 0.5, "al caminar los miembros adelantan/atrasan: {}", z_span(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_casi_quieto() {
|
||||
// En Idle los miembros no se balancean: el span en Z es chico.
|
||||
let mut a = Actor::new(Vec3::ZERO, 0.0); // Idle por defecto
|
||||
a.advance(FRAC_PI_2 / Clip::Idle.cadence());
|
||||
assert!(z_span(&a) < 0.45, "Idle no debería balancear: {}", z_span(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cambiar_de_clip_reinicia_la_fase() {
|
||||
let mut a = Actor::new(Vec3::ZERO, 0.0);
|
||||
a.set_clip(Clip::Walk);
|
||||
a.advance(1.0);
|
||||
assert!(a.phase > 0.0);
|
||||
a.set_clip(Clip::Run);
|
||||
assert_eq!(a.phase, 0.0, "un clip nuevo arranca la pose desde 0");
|
||||
// Repetir el mismo clip NO corta la fase.
|
||||
a.advance(0.5);
|
||||
let ph = a.phase;
|
||||
a.set_clip(Clip::Run);
|
||||
assert_eq!(a.phase, ph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cambio_de_clip_hace_cross_fade() {
|
||||
// Caminando con las piernas bien abiertas…
|
||||
let mut a = Actor::new(Vec3::ZERO, 0.0);
|
||||
a.set_clip(Clip::Walk);
|
||||
a.advance(FRAC_PI_2 / Clip::Walk.cadence());
|
||||
let span_walk = z_span(&a);
|
||||
assert!(span_walk > 0.5);
|
||||
|
||||
// …al pasar a Idle, JUSTO después la pose sigue siendo ~la de caminar
|
||||
// (blend≈0), no salta de golpe a quieto.
|
||||
a.set_clip(Clip::Idle);
|
||||
let span_inicio = z_span(&a);
|
||||
assert!((span_inicio - span_walk).abs() < 0.05, "el cross-fade arranca desde la pose saliente");
|
||||
|
||||
// Pasado el blend, ya es Idle (piernas juntas).
|
||||
a.advance(BLEND_DUR + 0.1);
|
||||
assert!(z_span(&a) < 0.45, "tras el cross-fade la pose es Idle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_towards_mira_a_mas_z() {
|
||||
let mut a = Actor::new(Vec3::ZERO, 0.0);
|
||||
a.face_towards(Vec3::new(0.0, 0.0, 5.0));
|
||||
assert!(a.facing.abs() < 1e-4, "mirar a +Z → yaw≈0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! **Modos de cámara** atados a un sujeto + la **secuencia de nacimiento** del
|
||||
//! corto. Dos modos generales, reusables por cualquier juego/escena:
|
||||
//! - [`CamMode::Subject`] — *primera persona*: la cámara **es** el sujeto (ojo en su
|
||||
//! cabeza, mira hacia su rumbo).
|
||||
//! - [`CamMode::Follow`] — *tercera persona*: detrás y arriba del sujeto, mirándolo.
|
||||
//!
|
||||
//! Y [`BirthSequence`], que guiona la apertura: la cámara **cae del cielo mirando
|
||||
//! abajo**, ve el huevo, y al aterrizar (nacer) **sale del sujeto** y se planta
|
||||
//! detrás — una transición suave `Subject → Follow`.
|
||||
|
||||
use llimphi_3d::glam::Vec3;
|
||||
use llimphi_3d::Camera3d;
|
||||
|
||||
use crate::actor::Actor;
|
||||
use crate::player::{forward_h, look_dir};
|
||||
use crate::potential::Egg;
|
||||
|
||||
/// Suavizado Hermite `3t²−2t³` (deriva nula en los extremos).
|
||||
fn smoothstep(x: f32) -> f32 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
x * x * (3.0 - 2.0 * x)
|
||||
}
|
||||
|
||||
/// **Modo de cámara** relativo a un sujeto (su `pos` = centro de pies, y su
|
||||
/// `facing` = rumbo). [`camera`](Self::camera) produce la [`Camera3d`] del frame.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CamMode {
|
||||
/// Primera persona: el ojo está a `eye_height` sobre los pies del sujeto y mira
|
||||
/// hacia su rumbo (con `pitch` de cabeceo). La cámara **es** el sujeto.
|
||||
Subject { eye_height: f32, pitch: f32 },
|
||||
/// Tercera persona: el ojo está `distance` detrás del sujeto y `height` arriba,
|
||||
/// mirándolo a la altura del pecho.
|
||||
Follow { distance: f32, height: f32 },
|
||||
}
|
||||
|
||||
impl CamMode {
|
||||
/// La cámara de este modo para un sujeto en `pos` (pies) mirando a `facing`.
|
||||
pub fn camera(&self, pos: Vec3, facing: f32) -> Camera3d {
|
||||
match *self {
|
||||
CamMode::Subject { eye_height, pitch } => {
|
||||
let eye = pos + Vec3::new(0.0, eye_height, 0.0);
|
||||
let dir = look_dir(facing, pitch);
|
||||
Camera3d { eye, target: eye + dir, ..Camera3d::default() }
|
||||
}
|
||||
CamMode::Follow { distance, height } => {
|
||||
let look = pos + Vec3::new(0.0, height, 0.0);
|
||||
let behind = forward_h(facing) * distance;
|
||||
let eye = pos - behind + Vec3::new(0.0, height + 0.6, 0.0);
|
||||
Camera3d { eye, target: look, ..Camera3d::default() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpola dos cámaras (ojo, objetivo y FOV) con suavizado — la base de las
|
||||
/// **transiciones** entre modos (p.ej. salir del sujeto hacia atrás).
|
||||
pub fn cam_lerp(a: &Camera3d, b: &Camera3d, t: f32) -> Camera3d {
|
||||
let s = smoothstep(t);
|
||||
Camera3d {
|
||||
eye: a.eye.lerp(b.eye, s),
|
||||
target: a.target.lerp(b.target, s),
|
||||
up: Vec3::Y,
|
||||
fovy_rad: a.fovy_rad + (b.fovy_rad - a.fovy_rad) * s,
|
||||
znear: a.znear,
|
||||
zfar: a.zfar,
|
||||
}
|
||||
}
|
||||
|
||||
/// **Secuencia de nacimiento**: la cámara cae del cielo sobre el huevo mirando
|
||||
/// abajo; al aterrizar el huevo eclosiona y la cámara —que llegó *metida en el
|
||||
/// sujeto*— sale hacia atrás a tercera persona. Determinista por tiempo
|
||||
/// (reproducible cuadro a cuadro).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BirthSequence {
|
||||
/// El huevo (lleva pos, rumbo y el potencial que nace).
|
||||
pub egg: Egg,
|
||||
/// Altura desde la que cae la cámara.
|
||||
pub sky_height: f32,
|
||||
/// Instante del aterrizaje/nacimiento (seg).
|
||||
pub t_land: f32,
|
||||
/// Duración de la salida del sujeto hacia atrás (seg).
|
||||
pub t_pull: f32,
|
||||
/// Distancia/altura del plano de seguimiento final.
|
||||
pub follow_distance: f32,
|
||||
pub follow_height: f32,
|
||||
}
|
||||
|
||||
impl BirthSequence {
|
||||
/// Secuencia con tiempos por defecto (caída ~2.4 s, salida ~1.2 s).
|
||||
pub fn new(egg: Egg) -> Self {
|
||||
Self {
|
||||
egg,
|
||||
sky_height: 60.0,
|
||||
t_land: 2.4,
|
||||
t_pull: 1.2,
|
||||
follow_distance: 3.5,
|
||||
follow_height: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Duración total de la secuencia (hasta un pequeño respiro tras la salida).
|
||||
pub fn duration(&self) -> f32 {
|
||||
self.t_land + self.t_pull + 1.0
|
||||
}
|
||||
|
||||
/// El recién nacido (materializa el potencial del huevo).
|
||||
pub fn newborn(&self) -> Actor {
|
||||
self.egg.newborn()
|
||||
}
|
||||
|
||||
/// Progreso de eclosión del huevo en `t`: arranca poco antes del aterrizaje y
|
||||
/// completa poco después (el huevo se abre **mientras** la cámara aterriza).
|
||||
pub fn hatch(&self, t: f32) -> f32 {
|
||||
let start = self.t_land - 0.4;
|
||||
let end = self.t_land + 0.3;
|
||||
((t - start) / (end - start)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Altura del ojo en primera persona = la cabeza del recién nacido.
|
||||
fn eye_height(&self) -> f32 {
|
||||
self.newborn().build.head_y
|
||||
}
|
||||
|
||||
/// Cámara en primera persona parada en el sujeto (recién nacido).
|
||||
fn subject_cam(&self) -> Camera3d {
|
||||
CamMode::Subject { eye_height: self.eye_height(), pitch: 0.0 }
|
||||
.camera(self.egg.pos, self.egg.facing)
|
||||
}
|
||||
|
||||
/// Cámara de seguimiento detrás del sujeto.
|
||||
fn follow_cam(&self) -> Camera3d {
|
||||
CamMode::Follow { distance: self.follow_distance, height: self.follow_height }
|
||||
.camera(self.egg.pos, self.egg.facing)
|
||||
}
|
||||
|
||||
/// Cámara cayendo del cielo, mirando hacia abajo al huevo.
|
||||
fn sky_cam(&self) -> Camera3d {
|
||||
let eye = self.egg.pos + Vec3::new(0.0, self.sky_height, 0.01);
|
||||
Camera3d { eye, target: self.egg.pos, ..Camera3d::default() }
|
||||
}
|
||||
|
||||
/// La cámara en el instante `t`: **caída** (cielo→sujeto, mirando abajo→al
|
||||
/// frente) hasta `t_land`; **salida** (sujeto→seguimiento) durante `t_pull`;
|
||||
/// luego, seguimiento fijo.
|
||||
pub fn camera(&self, t: f32) -> Camera3d {
|
||||
let subject = self.subject_cam();
|
||||
if t < self.t_land {
|
||||
// Cae del cielo y, al final, queda calzada en el sujeto.
|
||||
cam_lerp(&self.sky_cam(), &subject, (t / self.t_land.max(1e-3)).clamp(0.0, 1.0))
|
||||
} else if t < self.t_land + self.t_pull {
|
||||
// Sale del sujeto hacia atrás (3ª persona).
|
||||
let u = (t - self.t_land) / self.t_pull.max(1e-3);
|
||||
cam_lerp(&subject, &self.follow_cam(), u)
|
||||
} else {
|
||||
self.follow_cam()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actor::Age;
|
||||
use crate::potential::Hatchling;
|
||||
|
||||
fn egg() -> Egg {
|
||||
let mut e = Egg::new(Vec3::new(0.0, 0.0, 0.0), 1.2, Hatchling::human(Age::Baby));
|
||||
e.facing = 0.0; // mira a +Z
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_mira_al_frente_desde_la_cabeza() {
|
||||
let cam = CamMode::Subject { eye_height: 1.6, pitch: 0.0 }.camera(Vec3::ZERO, 0.0);
|
||||
assert!((cam.eye.y - 1.6).abs() < 1e-5, "ojo en la cabeza");
|
||||
// Mira hacia +Z (rumbo 0).
|
||||
assert!((cam.target - cam.eye).z > 0.5, "mira al frente (+Z)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_esta_detras_del_sujeto() {
|
||||
let cam = CamMode::Follow { distance: 4.0, height: 1.0 }.camera(Vec3::ZERO, 0.0);
|
||||
// Rumbo +Z → "detrás" es −Z; el ojo debe estar en z negativo.
|
||||
assert!(cam.eye.z < -1.0, "ojo detrás (−Z): {}", cam.eye.z);
|
||||
assert!(cam.eye.y > 1.0, "ojo por encima");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn la_secuencia_cae_del_cielo_y_termina_atras() {
|
||||
let seq = BirthSequence::new(egg());
|
||||
let high = seq.camera(0.0);
|
||||
assert!(high.eye.y > 40.0, "arranca alto en el cielo: {}", high.eye.y);
|
||||
// Al final = plano de seguimiento (detrás del sujeto).
|
||||
let end = seq.camera(seq.duration());
|
||||
let follow = seq.camera(1e9);
|
||||
assert!((end.eye - follow.eye).length() < 1e-3, "termina en seguimiento");
|
||||
assert!(end.eye.z < 0.0, "el seguimiento final está detrás (−Z)");
|
||||
// La eclosión arranca cerrada y termina abierta.
|
||||
assert_eq!(seq.hatch(0.0), 0.0);
|
||||
assert_eq!(seq.hatch(seq.duration()), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn la_camara_es_continua_en_el_aterrizaje() {
|
||||
// Sin saltos bruscos al pasar de caída a salida (mismo punto en t_land).
|
||||
let seq = BirthSequence::new(egg());
|
||||
let before = seq.camera(seq.t_land - 0.001);
|
||||
let after = seq.camera(seq.t_land + 0.001);
|
||||
assert!((before.eye - after.eye).length() < 0.2, "ojo continuo en el aterrizaje");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! `Critter` — un agente que **deambula** el mundo voxel por su cuenta: la
|
||||
//! misma física de cuerpo que el jugador ([`Player`]) más una voluntad mínima
|
||||
//! (cambia de rumbo cada tanto, salta a veces, gira al chocar). Sirve para
|
||||
//! poblar un mundo voxel de bichos vivos; el render los dibuja como cajas
|
||||
//! analíticas en el mismo pase de ray-march ([`llimphi_3d::Entity3d`]).
|
||||
//!
|
||||
//! Determinista (sin `rand`): cada bicho lleva su propia semilla y avanza un
|
||||
//! LCG, así un mundo se reproduce igual (y los tests/PNG son estables).
|
||||
|
||||
use llimphi_3d::{Entity3d, VoxelGrid};
|
||||
|
||||
use crate::{forward_h, Player};
|
||||
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
/// Velocidad de pasto de un bicho (más lento que el jugador).
|
||||
const CRITTER_SPEED: f32 = 3.2;
|
||||
/// Semi-tamaño de la caja del bicho (ancho, alto, fondo) en voxels.
|
||||
const HALF: [f32; 3] = [0.5, 0.7, 0.5];
|
||||
|
||||
/// Bicho que deambula: un [`Player`] (física) + rumbo y temporizadores de IA.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Critter {
|
||||
/// Cuerpo físico (gravedad + colisión), reusado del jugador.
|
||||
pub body: Player,
|
||||
/// Color de la caja al dibujarlo.
|
||||
pub color: [u8; 3],
|
||||
/// Rumbo actual de caminata (yaw, rad).
|
||||
heading: f32,
|
||||
/// Segundos hasta el próximo cambio de rumbo.
|
||||
timer: f32,
|
||||
/// Pide saltar en el próximo paso (one-shot).
|
||||
jump: bool,
|
||||
/// Estado del LCG (azar determinista por bicho).
|
||||
rng: u32,
|
||||
}
|
||||
|
||||
impl Critter {
|
||||
/// Bicho posado sobre la columna `(x, z)` del grid, con `color` y `seed`
|
||||
/// (distintas semillas → rumbos distintos).
|
||||
pub fn spawn_on(grid: &VoxelGrid, x: u32, z: u32, color: [u8; 3], seed: u32) -> Self {
|
||||
let mut body = Player::spawn_on(grid, x, z);
|
||||
body.speed = CRITTER_SPEED;
|
||||
let mut c = Self {
|
||||
body,
|
||||
color,
|
||||
heading: 0.0,
|
||||
timer: 0.0,
|
||||
jump: false,
|
||||
rng: seed | 1, // nunca 0 (un LCG con 0 se queda pegado en patrones pobres)
|
||||
};
|
||||
c.pick_new_goal();
|
||||
c
|
||||
}
|
||||
|
||||
/// Avanza un `dt`: actualiza la voluntad y mueve el cuerpo. Llamar por frame.
|
||||
pub fn step(&mut self, grid: &VoxelGrid, dt: f32) {
|
||||
self.timer -= dt;
|
||||
if self.timer <= 0.0 {
|
||||
self.pick_new_goal();
|
||||
}
|
||||
|
||||
let before = self.body.pos;
|
||||
let wish = forward_h(self.heading);
|
||||
let jump = self.jump && self.body.on_ground;
|
||||
self.body.step(grid, wish, jump, dt);
|
||||
self.jump = false;
|
||||
|
||||
// ¿Chocó? (apenas se movió en horizontal estando en el piso) → media
|
||||
// vuelta y nuevo rumbo, así no se queda empujando una pared.
|
||||
let dx = self.body.pos.x - before.x;
|
||||
let dz = self.body.pos.z - before.z;
|
||||
let moved = (dx * dx + dz * dz).sqrt();
|
||||
if self.body.on_ground && moved < 0.2 * CRITTER_SPEED * dt {
|
||||
self.heading += TAU * 0.5 + (self.next_f32() - 0.5);
|
||||
self.timer = 0.6 + self.next_f32();
|
||||
}
|
||||
}
|
||||
|
||||
/// Caja analítica para el renderer: centro = pies + medio-alto, así la caja
|
||||
/// se apoya en el suelo (los pies del cuerpo).
|
||||
pub fn entity(&self) -> Entity3d {
|
||||
Entity3d {
|
||||
pos: [self.body.pos.x, self.body.pos.y + HALF[1], self.body.pos.z],
|
||||
half: HALF,
|
||||
color: self.color,
|
||||
}
|
||||
}
|
||||
|
||||
/// Elige rumbo, duración y un eventual salto nuevos.
|
||||
fn pick_new_goal(&mut self) {
|
||||
self.heading = self.next_f32() * TAU;
|
||||
self.timer = 0.8 + self.next_f32() * 2.5;
|
||||
self.jump = self.next_f32() < 0.18;
|
||||
}
|
||||
|
||||
/// Próximo `f32` en `[0, 1)` del LCG (Numerical Recipes).
|
||||
fn next_f32(&mut self) -> f32 {
|
||||
self.rng = self.rng.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
// Bits altos (más aleatorios) → mantisa de 24 bits.
|
||||
(self.rng >> 8) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llimphi_3d::glam::Vec3;
|
||||
|
||||
/// Grid 32³ con piso sólido en `y=0`.
|
||||
fn grid_con_piso() -> VoxelGrid {
|
||||
let mut g = VoxelGrid::new([32, 8, 32]);
|
||||
for z in 0..32 {
|
||||
for x in 0..32 {
|
||||
g.set(x, 0, z, [90, 140, 80]);
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deambula_y_no_atraviesa_el_piso() {
|
||||
let g = grid_con_piso();
|
||||
let start = Vec3::new(16.5, 1.0, 16.5);
|
||||
let mut c = Critter::spawn_on(&g, 16, 16, [220, 220, 220], 7);
|
||||
assert!((c.body.pos - start).length() < 0.1);
|
||||
for _ in 0..600 {
|
||||
c.step(&g, 1.0 / 60.0);
|
||||
// Nunca cae por debajo del piso (pies en y≈1).
|
||||
assert!(c.body.pos.y >= 1.0 - 0.05, "se hundió: y={}", c.body.pos.y);
|
||||
}
|
||||
// Tras 10 s deambulando, se movió de donde arrancó.
|
||||
let dx = c.body.pos.x - start.x;
|
||||
let dz = c.body.pos.z - start.z;
|
||||
assert!((dx * dx + dz * dz).sqrt() > 1.0, "no deambuló");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebota_y_queda_dentro_del_corral() {
|
||||
// Corral 8×8 con paredes altas; el bicho debe quedar adentro.
|
||||
let mut g = VoxelGrid::new([8, 6, 8]);
|
||||
for z in 0..8 {
|
||||
for x in 0..8 {
|
||||
g.set(x, 0, z, [90, 140, 80]);
|
||||
if x == 0 || x == 7 || z == 0 || z == 7 {
|
||||
for y in 1..6 {
|
||||
g.set(x, y, z, [120, 120, 120]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut c = Critter::spawn_on(&g, 4, 4, [220, 180, 120], 3);
|
||||
for _ in 0..1200 {
|
||||
c.step(&g, 1.0 / 60.0);
|
||||
}
|
||||
// Dentro del corral interior (1..7) con su medio-ancho.
|
||||
assert!(c.body.pos.x > 1.0 - HALF[0] && c.body.pos.x < 7.0 + HALF[0], "x fuera: {}", c.body.pos.x);
|
||||
assert!(c.body.pos.z > 1.0 - HALF[0] && c.body.pos.z < 7.0 + HALF[0], "z fuera: {}", c.body.pos.z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dos_semillas_distintas_divergen() {
|
||||
let g = grid_con_piso();
|
||||
let mut a = Critter::spawn_on(&g, 16, 16, [255; 3], 1);
|
||||
let mut b = Critter::spawn_on(&g, 16, 16, [255; 3], 999);
|
||||
for _ in 0..300 {
|
||||
a.step(&g, 1.0 / 60.0);
|
||||
b.step(&g, 1.0 / 60.0);
|
||||
}
|
||||
assert!((a.body.pos - b.body.pos).length() > 0.5, "semillas no divergen");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! `director` — una **timeline guionada** para filmar: describe qué hace cada
|
||||
//! actor y qué hace la cámara *en función del tiempo*, de forma **determinista**
|
||||
//! (mismo `t` → mismo estado → reproducible cuadro a cuadro). Es la capa de
|
||||
//! *dirección* que faltaba: en vez de hardcodear el bucle de la escena, se
|
||||
//! declara un [`Sequence`] (el "guion") y se reproduce.
|
||||
//!
|
||||
//! Modelo, deliberadamente chico:
|
||||
//! - [`ActorScript`] = keyframes `(t, posición de grilla, clip?, rumbo?)` de un
|
||||
//! actor; `sample(t)` interpola la posición y decide el [`Clip`] (auto: camina
|
||||
//! si se mueve, quieto si no) y el rumbo (auto: dirección de marcha).
|
||||
//! - [`Shot`] = un plano de cámara ([`CameraTrack`]) con su instante de inicio;
|
||||
//! varios planos dan **cortes duros** (cambia de plano sin interpolar).
|
||||
//! - [`Sequence`] = el reparto de scripts + la lista de planos + duración.
|
||||
//!
|
||||
//! Es **contenido puro** (coordenadas de grilla, sin terreno ni GPU): el que
|
||||
//! reproduce (la app) mapea la posición de grilla al relieve y posa cada
|
||||
//! [`Actor`](crate::Actor) (con su cross-fade de clips). Reusable por cualquier
|
||||
//! película/juego voxel.
|
||||
|
||||
use llimphi_3d::glam::Vec3;
|
||||
use llimphi_3d::{Camera3d, CameraTrack};
|
||||
|
||||
use crate::actor::Clip;
|
||||
|
||||
/// Umbral de desplazamiento (unidades de grilla por segmento) para considerar
|
||||
/// que un actor "se mueve" (y por tanto camina y mira hacia donde va).
|
||||
const MOVING_EPS: f32 = 0.05;
|
||||
|
||||
/// Envuelve un ángulo a `[-π, π]` (la diferencia más corta entre dos rumbos).
|
||||
fn wrap_pi(mut a: f32) -> f32 {
|
||||
use std::f32::consts::PI;
|
||||
while a > PI {
|
||||
a -= 2.0 * PI;
|
||||
}
|
||||
while a < -PI {
|
||||
a += 2.0 * PI;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Un keyframe de actor: dónde está (grilla), opcionalmente qué clip reproduce y
|
||||
/// hacia dónde mira. `clip`/`face` en `None` = automático.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ActorKey {
|
||||
/// Instante (segundos).
|
||||
pub t: f32,
|
||||
/// Posición de grilla en `t`.
|
||||
pub gx: f32,
|
||||
pub gz: f32,
|
||||
/// Clip explícito mientras se sale de esta key (`None` = auto: caminar/quieto).
|
||||
pub clip: Option<Clip>,
|
||||
/// Rumbo explícito (yaw; `None` = auto: dirección de marcha).
|
||||
pub face: Option<f32>,
|
||||
}
|
||||
|
||||
impl ActorKey {
|
||||
/// Key en `(gx, gz)` a tiempo `t`, clip/rumbo automáticos.
|
||||
pub fn at(t: f32, gx: f32, gz: f32) -> Self {
|
||||
Self { t, gx, gz, clip: None, face: None }
|
||||
}
|
||||
|
||||
/// Fija el clip a reproducir desde esta key (p.ej. quedarse saludando).
|
||||
pub fn play(mut self, clip: Clip) -> Self {
|
||||
self.clip = Some(clip);
|
||||
self
|
||||
}
|
||||
|
||||
/// Fija el rumbo (yaw) desde esta key (p.ej. mirar a la cámara al detenerse).
|
||||
pub fn facing(mut self, yaw: f32) -> Self {
|
||||
self.face = Some(yaw);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Lo que un [`ActorScript`] dicta para un instante: dónde poner al actor
|
||||
/// (grilla), hacia dónde mirar y qué clip animar.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ActorSample {
|
||||
pub gx: f32,
|
||||
pub gz: f32,
|
||||
pub facing: f32,
|
||||
pub clip: Clip,
|
||||
}
|
||||
|
||||
/// El guion de **un** actor: una lista de [`ActorKey`] ordenada en el tiempo.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ActorScript {
|
||||
keys: Vec<ActorKey>,
|
||||
}
|
||||
|
||||
impl ActorScript {
|
||||
/// Crea el guion (ordena las keys por `t`).
|
||||
pub fn new(mut keys: Vec<ActorKey>) -> Self {
|
||||
keys.sort_by(|a, b| a.t.total_cmp(&b.t));
|
||||
Self { keys }
|
||||
}
|
||||
|
||||
/// Tiempo de la última key (duración del guion del actor).
|
||||
pub fn duration(&self) -> f32 {
|
||||
self.keys.last().map(|k| k.t).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Instantes (segundos) en que este actor **arranca un gesto** (clip emote
|
||||
/// explícito en una key). Son momentos expresivos del guion — el director los usa
|
||||
/// para acentuar la música.
|
||||
pub fn emote_onsets(&self) -> Vec<f32> {
|
||||
self.keys.iter().filter(|k| k.clip.is_some_and(|c| c.is_emote())).map(|k| k.t).collect()
|
||||
}
|
||||
|
||||
/// El estado dictado en `t`: posición interpolada + clip + rumbo. Antes/
|
||||
/// después del rango, clampa a la primera/última key (quieto).
|
||||
pub fn sample(&self, t: f32) -> ActorSample {
|
||||
let keys = &self.keys;
|
||||
if keys.is_empty() {
|
||||
return ActorSample { gx: 0.0, gz: 0.0, facing: 0.0, clip: Clip::Idle };
|
||||
}
|
||||
let last = keys.len() - 1;
|
||||
// Extremos: quieto en la primera/última key.
|
||||
if t <= keys[0].t || last == 0 {
|
||||
let k = &keys[0];
|
||||
return ActorSample {
|
||||
gx: k.gx,
|
||||
gz: k.gz,
|
||||
facing: k.face.unwrap_or(0.0),
|
||||
clip: k.clip.unwrap_or(Clip::Idle),
|
||||
};
|
||||
}
|
||||
if t >= keys[last].t {
|
||||
let k = &keys[last];
|
||||
return ActorSample {
|
||||
gx: k.gx,
|
||||
gz: k.gz,
|
||||
facing: k.face.unwrap_or_else(|| self.motion_facing(last.saturating_sub(1))),
|
||||
clip: k.clip.unwrap_or(Clip::Idle),
|
||||
};
|
||||
}
|
||||
// Segmento `[i, i+1]` que contiene a `t`.
|
||||
let i = keys.iter().rposition(|k| k.t <= t).unwrap_or(0).min(last - 1);
|
||||
let (a, b) = (&keys[i], &keys[i + 1]);
|
||||
let f = ((t - a.t) / (b.t - a.t).max(1e-6)).clamp(0.0, 1.0);
|
||||
let (dx, dz) = (b.gx - a.gx, b.gz - a.gz);
|
||||
let moving = dx.hypot(dz) > MOVING_EPS;
|
||||
// Rumbo: si el guion lo fija en ambas keys, **gira suave** entre ambas
|
||||
// (por el camino más corto); si sólo en la de salida, lo mantiene; si en
|
||||
// ninguna, automático (dirección de marcha, o la última al detenerse).
|
||||
let facing = match (a.face, b.face) {
|
||||
(Some(fa), Some(fb)) => fa + wrap_pi(fb - fa) * f,
|
||||
(Some(fa), None) => fa,
|
||||
(None, _) if moving => dx.atan2(dz), // yaw=0 → +Z, como Actor::face_towards
|
||||
(None, _) => self.motion_facing(i),
|
||||
};
|
||||
ActorSample {
|
||||
gx: a.gx + dx * f,
|
||||
gz: a.gz + dz * f,
|
||||
clip: a.clip.unwrap_or(if moving { Clip::Walk } else { Clip::Idle }),
|
||||
facing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rumbo del último segmento *con movimiento* hasta el índice `i` (para
|
||||
/// mantener la orientación cuando el actor está detenido); `0` si nunca se
|
||||
/// movió.
|
||||
fn motion_facing(&self, i: usize) -> f32 {
|
||||
for j in (0..=i).rev() {
|
||||
if j + 1 < self.keys.len() {
|
||||
let (a, b) = (&self.keys[j], &self.keys[j + 1]);
|
||||
let (dx, dz) = (b.gx - a.gx, b.gz - a.gz);
|
||||
if dx.hypot(dz) > MOVING_EPS {
|
||||
return dx.atan2(dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Un **plano** de cámara: una [`CameraTrack`] que arranca en `start` (segundos
|
||||
/// absolutos de la secuencia). Sus keys son **relativas** al plano (`t=0` =
|
||||
/// inicio del plano), así un plano es autocontenido y reubicable.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Shot {
|
||||
pub start: f32,
|
||||
pub track: CameraTrack,
|
||||
}
|
||||
|
||||
impl Shot {
|
||||
pub fn new(start: f32, track: CameraTrack) -> Self {
|
||||
Self { start, track }
|
||||
}
|
||||
}
|
||||
|
||||
/// El **guion completo**: el reparto (un [`ActorScript`] por actor), los planos
|
||||
/// de cámara (con cortes duros entre ellos) y la duración total. Reproducir =
|
||||
/// para cada `t`: `camera(t)` + `actor.sample(t)` por actor.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Sequence {
|
||||
pub actors: Vec<ActorScript>,
|
||||
shots: Vec<Shot>,
|
||||
pub duration: f32,
|
||||
}
|
||||
|
||||
impl Sequence {
|
||||
/// Crea la secuencia (ordena los planos por inicio).
|
||||
pub fn new(actors: Vec<ActorScript>, mut shots: Vec<Shot>, duration: f32) -> Self {
|
||||
shots.sort_by(|a, b| a.start.total_cmp(&b.start));
|
||||
Self { actors, shots, duration }
|
||||
}
|
||||
|
||||
/// Cantidad de cuadros a `fps` para la duración total.
|
||||
pub fn frames(&self, fps: u32) -> u32 {
|
||||
(self.duration * fps as f32).round() as u32
|
||||
}
|
||||
|
||||
/// La cámara en `t`: el plano activo (el último cuyo `start ≤ t`) muestreado a
|
||||
/// `t − start`. El salto entre planos es un **corte duro** (sin interpolar).
|
||||
pub fn camera(&self, t: f32) -> Camera3d {
|
||||
let shot = self
|
||||
.shots
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| s.start <= t)
|
||||
.or_else(|| self.shots.first());
|
||||
match shot {
|
||||
Some(s) => s.track.sample(t - s.start),
|
||||
None => Camera3d::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Los **"beats del guion"**: los instantes (segundos, ordenados, sin repetir)
|
||||
/// que merecen un acento musical — los **cortes de cámara** (inicio de cada plano
|
||||
/// salvo el primero) y los **gestos** de los actores. Es lo que deja que la banda
|
||||
/// sonora caiga *sobre la acción* en vez de sólo compartir duración. Dos tiempos a
|
||||
/// menos de `EPS` se consideran el mismo acento.
|
||||
pub fn beat_times(&self) -> Vec<f32> {
|
||||
const EPS: f32 = 0.05;
|
||||
let mut ts: Vec<f32> = Vec::new();
|
||||
// Cortes de cámara (el primer plano no es un corte).
|
||||
for s in self.shots.iter().skip(1) {
|
||||
ts.push(s.start);
|
||||
}
|
||||
// Gestos de cada actor.
|
||||
for a in &self.actors {
|
||||
ts.extend(a.emote_onsets());
|
||||
}
|
||||
ts.retain(|&t| t >= 0.0 && t <= self.duration + EPS);
|
||||
ts.sort_by(f32::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < EPS);
|
||||
ts
|
||||
}
|
||||
|
||||
/// Posición de grilla (sin altura) del **centroide** del reparto en `t`,
|
||||
/// útil para apuntar la cámara al grupo. `None` si no hay actores.
|
||||
pub fn cast_centroid(&self, t: f32) -> Option<Vec3> {
|
||||
if self.actors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut sx = 0.0;
|
||||
let mut sz = 0.0;
|
||||
for a in &self.actors {
|
||||
let s = a.sample(t);
|
||||
sx += s.gx;
|
||||
sz += s.gz;
|
||||
}
|
||||
let n = self.actors.len() as f32;
|
||||
Some(Vec3::new(sx / n, 0.0, sz / n))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
|
||||
#[test]
|
||||
fn camina_y_luego_emota() {
|
||||
// De (0,0) a (10,0) en 2 s, después se queda saludando.
|
||||
let s = ActorScript::new(vec![
|
||||
ActorKey::at(0.0, 0.0, 0.0),
|
||||
ActorKey::at(2.0, 10.0, 0.0).play(Clip::Wave).facing(FRAC_PI_2),
|
||||
]);
|
||||
// A mitad del trayecto: posición media, caminando, mirando a +X.
|
||||
let m = s.sample(1.0);
|
||||
assert!((m.gx - 5.0).abs() < 1e-4);
|
||||
assert_eq!(m.clip, Clip::Walk);
|
||||
assert!((m.facing - FRAC_PI_2).abs() < 1e-4, "mueve en +X → yaw=π/2");
|
||||
// Al final: quieto, saludando, mirando a donde dijimos.
|
||||
let e = s.sample(3.0);
|
||||
assert_eq!((e.gx, e.clip), (10.0, Clip::Wave));
|
||||
assert!((e.facing - FRAC_PI_2).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beats_del_guion_son_cortes_y_gestos() {
|
||||
use llimphi_3d::CamKey;
|
||||
// Actor que camina y a los 3 s saluda; otro que a los 4 s señala.
|
||||
let a1 = ActorScript::new(vec![
|
||||
ActorKey::at(0.0, 0.0, 0.0),
|
||||
ActorKey::at(3.0, 5.0, 0.0).play(Clip::Wave),
|
||||
]);
|
||||
let a2 = ActorScript::new(vec![
|
||||
ActorKey::at(0.0, 2.0, 0.0),
|
||||
ActorKey::at(4.0, 6.0, 0.0).play(Clip::Point),
|
||||
]);
|
||||
let s0 = CameraTrack::new(vec![CamKey::look(0.0, Vec3::ZERO, Vec3::Z, 50.0)]);
|
||||
let s1 = CameraTrack::new(vec![CamKey::look(0.0, Vec3::Y, Vec3::Z, 50.0)]);
|
||||
// Dos planos: el corte está en 2.5 s (el primero no cuenta).
|
||||
let seq = Sequence::new(vec![a1, a2], vec![Shot::new(0.0, s0), Shot::new(2.5, s1)], 5.0);
|
||||
let beats = seq.beat_times();
|
||||
// Esperado: corte 2.5, saludo 3.0, señal 4.0 (ordenados, sin el inicio).
|
||||
assert_eq!(beats.len(), 3);
|
||||
assert!((beats[0] - 2.5).abs() < 1e-4);
|
||||
assert!((beats[1] - 3.0).abs() < 1e-4);
|
||||
assert!((beats[2] - 4.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cortes_de_camara_son_duros() {
|
||||
use llimphi_3d::CamKey;
|
||||
let near = CameraTrack::new(vec![CamKey::look(0.0, Vec3::ZERO, Vec3::Z, 50.0)]);
|
||||
let far = CameraTrack::new(vec![CamKey::look(0.0, Vec3::new(100.0, 0.0, 0.0), Vec3::Z, 50.0)]);
|
||||
let seq = Sequence::new(vec![], vec![Shot::new(0.0, near), Shot::new(1.0, far)], 2.0);
|
||||
// Antes del corte: plano cercano (eye en origen).
|
||||
assert!(seq.camera(0.5).eye.x.abs() < 1e-3);
|
||||
// Después del corte (t≥1): plano lejano, sin interpolación intermedia.
|
||||
assert!((seq.camera(1.5).eye.x - 100.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! # llimphi-voxel — dinámica voxel/juego sobre `llimphi-3d`
|
||||
//!
|
||||
//! Capa **opcional** y más comprometida con la "dinámica tipo-Minecraft":
|
||||
//! genera y maneja el *contenido* y la *interacción* de un mundo voxel sobre el
|
||||
//! motor 3D **general** [`llimphi_3d`], que es quien renderiza
|
||||
//! ([`llimphi_3d::Scene3d`] + [`llimphi_3d::VoxelRenderer`]).
|
||||
//!
|
||||
//! Rama de dependencias pensada como **librería reusable por cualquier juego
|
||||
//! voxel** de la suite: `app → llimphi-voxel → llimphi-3d → wgpu`. El motor
|
||||
//! (cámara, depth compartido, ray-march, mallas) no sabe nada de juegos; acá
|
||||
//! vive lo que sí: world-gen ([`terrain`]) y picking/edición ([`raycast`]).
|
||||
//! El resto (chunks, streaming, bloques tipados) crece acá sin tocar el motor.
|
||||
|
||||
mod actor;
|
||||
mod camera_rig;
|
||||
mod critter;
|
||||
mod director;
|
||||
mod lod;
|
||||
mod player;
|
||||
mod potential;
|
||||
mod raycast;
|
||||
mod studio;
|
||||
mod terrain;
|
||||
mod vox;
|
||||
mod world_stream;
|
||||
mod worldgen;
|
||||
|
||||
pub use actor::{Actor, Age, Build, Clip, Pose};
|
||||
pub use camera_rig::{cam_lerp, BirthSequence, CamMode};
|
||||
pub use director::{ActorKey, ActorSample, ActorScript, Sequence, Shot};
|
||||
pub use vox::{load_grid, load_scene_grid, model_to_grid, scene_to_grid, stamp, VoxLoadError};
|
||||
pub use critter::Critter;
|
||||
pub use lod::{lod_skirt, lod_skirt_pyramid, LodParams, LodRing};
|
||||
pub use player::{forward_h, look_dir, right_h, Player};
|
||||
pub use potential::{Egg, Hatchling};
|
||||
pub use raycast::{raycast, VoxelHit};
|
||||
pub use studio::{
|
||||
world_dim, ActorKeySpec, ActorSpec, CharSpec, NamedWorld, Project, SceneSpec, ShotKind,
|
||||
ShotSpec, PREVIEW_DIM_XZ,
|
||||
};
|
||||
pub use terrain::{column_height, fill_terrain_window, terrain};
|
||||
pub use world_stream::WorldStream;
|
||||
pub use worldgen::{Flora, Material, WorldRecipe};
|
||||
@@ -0,0 +1,290 @@
|
||||
//! LOD del horizonte: una **malla gruesa** del terreno circundante como telón de
|
||||
//! fondo, para que más allá de la ventana voxel streameada se vean colinas
|
||||
//! lejanas en vez de un muro de niebla. Es el híbrido clásico **voxel cerca /
|
||||
//! malla-LOD lejos**: se compone con los voxels finos por el **depth compartido**
|
||||
//! de [`Scene3d`](llimphi_3d::Scene3d) (los voxels ocluyen la malla donde se
|
||||
//! solapan; afuera, la malla muestra el relieve distante).
|
||||
//!
|
||||
//! La malla se genera muestreando [`column_height`](crate::column_height) a paso
|
||||
//! grueso (sin deps de render: el `Renderer3d` es flat-color, así que la luz y la
|
||||
//! **niebla por distancia** se hornean en el color de cada vértice en CPU,
|
||||
//! imitando la atmósfera del pase voxel para que el horizonte funda sin costura).
|
||||
|
||||
use llimphi_3d::Vertex3d;
|
||||
|
||||
use crate::terrain::column_height;
|
||||
|
||||
/// Mezcla lineal de dos colores RGB `[f32;3]`.
|
||||
#[inline]
|
||||
fn mix3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
[
|
||||
a[0] + (b[0] - a[0]) * t,
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
a[2] + (b[2] - a[2]) * t,
|
||||
]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn srgb(c: [u8; 3]) -> [f32; 3] {
|
||||
[c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0]
|
||||
}
|
||||
|
||||
/// Color del terreno (banda por altura) ya **sombreado** por `shade` (difuso) —
|
||||
/// imita las bandas de `terrain.rs` para que la costura con los voxels finos no
|
||||
/// salte. `fh` = altura normalizada; `is_water` pinta la superficie del mar.
|
||||
fn band(fh: f32, is_water: bool) -> [f32; 3] {
|
||||
if is_water {
|
||||
return srgb([44, 96, 140]);
|
||||
}
|
||||
let rock = srgb([88, 86, 92]);
|
||||
let snow = srgb([236, 240, 250]);
|
||||
let grass_lo = srgb([54, 110, 52]);
|
||||
let grass_hi = srgb([96, 150, 70]);
|
||||
let sand = srgb([196, 182, 130]);
|
||||
if fh < 0.33 {
|
||||
sand
|
||||
} else if fh < 0.55 {
|
||||
mix3(grass_lo, grass_hi, (fh - 0.33) / 0.22)
|
||||
} else if fh < 0.72 {
|
||||
mix3(grass_hi, rock, (fh - 0.55) / 0.17)
|
||||
} else if fh < 0.82 {
|
||||
rock
|
||||
} else {
|
||||
mix3(rock, snow, (fh - 0.82) / 0.10)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parámetros de la falda LOD.
|
||||
pub struct LodParams {
|
||||
/// Centro de la ventana en **mundo** `[wx, wz]` (la malla se centra ahí, igual
|
||||
/// que los voxels: posición renderizada = `mundo − centro`).
|
||||
pub center_xz: [i32; 2],
|
||||
/// Lado de la ventana voxel fina (voxels) — se deja un **hueco** ahí para que
|
||||
/// los voxels la llenen (la malla gruesa sólo rodea).
|
||||
pub window_xz: u32,
|
||||
/// Medio-alcance de la falda más allá del centro (voxels).
|
||||
pub span: i32,
|
||||
/// Paso de muestreo grueso (voxels). Mayor = más barato / más facetado.
|
||||
pub stride: i32,
|
||||
/// Color del horizonte (hacia el que funde la niebla) + densidad (espeja la
|
||||
/// `Atmosphere` del pase voxel).
|
||||
pub sky_horizon: [u8; 3],
|
||||
pub fog_density: f32,
|
||||
/// Dirección hacia el sol (para el sombreado difuso horneado).
|
||||
pub sun_dir: [f32; 3],
|
||||
}
|
||||
|
||||
/// Un **anillo** de la falda LOD piramidal: a qué paso se muestrea y hasta dónde
|
||||
/// llega. Un anillo más lejano usa `stride` mayor (más barato, más facetado) — así el
|
||||
/// horizonte se extiende mucho sin reventar el límite de 65 535 vértices de un `u16`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LodRing {
|
||||
/// Paso de muestreo de este anillo (voxels). Mayor = más grueso/barato.
|
||||
pub stride: i32,
|
||||
/// Medio-alcance de este anillo desde el centro (voxels).
|
||||
pub span: i32,
|
||||
}
|
||||
|
||||
/// Genera la malla de la falda LOD para una ventana (un solo nivel). Devuelve
|
||||
/// `(vértices, índices u16)` listos para
|
||||
/// [`Renderer3d::set_geometry`](llimphi_3d::Renderer3d). `dim`/`seed` definen el mismo
|
||||
/// terreno procedural que los voxels. Para horizontes enormes usá
|
||||
/// [`lod_skirt_pyramid`] (varios anillos de paso creciente).
|
||||
pub fn lod_skirt(p: &LodParams, dim: [u32; 3], seed: u32) -> (Vec<Vertex3d>, Vec<u16>) {
|
||||
let stride = p.stride.max(1);
|
||||
let half = p.window_xz as i32 / 2;
|
||||
// Margen: solapar un poco el hueco con la ventana para que no quede una rendija
|
||||
// entre la malla y los voxels (el depth resuelve la oclusión del solape).
|
||||
let hole = (half - stride).max(0);
|
||||
ring_mesh(p, dim, seed, hole, p.span.max(stride), stride)
|
||||
}
|
||||
|
||||
/// Genera una **falda LOD piramidal**: varios anillos concéntricos de paso creciente,
|
||||
/// cada uno como su **propia malla** (así ninguno pasa el límite `u16`, y el conjunto
|
||||
/// cubre un horizonte mucho mayor que un nivel único). Los anillos deben venir
|
||||
/// ordenados de **adentro hacia afuera** (`span` creciente). El anillo interno deja el
|
||||
/// hueco de la ventana voxel fina; cada anillo siguiente arranca solapando un paso con
|
||||
/// el borde del anterior (sin rendija; el depth compartido resuelve el solape).
|
||||
/// Devuelve **una malla por anillo** — la app sube cada una a un `Renderer3d`.
|
||||
pub fn lod_skirt_pyramid(
|
||||
p: &LodParams,
|
||||
dim: [u32; 3],
|
||||
seed: u32,
|
||||
rings: &[LodRing],
|
||||
) -> Vec<(Vec<Vertex3d>, Vec<u16>)> {
|
||||
let half = p.window_xz as i32 / 2;
|
||||
let mut out = Vec::with_capacity(rings.len());
|
||||
// El primer anillo arranca en el borde de la ventana voxel (con un paso de solape).
|
||||
let mut inner = (half - rings.first().map(|r| r.stride.max(1)).unwrap_or(1)).max(0);
|
||||
for (k, r) in rings.iter().enumerate() {
|
||||
let stride = r.stride.max(1);
|
||||
let span = r.span.max(stride);
|
||||
out.push(ring_mesh(p, dim, seed, inner, span, stride));
|
||||
// El próximo anillo solapa un paso (suyo) con el borde de éste.
|
||||
let next_stride = rings.get(k + 1).map(|n| n.stride.max(1)).unwrap_or(0);
|
||||
inner = (span - next_stride).max(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Núcleo compartido: malla de un anillo `[-span, span]²` a paso `stride`, dejando un
|
||||
/// **hueco cuadrado** central de medio-lado `inner` (lo llena el nivel más fino o la
|
||||
/// ventana voxel). La luz difusa + la niebla por distancia se hornean en el color.
|
||||
fn ring_mesh(
|
||||
p: &LodParams,
|
||||
dim: [u32; 3],
|
||||
seed: u32,
|
||||
inner: i32,
|
||||
span: i32,
|
||||
stride: i32,
|
||||
) -> (Vec<Vertex3d>, Vec<u16>) {
|
||||
let [cx, cz] = p.center_xz;
|
||||
let dy = dim[1] as f32;
|
||||
let sea = (dim[1] as f32 * 0.30) as i32;
|
||||
let hole = inner.max(0);
|
||||
|
||||
// Altura renderizada de la columna de mundo (tierra o superficie del mar).
|
||||
let surf = |wx: i32, wz: i32| -> (i32, bool) {
|
||||
let h = column_height(wx, wz, dim, seed) as i32;
|
||||
if h < sea {
|
||||
(sea, true)
|
||||
} else {
|
||||
(h, false)
|
||||
}
|
||||
};
|
||||
|
||||
let sun = {
|
||||
let s = p.sun_dir;
|
||||
let l = (s[0] * s[0] + s[1] * s[1] + s[2] * s[2]).sqrt().max(1e-6);
|
||||
[s[0] / l, s[1] / l, s[2] / l]
|
||||
};
|
||||
let sky = srgb(p.sky_horizon);
|
||||
|
||||
// Grilla de vértices [cx-span, cx+span] × [cz-span, cz+span] a paso `stride`.
|
||||
let n = ((2 * span) / stride) as usize + 1;
|
||||
let mut verts: Vec<Vertex3d> = Vec::with_capacity(n * n);
|
||||
let coord = |i: usize| -> i32 { -span + i as i32 * stride };
|
||||
|
||||
for iz in 0..n {
|
||||
let wz = cz + coord(iz);
|
||||
for ix in 0..n {
|
||||
let wx = cx + coord(ix);
|
||||
let (h, water) = surf(wx, wz);
|
||||
// Normal por diferencias centrales del relieve (para el sombreado).
|
||||
let (hl, _) = surf(wx - stride, wz);
|
||||
let (hr, _) = surf(wx + stride, wz);
|
||||
let (hd, _) = surf(wx, wz - stride);
|
||||
let (hu, _) = surf(wx, wz + stride);
|
||||
let nx = (hl - hr) as f32;
|
||||
let nz = (hd - hu) as f32;
|
||||
let ny = 2.0 * stride as f32;
|
||||
let nl = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
|
||||
let ndl = ((nx * sun[0] + ny * sun[1] + nz * sun[2]) / nl).max(0.0);
|
||||
let shade = 0.45 + 0.6 * ndl; // ambiente + difuso (≈ pase voxel)
|
||||
|
||||
let fh = h as f32 / dy;
|
||||
let mut color = band(fh, water);
|
||||
color = [color[0] * shade, color[1] * shade, color[2] * shade];
|
||||
|
||||
// Niebla por distancia al centro (≈ cámara): funde al horizonte.
|
||||
let (dxf, dzf) = ((wx - cx) as f32, (wz - cz) as f32);
|
||||
let dist = (dxf * dxf + dzf * dzf).sqrt();
|
||||
// Cap < 1: el horizonte conserva algo de silueta/relieve (no se lava a
|
||||
// cielo puro), que es lo que hace legible que "el mundo sigue".
|
||||
let fog = (1.0 - (-dist * p.fog_density).exp()).min(0.9);
|
||||
color = mix3(color, sky, fog);
|
||||
|
||||
verts.push(Vertex3d {
|
||||
pos: [(wx - cx) as f32, h as f32 - dy * 0.5, (wz - cz) as f32],
|
||||
color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Índices: dos triángulos por celda, salvo las celdas dentro del hueco central
|
||||
// (las llena la ventana voxel fina).
|
||||
let mut indices: Vec<u16> = Vec::new();
|
||||
for iz in 0..n - 1 {
|
||||
for ix in 0..n - 1 {
|
||||
// Centro de la celda en mundo, para el test del hueco.
|
||||
let mwx = coord(ix) + stride / 2;
|
||||
let mwz = coord(iz) + stride / 2;
|
||||
if mwx.abs() < hole && mwz.abs() < hole {
|
||||
continue; // dentro de la ventana fina → la llenan los voxels
|
||||
}
|
||||
let a = (iz * n + ix) as u16;
|
||||
let b = (iz * n + ix + 1) as u16;
|
||||
let c = ((iz + 1) * n + ix) as u16;
|
||||
let d = ((iz + 1) * n + ix + 1) as u16;
|
||||
// CCW vista desde arriba.
|
||||
indices.extend_from_slice(&[a, c, b, b, c, d]);
|
||||
}
|
||||
}
|
||||
|
||||
(verts, indices)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skirt_genera_geometria_con_hueco() {
|
||||
let dim = [128, 56, 128];
|
||||
let p = LodParams {
|
||||
center_xz: [0, 0],
|
||||
window_xz: 128,
|
||||
span: 256,
|
||||
stride: 8,
|
||||
sky_horizon: [200, 216, 234],
|
||||
fog_density: 0.01,
|
||||
sun_dir: [0.5, 0.6, 0.3],
|
||||
};
|
||||
let (verts, indices) = lod_skirt(&p, dim, 1);
|
||||
assert!(!verts.is_empty() && !indices.is_empty());
|
||||
assert_eq!(indices.len() % 3, 0, "triángulos completos");
|
||||
assert!(*indices.iter().max().unwrap() < verts.len() as u16, "índices en rango");
|
||||
// Debe haber un hueco: menos triángulos que la grilla completa.
|
||||
let n = ((2 * p.span) / p.stride) as usize + 1;
|
||||
let full = (n - 1) * (n - 1) * 2;
|
||||
assert!(indices.len() / 3 < full, "el hueco recortó triángulos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piramide_apila_anillos_crecientes() {
|
||||
let dim = [128, 56, 128];
|
||||
let p = LodParams {
|
||||
center_xz: [1000, -500], // lejos del origen: el streaming de verdad
|
||||
window_xz: 128,
|
||||
span: 0, // ignorado por la pirámide (cada anillo trae el suyo)
|
||||
stride: 0,
|
||||
sky_horizon: [200, 216, 234],
|
||||
fog_density: 0.004,
|
||||
sun_dir: [0.5, 0.6, 0.3],
|
||||
};
|
||||
// Tres niveles: fino cerca, grueso lejos — el horizonte llega a 1536 voxels.
|
||||
let rings = [
|
||||
LodRing { stride: 6, span: 256 },
|
||||
LodRing { stride: 16, span: 640 },
|
||||
LodRing { stride: 40, span: 1536 },
|
||||
];
|
||||
let meshes = lod_skirt_pyramid(&p, dim, 1, &rings);
|
||||
assert_eq!(meshes.len(), 3, "una malla por anillo");
|
||||
for (k, (verts, indices)) in meshes.iter().enumerate() {
|
||||
assert!(!verts.is_empty() && !indices.is_empty(), "anillo {k} no vacío");
|
||||
assert_eq!(indices.len() % 3, 0, "triángulos completos en anillo {k}");
|
||||
assert!(*indices.iter().max().unwrap() < verts.len() as u16, "índices en rango anillo {k}");
|
||||
// Cada anillo respeta el límite u16 (es su propia malla).
|
||||
assert!(verts.len() <= u16::MAX as usize, "anillo {k} bajo el tope u16: {}", verts.len());
|
||||
}
|
||||
// El anillo externo, pese a cubrir un área ~6× mayor que el interno, usa menos
|
||||
// vértices (paso mucho más grueso) — el punto de la pirámide.
|
||||
assert!(
|
||||
meshes[2].0.len() < meshes[0].0.len(),
|
||||
"el anillo lejano es más barato: ext {} vs int {}",
|
||||
meshes[2].0.len(),
|
||||
meshes[0].0.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Física de jugador en primera persona sobre un [`VoxelGrid`] — el otro
|
||||
//! ingrediente núcleo de un juego voxel (además del picking de [`raycast`]):
|
||||
//! **caminar el mundo con gravedad y colisión**. Reusable por cualquier juego
|
||||
//! voxel de la suite; no toca la GPU ni el render.
|
||||
//!
|
||||
//! Coordenadas en **espacio de grilla** (voxel = 1, mundo en `[0, dim]`), las
|
||||
//! mismas que [`raycast`](crate::raycast) y `eye_mundo + dim/2`. El jugador es
|
||||
//! una caja AABB parada sobre el terreno; `pos` es el **centro de los pies**
|
||||
//! (x/z centrados, y en la base). La colisión se resuelve eje por eje
|
||||
//! (move-and-resolve), el método clásico y robusto para voxels.
|
||||
//!
|
||||
//! La mirada (`yaw`/`pitch`) la lleva la app (compartida con la cámara
|
||||
//! `orbit`/`fly`); acá viven sólo las helpers puras de base
|
||||
//! ([`forward_h`]/[`right_h`]/[`look_dir`]) para derivar el vector de avance.
|
||||
|
||||
use llimphi_3d::glam::Vec3;
|
||||
use llimphi_3d::VoxelGrid;
|
||||
|
||||
/// Semi-ancho del jugador en X/Z (la caja mide `2·HALF_W` de lado).
|
||||
const HALF_W: f32 = 0.3;
|
||||
/// Altura total de la caja (de los pies a la coronilla).
|
||||
const HEIGHT: f32 = 1.7;
|
||||
/// Altura del ojo sobre los pies (la cámara va acá).
|
||||
const EYE: f32 = 1.55;
|
||||
/// Aceleración de gravedad (voxels/s²).
|
||||
const GRAVITY: f32 = 30.0;
|
||||
/// Velocidad horizontal de caminata (voxels/s).
|
||||
const MOVE_SPEED: f32 = 8.0;
|
||||
/// Velocidad vertical inicial del salto (voxels/s).
|
||||
const JUMP_SPEED: f32 = 9.0;
|
||||
/// Margen para no “tocar” el voxel del plano siguiente por error de redondeo.
|
||||
const EPS: f32 = 1e-3;
|
||||
|
||||
/// Avance horizontal unitario para `yaw` (en grilla): `yaw=0` mira a `+Z`.
|
||||
pub fn forward_h(yaw: f32) -> Vec3 {
|
||||
let (s, c) = yaw.sin_cos();
|
||||
Vec3::new(s, 0.0, c)
|
||||
}
|
||||
|
||||
/// "Derecha" horizontal unitaria para `yaw` (perpendicular a [`forward_h`],
|
||||
/// con la convención de mano derecha del motor: a `yaw=0`, derecha = `+X`).
|
||||
pub fn right_h(yaw: f32) -> Vec3 {
|
||||
let (s, c) = yaw.sin_cos();
|
||||
Vec3::new(c, 0.0, -s)
|
||||
}
|
||||
|
||||
/// Dirección de mirada completa (incluye `pitch`), misma convención que
|
||||
/// [`Camera3d::fly`](llimphi_3d::Camera3d::fly). Útil como `dir` del raycast.
|
||||
pub fn look_dir(yaw: f32, pitch: f32) -> Vec3 {
|
||||
let (sy, cy) = yaw.sin_cos();
|
||||
let (sp, cp) = pitch.sin_cos();
|
||||
Vec3::new(cp * sy, sp, cp * cy)
|
||||
}
|
||||
|
||||
/// Cuerpo físico AABB con velocidad, parado sobre el terreno. Lo usa tanto el
|
||||
/// jugador (input directo) como los agentes ([`Critter`](crate::Critter), wander
|
||||
/// automático) — misma física, distinta voluntad.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Player {
|
||||
/// Centro de los pies (espacio de grilla).
|
||||
pub pos: Vec3,
|
||||
/// Velocidad actual (voxels/s).
|
||||
pub vel: Vec3,
|
||||
/// `true` si el último paso terminó apoyado en suelo (habilita el salto).
|
||||
pub on_ground: bool,
|
||||
/// Velocidad de caminata horizontal (voxels/s). Editable por cuerpo: el
|
||||
/// jugador anda rápido, un bicho puede pastar lento.
|
||||
pub speed: f32,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// Jugador parado con los pies en `feet` (centro de los pies, grilla).
|
||||
pub fn new(feet: Vec3) -> Self {
|
||||
Self {
|
||||
pos: feet,
|
||||
vel: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
speed: MOVE_SPEED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un jugador posado sobre la columna `(x, z)` del grid: pies un voxel
|
||||
/// por encima del suelo más alto (o sobre el piso `y=0` si la columna está
|
||||
/// vacía). Centrado en el voxel (`+0.5`).
|
||||
pub fn spawn_on(grid: &VoxelGrid, x: u32, z: u32) -> Self {
|
||||
let top = grid.height_at(x, z).map(|y| y as f32 + 1.0).unwrap_or(0.0);
|
||||
Self::new(Vec3::new(x as f32 + 0.5, top + EPS, z as f32 + 0.5))
|
||||
}
|
||||
|
||||
/// Posición del ojo (cámara) en espacio de grilla.
|
||||
pub fn eye(&self) -> Vec3 {
|
||||
self.pos + Vec3::new(0.0, EYE, 0.0)
|
||||
}
|
||||
|
||||
/// Avanza la física un paso `dt` segundos. `wish` es la dirección de
|
||||
/// caminata **horizontal** deseada (no necesita estar normalizada; se usa
|
||||
/// su dirección × [`MOVE_SPEED`]); `jump` solicita un salto (sólo prende si
|
||||
/// `on_ground`). Resuelve colisión contra `grid` eje por eje.
|
||||
pub fn step(&mut self, grid: &VoxelGrid, wish: Vec3, jump: bool, dt: f32) {
|
||||
let dt = dt.clamp(0.0, 0.05); // nunca un salto de física monstruoso
|
||||
|
||||
// Velocidad horizontal: directa desde el deseo (sin inercia, simple).
|
||||
let flat = Vec3::new(wish.x, 0.0, wish.z);
|
||||
let h = if flat.length_squared() > 1e-6 {
|
||||
flat.normalize() * self.speed
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
self.vel.x = h.x;
|
||||
self.vel.z = h.z;
|
||||
|
||||
// Gravedad + salto.
|
||||
self.vel.y -= GRAVITY * dt;
|
||||
if jump && self.on_ground {
|
||||
self.vel.y = JUMP_SPEED;
|
||||
}
|
||||
|
||||
// Mover y resolver eje por eje. on_ground se re-evalúa cada paso.
|
||||
self.on_ground = false;
|
||||
self.move_axis(grid, 0, self.vel.x * dt);
|
||||
self.move_axis(grid, 1, self.vel.y * dt);
|
||||
self.move_axis(grid, 2, self.vel.z * dt);
|
||||
}
|
||||
|
||||
/// AABB `[min, max]` del jugador en `pos`.
|
||||
fn aabb_at(&self, pos: Vec3) -> (Vec3, Vec3) {
|
||||
(
|
||||
Vec3::new(pos.x - HALF_W, pos.y, pos.z - HALF_W),
|
||||
Vec3::new(pos.x + HALF_W, pos.y + HEIGHT, pos.z + HALF_W),
|
||||
)
|
||||
}
|
||||
|
||||
/// Intenta desplazar `amount` en `axis` (0=X,1=Y,2=Z); si la caja chocaría
|
||||
/// con un sólido, cancela el movimiento en ese eje y anula la velocidad
|
||||
/// (marcando `on_ground` si fue al bajar).
|
||||
fn move_axis(&mut self, grid: &VoxelGrid, axis: usize, amount: f32) {
|
||||
if amount == 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut np = self.pos;
|
||||
np[axis] += amount;
|
||||
let (min, max) = self.aabb_at(np);
|
||||
if aabb_hits_solid(grid, min, max) {
|
||||
if axis == 1 && amount < 0.0 {
|
||||
self.on_ground = true;
|
||||
}
|
||||
self.vel[axis] = 0.0;
|
||||
} else {
|
||||
self.pos = np;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` si la caja `[min, max]` solapa algún voxel sólido del grid. Recorre
|
||||
/// las celdas enteras que toca la caja (cada voxel `(i,j,k)` ocupa
|
||||
/// `[i,i+1)³`).
|
||||
fn aabb_hits_solid(grid: &VoxelGrid, min: Vec3, max: Vec3) -> bool {
|
||||
let x0 = min.x.floor() as i32;
|
||||
let y0 = min.y.floor() as i32;
|
||||
let z0 = min.z.floor() as i32;
|
||||
// `-EPS` para que tocar exacto el plano del voxel siguiente no lo cuente.
|
||||
let x1 = (max.x - EPS).floor() as i32;
|
||||
let y1 = (max.y - EPS).floor() as i32;
|
||||
let z1 = (max.z - EPS).floor() as i32;
|
||||
for z in z0..=z1 {
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
if grid.is_solid(x, y, z) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Piso sólido en `y=0` (una capa) sobre un grid 8³.
|
||||
fn grid_con_piso() -> VoxelGrid {
|
||||
let mut g = VoxelGrid::new([8, 8, 8]);
|
||||
for z in 0..8 {
|
||||
for x in 0..8 {
|
||||
g.set(x, 0, z, [100, 100, 100]);
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cae_y_se_apoya_en_el_piso() {
|
||||
let g = grid_con_piso();
|
||||
// Arranca flotando bien arriba.
|
||||
let mut p = Player::new(Vec3::new(4.5, 5.0, 4.5));
|
||||
for _ in 0..240 {
|
||||
p.step(&g, Vec3::ZERO, false, 1.0 / 60.0);
|
||||
}
|
||||
// El piso ocupa [0,1); los pies deben quedar apoyados a y≈1.
|
||||
assert!(p.on_ground, "debería terminar en el suelo");
|
||||
assert!((p.pos.y - 1.0).abs() < 0.05, "pies en y≈1, dio {}", p.pos.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn una_pared_frena_el_avance() {
|
||||
let mut g = grid_con_piso();
|
||||
// Muro en x=6, toda la altura.
|
||||
for y in 0..8 {
|
||||
for z in 0..8 {
|
||||
g.set(6, y, z, [80, 80, 80]);
|
||||
}
|
||||
}
|
||||
let mut p = Player::new(Vec3::new(4.5, 1.0, 4.5));
|
||||
// Camina hacia +X contra el muro un buen rato.
|
||||
for _ in 0..120 {
|
||||
p.step(&g, Vec3::X, false, 1.0 / 60.0);
|
||||
}
|
||||
// No puede atravesar: su borde derecho (pos.x + HALF_W) queda en x<6.
|
||||
assert!(p.pos.x + HALF_W <= 6.0 + EPS, "atravesó el muro: x={}", p.pos.x);
|
||||
assert!(p.pos.x > 4.5, "debería haber avanzado algo hasta el muro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salta_solo_desde_el_suelo() {
|
||||
let g = grid_con_piso();
|
||||
let mut p = Player::new(Vec3::new(4.5, 1.0, 4.5));
|
||||
// Un paso para asentar on_ground.
|
||||
p.step(&g, Vec3::ZERO, false, 1.0 / 60.0);
|
||||
assert!(p.on_ground);
|
||||
// Salta: la velocidad vertical se vuelve positiva y despega.
|
||||
p.step(&g, Vec3::ZERO, true, 1.0 / 60.0);
|
||||
assert!(p.vel.y > 0.0, "el salto debe dar velocidad ascendente");
|
||||
assert!(p.pos.y > 1.0, "debe despegar del piso");
|
||||
// En el aire, un segundo intento de salto no reinyecta velocidad.
|
||||
let antes = p.vel.y;
|
||||
p.step(&g, Vec3::ZERO, true, 1.0 / 60.0);
|
||||
assert!(p.vel.y < antes, "no debe re-saltar en el aire");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! **Objeto potencial**: una cosa colocada en el mundo en su forma *latente* que,
|
||||
//! ante un disparador (acá: el aterrizaje/nacimiento), **eclosiona** en la entidad
|
||||
//! que será. El primero es el [`Egg`] — un *huevo* que lleva adentro su potencial
|
||||
//! ([`Hatchling`]: qué nace, con qué edad y colores) y al abrirse **da a luz** un
|
||||
//! [`Actor`] recién nacido.
|
||||
//!
|
||||
//! Es la pieza del corto: la cámara cae, ve el huevo, y al tocar suelo el huevo se
|
||||
//! abre y nace el niño. La forma generaliza (otros objetos potenciales podrían nacer
|
||||
//! plantas, animales, estructuras); hoy el caso es el *huevito humano*.
|
||||
|
||||
use llimphi_3d::glam::{Mat4, Vec3};
|
||||
use llimphi_3d::{push_cube, Vertex3d};
|
||||
|
||||
use crate::actor::{Actor, Age};
|
||||
|
||||
/// `T(center) · S(size)` — caja centrada en `center` escalada a `size`.
|
||||
fn trs(center: Vec3, size: Vec3) -> Mat4 {
|
||||
Mat4::from_translation(center) * Mat4::from_scale(size)
|
||||
}
|
||||
|
||||
/// **Lo que un huevo va a ser**: el potencial latente. Hoy un personaje (edad +
|
||||
/// colores); el motor lo materializa como [`Actor`] al nacer.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Hatchling {
|
||||
/// Edad con la que nace (el corto: [`Age::Baby`]).
|
||||
pub age: Age,
|
||||
pub skin: [f32; 3],
|
||||
pub shirt: [f32; 3],
|
||||
pub pants: [f32; 3],
|
||||
}
|
||||
|
||||
impl Hatchling {
|
||||
/// Un **huevito humano**: nace bebé, con una paleta cálida por defecto.
|
||||
pub fn human(age: Age) -> Self {
|
||||
Self {
|
||||
age,
|
||||
skin: [0.90, 0.74, 0.60],
|
||||
shirt: [0.86, 0.46, 0.44],
|
||||
pants: [0.22, 0.24, 0.32],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **Huevo** — un objeto potencial con forma de ovoide voxel. `hatch` va de `0`
|
||||
/// (intacto) a `1` (abierto: la tapa superior se levanta y se inclina, dejando
|
||||
/// salir al recién nacido). `pos` es la **base** del huevo en mundo.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Egg {
|
||||
/// Base del huevo, en mundo (mismas coords que el terreno/actor).
|
||||
pub pos: Vec3,
|
||||
/// Rumbo (yaw) — orienta al recién nacido y la apertura.
|
||||
pub facing: f32,
|
||||
/// Altura del huevo.
|
||||
pub size: f32,
|
||||
/// Color de la cáscara.
|
||||
pub shell: [f32; 3],
|
||||
/// Progreso de eclosión `[0,1]` (0 = intacto, 1 = abierto).
|
||||
pub hatch: f32,
|
||||
/// Qué nace de él.
|
||||
pub becomes: Hatchling,
|
||||
}
|
||||
|
||||
/// Fracción de la altura donde el huevo se "raja" (la tapa de arriba es la que se
|
||||
/// abre).
|
||||
const CRACK: f32 = 0.55;
|
||||
/// Slabs verticales con los que se aproxima el ovoide.
|
||||
const SLABS: usize = 9;
|
||||
|
||||
impl Egg {
|
||||
/// Huevo intacto en `pos` (base) de altura `size`, con el potencial `becomes`.
|
||||
pub fn new(pos: Vec3, size: f32, becomes: Hatchling) -> Self {
|
||||
Self { pos, facing: 0.0, size, shell: [0.94, 0.92, 0.86], hatch: 0.0, becomes }
|
||||
}
|
||||
|
||||
/// Avanza la eclosión `dt` segundos a `rate` (`1/seg` ⇒ se abre en ~1 s).
|
||||
pub fn advance(&mut self, dt: f32, rate: f32) {
|
||||
self.hatch = (self.hatch + dt * rate).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// `true` cuando el huevo terminó de abrirse.
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.hatch >= 1.0
|
||||
}
|
||||
|
||||
/// Matriz de ubicación en mundo (igual rol que [`Actor::model`]).
|
||||
pub fn model(&self) -> Mat4 {
|
||||
Mat4::from_translation(self.pos) * Mat4::from_rotation_y(self.facing)
|
||||
}
|
||||
|
||||
/// **Da a luz**: materializa el potencial como un [`Actor`] recién nacido, en la
|
||||
/// posición y rumbo del huevo, con la edad/colores de [`Hatchling`].
|
||||
pub fn newborn(&self) -> Actor {
|
||||
Actor::new(self.pos, self.facing)
|
||||
.with_age(self.becomes.age)
|
||||
.with_colors(self.becomes.skin, self.becomes.shirt, self.becomes.pants)
|
||||
}
|
||||
|
||||
/// Malla del cascarón en espacio local (base en el origen, ubicar con
|
||||
/// [`model`](Self::model)). Ovoide por slabs horizontales; la tapa por encima de
|
||||
/// [`CRACK`] **se separa** (se levanta y se inclina) según `hatch`, bisagra en la
|
||||
/// línea de la rajadura.
|
||||
pub fn mesh(&self) -> (Vec<Vertex3d>, Vec<u16>) {
|
||||
let mut v = Vec::with_capacity(8 * SLABS);
|
||||
let mut i = Vec::with_capacity(36 * SLABS);
|
||||
let half_w = self.size * 0.42; // medio-ancho máximo (en la panza)
|
||||
let thick = self.size / SLABS as f32;
|
||||
let pivot = Vec3::new(0.0, CRACK * self.size, 0.0);
|
||||
|
||||
// Transform de la tapa: bisagra en la rajadura, se levanta y se inclina.
|
||||
let lift = self.hatch * self.size * 0.75;
|
||||
let tilt = self.hatch * 0.9;
|
||||
let cap = Mat4::from_translation(pivot + Vec3::new(0.0, lift, 0.0))
|
||||
* Mat4::from_rotation_z(tilt)
|
||||
* Mat4::from_translation(-pivot);
|
||||
|
||||
for k in 0..SLABS {
|
||||
let tc = (k as f32 + 0.5) / SLABS as f32; // centro del slab en [0,1]
|
||||
// Medio-ancho del ovoide (elipse): 0 en los polos, máx en la panza.
|
||||
let r = (half_w * (4.0 * tc * (1.0 - tc)).sqrt()).max(self.size * 0.04);
|
||||
let y = tc * self.size;
|
||||
let base = trs(Vec3::new(0.0, y, 0.0), Vec3::new(r * 2.0, thick * 1.04, r * 2.0));
|
||||
let m = if tc > CRACK { cap * base } else { base };
|
||||
push_cube(&mut v, &mut i, m, self.shell);
|
||||
}
|
||||
(v, i)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn el_recien_nacido_hereda_edad_y_lugar() {
|
||||
let pos = Vec3::new(5.0, -2.0, 3.0);
|
||||
let egg = Egg::new(pos, 1.0, Hatchling::human(Age::Baby));
|
||||
let baby = egg.newborn();
|
||||
assert_eq!(baby.age, Age::Baby);
|
||||
assert_eq!(baby.pos, pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn la_eclosion_avanza_y_se_satura() {
|
||||
let mut egg = Egg::new(Vec3::ZERO, 1.0, Hatchling::human(Age::Baby));
|
||||
assert!(!egg.is_open());
|
||||
egg.advance(0.5, 1.0);
|
||||
assert!((egg.hatch - 0.5).abs() < 1e-5);
|
||||
egg.advance(10.0, 1.0); // se pasa → se clampa
|
||||
assert!(egg.is_open() && egg.hatch == 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn al_abrirse_la_tapa_se_levanta() {
|
||||
let intact = Egg::new(Vec3::ZERO, 2.0, Hatchling::human(Age::Baby));
|
||||
let mut open = intact;
|
||||
open.hatch = 1.0;
|
||||
let top = |e: &Egg| e.mesh().0.iter().map(|v| v.pos[1]).fold(f32::MIN, f32::max);
|
||||
assert!(top(&open) > top(&intact) + 0.5, "la tapa abierta queda más alta: {} vs {}", top(&open), top(&intact));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Picking/edición por **raycast de voxels** — la mecánica núcleo de un juego
|
||||
//! voxel (mirar → bloque). DDA Amanatides-Woo en CPU sobre un [`VoxelGrid`],
|
||||
//! espejo del traversal que hace el shader. Devuelve el voxel sólido golpeado,
|
||||
//! la cara de entrada (normal) y la celda vacía adyacente donde *colocar*.
|
||||
//!
|
||||
//! Coordenadas en **espacio de grilla** (voxel = 1, el mundo ocupa `[0, dim]`).
|
||||
//! El motor centra la grilla en el origen, así que para tirar el rayo desde la
|
||||
//! cámara: `origin_grilla = eye_mundo + dim/2`. [`raycast`] no toca la GPU;
|
||||
//! editar = `grid.set/clear` + `VoxelRenderer::sync` (subida incremental).
|
||||
|
||||
use llimphi_3d::VoxelGrid;
|
||||
|
||||
/// Resultado de un [`raycast`] que pegó en un voxel sólido.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct VoxelHit {
|
||||
/// Voxel sólido golpeado (coordenadas de grilla).
|
||||
pub cell: [i32; 3],
|
||||
/// Normal de la cara de entrada (uno de ±X/±Y/±Z), apuntando al aire desde
|
||||
/// donde vino el rayo. `[0,0,0]` si el origen ya estaba dentro de un sólido.
|
||||
pub normal: [i32; 3],
|
||||
/// Celda vacía adyacente (`cell + normal`): dónde **colocaría** un bloque
|
||||
/// nuevo un click de "construir".
|
||||
pub place: [i32; 3],
|
||||
/// Distancia (en unidades de voxel) del origen al impacto.
|
||||
pub dist: f32,
|
||||
}
|
||||
|
||||
/// Marcha un rayo `origin + t·dir` por la grilla hasta el primer voxel sólido,
|
||||
/// hasta `max_dist` unidades. `dir` no necesita estar normalizado (se normaliza
|
||||
/// acá, y `dist` queda en unidades de voxel). `None` si no pega nada.
|
||||
pub fn raycast(grid: &VoxelGrid, origin: [f32; 3], dir: [f32; 3], max_dist: f32) -> Option<VoxelHit> {
|
||||
// Normalizar para que t sea distancia real.
|
||||
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
|
||||
if len < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let d = [dir[0] / len, dir[1] / len, dir[2] / len];
|
||||
|
||||
// Celda inicial y parámetros DDA por eje.
|
||||
let mut cell = [
|
||||
origin[0].floor() as i32,
|
||||
origin[1].floor() as i32,
|
||||
origin[2].floor() as i32,
|
||||
];
|
||||
let mut step = [0i32; 3];
|
||||
let mut t_max = [f32::INFINITY; 3];
|
||||
let mut t_delta = [f32::INFINITY; 3];
|
||||
for a in 0..3 {
|
||||
if d[a] > 1e-9 {
|
||||
step[a] = 1;
|
||||
t_delta[a] = 1.0 / d[a];
|
||||
t_max[a] = (cell[a] as f32 + 1.0 - origin[a]) / d[a];
|
||||
} else if d[a] < -1e-9 {
|
||||
step[a] = -1;
|
||||
t_delta[a] = -1.0 / d[a];
|
||||
t_max[a] = (cell[a] as f32 - origin[a]) / d[a];
|
||||
}
|
||||
}
|
||||
|
||||
// ¿El origen ya está dentro de un sólido? (cavar desde adentro)
|
||||
if grid.is_solid(cell[0], cell[1], cell[2]) {
|
||||
return Some(VoxelHit { cell, normal: [0, 0, 0], place: cell, dist: 0.0 });
|
||||
}
|
||||
|
||||
let mut t;
|
||||
let mut normal = [0i32; 3];
|
||||
// Tope de pasos generoso (suma de extensiones + margen) para no colgar.
|
||||
let dim = grid.dim();
|
||||
let max_steps = (dim[0] + dim[1] + dim[2]) as i32 * 2 + 8;
|
||||
for _ in 0..max_steps {
|
||||
// Avanzar al siguiente plano de voxel (eje con menor t_max).
|
||||
let axis = if t_max[0] < t_max[1] && t_max[0] < t_max[2] {
|
||||
0
|
||||
} else if t_max[1] < t_max[2] {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
t = t_max[axis];
|
||||
if t > max_dist {
|
||||
return None;
|
||||
}
|
||||
cell[axis] += step[axis];
|
||||
t_max[axis] += t_delta[axis];
|
||||
normal = [0, 0, 0];
|
||||
normal[axis] = -step[axis]; // cara por la que entramos
|
||||
|
||||
if grid.is_solid(cell[0], cell[1], cell[2]) {
|
||||
return Some(VoxelHit {
|
||||
cell,
|
||||
normal,
|
||||
place: [cell[0] + normal[0], cell[1] + normal[1], cell[2] + normal[2]],
|
||||
dist: t,
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = normal;
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pega_un_voxel_solo_y_da_cara_y_place() {
|
||||
// Grid 8³ con un único voxel sólido en (4,4,4).
|
||||
let mut g = VoxelGrid::new([8, 8, 8]);
|
||||
g.set(4, 4, 4, [200, 50, 50]);
|
||||
|
||||
// Rayo desde -X hacia +X a la altura/profundidad del voxel.
|
||||
let hit = raycast(&g, [0.5, 4.5, 4.5], [1.0, 0.0, 0.0], 100.0).expect("debe pegar");
|
||||
assert_eq!(hit.cell, [4, 4, 4]);
|
||||
assert_eq!(hit.normal, [-1, 0, 0]); // entró por la cara -X
|
||||
assert_eq!(hit.place, [3, 4, 4]); // colocar queda un voxel antes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pega_aire() {
|
||||
let g = VoxelGrid::new([8, 8, 8]);
|
||||
assert!(raycast(&g, [0.5, 0.5, 0.5], [1.0, 0.0, 0.0], 100.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respeta_max_dist() {
|
||||
let mut g = VoxelGrid::new([64, 8, 8]);
|
||||
g.set(60, 4, 4, [10, 10, 10]);
|
||||
// El voxel está a ~55 unidades; con max_dist 10 no debe alcanzarlo.
|
||||
assert!(raycast(&g, [4.5, 4.5, 4.5], [1.0, 0.0, 0.0], 10.0).is_none());
|
||||
assert!(raycast(&g, [4.5, 4.5, 4.5], [1.0, 0.0, 0.0], 100.0).is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! `studio` — el **modelo-documento del creador de mundos**: un [`Project`]
|
||||
//! agnóstico de UI que junta los *artefactos* (mundos, personajes, escenas) bajo
|
||||
//! nombre, para que **una interfaz** los cree/edite y la **IA** los emita o lea.
|
||||
//!
|
||||
//! Es contenido puro y **serializable** (RON para edición a mano / salida de la IA;
|
||||
//! postcard para la CAS): no toca GPU ni ventana. La studio app (o cualquier otra)
|
||||
//! lo carga, lo pinta con sus widgets y lo guarda. Cada artefacto referencia tipos
|
||||
//! que ya existen en este crate ([`WorldRecipe`], [`Age`]) — el `Project` sólo les
|
||||
//! pone nombre y los agrupa.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::actor::{Actor, Age, Clip};
|
||||
use crate::director::{ActorKey, ActorScript};
|
||||
use crate::worldgen::WorldRecipe;
|
||||
use llimphi_3d::glam::Vec3;
|
||||
use llimphi_3d::Camera3d;
|
||||
|
||||
/// Dimensión por defecto de la grilla con la que el editor previsualiza un mundo
|
||||
/// (cúbica en XZ, alto = 0.4·lado, mínimo 48) — el mismo criterio que la app.
|
||||
pub const PREVIEW_DIM_XZ: u32 = 128;
|
||||
|
||||
/// Calcula el `dim` `[x, y, z]` de un mundo de lado `xz` (alto derivado).
|
||||
pub fn world_dim(xz: u32) -> [u32; 3] {
|
||||
let dy = (xz * 4 / 10).max(48);
|
||||
[xz, dy, xz]
|
||||
}
|
||||
|
||||
/// Un **mundo nombrado** del proyecto: nombre + su [`WorldRecipe`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NamedWorld {
|
||||
pub name: String,
|
||||
pub recipe: WorldRecipe,
|
||||
}
|
||||
|
||||
impl NamedWorld {
|
||||
pub fn new(name: impl Into<String>, recipe: WorldRecipe) -> Self {
|
||||
Self { name: name.into(), recipe }
|
||||
}
|
||||
}
|
||||
|
||||
/// **Especificación serializable de un personaje**: lo que un editor/IA fija (edad
|
||||
/// + colores). Se materializa con [`to_actor`](Self::to_actor) en un [`Actor`]
|
||||
/// posable. Los colores son `[r, g, b]` en `[0,1]` (como [`Actor`]).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CharSpec {
|
||||
pub name: String,
|
||||
pub age: Age,
|
||||
pub skin: [f32; 3],
|
||||
pub shirt: [f32; 3],
|
||||
pub pants: [f32; 3],
|
||||
}
|
||||
|
||||
impl CharSpec {
|
||||
/// Un personaje con la paleta por defecto de [`Actor::new`] a la edad dada.
|
||||
pub fn new(name: impl Into<String>, age: Age) -> Self {
|
||||
let a = Actor::new(Vec3::ZERO, 0.0);
|
||||
Self { name: name.into(), age, skin: a.skin, shirt: a.shirt, pants: a.pants }
|
||||
}
|
||||
|
||||
/// Materializa el spec en un [`Actor`] parado en `pos` mirando a `facing`.
|
||||
pub fn to_actor(&self, pos: Vec3, facing: f32) -> Actor {
|
||||
Actor::new(pos, facing)
|
||||
.with_age(self.age)
|
||||
.with_colors(self.skin, self.shirt, self.pants)
|
||||
}
|
||||
}
|
||||
|
||||
/// **Keyframe serializable** de un actor (espejo de [`ActorKey`]): dónde está en
|
||||
/// la grilla en `t`, y opcionalmente qué clip reproduce y hacia dónde mira.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct ActorKeySpec {
|
||||
pub t: f32,
|
||||
pub gx: f32,
|
||||
pub gz: f32,
|
||||
#[serde(default)]
|
||||
pub clip: Option<Clip>,
|
||||
#[serde(default)]
|
||||
pub face: Option<f32>,
|
||||
}
|
||||
|
||||
impl ActorKeySpec {
|
||||
/// Compila a un [`ActorKey`] del director.
|
||||
pub fn to_key(self) -> ActorKey {
|
||||
let mut k = ActorKey::at(self.t, self.gx, self.gz);
|
||||
if let Some(c) = self.clip {
|
||||
k = k.play(c);
|
||||
}
|
||||
if let Some(f) = self.face {
|
||||
k = k.facing(f);
|
||||
}
|
||||
k
|
||||
}
|
||||
}
|
||||
|
||||
/// **Actor de una escena**: qué personaje del proyecto lo interpreta (`character`,
|
||||
/// índice en [`Project::characters`]) y su guion de keyframes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActorSpec {
|
||||
pub character: usize,
|
||||
pub keys: Vec<ActorKeySpec>,
|
||||
}
|
||||
|
||||
impl ActorSpec {
|
||||
/// Compila los keyframes a un [`ActorScript`] reproducible.
|
||||
pub fn to_script(&self) -> ActorScript {
|
||||
ActorScript::new(self.keys.iter().map(|k| k.to_key()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// **Tipo de plano** de cámara: un encuadre cinematográfico de alto nivel que se
|
||||
/// resuelve contra el **centroide del reparto** (no contra `eye/target` crudos), así
|
||||
/// es trivial de elegir y de generar por IA. [`resolve`](Self::resolve) produce la
|
||||
/// [`Camera3d`] del frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ShotKind {
|
||||
/// Establecedor: lejos y alto, presenta la escena.
|
||||
Establishing,
|
||||
/// Primer plano: cerca, a la altura del pecho.
|
||||
CloseUp,
|
||||
/// Lateral: desde el costado.
|
||||
Side,
|
||||
/// Órbita: gira lento alrededor del reparto.
|
||||
Orbit,
|
||||
}
|
||||
|
||||
impl ShotKind {
|
||||
/// Todos los planos (para ciclar en un editor).
|
||||
pub const ALL: [ShotKind; 4] =
|
||||
[ShotKind::Establishing, ShotKind::CloseUp, ShotKind::Side, ShotKind::Orbit];
|
||||
|
||||
/// Nombre legible (español).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ShotKind::Establishing => "establecedor",
|
||||
ShotKind::CloseUp => "primer plano",
|
||||
ShotKind::Side => "lateral",
|
||||
ShotKind::Orbit => "órbita",
|
||||
}
|
||||
}
|
||||
|
||||
/// El plano siguiente (cicla).
|
||||
pub fn next(self) -> ShotKind {
|
||||
let i = ShotKind::ALL.iter().position(|&k| k == self).unwrap_or(0);
|
||||
ShotKind::ALL[(i + 1) % ShotKind::ALL.len()]
|
||||
}
|
||||
|
||||
/// Resuelve la cámara del plano: mira a `look` (centroide del reparto, ya
|
||||
/// elevado a la altura del pecho), con el ojo según el tipo, a distancia base
|
||||
/// `d` (escala con el tamaño del reparto). `t` (seg) anima la órbita.
|
||||
pub fn resolve(self, look: Vec3, d: f32, t: f32) -> Camera3d {
|
||||
let (eye, fov) = match self {
|
||||
ShotKind::Establishing => {
|
||||
(look + Vec3::new(-0.5 * d, 0.9 * d, -1.6 * d), 50.0)
|
||||
}
|
||||
ShotKind::CloseUp => (look + Vec3::new(0.25 * d, 0.45 * d, -0.85 * d), 40.0),
|
||||
ShotKind::Side => (look + Vec3::new(1.35 * d, 0.4 * d, 0.15 * d), 46.0),
|
||||
ShotKind::Orbit => {
|
||||
let a = t * 0.6;
|
||||
(look + Vec3::new(a.cos() * 1.3 * d, 0.6 * d, a.sin() * 1.3 * d), 48.0)
|
||||
}
|
||||
};
|
||||
Camera3d { eye, target: look, fovy_rad: fov_f32_to_rad(fov), ..Camera3d::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Grados → radianes (helper local para no depender de glam en el call site).
|
||||
fn fov_f32_to_rad(deg: f32) -> f32 {
|
||||
deg * std::f32::consts::PI / 180.0
|
||||
}
|
||||
|
||||
/// Un **plano** de la escena: el tipo de encuadre y desde qué instante (seg) está
|
||||
/// activo. El plano vigente en `t` es el último con `start ≤ t` (corte duro entre
|
||||
/// planos).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct ShotSpec {
|
||||
pub start: f32,
|
||||
pub kind: ShotKind,
|
||||
}
|
||||
|
||||
/// **Especificación serializable de una escena**: el mundo de fondo (`world`,
|
||||
/// índice en [`Project::worlds`]), la duración, el reparto guionado y los **planos**
|
||||
/// de cámara. Es la versión editable/IA-emisible del [`Sequence`](crate::Sequence)
|
||||
/// del director; se compila con [`scripts`](Self::scripts) y se reproduce posando
|
||||
/// cada actor en `sample(t)` con la cámara del plano vigente.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneSpec {
|
||||
pub name: String,
|
||||
pub world: usize,
|
||||
pub duration: f32,
|
||||
pub actors: Vec<ActorSpec>,
|
||||
#[serde(default)]
|
||||
pub shots: Vec<ShotSpec>,
|
||||
}
|
||||
|
||||
impl SceneSpec {
|
||||
/// Los guiones de los actores, listos para `sample(t)`.
|
||||
pub fn scripts(&self) -> Vec<ActorScript> {
|
||||
self.actors.iter().map(|a| a.to_script()).collect()
|
||||
}
|
||||
|
||||
/// Los **instantes (seg) que merecen un acento musical**: los cortes de cámara
|
||||
/// (inicio de cada plano salvo el primero) y los **gestos** de los actores (keys
|
||||
/// con un clip *emote*). Es lo que deja caer la banda sonora *sobre la acción*.
|
||||
/// Ordenados, sin repetir (dos a menos de `EPS` se funden). Espeja
|
||||
/// [`Sequence::beat_times`](crate::Sequence::beat_times).
|
||||
pub fn beat_times(&self) -> Vec<f32> {
|
||||
const EPS: f32 = 0.05;
|
||||
let mut ts: Vec<f32> = Vec::new();
|
||||
for s in self.shots.iter().skip(1) {
|
||||
ts.push(s.start);
|
||||
}
|
||||
for a in &self.actors {
|
||||
for k in &a.keys {
|
||||
if k.clip.is_some_and(|c| c.is_emote()) {
|
||||
ts.push(k.t);
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.retain(|&t| t >= 0.0 && t <= self.duration + EPS);
|
||||
ts.sort_by(f32::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < EPS);
|
||||
ts
|
||||
}
|
||||
|
||||
/// El plano vigente en `t` (el último con `start ≤ t`); `Establishing` si no
|
||||
/// hay planos definidos.
|
||||
pub fn active_shot(&self, t: f32) -> ShotKind {
|
||||
self.shots
|
||||
.iter()
|
||||
.filter(|s| s.start <= t)
|
||||
.last()
|
||||
.map(|s| s.kind)
|
||||
.unwrap_or(ShotKind::Establishing)
|
||||
}
|
||||
|
||||
/// **Escena patrón "entran y saludan"**: `n` actores entran caminando por el
|
||||
/// centro del mundo de izquierda a derecha, se giran y hacen `gesture`. Coords
|
||||
/// de grilla (la altura del terreno se aplica al reproducir). La base tanto del
|
||||
/// arranque como de la generación por IA.
|
||||
pub fn walk_and_emote(
|
||||
name: impl Into<String>,
|
||||
world: usize,
|
||||
n: usize,
|
||||
gesture: Clip,
|
||||
dim: [u32; 3],
|
||||
) -> Self {
|
||||
use std::f32::consts::{FRAC_PI_2, PI};
|
||||
let n = n.clamp(1, 5);
|
||||
let margin = 18.0_f32;
|
||||
let gx0 = margin;
|
||||
let gx1 = (dim[0] as f32 - margin).max(gx0 + 1.0);
|
||||
let cz = dim[2] as f32 * 0.5;
|
||||
let (t_walk, t_turn, dur) = (2.6_f32, 3.0_f32, 5.6_f32);
|
||||
|
||||
let mut actors = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let off = (i as f32 - (n as f32 - 1.0) / 2.0) * 3.0;
|
||||
let gz = cz + off;
|
||||
actors.push(ActorSpec {
|
||||
character: i,
|
||||
keys: vec![
|
||||
ActorKeySpec { t: 0.0, gx: gx0, gz, clip: None, face: None },
|
||||
ActorKeySpec { t: t_walk, gx: gx1, gz, clip: None, face: Some(FRAC_PI_2) },
|
||||
ActorKeySpec { t: t_turn, gx: gx1, gz, clip: Some(gesture), face: Some(PI) },
|
||||
ActorKeySpec { t: dur, gx: gx1, gz, clip: Some(gesture), face: Some(PI) },
|
||||
],
|
||||
});
|
||||
}
|
||||
// Dos planos: establecedor durante la caminata, primer plano en el gesto.
|
||||
let shots = vec![
|
||||
ShotSpec { start: 0.0, kind: ShotKind::Establishing },
|
||||
ShotSpec { start: t_turn, kind: ShotKind::CloseUp },
|
||||
];
|
||||
Self { name: name.into(), world, duration: dur, actors, shots }
|
||||
}
|
||||
}
|
||||
|
||||
/// El **proyecto**: la bolsa de artefactos del creador (mundos, personajes,
|
||||
/// escenas). Vacío por defecto; [`starter`](Self::starter) trae algo que tocar.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub worlds: Vec<NamedWorld>,
|
||||
#[serde(default)]
|
||||
pub characters: Vec<CharSpec>,
|
||||
#[serde(default)]
|
||||
pub scenes: Vec<SceneSpec>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Proyecto de arranque: el desierto y la pradera, un trío de personajes
|
||||
/// distinguibles y una escena demo (entran y saludan en el desierto).
|
||||
pub fn starter() -> Self {
|
||||
let characters = vec![
|
||||
CharSpec { name: "rojo".into(), age: Age::Adult, skin: [0.90, 0.72, 0.58], shirt: [0.82, 0.28, 0.26], pants: [0.20, 0.20, 0.28] },
|
||||
CharSpec { name: "azul".into(), age: Age::Adult, skin: [0.86, 0.68, 0.54], shirt: [0.22, 0.55, 0.78], pants: [0.18, 0.20, 0.24] },
|
||||
CharSpec { name: "amarillo".into(), age: Age::Adult, skin: [0.92, 0.78, 0.62], shirt: [0.92, 0.80, 0.30], pants: [0.26, 0.22, 0.20] },
|
||||
];
|
||||
let dim = world_dim(PREVIEW_DIM_XZ);
|
||||
Self {
|
||||
worlds: vec![
|
||||
NamedWorld::new("desierto", WorldRecipe::desert(1337)),
|
||||
NamedWorld::new("pradera", WorldRecipe::grassland(1337)),
|
||||
],
|
||||
characters,
|
||||
scenes: vec![SceneSpec::walk_and_emote("saludo en el desierto", 0, 3, Clip::Wave, dim)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Agrega un mundo y devuelve su índice.
|
||||
pub fn add_world(&mut self, w: NamedWorld) -> usize {
|
||||
self.worlds.push(w);
|
||||
self.worlds.len() - 1
|
||||
}
|
||||
|
||||
/// Agrega una escena y devuelve su índice.
|
||||
pub fn add_scene(&mut self, s: SceneSpec) -> usize {
|
||||
self.scenes.push(s);
|
||||
self.scenes.len() - 1
|
||||
}
|
||||
|
||||
/// Personaje `i`, o uno por defecto si el índice se sale (escenas que piden
|
||||
/// más actores que personajes hay).
|
||||
pub fn character_or_default(&self, i: usize) -> CharSpec {
|
||||
self.characters
|
||||
.get(i)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| CharSpec::new("actor", Age::Adult))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn proyecto_round_trip_ron() {
|
||||
let p = Project::starter();
|
||||
let s = ron::ser::to_string(&p).expect("serializa a ron");
|
||||
let back: Project = ron::from_str(&s).expect("deserializa de ron");
|
||||
assert_eq!(back.worlds.len(), p.worlds.len());
|
||||
assert_eq!(back.worlds[0].name, "desierto");
|
||||
// La receta sobrevive el viaje (un parámetro de muestra).
|
||||
assert!((back.worlds[0].recipe.base - p.worlds[0].recipe.base).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charspec_se_materializa_con_la_edad() {
|
||||
let spec = CharSpec::new("nene", Age::Baby);
|
||||
let actor = spec.to_actor(Vec3::ZERO, 0.0);
|
||||
assert_eq!(actor.age, Age::Baby);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_dim_minimo_48_de_alto() {
|
||||
assert_eq!(world_dim(64)[1], 48); // 64*0.4=25.6 → clamp a 48
|
||||
assert_eq!(world_dim(192)[1], 76);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escena_round_trip_y_compila_a_guiones() {
|
||||
let dim = world_dim(128);
|
||||
let s = SceneSpec::walk_and_emote("demo", 0, 3, Clip::Wave, dim);
|
||||
// RON ida y vuelta.
|
||||
let txt = ron::ser::to_string(&s).expect("ron");
|
||||
let back: SceneSpec = ron::from_str(&txt).expect("de-ron");
|
||||
assert_eq!(back.actors.len(), 3);
|
||||
// Compila a guiones reproducibles: a mitad de la caminata el actor se movió.
|
||||
let scripts = back.scripts();
|
||||
let start = scripts[0].sample(0.0);
|
||||
let mid = scripts[0].sample(1.3);
|
||||
assert!(mid.gx > start.gx, "el actor avanza en X: {} → {}", start.gx, mid.gx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plano_vigente_corta_en_el_tiempo() {
|
||||
let dim = world_dim(128);
|
||||
let s = SceneSpec::walk_and_emote("demo", 0, 2, Clip::Wave, dim);
|
||||
// Arranca en establecedor; tras el giro (t≈3) pasa a primer plano.
|
||||
assert_eq!(s.active_shot(0.5), ShotKind::Establishing);
|
||||
assert_eq!(s.active_shot(3.5), ShotKind::CloseUp);
|
||||
// El plano resuelve una cámara que mira al centroide.
|
||||
let look = Vec3::new(10.0, 2.0, 10.0);
|
||||
let cam = ShotKind::CloseUp.resolve(look, 9.0, 1.0);
|
||||
assert_eq!(cam.target, look);
|
||||
assert!((cam.eye - look).length() > 1.0, "el ojo está separado del objetivo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beats_son_cortes_y_gestos() {
|
||||
let dim = world_dim(128);
|
||||
let s = SceneSpec::walk_and_emote("demo", 0, 2, Clip::Wave, dim);
|
||||
// walk_and_emote: corte de cámara a t_turn (3.0) + el gesto Wave a t_turn.
|
||||
// Caen en el mismo instante → se funden en un solo beat.
|
||||
let beats = s.beat_times();
|
||||
assert!(!beats.is_empty(), "hay al menos un acento");
|
||||
assert!(beats.iter().all(|&t| t >= 0.0 && t <= s.duration + 0.1));
|
||||
assert!(beats.iter().any(|&t| (t - 3.0).abs() < 0.2), "acento cerca del gesto/corte");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_trae_escena_y_personajes() {
|
||||
let p = Project::starter();
|
||||
assert_eq!(p.characters.len(), 3);
|
||||
assert_eq!(p.scenes.len(), 1);
|
||||
assert_eq!(p.scenes[0].world, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! World-gen procedural: terreno por ruido fractal (fbm propio, sin deps) →
|
||||
//! [`VoxelGrid`] coloreado por bandas de altura/pendiente (agua, arena, pasto,
|
||||
//! roca, nieve) con árboles. Contenido reusable por cualquier app/juego voxel.
|
||||
//!
|
||||
//! El terreno se define como **función pura de coordenadas de mundo** ([`column_height`]):
|
||||
//! el mismo punto `(wx, wz)` da siempre el mismo relieve, sin importar en qué
|
||||
//! ventana caiga. Eso es lo que hace posible el *streaming* (M6): mover una
|
||||
//! ventana acotada por un mundo ilimitado y que las costuras encajen
|
||||
//! ([`fill_terrain_window`] + [`WorldStream`](crate::WorldStream)).
|
||||
|
||||
use llimphi_3d::VoxelGrid;
|
||||
|
||||
/// Hash entero → `f32` en `[0, 1)`. Mezcla estilo PCG/xxhash chico, determinista.
|
||||
/// Funciona con coordenadas negativas (`as u32` envuelve de forma estable).
|
||||
#[inline]
|
||||
pub(crate) fn hash2(x: i32, y: i32, seed: u32) -> f32 {
|
||||
let mut h = seed
|
||||
.wrapping_add((x as u32).wrapping_mul(0x9E37_79B9))
|
||||
.wrapping_add((y as u32).wrapping_mul(0x85EB_CA77));
|
||||
h ^= h >> 15;
|
||||
h = h.wrapping_mul(0x2C1B_3C6D);
|
||||
h ^= h >> 12;
|
||||
h = h.wrapping_mul(0x297A_2D39);
|
||||
h ^= h >> 15;
|
||||
(h & 0x00FF_FFFF) as f32 / 0x0100_0000 as f32
|
||||
}
|
||||
|
||||
/// Suaviza `t` con la curva quíntica de Perlin (`6t⁵−15t⁴+10t³`).
|
||||
#[inline]
|
||||
pub(crate) fn smooth(t: f32) -> f32 {
|
||||
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||
}
|
||||
|
||||
/// Ruido de valor bilineal en `(x, y)` continuos sobre la lattice entera.
|
||||
/// `floor` maneja negativos (continuidad sobre coordenadas de mundo con signo).
|
||||
fn value_noise(x: f32, y: f32, seed: u32) -> f32 {
|
||||
let xi = x.floor() as i32;
|
||||
let yi = y.floor() as i32;
|
||||
let xf = smooth(x - xi as f32);
|
||||
let yf = smooth(y - yi as f32);
|
||||
let a = hash2(xi, yi, seed);
|
||||
let b = hash2(xi + 1, yi, seed);
|
||||
let c = hash2(xi, yi + 1, seed);
|
||||
let d = hash2(xi + 1, yi + 1, seed);
|
||||
let top = a + (b - a) * xf;
|
||||
let bot = c + (d - c) * xf;
|
||||
top + (bot - top) * yf
|
||||
}
|
||||
|
||||
/// Fractional Brownian motion: suma de octavas de `value_noise` (frecuencia ×2,
|
||||
/// amplitud ×`gain` por octava). Devuelve `[0, 1]` aprox (normalizado).
|
||||
pub(crate) fn fbm(x: f32, y: f32, octaves: u32, seed: u32) -> f32 {
|
||||
let mut freq = 1.0;
|
||||
let mut amp = 1.0;
|
||||
let mut sum = 0.0;
|
||||
let mut norm = 0.0;
|
||||
for o in 0..octaves {
|
||||
sum += value_noise(x * freq, y * freq, seed.wrapping_add(o.wrapping_mul(7919))) * amp;
|
||||
norm += amp;
|
||||
freq *= 2.0;
|
||||
amp *= 0.5;
|
||||
}
|
||||
sum / norm
|
||||
}
|
||||
|
||||
/// Mezcla lineal de dos colores RGB.
|
||||
#[inline]
|
||||
fn mix(a: [u8; 3], b: [u8; 3], t: f32) -> [u8; 3] {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
[
|
||||
(a[0] as f32 + (b[0] as f32 - a[0] as f32) * t) as u8,
|
||||
(a[1] as f32 + (b[1] as f32 - a[1] as f32) * t) as u8,
|
||||
(a[2] as f32 + (b[2] as f32 - a[2] as f32) * t) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Frecuencia espacial del relieve: ~4 colinas grandes a lo ancho de una ventana
|
||||
/// de lado `span`. Es función del **tamaño de ventana** (constante mientras la
|
||||
/// ventana no cambie de tamaño), así dos ventanas del mismo `dim` comparten la
|
||||
/// misma escala → el streaming encaja. `min_h`/`amp` salen del alto del mundo.
|
||||
#[inline]
|
||||
pub(crate) fn world_scale(dim: [u32; 3]) -> f32 {
|
||||
4.0 / dim[0].max(dim[2]) as f32
|
||||
}
|
||||
|
||||
/// Nivel del mar (índice `y`) para un mundo de alto `dy`.
|
||||
#[inline]
|
||||
fn sea_level(dy: u32) -> u32 {
|
||||
(dy as f32 * 0.30) as u32
|
||||
}
|
||||
|
||||
/// Altura del terreno (índice `y` del voxel sólido superior) en la columna de
|
||||
/// **mundo** `(wx, wz)`, para un mundo de dimensiones `dim` y `seed`. Es una
|
||||
/// **función pura**: el mismo punto da la misma altura en cualquier ventana —
|
||||
/// la clave de la continuidad del streaming. Combina un fbm base estirado
|
||||
/// (océanos↔picos) con un término *ridged* sólo en lo alto (crestas afiladas).
|
||||
pub fn column_height(wx: i32, wz: i32, dim: [u32; 3], seed: u32) -> u32 {
|
||||
let dy = dim[1];
|
||||
let scale = world_scale(dim);
|
||||
let min_h = (dy as f32 * 0.03) as u32;
|
||||
let amp = dy as f32 * 0.95;
|
||||
|
||||
let c = fbm(wx as f32 * scale, wz as f32 * scale, 6, seed);
|
||||
let e0 = ((c - 0.35) * 2.0).clamp(0.0, 1.0);
|
||||
let e = e0 * e0 * (3.0 - 2.0 * e0); // smoothstep → mesetas + valles
|
||||
let ridge =
|
||||
1.0 - (fbm(wx as f32 * scale * 2.3, wz as f32 * scale * 2.3, 5, seed ^ 99) - 0.5).abs() * 2.0;
|
||||
let e = (e + e * e * ridge * 0.55).min(1.0);
|
||||
(min_h + (e * amp) as u32).min(dy - 1)
|
||||
}
|
||||
|
||||
/// Padding (en voxels) alrededor de la ventana para precomputar alturas: cubre
|
||||
/// el cálculo de pendiente (±1) y la copa de los árboles rooteados afuera (±2).
|
||||
const PAD: i32 = 3;
|
||||
|
||||
/// Rellena `g` con el paisaje voxel cuya esquina local `(0,0)` cae en la columna
|
||||
/// de **mundo** `origin = [wx, wz]`. Vacía el grid primero (`clear_all`) y lo
|
||||
/// deja **dirty** para que `VoxelRenderer::sync` re-suba (o reconstruir el
|
||||
/// renderer). Es la primitiva del streaming: dos ventanas contiguas encajan
|
||||
/// porque todo sale de [`column_height`] (función de mundo).
|
||||
///
|
||||
/// `terrain(dim, seed)` es el caso `origin = [0, 0]` con el dirty reseteado.
|
||||
pub fn fill_terrain_window(g: &mut VoxelGrid, origin: [i32; 2], seed: u32) {
|
||||
let dim = g.dim();
|
||||
let [dx, dy, dz] = dim;
|
||||
let sea = sea_level(dy);
|
||||
let (ox, oz) = (origin[0], origin[1]);
|
||||
|
||||
g.clear_all();
|
||||
|
||||
let rock = [88, 86, 92];
|
||||
let snow = [236, 240, 250];
|
||||
let grass_lo = [54, 110, 52];
|
||||
let grass_hi = [96, 150, 70];
|
||||
let sand = [196, 182, 130];
|
||||
let deep = [22, 52, 96];
|
||||
let shallow = [44, 110, 150];
|
||||
|
||||
// Heightmap precomputado sobre la ventana padeada (PAD a cada lado): da
|
||||
// pendiente y copas correctas en las costuras sin recomputar fbm de más.
|
||||
let pw = dx as i32 + 2 * PAD;
|
||||
let pd = dz as i32 + 2 * PAD;
|
||||
let mut heights = vec![0u32; (pw * pd) as usize];
|
||||
for lz in 0..pd {
|
||||
for lx in 0..pw {
|
||||
let wx = ox + lx - PAD;
|
||||
let wz = oz + lz - PAD;
|
||||
heights[(lx + lz * pw) as usize] = column_height(wx, wz, dim, seed);
|
||||
}
|
||||
}
|
||||
// Altura en coordenada LOCAL de ventana (puede ser negativa hasta -PAD).
|
||||
let h_at = |lx: i32, lz: i32| heights[((lx + PAD) + (lz + PAD) * pw) as usize];
|
||||
|
||||
// Terreno + agua, columna por columna de la ventana.
|
||||
for lz in 0..dz {
|
||||
for lx in 0..dx {
|
||||
let (li, lj) = (lx as i32, lz as i32);
|
||||
let (wx, wz) = (ox + li, oz + lj);
|
||||
let h = h_at(li, lj);
|
||||
// Pendiente: diferencia con vecinos → roca en acantilados.
|
||||
let slope = (h as i32 - h_at(li - 1, lj) as i32)
|
||||
.abs()
|
||||
.max((h as i32 - h_at(li, lj - 1) as i32).abs()) as f32;
|
||||
|
||||
for y in 0..=h.min(dy - 1) {
|
||||
let fh = y as f32 / dy as f32;
|
||||
// Jitter por ruido en coordenadas de MUNDO (seamless entre ventanas).
|
||||
let jitter = hash2(wx, wz.wrapping_mul(31).wrapping_add(y as i32), seed ^ 0xABCD) * 0.06 - 0.03;
|
||||
let band = fh + jitter;
|
||||
let col = if y == h && slope > 2.5 && band > 0.34 {
|
||||
rock
|
||||
} else if band < 0.33 {
|
||||
sand
|
||||
} else if band < 0.55 {
|
||||
mix(grass_lo, grass_hi, (band - 0.33) / 0.22)
|
||||
} else if band < 0.72 {
|
||||
mix(grass_hi, rock, (band - 0.55) / 0.17)
|
||||
} else if band < 0.82 {
|
||||
rock
|
||||
} else {
|
||||
mix(rock, snow, (band - 0.82) / 0.10)
|
||||
};
|
||||
g.set(lx, y, lz, col);
|
||||
}
|
||||
|
||||
// Agua: llena lo vacío bajo el nivel del mar (lagos/océano).
|
||||
if h < sea {
|
||||
for y in (h + 1)..=sea.min(dy - 1) {
|
||||
let depth = (sea - y) as f32 / sea.max(1) as f32;
|
||||
g.set(lx, y, lz, mix(shallow, deep, depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Árboles: roots barridos sobre la ventana padeada (±2) para que las copas
|
||||
// de árboles rooteados justo afuera asomen dentro, y las de borde se recorten.
|
||||
let leaf_base = [40, 96, 44];
|
||||
let leaf_hi = [62, 124, 58];
|
||||
let trunk = [96, 64, 38];
|
||||
for lz in -2..dz as i32 + 2 {
|
||||
for lx in -2..dx as i32 + 2 {
|
||||
let (wx, wz) = (ox + lx, oz + lz);
|
||||
let h = h_at(lx, lz);
|
||||
let fh = h as f32 / dy as f32;
|
||||
if h <= sea + 1 || fh < 0.33 || fh > 0.56 {
|
||||
continue;
|
||||
}
|
||||
if hash2(wx, wz, seed ^ 0x7717) > 0.016 {
|
||||
continue;
|
||||
}
|
||||
let th = 4 + (hash2(wx, wz, seed ^ 0x33) * 3.0) as u32;
|
||||
let top = (h + th).min(dy - 1);
|
||||
// Tronco (sólo si la columna cae dentro de la ventana).
|
||||
if (0..dx as i32).contains(&lx) && (0..dz as i32).contains(&lz) {
|
||||
for y in (h + 1)..=top {
|
||||
g.set(lx as u32, y, lz as u32, trunk);
|
||||
}
|
||||
}
|
||||
// Copa: elipsoide de hojas, recortada a la ventana.
|
||||
let r = 2i32;
|
||||
let cy = top as i32;
|
||||
for dz2 in -r..=r {
|
||||
for dy2 in -r..=(r + 1) {
|
||||
for dx2 in -r..=r {
|
||||
if dx2 * dx2 + dy2 * dy2 + dz2 * dz2 > r * r + 1 {
|
||||
continue;
|
||||
}
|
||||
let (gx, gy, gz) = (lx + dx2, cy + dy2, lz + dz2);
|
||||
if (0..dx as i32).contains(&gx)
|
||||
&& (0..dy as i32).contains(&gy)
|
||||
&& (0..dz as i32).contains(&gz)
|
||||
{
|
||||
// Jitter de hoja por mundo (estable entre ventanas).
|
||||
let v = hash2((wx + dx2) * 13 + gy, (wz + dz2) * 7 + gy, seed ^ 0x55) * 0.25;
|
||||
g.set(gx as u32, gy as u32, gz as u32, mix(leaf_base, leaf_hi, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Genera un paisaje voxel en un grid `dim = [dx, dy, dz]` (con `y` arriba),
|
||||
/// determinista por `seed`. El terreno ocupa hasta ~`0.85·dy` de alto; el nivel
|
||||
/// del mar queda en `~0.30·dy`. Devuelve un [`VoxelGrid`] de `llimphi-3d` listo
|
||||
/// para `VoxelRenderer`/`Scene3d`. Equivale a [`fill_terrain_window`] con
|
||||
/// `origin = [0, 0]` (con el dirty reseteado: el grid es nuevo, el primer upload
|
||||
/// es completo de todos modos).
|
||||
pub fn terrain(dim: [u32; 3], seed: u32) -> VoxelGrid {
|
||||
let mut g = VoxelGrid::new(dim);
|
||||
fill_terrain_window(&mut g, [0, 0], seed);
|
||||
g.reset_dirty();
|
||||
g
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `column_height` es función PURA de mundo: el mismo `(wx, wz)` da la misma
|
||||
/// altura aunque el origen de ventana cambie. Sin esto el streaming tendría
|
||||
/// costuras (escalones en los bordes de ventana).
|
||||
#[test]
|
||||
fn column_height_es_independiente_de_la_ventana() {
|
||||
let dim = [96, 48, 96];
|
||||
for &(wx, wz) in &[(0, 0), (37, -12), (-200, 5), (1000, -1000)] {
|
||||
let a = column_height(wx, wz, dim, 7);
|
||||
let b = column_height(wx, wz, dim, 7);
|
||||
assert_eq!(a, b, "determinista en ({wx},{wz})");
|
||||
assert!(a < dim[1], "altura dentro del mundo");
|
||||
}
|
||||
}
|
||||
|
||||
/// Dos ventanas que solapan en mundo coinciden voxel-a-voxel en la zona
|
||||
/// común: una columna de mundo se ve igual desde cualquier ventana. Es la
|
||||
/// prueba dura de continuidad del streaming (sin GPU).
|
||||
#[test]
|
||||
fn ventanas_solapadas_coinciden_en_la_zona_comun() {
|
||||
let dim = [64, 40, 64];
|
||||
let seed = 4242;
|
||||
// Ventana A en origen (0,0); ventana B desplazada (+16,+16). Solapan en
|
||||
// el rango de mundo x,z ∈ [16, 64).
|
||||
let mut a = VoxelGrid::new(dim);
|
||||
fill_terrain_window(&mut a, [0, 0], seed);
|
||||
let mut b = VoxelGrid::new(dim);
|
||||
fill_terrain_window(&mut b, [16, 16], seed);
|
||||
|
||||
let mut comparados = 0u32;
|
||||
for wz in 16..64u32 {
|
||||
for wx in 16..64u32 {
|
||||
for y in 0..dim[1] {
|
||||
// mundo → local de cada ventana.
|
||||
let va = a.get(wx, y, wz).unwrap();
|
||||
let vb = b.get(wx - 16, y, wz - 16).unwrap();
|
||||
assert_eq!(va, vb, "discrepan en mundo ({wx},{y},{wz})");
|
||||
comparados += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(comparados > 10_000, "se compararon columnas de verdad");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Importación de modelos **MagicaVoxel `.vox`** al motor: convierte un
|
||||
//! [`foreign_vox::VoxModel`] (neutral, leído por el puente) en un
|
||||
//! [`VoxelGrid`](llimphi_3d::VoxelGrid) del motor, para meter **sets y
|
||||
//! personajes diseñados afuera** a una escena/película voxel.
|
||||
//!
|
||||
//! Ejes: MagicaVoxel usa `z` como **arriba**; el motor usa `y` arriba. La
|
||||
//! conversión mapea `vox (x, y, z) → grid (x, z, y)` (la `z` del `.vox` sube a la
|
||||
//! `y` del grid, la `y` del `.vox` pasa a la profundidad `z`).
|
||||
//!
|
||||
//! La capa de juego es la dueña de esto (no el motor ni el puente): el puente
|
||||
//! sólo entiende bytes, el motor sólo voxels; acá se casan (CLAUDE.md regla #4).
|
||||
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
use foreign_vox::{VoxError, VoxModel};
|
||||
use llimphi_3d::VoxelGrid;
|
||||
|
||||
/// Error al cargar un `.vox` desde disco.
|
||||
#[derive(Debug)]
|
||||
pub enum VoxLoadError {
|
||||
/// Falló la lectura del archivo.
|
||||
Io(std::io::Error),
|
||||
/// El contenido no es un `.vox` válido.
|
||||
Parse(VoxError),
|
||||
}
|
||||
|
||||
impl fmt::Display for VoxLoadError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
VoxLoadError::Io(e) => write!(f, "leyendo .vox: {e}"),
|
||||
VoxLoadError::Parse(e) => write!(f, "parseando .vox: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VoxLoadError {}
|
||||
|
||||
/// Convierte un `VoxModel` en un `VoxelGrid` ajustado a su tamaño (con los ejes
|
||||
/// remapeados a la convención del motor). El estado dirty queda limpio (el grid
|
||||
/// recién hecho se sube entero).
|
||||
pub fn model_to_grid(m: &VoxModel) -> VoxelGrid {
|
||||
// dim del grid = [x, z(vox→y), y(vox→z)], mínimo 1 por eje.
|
||||
let dim = [m.size[0].max(1), m.size[2].max(1), m.size[1].max(1)];
|
||||
let mut g = VoxelGrid::new(dim);
|
||||
stamp(&mut g, m, [0, 0, 0]);
|
||||
g.reset_dirty();
|
||||
g
|
||||
}
|
||||
|
||||
/// **Estampa** los voxels de un modelo dentro de un grid existente, con la
|
||||
/// esquina del modelo en `origin` (espacio de grilla del motor). Para componer
|
||||
/// *sets*: meter varias piezas `.vox` en un mismo mundo. Voxels transparentes
|
||||
/// (`alpha 0`) se omiten; los que caen fuera del grid, también (`set` los ignora).
|
||||
pub fn stamp(grid: &mut VoxelGrid, m: &VoxModel, origin: [u32; 3]) {
|
||||
for v in &m.voxels {
|
||||
let c = m.color(v);
|
||||
if c[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let gx = origin[0] + v.x as u32;
|
||||
let gy = origin[1] + v.z as u32; // z-arriba (vox) → y-arriba (grid)
|
||||
let gz = origin[2] + v.y as u32;
|
||||
grid.set(gx, gy, gz, [c[0], c[1], c[2]]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Carga el **primer** modelo de un archivo `.vox` como `VoxelGrid`.
|
||||
pub fn load_grid(path: impl AsRef<Path>) -> Result<VoxelGrid, VoxLoadError> {
|
||||
let bytes = std::fs::read(path).map_err(VoxLoadError::Io)?;
|
||||
let models = foreign_vox::parse(&bytes).map_err(VoxLoadError::Parse)?;
|
||||
// `parse` ya garantiza ≥1 modelo (si no, devuelve NoModel).
|
||||
Ok(model_to_grid(&models[0]))
|
||||
}
|
||||
|
||||
/// Compone una **escena multi-modelo** (`nTRN/nGRP/nSHP`) en un único `VoxelGrid`,
|
||||
/// colocando cada modelo en su traslación de mundo resuelta por el grafo. En
|
||||
/// MagicaVoxel la traslación `_t` ubica el **centro** del modelo, así que la esquina
|
||||
/// va en `t − size/2`. El grid se dimensiona a la caja envolvente de todas las piezas
|
||||
/// y se desplaza para que el mínimo caiga en el origen. Si la escena no trae grafo
|
||||
/// (`.vox` viejo de un solo modelo), cae a [`model_to_grid`] del primer modelo.
|
||||
pub fn scene_to_grid(scene: &foreign_vox::Scene) -> VoxelGrid {
|
||||
if scene.placements.is_empty() || scene.models.is_empty() {
|
||||
return model_to_grid(&scene.models[0]);
|
||||
}
|
||||
// Esquina (espacio grid: [x, z, y]) y tamaño grid de cada colocación.
|
||||
let mut corners: Vec<([i32; 3], [i32; 3])> = Vec::with_capacity(scene.placements.len());
|
||||
for p in &scene.placements {
|
||||
let m = &scene.models[p.model];
|
||||
let sz_grid = [m.size[0] as i32, m.size[2] as i32, m.size[1] as i32];
|
||||
let t = p.translation; // vox (z-arriba)
|
||||
// Centro → esquina (vox), luego remapeo de ejes a grid.
|
||||
let corner_vox = [t[0] - m.size[0] as i32 / 2, t[1] - m.size[1] as i32 / 2, t[2] - m.size[2] as i32 / 2];
|
||||
let corner_grid = [corner_vox[0], corner_vox[2], corner_vox[1]];
|
||||
corners.push((corner_grid, sz_grid));
|
||||
}
|
||||
// Caja envolvente.
|
||||
let mut lo = [i32::MAX; 3];
|
||||
let mut hi = [i32::MIN; 3];
|
||||
for (c, s) in &corners {
|
||||
for a in 0..3 {
|
||||
lo[a] = lo[a].min(c[a]);
|
||||
hi[a] = hi[a].max(c[a] + s[a]);
|
||||
}
|
||||
}
|
||||
let dim = [
|
||||
(hi[0] - lo[0]).max(1) as u32,
|
||||
(hi[1] - lo[1]).max(1) as u32,
|
||||
(hi[2] - lo[2]).max(1) as u32,
|
||||
];
|
||||
let mut g = VoxelGrid::new(dim);
|
||||
for (p, (c, _)) in scene.placements.iter().zip(&corners) {
|
||||
let origin = [(c[0] - lo[0]) as u32, (c[1] - lo[1]) as u32, (c[2] - lo[2]) as u32];
|
||||
stamp(&mut g, &scene.models[p.model], origin);
|
||||
}
|
||||
g.reset_dirty();
|
||||
g
|
||||
}
|
||||
|
||||
/// Carga una **escena** `.vox` completa (multi-modelo con grafo) como un único
|
||||
/// `VoxelGrid` compuesto. Para un `.vox` de un solo modelo equivale a [`load_grid`].
|
||||
pub fn load_scene_grid(path: impl AsRef<Path>) -> Result<VoxelGrid, VoxLoadError> {
|
||||
let bytes = std::fs::read(path).map_err(VoxLoadError::Io)?;
|
||||
let scene = foreign_vox::parse_scene(&bytes).map_err(VoxLoadError::Parse)?;
|
||||
Ok(scene_to_grid(&scene))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use foreign_vox::Voxel;
|
||||
|
||||
#[test]
|
||||
fn remapea_ejes_z_arriba_a_y_arriba() {
|
||||
let mut m = VoxModel::new([2, 3, 4]); // x=2, y=3, z=4 (z arriba)
|
||||
m.palette[1] = [10, 20, 30, 255];
|
||||
// Voxel en vox (1, 2, 3) → grid (1, 3, 2).
|
||||
m.voxels = vec![Voxel { x: 1, y: 2, z: 3, i: 1 }];
|
||||
let g = model_to_grid(&m);
|
||||
assert_eq!(g.dim(), [2, 4, 3], "dim = [x, z, y]");
|
||||
assert!(g.is_solid(1, 3, 2), "vox(x,y,z) → grid(x,z,y)");
|
||||
assert_eq!(g.get(1, 3, 2), Some([10, 20, 30, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omite_transparentes() {
|
||||
let mut m = VoxModel::new([2, 2, 2]);
|
||||
m.palette[1] = [0, 0, 0, 0]; // transparente
|
||||
m.voxels = vec![Voxel { x: 0, y: 0, z: 0, i: 1 }];
|
||||
let g = model_to_grid(&m);
|
||||
assert!(!g.is_solid(0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escena_compone_dos_modelos_con_offset() {
|
||||
use foreign_vox::{Placement, Scene};
|
||||
// Dos cubitos 2³ con un voxel en su esquina; el 2º trasladado +10 en x (vox).
|
||||
let cubo = || {
|
||||
let mut m = VoxModel::new([2, 2, 2]);
|
||||
m.palette[1] = [50, 60, 70, 255];
|
||||
m.voxels = vec![Voxel { x: 0, y: 0, z: 0, i: 1 }];
|
||||
m
|
||||
};
|
||||
let scene = Scene {
|
||||
models: vec![cubo(), cubo()],
|
||||
placements: vec![
|
||||
Placement { model: 0, translation: [0, 0, 0] },
|
||||
Placement { model: 1, translation: [10, 0, 0] },
|
||||
],
|
||||
};
|
||||
let g = scene_to_grid(&scene);
|
||||
// Ambos modelos presentes y separados por ~10 en x (centro→esquina = −1 cada uno,
|
||||
// misma resta, así que la separación entre voxels se conserva = 10).
|
||||
let (dx, dy, dz) = (g.dim()[0] as i32, g.dim()[1] as i32, g.dim()[2] as i32);
|
||||
let solid: Vec<i32> =
|
||||
(0..dx).filter(|&x| (0..dy).any(|y| (0..dz).any(|z| g.is_solid(x, y, z)))).collect();
|
||||
assert_eq!(solid.len(), 2, "dos columnas sólidas separadas");
|
||||
assert_eq!(solid[1] - solid[0], 10, "separación de mundo preservada");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! `WorldStream` — ventana voxel acotada que se desliza por un mundo procedural
|
||||
//! **ilimitado** (M6: streaming). En vez de un grid fijo centrado en el origen,
|
||||
//! mantenemos una ventana de `dim` voxels cuya esquina cae en una columna de
|
||||
//! **mundo** `origin`; al caminar, [`WorldStream::follow`] reubica la ventana
|
||||
//! (en pasos de `step`) y regenera el terreno con [`fill_terrain_window`]. Como
|
||||
//! el relieve es función pura de mundo ([`column_height`]), las costuras encajan
|
||||
//! y se puede caminar indefinidamente sin "muro" ni repetición.
|
||||
//!
|
||||
//! **Alcance (MVP):** la regeneración es de la ventana **entera** por cada paso
|
||||
//! cruzado (no un *shift* parcial que sólo rellene el borde nuevo). Es O(ventana)
|
||||
//! por reubicación, no por frame — sólo al cruzar un múltiplo de `step`. El grid
|
||||
//! queda **dirty completo** para que `VoxelRenderer::sync` re-suba (o se
|
||||
//! reconstruya el renderer). El *shift* incremental (copiar la zona común y
|
||||
//! generar sólo la franja nueva) y el LOD del horizonte quedan como optimización
|
||||
//! futura — ver `MOTOR-VOXEL.md` §7.
|
||||
|
||||
use llimphi_3d::VoxelGrid;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::terrain::fill_terrain_window;
|
||||
|
||||
/// Ventana de mundo que sigue a un foco (la cámara/jugador) por un terreno
|
||||
/// procedural ilimitado.
|
||||
pub struct WorldStream {
|
||||
grid: VoxelGrid,
|
||||
dim: [u32; 3],
|
||||
seed: u32,
|
||||
/// Columna de mundo `[wx, wz]` donde cae la esquina local `(0,0)`.
|
||||
origin: [i32; 2],
|
||||
/// Granularidad de reubicación (voxels). Recentrar sólo cuando el foco se
|
||||
/// aleja del centro más de medio paso evita regenerar cada voxel caminado.
|
||||
step: i32,
|
||||
/// **Ediciones persistentes** por coordenada de **mundo** (`[wx, wy, wz]` →
|
||||
/// RGBA; `a = 0` = voxel cavado/aire). Como el terreno se regenera desde la
|
||||
/// semilla cada vez que la ventana vuelve a una zona, sin esto los cambios del
|
||||
/// jugador se perderían al alejarse y volver. Se re-aplican sobre el terreno
|
||||
/// fresco en cada `follow` (overlay). Es el estado a serializar para la
|
||||
/// persistencia CAS a disco (futuro): `mundo → BLAKE3(postcard(patch))`.
|
||||
edits: HashMap<[i32; 3], [u8; 4]>,
|
||||
}
|
||||
|
||||
impl WorldStream {
|
||||
/// Crea una ventana de `dim` centrada en la columna de mundo `(center_x,
|
||||
/// center_z)` y la genera. `step` = granularidad de reubicación en voxels
|
||||
/// (típicamente el lado de un *brick*, p.ej. 8).
|
||||
pub fn new(dim: [u32; 3], seed: u32, center_x: i32, center_z: i32, step: u32) -> Self {
|
||||
let step = step.max(1) as i32;
|
||||
let origin = Self::origin_for(dim, center_x, center_z, step);
|
||||
let mut grid = VoxelGrid::new(dim);
|
||||
fill_terrain_window(&mut grid, origin, seed);
|
||||
Self { grid, dim, seed, origin, step, edits: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Origen (esquina) de ventana que centra la columna de mundo `(cx, cz)`,
|
||||
/// **snappeado** a `step` para que sólo cambie a saltos discretos.
|
||||
fn origin_for(dim: [u32; 3], cx: i32, cz: i32, step: i32) -> [i32; 2] {
|
||||
let half_x = dim[0] as i32 / 2;
|
||||
let half_z = dim[2] as i32 / 2;
|
||||
[
|
||||
snap(cx - half_x, step),
|
||||
snap(cz - half_z, step),
|
||||
]
|
||||
}
|
||||
|
||||
/// Reubica la ventana para centrar la columna de mundo `(cx, cz)` y, si el
|
||||
/// origen snappeado cambió, **regenera** el terreno. Devuelve `true` si hubo
|
||||
/// regeneración (el grid quedó dirty → el caller debe `sync`/reconstruir).
|
||||
pub fn follow(&mut self, cx: i32, cz: i32) -> bool {
|
||||
let want = Self::origin_for(self.dim, cx, cz, self.step);
|
||||
if want == self.origin {
|
||||
return false;
|
||||
}
|
||||
self.origin = want;
|
||||
fill_terrain_window(&mut self.grid, want, self.seed);
|
||||
// Re-aplicar las ediciones persistentes que caen en la ventana nueva
|
||||
// (overlay sobre el terreno fresco): así un cráter/estructura sobrevive
|
||||
// alejarse y volver.
|
||||
self.reapply_edits();
|
||||
true
|
||||
}
|
||||
|
||||
/// Edita un voxel en coordenadas de **mundo** y lo **persiste**: `Some(rgb)`
|
||||
/// coloca un bloque sólido, `None` lo cava (aire). El cambio se registra en
|
||||
/// `edits` (sobrevive el regen del streaming) y, si el voxel cae en la ventana
|
||||
/// actual, se aplica al grid (que queda dirty → el caller hace `sync`/scroll).
|
||||
pub fn edit(&mut self, wx: i32, wy: i32, wz: i32, block: Option<[u8; 3]>) {
|
||||
let v = match block {
|
||||
Some(rgb) => [rgb[0], rgb[1], rgb[2], 255],
|
||||
None => [0, 0, 0, 0],
|
||||
};
|
||||
self.edits.insert([wx, wy, wz], v);
|
||||
self.apply_voxel(wx, wy, wz, v);
|
||||
}
|
||||
|
||||
/// Cantidad de voxels editados persistidos (para reportar/serializar).
|
||||
pub fn edit_count(&self) -> usize {
|
||||
self.edits.len()
|
||||
}
|
||||
|
||||
/// Serializa las ediciones a un blob **postcard** (lista de `([wx,wy,wz],
|
||||
/// RGBA)`), apto para guardar en la CAS de tawasuyu direccionado por su
|
||||
/// **BLAKE3** (`mundo → BLAKE3(blob)`) y recargar entre ejecuciones. Es la
|
||||
/// **persistencia a disco** del estado in-memory de [`Self::edit`].
|
||||
pub fn export_edits(&self) -> Vec<u8> {
|
||||
let mut v: Vec<([i32; 3], [u8; 4])> = self.edits.iter().map(|(&k, &val)| (k, val)).collect();
|
||||
// Orden canónico (el HashMap no es determinista) → mismas ediciones dan el
|
||||
// mismo blob y, por ende, la misma dirección BLAKE3 (dedup/integridad CAS).
|
||||
v.sort_unstable_by_key(|(k, _)| *k);
|
||||
postcard::to_allocvec(&v).expect("postcard ediciones")
|
||||
}
|
||||
|
||||
/// Carga ediciones desde un blob de [`Self::export_edits`], las fusiona en el
|
||||
/// mapa persistente y las **re-aplica** sobre la ventana actual. Devuelve la
|
||||
/// cantidad cargada, o `None` si el blob no decodifica.
|
||||
pub fn import_edits(&mut self, bytes: &[u8]) -> Option<usize> {
|
||||
let v: Vec<([i32; 3], [u8; 4])> = postcard::from_bytes(bytes).ok()?;
|
||||
let n = v.len();
|
||||
for (k, val) in v {
|
||||
self.edits.insert(k, val);
|
||||
}
|
||||
self.reapply_edits();
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// Aplica un voxel `v` (RGBA, `a=0`=aire) al grid si su coordenada de mundo
|
||||
/// cae en la ventana actual. No-op si está afuera (ya quedó en `edits`).
|
||||
fn apply_voxel(&mut self, wx: i32, wy: i32, wz: i32, v: [u8; 4]) {
|
||||
let lx = wx - self.origin[0];
|
||||
let lz = wz - self.origin[1];
|
||||
if lx < 0 || wy < 0 || lz < 0 {
|
||||
return;
|
||||
}
|
||||
let (lx, ly, lz) = (lx as u32, wy as u32, lz as u32);
|
||||
if lx < self.dim[0] && ly < self.dim[1] && lz < self.dim[2] {
|
||||
if v[3] > 0 {
|
||||
self.grid.set(lx, ly, lz, [v[0], v[1], v[2]]);
|
||||
} else {
|
||||
self.grid.clear(lx, ly, lz);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-aplica todas las ediciones que caen en la ventana actual sobre el grid.
|
||||
fn reapply_edits(&mut self) {
|
||||
// Split de borrows: iterar `edits` (inmutable) y mutar `grid`.
|
||||
let Self { edits, grid, origin, dim, .. } = self;
|
||||
for (&[wx, wy, wz], &v) in edits.iter() {
|
||||
let lx = wx - origin[0];
|
||||
let lz = wz - origin[1];
|
||||
if lx < 0 || wy < 0 || lz < 0 {
|
||||
continue;
|
||||
}
|
||||
let (lx, ly, lz) = (lx as u32, wy as u32, lz as u32);
|
||||
if lx < dim[0] && ly < dim[1] && lz < dim[2] {
|
||||
if v[3] > 0 {
|
||||
grid.set(lx, ly, lz, [v[0], v[1], v[2]]);
|
||||
} else {
|
||||
grid.clear(lx, ly, lz);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid actual (para renderizar / `VoxelRenderer::new`).
|
||||
pub fn grid(&self) -> &VoxelGrid {
|
||||
&self.grid
|
||||
}
|
||||
|
||||
/// Grid mutable (para `VoxelRenderer::sync`, que toma `&mut`).
|
||||
pub fn grid_mut(&mut self) -> &mut VoxelGrid {
|
||||
&mut self.grid
|
||||
}
|
||||
|
||||
/// Columna de mundo de la esquina local `(0,0)`.
|
||||
pub fn origin(&self) -> [i32; 2] {
|
||||
self.origin
|
||||
}
|
||||
|
||||
/// Origen en **voxels 3D** (`y = 0`: el mundo no scrollea en vertical), para
|
||||
/// pasar a [`VoxelRenderer::scroll_to`](llimphi_3d::VoxelRenderer::scroll_to).
|
||||
/// Si `step` es múltiplo de [`llimphi_3d::VOXEL_BRICK`] queda alineado a brick.
|
||||
pub fn origin_voxel(&self) -> [i32; 3] {
|
||||
[self.origin[0], 0, self.origin[1]]
|
||||
}
|
||||
|
||||
/// Mapea una columna de **mundo** a coordenada **local** de ventana, o `None`
|
||||
/// si cae afuera. Para ubicar al jugador/entidades dentro del grid actual.
|
||||
pub fn world_to_local(&self, wx: i32, wz: i32) -> Option<(u32, u32)> {
|
||||
let lx = wx - self.origin[0];
|
||||
let lz = wz - self.origin[1];
|
||||
if lx >= 0 && lz >= 0 && lx < self.dim[0] as i32 && lz < self.dim[2] as i32 {
|
||||
Some((lx as u32, lz as u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapea una coordenada **local** de ventana a su columna de **mundo**.
|
||||
pub fn local_to_world(&self, lx: u32, lz: u32) -> (i32, i32) {
|
||||
(self.origin[0] + lx as i32, self.origin[1] + lz as i32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Redondea `v` al múltiplo de `step` más cercano hacia abajo (floor con signo).
|
||||
#[inline]
|
||||
fn snap(v: i32, step: i32) -> i32 {
|
||||
(v.div_euclid(step)) * step
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snap_redondea_con_signo() {
|
||||
assert_eq!(snap(0, 8), 0);
|
||||
assert_eq!(snap(7, 8), 0);
|
||||
assert_eq!(snap(8, 8), 8);
|
||||
assert_eq!(snap(-1, 8), -8);
|
||||
assert_eq!(snap(-8, 8), -8);
|
||||
assert_eq!(snap(-9, 8), -16);
|
||||
}
|
||||
|
||||
/// Caminar menos de un paso NO regenera; cruzar un paso SÍ.
|
||||
#[test]
|
||||
fn follow_regenera_solo_al_cruzar_un_paso() {
|
||||
let dim = [48, 32, 48];
|
||||
let mut s = WorldStream::new(dim, 9, 0, 0, 8);
|
||||
let o0 = s.origin();
|
||||
// Moverse dentro del mismo bloque de 8: sin regen.
|
||||
assert!(!s.follow(3, 2));
|
||||
assert_eq!(s.origin(), o0);
|
||||
// Cruzar varios pasos: regen y el origen siguió al foco.
|
||||
assert!(s.follow(64, 0));
|
||||
assert_ne!(s.origin(), o0);
|
||||
// El centro de ventana quedó cerca del foco (dentro de un paso).
|
||||
let cx = s.origin()[0] + dim[0] as i32 / 2;
|
||||
assert!((cx - 64).abs() <= 8, "ventana centrada en el foco (cx={cx})");
|
||||
}
|
||||
|
||||
/// Tras seguir el foco, el contenido coincide con generar esa ventana de cero
|
||||
/// (la regeneración es completa y determinista por origen).
|
||||
#[test]
|
||||
fn contenido_tras_follow_igual_a_generacion_directa() {
|
||||
let dim = [40, 28, 40];
|
||||
let seed = 555;
|
||||
let mut s = WorldStream::new(dim, seed, 0, 0, 8);
|
||||
s.follow(120, -80);
|
||||
let origin = s.origin();
|
||||
|
||||
let mut directo = VoxelGrid::new(dim);
|
||||
fill_terrain_window(&mut directo, origin, seed);
|
||||
|
||||
for z in 0..dim[2] {
|
||||
for y in 0..dim[1] {
|
||||
for x in 0..dim[0] {
|
||||
assert_eq!(
|
||||
s.grid().get(x, y, z),
|
||||
directo.get(x, y, z),
|
||||
"discrepa en ({x},{y},{z})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Una edición sobrevive alejarse y volver: el terreno se regenera desde la
|
||||
/// semilla, pero el overlay de `edits` la re-aplica.
|
||||
#[test]
|
||||
fn una_edicion_sobrevive_alejarse_y_volver() {
|
||||
let dim = [48, 40, 48];
|
||||
let seed = 7;
|
||||
let mut s = WorldStream::new(dim, seed, 0, 0, 8);
|
||||
|
||||
// Bloque magenta en lo alto del aire sobre la columna de mundo (0,0):
|
||||
// el terreno nunca pone magenta ahí (es aire), así el origen del píxel es
|
||||
// inequívocamente la edición.
|
||||
let (wx, wy, wz) = (0, dim[1] as i32 - 3, 0);
|
||||
let magenta = [255, 0, 255];
|
||||
s.edit(wx, wy, wz, Some(magenta));
|
||||
assert_eq!(s.edit_count(), 1);
|
||||
|
||||
let read = |s: &WorldStream| {
|
||||
let (lx, lz) = s.world_to_local(wx, wz).expect("en ventana");
|
||||
s.grid().get(lx, wy as u32, lz)
|
||||
};
|
||||
assert_eq!(read(&s), Some([255, 0, 255, 255]), "presente recién editada");
|
||||
|
||||
// Alejarse MUCHO (el voxel sale de la ventana → terreno regenerado, sin él).
|
||||
assert!(s.follow(2000, 2000));
|
||||
assert!(s.world_to_local(wx, wz).is_none(), "fuera de la ventana lejana");
|
||||
assert_eq!(s.edit_count(), 1, "la edición sigue persistida");
|
||||
|
||||
// Volver al origen: el terreno se regenera Y el overlay re-aplica la edición.
|
||||
assert!(s.follow(0, 0));
|
||||
assert_eq!(read(&s), Some([255, 0, 255, 255]), "sobrevivió al regen");
|
||||
|
||||
// Y sin la edición, ese voxel sería aire (prueba que no es terreno).
|
||||
let mut limpio = VoxelGrid::new(dim);
|
||||
fill_terrain_window(&mut limpio, s.origin(), seed);
|
||||
let (lx, lz) = s.world_to_local(wx, wz).unwrap();
|
||||
assert_eq!(limpio.get(lx, wy as u32, lz), Some([0, 0, 0, 0]), "terreno solo = aire");
|
||||
}
|
||||
|
||||
/// Cavar (edit con `None`) también persiste: un voxel sólido del terreno
|
||||
/// queda vacío tras alejarse y volver.
|
||||
#[test]
|
||||
fn cavar_persiste() {
|
||||
let dim = [48, 40, 48];
|
||||
let seed = 13;
|
||||
let mut s = WorldStream::new(dim, seed, 0, 0, 8);
|
||||
|
||||
// Buscar un voxel SÓLIDO del terreno en la columna central y cavarlo.
|
||||
let (lx0, lz0) = s.world_to_local(0, 0).unwrap();
|
||||
let h = s.grid().height_at(lx0, lz0).expect("columna con terreno");
|
||||
let (wx, wy, wz) = (0, h as i32, 0);
|
||||
assert!(s.grid().is_solid(lx0 as i32, wy, lz0 as i32), "sólido antes");
|
||||
|
||||
s.edit(wx, wy, wz, None); // cavar
|
||||
let local_solid = |s: &WorldStream| {
|
||||
let (lx, lz) = s.world_to_local(wx, wz).unwrap();
|
||||
s.grid().is_solid(lx as i32, wy, lz as i32)
|
||||
};
|
||||
assert!(!local_solid(&s), "cavado tras editar");
|
||||
|
||||
s.follow(3000, -3000);
|
||||
s.follow(0, 0);
|
||||
assert!(!local_solid(&s), "sigue cavado tras volver");
|
||||
}
|
||||
|
||||
/// CAS a disco (simulada en memoria): exportar las ediciones de un mundo y
|
||||
/// re-importarlas en otro recién creado las restaura; el blob es canónico
|
||||
/// (misma dirección BLAKE3) sin importar el orden de edición.
|
||||
#[test]
|
||||
fn ediciones_round_trip_por_blob() {
|
||||
let dim = [48, 40, 48];
|
||||
let seed = 99;
|
||||
|
||||
// Mundo A: unas cuantas ediciones (en distinto orden que B).
|
||||
let mut a = WorldStream::new(dim, seed, 0, 0, 8);
|
||||
a.edit(0, 30, 0, Some([10, 20, 30]));
|
||||
a.edit(-5, 12, 7, None);
|
||||
a.edit(3, 25, -2, Some([200, 100, 50]));
|
||||
let blob_a = a.export_edits();
|
||||
|
||||
// Mundo B: las MISMAS ediciones en otro orden → mismo blob canónico.
|
||||
let mut b = WorldStream::new(dim, seed, 0, 0, 8);
|
||||
b.edit(3, 25, -2, Some([200, 100, 50]));
|
||||
b.edit(0, 30, 0, Some([10, 20, 30]));
|
||||
b.edit(-5, 12, 7, None);
|
||||
assert_eq!(a.export_edits(), b.export_edits(), "blob canónico (orden-indep.)");
|
||||
assert_eq!(blake3::hash(&blob_a), blake3::hash(&b.export_edits()), "misma dirección CAS");
|
||||
|
||||
// Mundo C: vacío → importa el blob de A → recupera las 3 ediciones.
|
||||
let mut c = WorldStream::new(dim, seed, 0, 0, 8);
|
||||
assert_eq!(c.edit_count(), 0);
|
||||
assert_eq!(c.import_edits(&blob_a), Some(3));
|
||||
assert_eq!(c.edit_count(), 3);
|
||||
// Y el contenido coincide con A en la ventana (la edición magenta arriba).
|
||||
let (lx, lz) = c.world_to_local(0, 0).unwrap();
|
||||
assert_eq!(c.grid().get(lx, 30, lz), Some([10, 20, 30, 255]), "edición restaurada");
|
||||
|
||||
// Blob inválido → None, sin romper.
|
||||
assert_eq!(c.import_edits(&[0xff, 0xff, 0xff]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! **Creador de mundos**: en vez de un terreno hardcodeado (como [`terrain`]
|
||||
//! (crate::terrain), que es un mundo de pasto fijo), un [`WorldRecipe`]
|
||||
//! *parametriza* el relieve y los materiales — y produce el [`VoxelGrid`]. Un
|
||||
//! mundo concreto (desierto, pradera, …) es una **receta**, no una función nueva.
|
||||
//!
|
||||
//! Dos cosas que pidió el corto del desierto salen de acá:
|
||||
//! - **Materiales** ([`Material`]): arena, agua, roca, cactus… cada uno con su
|
||||
//! color. El terreno se pinta por material, no por banda de altura cruda.
|
||||
//! - **Receta del desierto** ([`WorldRecipe::desert`]): llano de arena, **pocas
|
||||
//! montañas**, **pocos ríos**, **cactus** ralos.
|
||||
//!
|
||||
//! La altura sigue siendo **función pura de mundo** ([`WorldRecipe::column_height`]):
|
||||
//! mismo `(wx, wz)` → misma columna, así un mundo-receta también podrá streamear
|
||||
//! (cuando se cablee a [`WorldStream`](crate::WorldStream)).
|
||||
|
||||
use llimphi_3d::VoxelGrid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::terrain::{fbm, hash2, smooth, world_scale};
|
||||
|
||||
/// Un **material** del mundo: la unidad semántica con la que el creador pinta los
|
||||
/// voxels (en vez de un color suelto). Da color y solidez; más adelante puede
|
||||
/// llevar propiedades físicas (flotabilidad del agua, daño del cactus, etc.).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Material {
|
||||
/// Vacío (aire) — no se dibuja.
|
||||
Air,
|
||||
/// Arena del desierto.
|
||||
Sand,
|
||||
/// Tierra/pasto (praderas).
|
||||
Grass,
|
||||
/// Roca de acantilado / montaña.
|
||||
Rock,
|
||||
/// Nieve de cumbre.
|
||||
Snow,
|
||||
/// Agua (superficie de ríos/lagos/mar).
|
||||
Water,
|
||||
/// Carne de cactus (verde).
|
||||
Cactus,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
/// Color RGB del material.
|
||||
pub fn color(self) -> [u8; 3] {
|
||||
match self {
|
||||
Material::Air => [0, 0, 0],
|
||||
Material::Sand => [214, 188, 130],
|
||||
Material::Grass => [84, 140, 64],
|
||||
Material::Rock => [108, 102, 98],
|
||||
Material::Snow => [236, 240, 250],
|
||||
Material::Water => [54, 118, 158],
|
||||
Material::Cactus => [74, 128, 70],
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` salvo para [`Material::Air`].
|
||||
pub fn is_solid(self) -> bool {
|
||||
!matches!(self, Material::Air)
|
||||
}
|
||||
|
||||
/// Todos los materiales, en orden de catálogo (para que un editor cicle entre
|
||||
/// ellos).
|
||||
pub const ALL: [Material; 7] = [
|
||||
Material::Air,
|
||||
Material::Sand,
|
||||
Material::Grass,
|
||||
Material::Rock,
|
||||
Material::Snow,
|
||||
Material::Water,
|
||||
Material::Cactus,
|
||||
];
|
||||
|
||||
/// Nombre legible (español) para la UI.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Material::Air => "aire",
|
||||
Material::Sand => "arena",
|
||||
Material::Grass => "pasto",
|
||||
Material::Rock => "roca",
|
||||
Material::Snow => "nieve",
|
||||
Material::Water => "agua",
|
||||
Material::Cactus => "cactus",
|
||||
}
|
||||
}
|
||||
|
||||
/// El material siguiente en el catálogo (cicla) — para botones de ciclo.
|
||||
pub fn next(self) -> Material {
|
||||
let i = Material::ALL.iter().position(|&m| m == self).unwrap_or(0);
|
||||
Material::ALL[(i + 1) % Material::ALL.len()]
|
||||
}
|
||||
}
|
||||
|
||||
/// Qué planta esparce un mundo y con qué forma.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Flora {
|
||||
/// Sin vegetación.
|
||||
None,
|
||||
/// Cactus columnar (tronco + algún brazo) — el desierto.
|
||||
Cactus,
|
||||
}
|
||||
|
||||
impl Flora {
|
||||
/// Todas las opciones de flora (para ciclar en un editor).
|
||||
pub const ALL: [Flora; 2] = [Flora::None, Flora::Cactus];
|
||||
|
||||
/// Nombre legible (español) para la UI.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Flora::None => "ninguna",
|
||||
Flora::Cactus => "cactus",
|
||||
}
|
||||
}
|
||||
|
||||
/// La flora siguiente (cicla).
|
||||
pub fn next(self) -> Flora {
|
||||
let i = Flora::ALL.iter().position(|&f| f == self).unwrap_or(0);
|
||||
Flora::ALL[(i + 1) % Flora::ALL.len()]
|
||||
}
|
||||
}
|
||||
|
||||
/// La **receta de un mundo**: parámetros del relieve + materiales + flora. Producí
|
||||
/// el `VoxelGrid` con [`generate`](Self::generate). Presets: [`desert`](Self::desert),
|
||||
/// [`grassland`](Self::grassland).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct WorldRecipe {
|
||||
/// Semilla del ruido (mismo seed → mismo mundo).
|
||||
pub seed: u32,
|
||||
/// Nivel base del suelo, fracción del alto del mundo `[0,1]` (la llanura).
|
||||
pub base: f32,
|
||||
/// Amplitud de las ondulaciones suaves del suelo (dunas), fracción del alto.
|
||||
pub dune: f32,
|
||||
/// Amplitud de las montañas, fracción del alto.
|
||||
pub relief: f32,
|
||||
/// **Densidad de montañas** `[0,1]`: 0 = casi todo llano (sólo pocos picos
|
||||
/// asoman), 1 = relieve por todos lados.
|
||||
pub mountains: f32,
|
||||
/// Nivel del agua, fracción del alto `[0,1]`: las depresiones por debajo se
|
||||
/// llenan de [`Material::Water`].
|
||||
pub water_level: f32,
|
||||
/// **Densidad de ríos** `[0,1]`: 0 = sin ríos; mayor = canales más anchos/
|
||||
/// frecuentes tallados hacia el agua.
|
||||
pub rivers: f32,
|
||||
/// Material de la superficie sólida (arena en el desierto, pasto en pradera).
|
||||
pub ground: Material,
|
||||
/// Material de acantilados/altura (roca).
|
||||
pub cliff: Material,
|
||||
/// Material de cumbre por encima de `peak_at` (nieve); `Air` = sin cumbre.
|
||||
pub peak: Material,
|
||||
/// Altura normalizada `[0,1]` a partir de la cual aparece `peak`.
|
||||
pub peak_at: f32,
|
||||
/// Flora y su densidad `[0,1]`.
|
||||
pub flora: Flora,
|
||||
pub flora_density: f32,
|
||||
}
|
||||
|
||||
impl WorldRecipe {
|
||||
/// **Desierto llano**: arena, pocas montañas, pocos ríos, cactus ralos. El
|
||||
/// mundo de apertura del corto.
|
||||
pub fn desert(seed: u32) -> Self {
|
||||
Self {
|
||||
seed,
|
||||
base: 0.30,
|
||||
dune: 0.05, // ondulación suave (dunas bajas)
|
||||
relief: 0.45, // las pocas montañas que hay, altas
|
||||
mountains: 0.12, // pero MUY ralas
|
||||
water_level: 0.26, // por debajo del suelo base → casi sin agua salvo ríos
|
||||
rivers: 0.18, // pocos ríos
|
||||
ground: Material::Sand,
|
||||
cliff: Material::Rock,
|
||||
peak: Material::Air, // sin nieve en el desierto
|
||||
peak_at: 1.0,
|
||||
flora: Flora::Cactus,
|
||||
flora_density: 0.010,
|
||||
}
|
||||
}
|
||||
|
||||
/// **Pradera**: el mundo verde clásico (equivalente conceptual a [`terrain`]
|
||||
/// (crate::terrain)), expresado como receta para mostrar que el creador es
|
||||
/// general.
|
||||
pub fn grassland(seed: u32) -> Self {
|
||||
Self {
|
||||
seed,
|
||||
base: 0.22,
|
||||
dune: 0.10,
|
||||
relief: 0.7,
|
||||
mountains: 0.5,
|
||||
water_level: 0.30,
|
||||
rivers: 0.25,
|
||||
ground: Material::Grass,
|
||||
cliff: Material::Rock,
|
||||
peak: Material::Snow,
|
||||
peak_at: 0.80,
|
||||
flora: Flora::None,
|
||||
flora_density: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// **Altura del terreno** (índice `y` del voxel sólido superior) en la columna
|
||||
/// de **mundo** `(wx, wz)`. Función pura: mismo punto → misma altura en
|
||||
/// cualquier ventana (continuidad para el streaming). Combina llanura base +
|
||||
/// dunas suaves + montañas *gated* (sólo asoman donde el fbm supera un umbral,
|
||||
/// así `mountains` bajo deja casi todo plano) + ríos tallados hacia el agua.
|
||||
pub fn column_height(&self, wx: i32, wz: i32, dim: [u32; 3]) -> u32 {
|
||||
let dy = dim[1] as f32;
|
||||
let scale = world_scale(dim);
|
||||
let s = self.seed;
|
||||
let (fx, fz) = (wx as f32 * scale, wz as f32 * scale);
|
||||
|
||||
let base = self.base * dy;
|
||||
// Dunas: ondulación suave centrada en cero.
|
||||
let dunes = (fbm(fx * 1.7, fz * 1.7, 4, s ^ 0x11) - 0.5) * 2.0;
|
||||
let dune_h = dunes * self.dune * dy;
|
||||
// Montañas gated: sólo la parte alta del fbm sobresale. `mountains` baja el
|
||||
// umbral → más raras y aisladas.
|
||||
let c = fbm(fx, fz, 6, s);
|
||||
let thr = 1.0 - self.mountains.clamp(0.0, 1.0);
|
||||
let m = ((c - thr).max(0.0) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
|
||||
let mtn_h = smooth(m) * self.relief * dy;
|
||||
|
||||
let mut h = base + dune_h + mtn_h;
|
||||
|
||||
// Ríos: una "cresta" de ruido (ridged) marca líneas; cerca de la línea el
|
||||
// terreno se hunde hasta un lecho bajo el nivel del agua.
|
||||
if self.rivers > 0.0 {
|
||||
let r = 1.0 - (fbm(fx * 0.8, fz * 0.8, 4, s ^ 0x77) - 0.5).abs() * 2.0;
|
||||
let width = 0.03 + 0.10 * self.rivers.clamp(0.0, 1.0);
|
||||
if r > 1.0 - width {
|
||||
let t = ((r - (1.0 - width)) / width).clamp(0.0, 1.0);
|
||||
let bed = (self.water_level * dy - 2.0).max(1.0);
|
||||
h += (bed - h) * smooth(t);
|
||||
}
|
||||
}
|
||||
|
||||
(h.clamp(1.0, dy - 1.0)) as u32
|
||||
}
|
||||
|
||||
/// Nivel del agua como índice `y`.
|
||||
#[inline]
|
||||
pub fn water_y(&self, dim: [u32; 3]) -> u32 {
|
||||
(self.water_level * dim[1] as f32) as u32
|
||||
}
|
||||
|
||||
/// Material del voxel sólido `(wx, y, wz)` de una columna de altura `h` con
|
||||
/// `slope` (desnivel con vecinos). Decide arena/roca/nieve por altura+pendiente.
|
||||
fn ground_material(&self, wx: i32, wz: i32, y: u32, h: u32, slope: f32, dim: [u32; 3]) -> Material {
|
||||
let dy = dim[1] as f32;
|
||||
let fh = y as f32 / dy;
|
||||
// Acantilado: la cara superior en pendiente fuerte es roca.
|
||||
if y == h && slope > 2.5 {
|
||||
return self.cliff;
|
||||
}
|
||||
// Cumbre nevada (si la receta la tiene).
|
||||
if self.peak.is_solid() && fh > self.peak_at {
|
||||
return self.peak;
|
||||
}
|
||||
// Jitter para que la transición a roca no sea una línea perfecta.
|
||||
let jitter = hash2(wx, wz.wrapping_mul(31).wrapping_add(y as i32), self.seed ^ 0xABCD) * 0.06 - 0.03;
|
||||
// Roca alta general (debajo de la cumbre): más arriba de 0.72 del alto.
|
||||
if fh + jitter > 0.72 {
|
||||
return self.cliff;
|
||||
}
|
||||
self.ground
|
||||
}
|
||||
|
||||
/// `true` si en la columna `(wx, wz)` brota una planta (gate de densidad por
|
||||
/// hash de mundo → determinista y seamless). Sólo en suelo seco y llano.
|
||||
fn has_flora(&self, wx: i32, wz: i32, h: u32, slope: f32, dim: [u32; 3]) -> bool {
|
||||
if self.flora == Flora::None || self.flora_density <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
if h <= self.water_y(dim) + 1 || slope > 1.5 {
|
||||
return false; // ni en el agua/orilla ni en pendiente
|
||||
}
|
||||
hash2(wx.wrapping_mul(7), wz.wrapping_mul(13), self.seed ^ 0xC4C7) < self.flora_density
|
||||
}
|
||||
|
||||
/// **Construye el mundo**: rellena un `VoxelGrid` de tamaño `dim` con la esquina
|
||||
/// en la columna de mundo `origin = [wx, wz]` (usá `[0,0]` para el mundo
|
||||
/// centrado). Terreno + agua + flora, todo por material. Deja el grid limpio
|
||||
/// de *dirty* (se sube entero).
|
||||
pub fn generate_window(&self, dim: [u32; 3], origin: [i32; 2]) -> VoxelGrid {
|
||||
let [dx, dy, dz] = dim;
|
||||
let mut g = VoxelGrid::new(dim);
|
||||
let (ox, oz) = (origin[0], origin[1]);
|
||||
let water_y = self.water_y(dim);
|
||||
|
||||
let h_at = |lx: i32, lz: i32| self.column_height(ox + lx, oz + lz, dim);
|
||||
|
||||
for lz in 0..dz as i32 {
|
||||
for lx in 0..dx as i32 {
|
||||
let (wx, wz) = (ox + lx, oz + lz);
|
||||
let h = h_at(lx, lz);
|
||||
let slope = (h as i32 - h_at(lx - 1, lz) as i32)
|
||||
.abs()
|
||||
.max((h as i32 - h_at(lx, lz - 1) as i32).abs()) as f32;
|
||||
|
||||
// Columna sólida.
|
||||
for y in 0..=h.min(dy - 1) {
|
||||
let m = self.ground_material(wx, wz, y, h, slope, dim);
|
||||
g.set(lx as u32, y, lz as u32, m.color());
|
||||
}
|
||||
// Agua: llena por encima del terreno hasta el nivel del agua.
|
||||
if h < water_y {
|
||||
for y in (h + 1)..=water_y.min(dy - 1) {
|
||||
g.set(lx as u32, y, lz as u32, Material::Water.color());
|
||||
}
|
||||
}
|
||||
// Flora.
|
||||
if self.has_flora(wx, wz, h, slope, dim) {
|
||||
self.place_flora(&mut g, lx as u32, h + 1, lz as u32, wx, wz, dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g.reset_dirty();
|
||||
g
|
||||
}
|
||||
|
||||
/// Mundo centrado en el origen (`origin = [0,0]`).
|
||||
pub fn generate(&self, dim: [u32; 3]) -> VoxelGrid {
|
||||
self.generate_window(dim, [0, 0])
|
||||
}
|
||||
|
||||
/// Coloca una planta con la base en `(x, base_y, z)` (local al grid). Hoy sólo
|
||||
/// el **cactus** columnar: tronco de 3–6 de alto + 0–2 brazos en L.
|
||||
fn place_flora(&self, g: &mut VoxelGrid, x: u32, base_y: u32, z: u32, wx: i32, wz: i32, dim: [u32; 3]) {
|
||||
if self.flora != Flora::Cactus {
|
||||
return;
|
||||
}
|
||||
let dy = dim[1];
|
||||
let col = Material::Cactus.color();
|
||||
// Altura del tronco: 3..6 determinista por la columna.
|
||||
let th = 3 + (hash2(wx, wz, self.seed ^ 0x1357) * 4.0) as u32;
|
||||
for k in 0..th {
|
||||
let y = base_y + k;
|
||||
if y >= dy {
|
||||
break;
|
||||
}
|
||||
g.set(x, y, z, col);
|
||||
}
|
||||
// Brazos: un par de salientes laterales a media altura (forma de candelabro).
|
||||
let arm_seed = hash2(wx.wrapping_add(1), wz, self.seed ^ 0x2468);
|
||||
if th >= 4 && arm_seed > 0.45 {
|
||||
let ay = base_y + th / 2;
|
||||
// Brazo a +x: un voxel al costado y dos hacia arriba.
|
||||
let arm = [(x.wrapping_add(1), ay, z), (x.wrapping_add(1), ay + 1, z)];
|
||||
for &(ax, ya, az) in &arm {
|
||||
if ax < dim[0] && ya < dy {
|
||||
g.set(ax, ya, az, col);
|
||||
}
|
||||
}
|
||||
if arm_seed > 0.7 && x > 0 {
|
||||
// Brazo opuesto a -x.
|
||||
let arm2 = [(x - 1, ay + 1, z), (x - 1, ay + 2, z)];
|
||||
for &(ax, ya, az) in &arm2 {
|
||||
if ya < dy {
|
||||
g.set(ax, ya, az, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn altura_es_funcion_pura_de_mundo() {
|
||||
let r = WorldRecipe::desert(7);
|
||||
let dim = [96, 48, 96];
|
||||
// El mismo punto de mundo da la misma altura, lo pidas desde donde lo pidas.
|
||||
assert_eq!(r.column_height(1234, -567, dim), r.column_height(1234, -567, dim));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desierto_es_mas_llano_que_pradera() {
|
||||
let dim = [128, 56, 128];
|
||||
let var = |r: &WorldRecipe| {
|
||||
let mut hs = Vec::new();
|
||||
for z in (0..400).step_by(7) {
|
||||
for x in (0..400).step_by(7) {
|
||||
hs.push(r.column_height(x, z, dim) as f32);
|
||||
}
|
||||
}
|
||||
let mean = hs.iter().sum::<f32>() / hs.len() as f32;
|
||||
hs.iter().map(|h| (h - mean).powi(2)).sum::<f32>() / hs.len() as f32
|
||||
};
|
||||
let desert = var(&WorldRecipe::desert(42));
|
||||
let grass = var(&WorldRecipe::grassland(42));
|
||||
assert!(desert < grass, "el desierto es más llano: var {desert:.1} vs {grass:.1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn el_desierto_pinta_arena_agua_y_cactus() {
|
||||
let dim = [128, 48, 128];
|
||||
let r = WorldRecipe::desert(3);
|
||||
let g = r.generate(dim);
|
||||
let mut seen = [false; 3]; // [arena, agua, cactus]
|
||||
for z in 0..dim[2] {
|
||||
for x in 0..dim[0] {
|
||||
for y in 0..dim[1] {
|
||||
if let Some(c) = g.get(x, y, z) {
|
||||
if c[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let rgb = [c[0], c[1], c[2]];
|
||||
if rgb == Material::Sand.color() {
|
||||
seen[0] = true;
|
||||
} else if rgb == Material::Water.color() {
|
||||
seen[1] = true;
|
||||
} else if rgb == Material::Cactus.color() {
|
||||
seen[2] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(seen[0], "hay arena");
|
||||
assert!(seen[1], "hay agua (ríos)");
|
||||
assert!(seen[2], "hay cactus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismo_seed_mismo_mundo() {
|
||||
let dim = [64, 40, 64];
|
||||
let a = WorldRecipe::desert(99).generate(dim);
|
||||
let b = WorldRecipe::desert(99).generate(dim);
|
||||
// Un muestreo basta: deterministas.
|
||||
for (x, y, z) in [(10, 12, 10), (30, 8, 44), (60, 20, 5)] {
|
||||
assert_eq!(a.get(x, y, z), b.get(x, y, z), "({x},{y},{z}) difiere");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user