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