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//! Demo de M5 — **dimensiones / mundos paralelos**. Tres mundos voxel
2//! independientes (Jardín, Inframundo, Cristal), cada uno con su grid, su cielo,
3//! su sol y sus entidades. La cámara ve la dimensión activa; "viajar" = cambiar
4//! cuál se renderiza.
5//!
6//! - **Arrastrar**: orbita. **Rueda**: zoom.
7//! - **Tab / N**: siguiente dimensión. **P**: anterior. **1/2/3**: ir a una.
8//! - Las entidades de la dimensión activa orbitan solas.
9//!
10//! `cargo run -p llimphi-3d --example voxel_dimensiones --release`
11//! `… --release -- --shot` → vuelca un PNG por dimensión a /tmp/m5_*.png
12
13use std::sync::{Arc, Mutex};
14use std::time::Duration;
15
16use llimphi_3d::glam::Vec3;
17use llimphi_3d::{Atmosphere, Camera3d, Dimension, Entity3d, Multiverse, VoxelGrid};
18use llimphi_ui::llimphi_hal::{wgpu, Hal};
19use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
20use llimphi_ui::llimphi_layout::LayoutTree;
21use llimphi_ui::llimphi_raster::peniko::Color;
22use llimphi_ui::llimphi_raster::{vello, Renderer};
23use llimphi_ui::{
24 mount, paint_gpu, App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View,
25 WheelDelta,
26};
27
28const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
29const DIM: u32 = 64;
30
31// ── Construcción de los tres mundos ─────────────────────────────────────────
32
33fn world_jardin(d: u32) -> Dimension {
34 Dimension::new("Jardín", VoxelGrid::demo_scene([d, d, d]))
35 .with_sky([20, 30, 26])
36 .with_sun([0.5, 1.0, 0.35])
37 .with_atmosphere(Atmosphere {
38 sky_zenith: [70, 130, 90],
39 sky_horizon: [196, 222, 188],
40 fog_density: 0.22 / d as f32,
41 god_rays: 0.0,
42 })
43 .with_entities(orbit_entities(
44 d,
45 &[[235, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]],
46 ))
47}
48
49fn world_inframundo(d: u32) -> Dimension {
50 let mut g = VoxelGrid::new([d, d, d]);
51 // Piso de lava (damero rojo/naranja).
52 for z in 0..d {
53 for x in 0..d {
54 let chk = ((x / 4 + z / 4) % 2) == 0;
55 let c = if chk { [150, 45, 22] } else { [185, 70, 28] };
56 for y in 0..2 {
57 g.set(x, y, z, c);
58 }
59 }
60 }
61 // Estalagmitas (columnas que se afinan hacia arriba).
62 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)] {
63 for y in 2..(2 + h).min(d) {
64 let t = (y - 2) as f32 / h as f32;
65 let r = ((1.0 - t) * 3.0).round() as i32;
66 for dx in -r..=r {
67 for dz in -r..=r {
68 let x = sx as i32 + dx;
69 let z = sz as i32 + dz;
70 if x >= 0 && z >= 0 {
71 let shade = 60 + (t * 70.0) as u8;
72 g.set(x as u32, y, z as u32, [120 + shade / 2, 50, 30]);
73 }
74 }
75 }
76 }
77 }
78 Dimension::new("Inframundo", g)
79 .with_sky([28, 8, 8])
80 .with_sun([0.35, 0.7, 0.5])
81 .with_atmosphere(Atmosphere {
82 sky_zenith: [60, 12, 10],
83 sky_horizon: [180, 70, 24],
84 fog_density: 0.4 / d as f32,
85 god_rays: 0.0,
86 })
87 .with_entities(orbit_entities(d, &[[255, 140, 30], [255, 90, 20], [255, 200, 60]]))
88}
89
90fn world_cristal(d: u32) -> Dimension {
91 let mut g = VoxelGrid::new([d, d, d]);
92 // Cristales octaédricos flotando en el vacío (sin piso).
93 let crystals: [(u32, u32, u32, [u8; 3]); 6] = [
94 (d / 2, d * 3 / 4, d / 2, [120, 220, 255]),
95 (d / 3, d / 2, d * 2 / 3, [200, 160, 255]),
96 (d * 2 / 3, d * 3 / 5, d / 3, [160, 255, 220]),
97 (d / 4, d * 2 / 3, d / 4, [255, 240, 200]),
98 (d * 3 / 4, d / 2, d * 3 / 4, [180, 200, 255]),
99 (d / 2, d / 3, d * 4 / 5, [220, 180, 255]),
100 ];
101 for (cx, cy, cz, col) in crystals {
102 let r = 4i32;
103 for dx in -r..=r {
104 for dy in -r..=r {
105 for dz in -r..=r {
106 if dx.abs() + dy.abs() + dz.abs() <= r {
107 let x = cx as i32 + dx;
108 let y = cy as i32 + dy;
109 let z = cz as i32 + dz;
110 if x >= 0 && y >= 0 && z >= 0 {
111 g.set(x as u32, y as u32, z as u32, col);
112 }
113 }
114 }
115 }
116 }
117 }
118 Dimension::new("Cristal", g)
119 .with_sky([10, 10, 22])
120 .with_sun([0.4, 0.8, 0.45])
121 .with_atmosphere(Atmosphere {
122 sky_zenith: [24, 18, 60],
123 sky_horizon: [120, 90, 200],
124 fog_density: 0.28 / d as f32,
125 god_rays: 0.0,
126 })
127 .with_entities(orbit_entities(d, &[[120, 240, 255], [220, 180, 255]]))
128}
129
130/// Entidades distribuidas en una órbita ecuatorial (se animan girando).
131fn orbit_entities(d: u32, colors: &[[u8; 3]]) -> Vec<Entity3d> {
132 let n = colors.len();
133 let df = d as f32;
134 (0..n)
135 .map(|k| {
136 let a = k as f32 / n as f32 * std::f32::consts::TAU;
137 Entity3d {
138 pos: [df * 0.5 + a.cos() * df * 0.42, df * 0.45, df * 0.5 + a.sin() * df * 0.42],
139 half: [df * 0.05, df * 0.05, df * 0.05],
140 color: colors[k],
141 }
142 })
143 .collect()
144}
145
146fn build_multiverse(d: u32) -> Multiverse {
147 Multiverse::new(vec![world_jardin(d), world_inframundo(d), world_cristal(d)])
148}
149
150fn rotate_y(e: &mut Entity3d, center: [f32; 3], ang: f32) {
151 let dx = e.pos[0] - center[0];
152 let dz = e.pos[2] - center[2];
153 let (s, c) = ang.sin_cos();
154 e.pos[0] = center[0] + dx * c - dz * s;
155 e.pos[2] = center[2] + dx * s + dz * c;
156}
157
158// ── App interactiva ─────────────────────────────────────────────────────────
159
160#[derive(Clone)]
161enum Msg {
162 Orbit(f32, f32),
163 Zoom(f32),
164 Tick,
165 Next,
166 Prev,
167 Go(usize),
168}
169
170struct Model {
171 yaw: f32,
172 pitch: f32,
173 dist: f32,
174 active: usize,
175 names: Vec<String>,
176 /// Color de cielo por dimensión. Se arma al cargar el multiverso pero el
177 /// demo todavía no lo aplica al render — queda como dato listo para cuando
178 /// el cambio de dimensión tiña el fondo.
179 #[allow(dead_code)]
180 skies: Vec<[u8; 3]>,
181 mv: Arc<Mutex<Multiverse>>,
182}
183
184struct DimApp;
185
186impl App for DimApp {
187 type Model = Model;
188 type Msg = Msg;
189
190 fn title() -> &'static str {
191 "llimphi-3d · dimensiones"
192 }
193 fn initial_size() -> (u32, u32) {
194 (1000, 720)
195 }
196
197 fn init(handle: &Handle<Msg>) -> Model {
198 handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
199 let mv = build_multiverse(DIM);
200 Model {
201 yaw: 35_f32.to_radians(),
202 pitch: 30_f32.to_radians(),
203 dist: DIM as f32 * 1.7,
204 active: mv.active(),
205 names: mv.names(),
206 skies: mv.skies(),
207 mv: Arc::new(Mutex::new(mv)),
208 }
209 }
210
211 fn window_title(model: &Model) -> Option<String> {
212 Some(format!(
213 "llimphi-3d · {} ({}/{}) — Tab=siguiente",
214 model.names[model.active],
215 model.active + 1,
216 model.names.len()
217 ))
218 }
219
220 fn on_key(_model: &Model, ev: &KeyEvent) -> Option<Msg> {
221 if !matches!(ev.state, KeyState::Pressed) {
222 return None;
223 }
224 match &ev.key {
225 Key::Named(NamedKey::Tab) => Some(Msg::Next),
226 Key::Character(c) => match c.as_str() {
227 "n" | "N" => Some(Msg::Next),
228 "p" | "P" => Some(Msg::Prev),
229 "1" => Some(Msg::Go(0)),
230 "2" => Some(Msg::Go(1)),
231 "3" => Some(Msg::Go(2)),
232 _ => None,
233 },
234 _ => None,
235 }
236 }
237
238 fn on_wheel(_m: &Model, delta: WheelDelta, _c: (f32, f32), _mods: Modifiers) -> Option<Msg> {
239 Some(Msg::Zoom(delta.y))
240 }
241
242 fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
243 match msg {
244 Msg::Orbit(dx, dy) => {
245 model.yaw -= dx * 0.008;
246 model.pitch += dy * 0.008;
247 }
248 Msg::Zoom(dy) => {
249 let f = (1.0 + dy * 0.1).clamp(0.5, 1.5);
250 model.dist = (model.dist * f).clamp(DIM as f32 * 0.5, DIM as f32 * 4.0);
251 }
252 Msg::Tick => {
253 // Anima las entidades de la dimensión activa.
254 let mut mv = model.mv.lock().unwrap();
255 let c = [DIM as f32 * 0.5, DIM as f32 * 0.45, DIM as f32 * 0.5];
256 for e in &mut mv.active_dim_mut().entities {
257 rotate_y(e, c, 0.02);
258 }
259 }
260 Msg::Next => {
261 model.mv.lock().unwrap().next();
262 model.active = model.mv.lock().unwrap().active();
263 }
264 Msg::Prev => {
265 model.mv.lock().unwrap().prev();
266 model.active = model.mv.lock().unwrap().active();
267 }
268 Msg::Go(i) => {
269 let mut mv = model.mv.lock().unwrap();
270 mv.switch(i);
271 model.active = mv.active();
272 }
273 }
274 model
275 }
276
277 fn view(model: &Model) -> View<Msg> {
278 let camera = Camera3d::orbit(Vec3::ZERO, model.yaw, model.pitch, model.dist);
279 let mv = model.mv.clone();
280
281 let canvas = View::new(fill())
282 .gpu_paint_with(move |device, queue, encoder, target, _rect, vp| {
283 mv.lock().unwrap().render(device, queue, encoder, target, vp, &camera);
284 })
285 .draggable(|phase, dx, dy| match phase {
286 DragPhase::Move => Some(Msg::Orbit(dx, dy)),
287 DragPhase::End => None,
288 });
289
290 View::new(fill()).children(vec![canvas])
291 }
292}
293
294fn fill() -> Style {
295 Style {
296 size: Size {
297 width: percent(1.0),
298 height: percent(1.0),
299 },
300 ..Default::default()
301 }
302}
303
304fn main() {
305 let args: Vec<String> = std::env::args().collect();
306 if args.iter().any(|a| a == "--shot") {
307 shot();
308 return;
309 }
310 llimphi_ui::run::<DimApp>();
311}
312
313/// Vuelca un PNG por dimensión por el compositor real (mount → paint_gpu).
314fn shot() {
315 const W: u32 = 1000;
316 const H: u32 = 720;
317 let mv = Arc::new(Mutex::new(build_multiverse(DIM)));
318 let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), DIM as f32 * 1.7);
319
320 let hal = pollster::block_on(Hal::new(None)).expect("hal");
321 let mut renderer = Renderer::new(&hal).expect("renderer");
322 let count = mv.lock().unwrap().count();
323
324 for i in 0..count {
325 let (name, sky) = {
326 let mut g = mv.lock().unwrap();
327 g.switch(i);
328 (g.active_name().to_string(), g.active_dim().sky)
329 };
330 let model_mv = mv.clone();
331 let cam = camera;
332 let canvas: View<Msg> = View::new(fill()).gpu_paint_with(
333 move |device, queue, encoder, target, _rect, vp| {
334 model_mv.lock().unwrap().render(device, queue, encoder, target, vp, &cam);
335 },
336 );
337 let view: View<Msg> = View::new(fill()).children(vec![canvas]);
338
339 let mut layout = LayoutTree::new();
340 let mounted = mount(&mut layout, view);
341 let computed = layout.compute(mounted.root, (W as f32, H as f32)).expect("layout");
342
343 let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
344 label: Some("inter"),
345 size: wgpu::Extent3d {
346 width: W,
347 height: H,
348 depth_or_array_layers: 1,
349 },
350 mip_level_count: 1,
351 sample_count: 1,
352 dimension: wgpu::TextureDimension::D2,
353 format: FMT,
354 usage: wgpu::TextureUsages::STORAGE_BINDING
355 | wgpu::TextureUsages::TEXTURE_BINDING
356 | wgpu::TextureUsages::RENDER_ATTACHMENT
357 | wgpu::TextureUsages::COPY_SRC,
358 view_formats: &[],
359 });
360 let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
361 renderer
362 .render_to_view(&hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(sky[0], sky[1], sky[2], 255))
363 .expect("base");
364
365 let mut enc = hal
366 .device
367 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") });
368 let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &inter_view, (W, H));
369 hal.queue.submit(std::iter::once(enc.finish()));
370 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
371 assert!(any, "gpu_painter no corrió");
372
373 let out = format!("/tmp/m5_{i}_{}.png", name.to_lowercase());
374 write_png(&hal, &inter, W, H, &out);
375 eprintln!("dimensión {i} = {name} → {out}");
376 }
377}
378
379fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32, path: &str) {
380 use std::fs::File;
381 use std::io::BufWriter;
382 let unpadded = (w * 4) as usize;
383 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
384 let padded = unpadded.div_ceil(align) * align;
385 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
386 label: Some("readback"),
387 size: (padded * h as usize) as u64,
388 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
389 mapped_at_creation: false,
390 });
391 let mut enc = hal
392 .device
393 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
394 enc.copy_texture_to_buffer(
395 wgpu::TexelCopyTextureInfo {
396 texture: target,
397 mip_level: 0,
398 origin: wgpu::Origin3d::ZERO,
399 aspect: wgpu::TextureAspect::All,
400 },
401 wgpu::TexelCopyBufferInfo {
402 buffer: &buf,
403 layout: wgpu::TexelCopyBufferLayout {
404 offset: 0,
405 bytes_per_row: Some(padded as u32),
406 rows_per_image: Some(h),
407 },
408 },
409 wgpu::Extent3d {
410 width: w,
411 height: h,
412 depth_or_array_layers: 1,
413 },
414 );
415 hal.queue.submit(std::iter::once(enc.finish()));
416 let slice = buf.slice(..);
417 let (tx, rx) = std::sync::mpsc::channel();
418 slice.map_async(wgpu::MapMode::Read, move |r| {
419 let _ = tx.send(r);
420 });
421 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
422 rx.recv().unwrap().unwrap();
423 let data = slice.get_mapped_range();
424 let mut pixels = Vec::with_capacity((w * h * 4) as usize);
425 for row in 0..h as usize {
426 let s = row * padded;
427 pixels.extend_from_slice(&data[s..s + unpadded]);
428 }
429 drop(data);
430 buf.unmap();
431 let file = File::create(path).expect("png");
432 let mut penc = png::Encoder::new(BufWriter::new(file), w, h);
433 penc.set_color(png::ColorType::Rgba);
434 penc.set_depth(png::BitDepth::Eight);
435 let mut wr = penc.write_header().unwrap();
436 wr.write_image_data(&pixels).unwrap();
437}