tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
1//! **Arnés de tomas para los catálogos SDF** — lo comparten `bestiario_demo`,
2//! `jardin_demo` y `multitud_demo` (`#[path = "comun/mod.rs"] mod comun;`; cargo no
3//! lo toma por ejemplo porque no es `examples/*.rs` ni `examples/*/main.rs`).
4//!
5//! Hace tres cosas y ninguna es 3D: **encuadra** (cada bicho o planta se escala para
6//! que su esfera envolvente ocupe lo mismo — si no, la ballena llena el cuadro y la
7//! hormiga es un punto), **compone** la hoja de contacto, y **etiqueta** cada casilla
8//! con su nombre. Sin el nombre, una hoja de ochenta y cinco animales obliga a
9//! contar filas con el dedo.
10
11use std::fs::File;
12use std::io::BufWriter;
13
14use llimphi_3d::glam::Vec3;
15use llimphi_3d::{Atmosphere, Camera3d, Scene3d, SdfSer, VoxelGrid, VoxelRenderer};
16use llimphi_hal::{wgpu, Hal};
17use llimphi_raster::peniko::{Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat};
18use llimphi_raster::{kurbo, vello, Renderer};
19
20pub const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
21/// Lado del mundito de cada casilla (voxels).
22pub const DIM: u32 = 128;
23/// Alto del piso.
24pub const PISO: u32 = 2;
25/// Radio al que se lleva la esfera envolvente de todo sujeto: el encuadre común.
26pub const RADIO: f32 = 30.0;
27
28pub struct Toma {
29 pub hal: Hal,
30 pub renderer: Renderer,
31 pub ts: llimphi_text::Typesetter,
32}
33
34impl Toma {
35 pub fn nueva() -> Self {
36 let hal = pollster::block_on(Hal::new(None)).expect("hal");
37 let renderer = Renderer::new(&hal).expect("renderer");
38 Self { hal, renderer, ts: llimphi_text::Typesetter::default() }
39 }
40
41 /// Piso a cuadros del tamaño del mundito de una casilla.
42 pub fn piso(&self, claro: [u8; 3], oscuro: [u8; 3]) -> VoxelGrid {
43 let mut g = VoxelGrid::new([DIM, DIM, DIM]);
44 for x in 0..DIM {
45 for z in 0..DIM {
46 let c = if (x / 10 + z / 10) % 2 == 0 { claro } else { oscuro };
47 for y in 0..PISO {
48 g.set(x, y, z, c);
49 }
50 }
51 }
52 g
53 }
54
55 /// **Una casilla**: el sujeto (ya posado) sobre el piso, encuadrado. `yaw_deg`
56 /// gira la cámara alrededor.
57 pub fn casilla(
58 &mut self,
59 grid: &VoxelGrid,
60 sers: Vec<SdfSer>,
61 centro_y: f32,
62 radio: f32,
63 yaw_deg: f32,
64 w: u32,
65 h: u32,
66 ) -> Vec<u8> {
67 let mut vr = VoxelRenderer::new(&self.hal.device, &self.hal.queue, FMT, grid);
68 vr.sun_dir = normaliza([0.40, 0.62, 0.68]);
69 vr.atmosphere = Atmosphere {
70 sky_zenith: [78, 122, 186],
71 sky_horizon: [206, 222, 240],
72 fog_density: 0.10 / DIM as f32,
73 god_rays: 0.0,
74 };
75 vr.sers = sers;
76 let half = DIM as f32 * 0.5;
77 let look = Vec3::new(0.0, PISO as f32 + centro_y - half, 0.0);
78 let camera = Camera3d::orbit(
79 look,
80 yaw_deg.to_radians(),
81 16_f32.to_radians(),
82 // 2.15, no 3.05: con el encuadre viejo el sujeto ocupaba un cuarto
83 // de la casilla y la hoja de contacto no servía para mirar nada.
84 radio * 2.15,
85 );
86 let mut scene = Scene3d::new();
87 self.render(&mut scene, &mut vr, &camera, w, h)
88 }
89
90 fn render(
91 &mut self,
92 scene: &mut Scene3d,
93 vr: &mut VoxelRenderer,
94 camera: &Camera3d,
95 w: u32,
96 h: u32,
97 ) -> Vec<u8> {
98 let tex = self.textura(w, h);
99 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
100 self.renderer
101 .render_to_view(&self.hal, &vello::Scene::new(), &view, w, h, Color::from_rgba8(16, 18, 26, 255))
102 .expect("base");
103 let mut enc = self
104 .hal
105 .device
106 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("casilla") });
107 scene.render(
108 &self.hal.device,
109 &self.hal.queue,
110 &mut enc,
111 &view,
112 (w, h),
113 camera,
114 Some(vr),
115 &[],
116 );
117 self.hal.queue.submit(std::iter::once(enc.finish()));
118 let _ = self.hal.device.poll(wgpu::PollType::wait_indefinitely());
119 leer(&self.hal, &tex, w, h)
120 }
121
122 fn textura(&self, w: u32, h: u32) -> wgpu::Texture {
123 self.hal.device.create_texture(&wgpu::TextureDescriptor {
124 label: Some("toma"),
125 size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
126 mip_level_count: 1,
127 sample_count: 1,
128 dimension: wgpu::TextureDimension::D2,
129 format: FMT,
130 usage: wgpu::TextureUsages::STORAGE_BINDING
131 | wgpu::TextureUsages::TEXTURE_BINDING
132 | wgpu::TextureUsages::RENDER_ATTACHMENT
133 | wgpu::TextureUsages::COPY_SRC,
134 view_formats: &[],
135 })
136 }
137
138 /// **La hoja de contacto**: pega las casillas en una grilla y le escribe el
139 /// nombre a cada una (banda inferior), más un título arriba.
140 pub fn hoja(
141 &mut self,
142 titulo: &str,
143 casillas: &[(String, Vec<u8>)],
144 cols: usize,
145 tw: u32,
146 th: u32,
147 ) -> (Vec<u8>, u32, u32) {
148 let filas = casillas.len().div_ceil(cols) as u32;
149 let cab = 46u32;
150 let w = cols as u32 * tw;
151 let h = filas * th + cab;
152 // 1) pegado en CPU.
153 let mut px = vec![0u8; (w * h * 4) as usize];
154 for (i, y) in (0..h).enumerate() {
155 let _ = i;
156 for x in 0..w {
157 let o = ((y * w + x) * 4) as usize;
158 px[o] = 20;
159 px[o + 1] = 22;
160 px[o + 2] = 30;
161 px[o + 3] = 255;
162 }
163 }
164 for (i, (_, tile)) in casillas.iter().enumerate() {
165 let cx = (i % cols) as u32 * tw;
166 let cy = cab + (i / cols) as u32 * th;
167 for y in 0..th.min(th) {
168 let dst = (((cy + y) * w + cx) * 4) as usize;
169 let src = (y * tw * 4) as usize;
170 let n = (tw * 4) as usize;
171 if src + n <= tile.len() && dst + n <= px.len() {
172 px[dst..dst + n].copy_from_slice(&tile[src..src + n]);
173 }
174 }
175 }
176 // 2) segunda pasada: la imagen como fondo y el texto encima (vello).
177 let img = ImageBrush::new(ImageData {
178 data: Blob::new(std::sync::Arc::new(px)),
179 format: ImageFormat::Rgba8,
180 alpha_type: ImageAlphaType::Alpha,
181 width: w,
182 height: h,
183 });
184 let mut scene = vello::Scene::new();
185 scene.draw_image(img.as_ref(), kurbo::Affine::IDENTITY);
186 let blanco = Color::from_rgba8(240, 240, 236, 255);
187 let sombra = Color::from_rgba8(12, 12, 16, 210);
188 // Título.
189 let lay = texto(&mut self.ts, titulo, 30.0, w as f32 - 24.0);
190 llimphi_text::draw_layout(&mut scene, &lay, blanco, (16.0, 8.0));
191 for (i, (nombre, _)) in casillas.iter().enumerate() {
192 let cx = (i % cols) as f32 * tw as f32;
193 let cy = cab as f32 + (i / cols) as f32 * th as f32;
194 // Banda oscura para que el nombre se lea sobre cualquier fondo.
195 scene.fill(
196 vello::peniko::Fill::NonZero,
197 kurbo::Affine::IDENTITY,
198 sombra,
199 None,
200 &kurbo::Rect::new(
201 cx as f64,
202 (cy + th as f32 - 24.0) as f64,
203 (cx + tw as f32) as f64,
204 (cy + th as f32) as f64,
205 ),
206 );
207 let lay = texto(&mut self.ts, nombre, 17.0, tw as f32 - 12.0);
208 llimphi_text::draw_layout(
209 &mut scene,
210 &lay,
211 blanco,
212 (cx as f64 + 8.0, (cy + th as f32 - 22.0) as f64),
213 );
214 }
215 let tex = self.textura(w, h);
216 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
217 self.renderer
218 .render_to_view(&self.hal, &scene, &view, w, h, Color::from_rgba8(20, 22, 30, 255))
219 .expect("hoja");
220 let _ = self.hal.device.poll(wgpu::PollType::wait_indefinitely());
221 (leer(&self.hal, &tex, w, h), w, h)
222 }
223}
224
225/// El readback, público para los demos que arman su propia toma (la calle de
226/// `multitud_demo` no es una casilla del catálogo).
227pub fn leer_pub(hal: &Hal, tex: &wgpu::Texture, w: u32, h: u32) -> Vec<u8> {
228 leer(hal, tex, w, h)
229}
230
231fn leer(hal: &Hal, tex: &wgpu::Texture, w: u32, h: u32) -> Vec<u8> {
232 let unpadded = (w * 4) as usize;
233 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
234 let padded = unpadded.div_ceil(align) * align;
235 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
236 label: Some("readback"),
237 size: (padded * h as usize) as u64,
238 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
239 mapped_at_creation: false,
240 });
241 let mut enc = hal
242 .device
243 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
244 enc.copy_texture_to_buffer(
245 wgpu::TexelCopyTextureInfo {
246 texture: tex,
247 mip_level: 0,
248 origin: wgpu::Origin3d::ZERO,
249 aspect: wgpu::TextureAspect::All,
250 },
251 wgpu::TexelCopyBufferInfo {
252 buffer: &buf,
253 layout: wgpu::TexelCopyBufferLayout {
254 offset: 0,
255 bytes_per_row: Some(padded as u32),
256 rows_per_image: Some(h),
257 },
258 },
259 wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
260 );
261 hal.queue.submit(std::iter::once(enc.finish()));
262 let slice = buf.slice(..);
263 let (tx, rx) = std::sync::mpsc::channel();
264 slice.map_async(wgpu::MapMode::Read, move |r| {
265 let _ = tx.send(r);
266 });
267 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
268 rx.recv().unwrap().unwrap();
269 let data = slice.get_mapped_range();
270 let mut px = Vec::with_capacity((w * h * 4) as usize);
271 for row in 0..h as usize {
272 let s = row * padded;
273 px.extend_from_slice(&data[s..s + unpadded]);
274 }
275 drop(data);
276 buf.unmap();
277 px
278}
279
280pub fn png(px: &[u8], w: u32, h: u32, path: &str) {
281 let file = File::create(path).expect("png");
282 let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
283 enc.set_color(png::ColorType::Rgba);
284 enc.set_depth(png::BitDepth::Eight);
285 let mut wtr = enc.write_header().unwrap();
286 wtr.write_image_data(px).unwrap();
287}
288
289/// **Cuántos píxeles ocupa el sujeto**: los que difieren de la MISMA casilla
290/// renderizada vacía.
291///
292/// La primera versión comparaba contra un color de cielo escrito a mano y daba
293/// `96000 px` —la casilla entera— para las treinta plantas y los ochenta y cinco
294/// animales, porque el cielo es un degradé y el piso es un damero: casi ningún
295/// píxel coincidía con ese color. Un contador que da siempre el máximo no es un
296/// contador chico, es uno que **miente** (Regla 8.ter), y encima decía «✓ todo
297/// dibujó de sobra» mientras la primera hoja tenía cinco defectos a la vista.
298pub fn ocupacion(px: &[u8], vacia: &[u8]) -> usize {
299 px.chunks_exact(4)
300 .zip(vacia.chunks_exact(4))
301 .filter(|(a, b)| {
302 let d = (a[0] as i32 - b[0] as i32).abs()
303 + (a[1] as i32 - b[1] as i32).abs()
304 + (a[2] as i32 - b[2] as i32).abs();
305 d > 24
306 })
307 .count()
308}
309
310/// Directorio de salida (respeta `CLAUDE_JOB_DIR` como el resto de los demos).
311pub fn dir() -> String {
312 std::env::var("CLAUDE_JOB_DIR")
313 .map(|d| format!("{d}/tmp"))
314 .unwrap_or_else(|_| "/tmp".into())
315}
316
317/// Un layout de una línea: la firma pública del typesetter pide doce argumentos y
318/// once son siempre los mismos acá.
319fn texto(
320 ts: &mut llimphi_text::Typesetter,
321 s: &str,
322 px: f32,
323 ancho: f32,
324) -> llimphi_text::parley::Layout<()> {
325 ts.layout(
326 s,
327 px,
328 Some(ancho),
329 llimphi_text::Alignment::Start,
330 1.15,
331 false,
332 None,
333 400.0,
334 false,
335 false,
336 0.0,
337 0.0,
338 )
339}
340
341fn normaliza(v: [f32; 3]) -> [f32; 3] {
342 let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
343 [v[0] / l, v[1] / l, v[2] / l]
344}