tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! Certificación del **reparto con LOD**: una bandada de 16 humanoides alejándose
2//! del ojo, repartida por [`reparto_sdf`] dentro del presupuesto del uniform
3//! (16 seres / 240 primitivas).
4//!
5//! Sin LOD, 16 caras enteras son ~500 primitivas: el empacador cortaba la lista y los
6//! del fondo desaparecían (o peor, salían mutilados). Acá se ve que entran los 16 y
7//! —lo que importa— que la degradación NO se nota: el que perdió la cara está lo
8//! bastante lejos como para que no hubiera cara que ver.
9//!
10//! `cargo run -p llimphi-voxel --example manada_lod_demo --release`
11
12use std::fs::File;
13use std::io::BufWriter;
14
15use llimphi_3d::glam::Vec3;
16use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer, VOXEL_MAX_SER_PRIMS};
17use llimphi_hal::{wgpu, Hal};
18use llimphi_raster::peniko::Color;
19use llimphi_raster::{vello, Renderer};
20use llimphi_voxel::{reparto_sdf, Age, CharSpec, Clip, DetalleSer, EnCuadro};
21
22const W: u32 = 1280;
23const H: u32 = 720;
24const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
25const BG: [u8; 3] = [22, 26, 36];
26
27const DIM: u32 = 128;
28const FLOOR_H: u32 = 2;
29/// Cuántos y con qué separación (en unidades de mundo; un adulto mide ~1.8).
30const N: usize = 16;
31const PASO: f32 = 3.4;
32
33fn main() {
34 let hal = pollster::block_on(Hal::new(None)).expect("hal");
35 let mut renderer = Renderer::new(&hal).expect("renderer");
36 let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
37
38 // Bandada en diagonal (para que no se tapen entre sí), alejándose en Z.
39 let specs: Vec<CharSpec> = (0..N)
40 .map(|i| {
41 let mut c = CharSpec::new(format!("ser{i}"), Age::Adult);
42 // Colores variados para distinguirlos (hash simple por índice).
43 let t = i as f32 / N as f32;
44 c.shirt = [0.20 + 0.5 * t, 0.62 - 0.3 * t, 0.55 + 0.3 * (1.0 - t)];
45 c
46 })
47 .collect();
48
49 let x0 = DIM as f32 * 0.5;
50 let z0 = DIM as f32 * 0.18;
51 let cuadro: Vec<EnCuadro> = specs
52 .iter()
53 .enumerate()
54 .map(|(i, spec)| EnCuadro {
55 spec,
56 pos: Vec3::new(
57 x0 + (i as f32 * 0.9).sin() * 4.0 + (i as f32 % 2.0 - 0.5) * 3.0,
58 FLOOR_H as f32,
59 z0 + i as f32 * PASO,
60 ),
61 yaw: 0.0, // todos de frente a la cámara
62 clip: if i % 3 == 0 { Clip::Wave } else { Clip::Walk },
63 fase: i as f32 * 0.7,
64 })
65 .collect();
66
67 // Cámara delante de la fila, a la altura de la cara del primero.
68 let half = DIM as f32 * 0.5;
69 let ojo_grid = Vec3::new(x0, FLOOR_H as f32 + 2.6, z0 - 4.2);
70 let camera = Camera3d::orbit(
71 Vec3::new(x0 - half, FLOOR_H as f32 + 1.4 - half, z0 + 10.0 - half),
72 0.0,
73 6_f32.to_radians(),
74 16.0,
75 );
76
77 let sers = reparto_sdf(&cuadro, ojo_grid, true);
78
79 println!("manada_lod_demo — {N} humanoides, presupuesto {VOXEL_MAX_SER_PRIMS} primitivas");
80 let mut total = 0;
81 for (i, s) in sers.iter().enumerate() {
82 let dist = ojo_grid.distance(Vec3::from(s.pos) + Vec3::new(0.0, 0.9, 0.0));
83 let d = DetalleSer::por_distancia(dist, 1.8);
84 total += s.prims.len();
85 println!(
86 " #{i:2} dist={dist:5.1} {:?}\t{} prims\t(acumulado {total})",
87 d,
88 s.prims.len()
89 );
90 }
91 println!(" → {} seres, {total} primitivas de {VOXEL_MAX_SER_PRIMS}", sers.len());
92 assert_eq!(sers.len(), N, "entran los {N}");
93 assert!(total <= VOXEL_MAX_SER_PRIMS, "presupuesto excedido: {total}");
94 // Nadie mutilado: el cuerpo entero son 10 primitivas.
95 for (i, s) in sers.iter().enumerate() {
96 assert!(s.prims.len() >= 10, "#{i} salió incompleto ({} prims)", s.prims.len());
97 }
98
99 // Piso damero.
100 let mut grid = VoxelGrid::new([DIM, DIM, DIM]);
101 for x in 0..DIM {
102 for z in 0..DIM {
103 for y in 0..FLOOR_H {
104 let c = if (x / 6 + z / 6) % 2 == 0 { [150, 146, 136] } else { [120, 116, 110] };
105 grid.set(x, y, z, c);
106 }
107 }
108 }
109
110 let px = render(&hal, &mut renderer, &grid, sers, &camera);
111 let cov = coverage(&px);
112 let out = format!("{dir}/manada_lod.png");
113 write_png(&px, &out);
114 println!("✓ {cov} px cubiertos → {out}");
115 assert!(cov > 20_000, "la bandada se renderizó ({cov} px)");
116}
117
118fn render(
119 hal: &Hal,
120 renderer: &mut Renderer,
121 grid: &VoxelGrid,
122 sers: Vec<llimphi_3d::SdfSer>,
123 camera: &Camera3d,
124) -> Vec<u8> {
125 let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, grid);
126 vr.sun_dir = normalize([0.4, 0.7, 0.55]);
127 vr.sers = sers;
128
129 let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
130 label: Some("inter"),
131 size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
132 mip_level_count: 1,
133 sample_count: 1,
134 dimension: wgpu::TextureDimension::D2,
135 format: FMT,
136 usage: wgpu::TextureUsages::STORAGE_BINDING
137 | wgpu::TextureUsages::TEXTURE_BINDING
138 | wgpu::TextureUsages::RENDER_ATTACHMENT
139 | wgpu::TextureUsages::COPY_SRC,
140 view_formats: &[],
141 });
142 let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
143 let base = vello::Scene::new();
144 renderer
145 .render_to_view(hal, &base, &view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
146 .expect("base");
147 let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
148 vr.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), camera);
149 hal.queue.submit(std::iter::once(enc.finish()));
150 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
151 readback(hal, &inter)
152}
153
154fn normalize(v: [f32; 3]) -> [f32; 3] {
155 let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
156 [v[0] / l, v[1] / l, v[2] / l]
157}
158
159fn coverage(px: &[u8]) -> usize {
160 (0..(W * H) as usize)
161 .filter(|&i| {
162 let o = i * 4;
163 (px[o] as i32 - BG[0] as i32).abs()
164 + (px[o + 1] as i32 - BG[1] as i32).abs()
165 + (px[o + 2] as i32 - BG[2] as i32).abs()
166 > 12
167 })
168 .count()
169}
170
171fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
172 let unpadded = (W * 4) as usize;
173 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
174 let padded = unpadded.div_ceil(align) * align;
175 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
176 label: Some("readback"),
177 size: (padded * H as usize) as u64,
178 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
179 mapped_at_creation: false,
180 });
181 let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
182 enc.copy_texture_to_buffer(
183 wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
184 wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(H) } },
185 wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
186 );
187 hal.queue.submit(std::iter::once(enc.finish()));
188 let slice = buf.slice(..);
189 let (tx, rx) = std::sync::mpsc::channel();
190 slice.map_async(wgpu::MapMode::Read, move |r| {
191 let _ = tx.send(r);
192 });
193 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
194 rx.recv().unwrap().unwrap();
195 let data = slice.get_mapped_range();
196 let mut pixels = Vec::with_capacity((W * H * 4) as usize);
197 for row in 0..H as usize {
198 let s = row * padded;
199 pixels.extend_from_slice(&data[s..s + unpadded]);
200 }
201 drop(data);
202 buf.unmap();
203 pixels
204}
205
206fn write_png(pixels: &[u8], path: &str) {
207 let file = File::create(path).expect("png");
208 let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
209 enc.set_color(png::ColorType::Rgba);
210 enc.set_depth(png::BitDepth::Eight);
211 let mut w = enc.write_header().unwrap();
212 w.write_image_data(pixels).unwrap();
213}