tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! Certificación visual del **ser SDF** ([`ser_sdf`]): el personaje como ensamble de
2//! primitivas analíticas fundidas por `smin`, sphere-traceado en el MISMO pase que el
3//! mundo voxel (piso damero), con rampa toon + rim y cara por decals (ojos/sonrisa/
4//! rubor). Es la técnica "hero/cerca"; `humanoide_voxel` queda de LOD.
5//!
6//! Compone contra un PISO VOXEL real → certifica de paso la oclusión por `t` y la
7//! sombra del ser proyectada sobre los voxels (el shadow ray del sol marcha los SDF).
8//!
9//! `cargo run -p llimphi-voxel --example ser_sdf_demo --release`
10
11use std::fs::File;
12use std::io::BufWriter;
13
14use llimphi_3d::glam::Vec3;
15use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
16use llimphi_hal::{wgpu, Hal};
17use llimphi_raster::peniko::Color;
18use llimphi_raster::{vello, Renderer};
19use llimphi_voxel::{ser_sdf, Clip, Colores, EstiloSer, Pose};
20
21const W: u32 = 640;
22const H: u32 = 720;
23const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
24const BG: [u8; 3] = [22, 26, 36];
25
26/// Lado del mundo (piso damero abajo, aire arriba: el ser vive en el aire).
27const DIM: u32 = 112;
28const FLOOR_H: u32 = 2;
29
30fn main() {
31 let hal = pollster::block_on(Hal::new(None)).expect("hal");
32 let mut renderer = Renderer::new(&hal).expect("renderer");
33 let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
34
35 let col = Colores::nuevos([0.90, 0.72, 0.58], [0.22, 0.60, 0.72], [0.20, 0.24, 0.36]);
36
37 let idle = Pose::default();
38 let saludo = Clip::Wave.pose(1.2);
39 let festejo = Clip::Cheer.pose(1.0);
40 let casos: [(&str, EstiloSer, Pose); 6] = [
41 ("chibi_amable", EstiloSer::amable(), idle),
42 ("chibi_saludo", EstiloSer::amable(), saludo),
43 ("chibi_festejo", EstiloSer::amable(), festejo),
44 ("robusto", EstiloSer::robusto(), idle),
45 ("agil", EstiloSer::agil(), saludo),
46 ("bebe", EstiloSer::por_edad(llimphi_voxel::Age::Baby), idle),
47 ];
48
49 // Piso damero (el mundo voxel con el que el ser compone y sobre el que sombrea).
50 let mut grid = VoxelGrid::new([DIM, DIM, DIM]);
51 for x in 0..DIM {
52 for z in 0..DIM {
53 for y in 0..FLOOR_H {
54 let c = if (x / 8 + z / 8) % 2 == 0 { [148, 142, 132] } else { [118, 114, 108] };
55 grid.set(x, y, z, c);
56 }
57 }
58 }
59
60 let altura = 72.0; // misma referencia que el preview del studio (nivel Seres)
61 println!("ser_sdf_demo — ser SDF analítico (smin + toon + decals) ALT={altura}");
62 for (nombre, estilo, pose) in &casos {
63 let mut ser = ser_sdf(estilo, pose, altura, col);
64 // Bisección de artefactos: QUITA="3,18,19" remueve primitivas por índice.
65 if let Ok(q) = std::env::var("QUITA") {
66 let fuera: Vec<usize> = q.split(',').filter_map(|s| s.trim().parse().ok()).collect();
67 ser.prims = ser
68 .prims
69 .into_iter()
70 .enumerate()
71 .filter(|(i, _)| !fuera.contains(i))
72 .map(|(_, p)| p)
73 .collect();
74 }
75 // Posarlo parado sobre el piso, centrado en la grilla (coords de voxel [0,dim]).
76 // SER_YAW=<rad> lo gira (ejercita la rotación local del shader).
77 let syaw: f32 = std::env::var("SER_YAW").ok().and_then(|s| s.parse().ok()).unwrap_or(0.0);
78 let posado = ser.posado([DIM as f32 * 0.5, FLOOR_H as f32, DIM as f32 * 0.5], syaw);
79 let n_prims = posado.prims.len();
80 let px = render_ser(&hal, &mut renderer, &grid, posado);
81 let cov = coverage(&px);
82 let out = format!("{dir}/ser_sdf_{nombre}.png");
83 write_png(&px, &out);
84 println!(
85 " {nombre:14} cabezas={:.1} prims={n_prims} → {cov} px cubiertos → {out}",
86 estilo.cabezas
87 );
88 assert!(cov > 4000, "{nombre}: se renderizó ({cov} px)");
89 }
90 println!("✓ certificado: seres SDF trazados con el mundo voxel (oclusión + sombra + cara).");
91}
92
93/// Renderiza el ser SDF sobre el piso voxel, cámara 3/4 al torso/cara.
94fn render_ser(hal: &Hal, renderer: &mut Renderer, grid: &VoxelGrid, ser: llimphi_3d::SdfSer) -> Vec<u8> {
95 let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, grid);
96 vr.sun_dir = normalize([0.4, 0.6, 0.7]);
97 vr.sers.push(ser);
98
99 // Cámara en coords CENTRADAS (el shader hace `ro = cam_eye + dim/2`): mirar al
100 // centro del cuerpo (~55% de la altura del ser sobre el piso).
101 let alto = 72.0f32;
102 let look = Vec3::new(
103 0.0,
104 FLOOR_H as f32 + alto * 0.55 - DIM as f32 * 0.5,
105 0.0,
106 );
107 let yaw: f32 = std::env::var("YAW").ok().and_then(|s| s.parse().ok()).unwrap_or(28.0);
108 let camera = Camera3d::orbit(look, yaw.to_radians(), 10_f32.to_radians(), alto * 1.9);
109
110 let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
111 label: Some("inter"),
112 size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
113 mip_level_count: 1,
114 sample_count: 1,
115 dimension: wgpu::TextureDimension::D2,
116 format: FMT,
117 usage: wgpu::TextureUsages::STORAGE_BINDING
118 | wgpu::TextureUsages::TEXTURE_BINDING
119 | wgpu::TextureUsages::RENDER_ATTACHMENT
120 | wgpu::TextureUsages::COPY_SRC,
121 view_formats: &[],
122 });
123 let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
124 let base = vello::Scene::new();
125 renderer
126 .render_to_view(hal, &base, &view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
127 .expect("base");
128 let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
129 vr.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), &camera);
130 hal.queue.submit(std::iter::once(enc.finish()));
131 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
132 readback(hal, &inter)
133}
134
135fn normalize(v: [f32; 3]) -> [f32; 3] {
136 let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
137 [v[0] / l, v[1] / l, v[2] / l]
138}
139
140fn coverage(px: &[u8]) -> usize {
141 (0..(W * H) as usize)
142 .filter(|&i| {
143 let o = i * 4;
144 (px[o] as i32 - BG[0] as i32).abs()
145 + (px[o + 1] as i32 - BG[1] as i32).abs()
146 + (px[o + 2] as i32 - BG[2] as i32).abs()
147 > 12
148 })
149 .count()
150}
151
152fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
153 let unpadded = (W * 4) as usize;
154 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
155 let padded = unpadded.div_ceil(align) * align;
156 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
157 label: Some("readback"),
158 size: (padded * H as usize) as u64,
159 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
160 mapped_at_creation: false,
161 });
162 let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
163 enc.copy_texture_to_buffer(
164 wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
165 wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(H) } },
166 wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
167 );
168 hal.queue.submit(std::iter::once(enc.finish()));
169 let slice = buf.slice(..);
170 let (tx, rx) = std::sync::mpsc::channel();
171 slice.map_async(wgpu::MapMode::Read, move |r| {
172 let _ = tx.send(r);
173 });
174 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
175 rx.recv().unwrap().unwrap();
176 let data = slice.get_mapped_range();
177 let mut pixels = Vec::with_capacity((W * H * 4) as usize);
178 for row in 0..H as usize {
179 let s = row * padded;
180 pixels.extend_from_slice(&data[s..s + unpadded]);
181 }
182 drop(data);
183 buf.unmap();
184 pixels
185}
186
187fn write_png(pixels: &[u8], path: &str) {
188 let file = File::create(path).expect("png");
189 let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
190 enc.set_color(png::ColorType::Rgba);
191 enc.set_depth(png::BitDepth::Eight);
192 let mut w = enc.write_header().unwrap();
193 w.write_image_data(pixels).unwrap();
194}