//! **Arnés de tomas para los catálogos SDF** — lo comparten `bestiario_demo`, //! `jardin_demo` y `multitud_demo` (`#[path = "comun/mod.rs"] mod comun;`; cargo no //! lo toma por ejemplo porque no es `examples/*.rs` ni `examples/*/main.rs`). //! //! Hace tres cosas y ninguna es 3D: **encuadra** (cada bicho o planta se escala para //! que su esfera envolvente ocupe lo mismo — si no, la ballena llena el cuadro y la //! hormiga es un punto), **compone** la hoja de contacto, y **etiqueta** cada casilla //! con su nombre. Sin el nombre, una hoja de ochenta y cinco animales obliga a //! contar filas con el dedo. use std::fs::File; use std::io::BufWriter; use llimphi_3d::glam::Vec3; use llimphi_3d::{Atmosphere, Camera3d, Scene3d, SdfSer, VoxelGrid, VoxelRenderer}; use llimphi_hal::{wgpu, Hal}; use llimphi_raster::peniko::{Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; use llimphi_raster::{kurbo, vello, Renderer}; pub const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Lado del mundito de cada casilla (voxels). pub const DIM: u32 = 128; /// Alto del piso. pub const PISO: u32 = 2; /// Radio al que se lleva la esfera envolvente de todo sujeto: el encuadre común. pub const RADIO: f32 = 30.0; pub struct Toma { pub hal: Hal, pub renderer: Renderer, pub ts: llimphi_text::Typesetter, } impl Toma { pub fn nueva() -> Self { let hal = pollster::block_on(Hal::new(None)).expect("hal"); let renderer = Renderer::new(&hal).expect("renderer"); Self { hal, renderer, ts: llimphi_text::Typesetter::default() } } /// Piso a cuadros del tamaño del mundito de una casilla. pub fn piso(&self, claro: [u8; 3], oscuro: [u8; 3]) -> VoxelGrid { let mut g = VoxelGrid::new([DIM, DIM, DIM]); for x in 0..DIM { for z in 0..DIM { let c = if (x / 10 + z / 10) % 2 == 0 { claro } else { oscuro }; for y in 0..PISO { g.set(x, y, z, c); } } } g } /// **Una casilla**: el sujeto (ya posado) sobre el piso, encuadrado. `yaw_deg` /// gira la cámara alrededor. pub fn casilla( &mut self, grid: &VoxelGrid, sers: Vec, centro_y: f32, radio: f32, yaw_deg: f32, w: u32, h: u32, ) -> Vec { let mut vr = VoxelRenderer::new(&self.hal.device, &self.hal.queue, FMT, grid); vr.sun_dir = normaliza([0.40, 0.62, 0.68]); vr.atmosphere = Atmosphere { sky_zenith: [78, 122, 186], sky_horizon: [206, 222, 240], fog_density: 0.10 / DIM as f32, god_rays: 0.0, }; vr.sers = sers; let half = DIM as f32 * 0.5; let look = Vec3::new(0.0, PISO as f32 + centro_y - half, 0.0); let camera = Camera3d::orbit( look, yaw_deg.to_radians(), 16_f32.to_radians(), // 2.15, no 3.05: con el encuadre viejo el sujeto ocupaba un cuarto // de la casilla y la hoja de contacto no servía para mirar nada. radio * 2.15, ); let mut scene = Scene3d::new(); self.render(&mut scene, &mut vr, &camera, w, h) } fn render( &mut self, scene: &mut Scene3d, vr: &mut VoxelRenderer, camera: &Camera3d, w: u32, h: u32, ) -> Vec { let tex = self.textura(w, h); let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); self.renderer .render_to_view(&self.hal, &vello::Scene::new(), &view, w, h, Color::from_rgba8(16, 18, 26, 255)) .expect("base"); let mut enc = self .hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("casilla") }); scene.render( &self.hal.device, &self.hal.queue, &mut enc, &view, (w, h), camera, Some(vr), &[], ); self.hal.queue.submit(std::iter::once(enc.finish())); let _ = self.hal.device.poll(wgpu::PollType::wait_indefinitely()); leer(&self.hal, &tex, w, h) } fn textura(&self, w: u32, h: u32) -> wgpu::Texture { self.hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("toma"), 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: &[], }) } /// **La hoja de contacto**: pega las casillas en una grilla y le escribe el /// nombre a cada una (banda inferior), más un título arriba. pub fn hoja( &mut self, titulo: &str, casillas: &[(String, Vec)], cols: usize, tw: u32, th: u32, ) -> (Vec, u32, u32) { let filas = casillas.len().div_ceil(cols) as u32; let cab = 46u32; let w = cols as u32 * tw; let h = filas * th + cab; // 1) pegado en CPU. let mut px = vec![0u8; (w * h * 4) as usize]; for (i, y) in (0..h).enumerate() { let _ = i; for x in 0..w { let o = ((y * w + x) * 4) as usize; px[o] = 20; px[o + 1] = 22; px[o + 2] = 30; px[o + 3] = 255; } } for (i, (_, tile)) in casillas.iter().enumerate() { let cx = (i % cols) as u32 * tw; let cy = cab + (i / cols) as u32 * th; for y in 0..th.min(th) { let dst = (((cy + y) * w + cx) * 4) as usize; let src = (y * tw * 4) as usize; let n = (tw * 4) as usize; if src + n <= tile.len() && dst + n <= px.len() { px[dst..dst + n].copy_from_slice(&tile[src..src + n]); } } } // 2) segunda pasada: la imagen como fondo y el texto encima (vello). let img = ImageBrush::new(ImageData { data: Blob::new(std::sync::Arc::new(px)), format: ImageFormat::Rgba8, alpha_type: ImageAlphaType::Alpha, width: w, height: h, }); let mut scene = vello::Scene::new(); scene.draw_image(img.as_ref(), kurbo::Affine::IDENTITY); let blanco = Color::from_rgba8(240, 240, 236, 255); let sombra = Color::from_rgba8(12, 12, 16, 210); // Título. let lay = texto(&mut self.ts, titulo, 30.0, w as f32 - 24.0); llimphi_text::draw_layout(&mut scene, &lay, blanco, (16.0, 8.0)); for (i, (nombre, _)) in casillas.iter().enumerate() { let cx = (i % cols) as f32 * tw as f32; let cy = cab as f32 + (i / cols) as f32 * th as f32; // Banda oscura para que el nombre se lea sobre cualquier fondo. scene.fill( vello::peniko::Fill::NonZero, kurbo::Affine::IDENTITY, sombra, None, &kurbo::Rect::new( cx as f64, (cy + th as f32 - 24.0) as f64, (cx + tw as f32) as f64, (cy + th as f32) as f64, ), ); let lay = texto(&mut self.ts, nombre, 17.0, tw as f32 - 12.0); llimphi_text::draw_layout( &mut scene, &lay, blanco, (cx as f64 + 8.0, (cy + th as f32 - 22.0) as f64), ); } let tex = self.textura(w, h); let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); self.renderer .render_to_view(&self.hal, &scene, &view, w, h, Color::from_rgba8(20, 22, 30, 255)) .expect("hoja"); let _ = self.hal.device.poll(wgpu::PollType::wait_indefinitely()); (leer(&self.hal, &tex, w, h), w, h) } } /// El readback, público para los demos que arman su propia toma (la calle de /// `multitud_demo` no es una casilla del catálogo). pub fn leer_pub(hal: &Hal, tex: &wgpu::Texture, w: u32, h: u32) -> Vec { leer(hal, tex, w, h) } fn leer(hal: &Hal, tex: &wgpu::Texture, w: u32, h: u32) -> Vec { 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: tex, 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 px = Vec::with_capacity((w * h * 4) as usize); for row in 0..h as usize { let s = row * padded; px.extend_from_slice(&data[s..s + unpadded]); } drop(data); buf.unmap(); px } pub fn png(px: &[u8], w: u32, h: u32, path: &str) { let file = File::create(path).expect("png"); let mut enc = png::Encoder::new(BufWriter::new(file), w, h); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight); let mut wtr = enc.write_header().unwrap(); wtr.write_image_data(px).unwrap(); } /// **Cuántos píxeles ocupa el sujeto**: los que difieren de la MISMA casilla /// renderizada vacía. /// /// La primera versión comparaba contra un color de cielo escrito a mano y daba /// `96000 px` —la casilla entera— para las treinta plantas y los ochenta y cinco /// animales, porque el cielo es un degradé y el piso es un damero: casi ningún /// píxel coincidía con ese color. Un contador que da siempre el máximo no es un /// contador chico, es uno que **miente** (Regla 8.ter), y encima decía «✓ todo /// dibujó de sobra» mientras la primera hoja tenía cinco defectos a la vista. pub fn ocupacion(px: &[u8], vacia: &[u8]) -> usize { px.chunks_exact(4) .zip(vacia.chunks_exact(4)) .filter(|(a, b)| { let d = (a[0] as i32 - b[0] as i32).abs() + (a[1] as i32 - b[1] as i32).abs() + (a[2] as i32 - b[2] as i32).abs(); d > 24 }) .count() } /// Directorio de salida (respeta `CLAUDE_JOB_DIR` como el resto de los demos). pub fn dir() -> String { std::env::var("CLAUDE_JOB_DIR") .map(|d| format!("{d}/tmp")) .unwrap_or_else(|_| "/tmp".into()) } /// Un layout de una línea: la firma pública del typesetter pide doce argumentos y /// once son siempre los mismos acá. fn texto( ts: &mut llimphi_text::Typesetter, s: &str, px: f32, ancho: f32, ) -> llimphi_text::parley::Layout<()> { ts.layout( s, px, Some(ancho), llimphi_text::Alignment::Start, 1.15, false, None, 400.0, false, false, 0.0, 0.0, ) } fn normaliza(v: [f32; 3]) -> [f32; 3] { let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6); [v[0] / l, v[1] / l, v[2] / l] }