tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! **Showreel** del motor Llimphi — para r/rust. NO es eye-candy abstracto:
2//! es una vitrina de **widgets reales** del toolkit, *en acción*. Cada frame
3//! reconstruye un árbol `View` con widgets de verdad (`llimphi-widget-switch`,
4//! `-slider`, `-progress`, `-button`, `-segmented`) cuyo **estado** se deriva
5//! del tiempo normalizado `t∈[0,1]` — el toggle se enciende, el slider sube,
6//! la barra avanza, el segmented cambia de pestaña. Se montan con el `mount` /
7//! `paint` / `compute_with_measure` reales (taffy + parley + vello), idéntico
8//! al eventloop. No se dibujan a mano: si existe el widget, se usa el widget.
9//!
10//! El render es **headless y determinista** (sin reloj, sin runtime, sin
11//! winit): frame `i` de `N` → `t = i/(N-1)` → View → layout → vello::Scene →
12//! wgpu → PNG. Las tarjetas entran desde el primer cuadro; el wordmark de
13//! cierre es `paint_with` sobre un nodo full-screen, encima de los widgets.
14//!
15//! ```text
16//! cargo run -p llimphi-compositor --example showreel --release -- \
17//! [out_dir] [n_frames] [W] [H]
18//! ```
19//! Defaults: `out_dir=showreel_frames`, `n_frames=360`, `W=1600`, `H=900`.
20
21use std::fs::{create_dir_all, File};
22use std::io::BufWriter;
23
24use llimphi_compositor::{
25 measure_text_node, mount, paint, DragPhase, PaintRect, Shadow, View,
26};
27use llimphi_hal::{wgpu, Hal};
28use llimphi_layout::taffy;
29use llimphi_layout::taffy::prelude::{
30 auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style,
31};
32use llimphi_layout::taffy::Rect;
33use llimphi_layout::LayoutTree;
34use llimphi_raster::peniko::{self, Color, Gradient};
35use llimphi_raster::{vello, Renderer};
36use llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter};
37use llimphi_theme::motion;
38use vello::kurbo::{Affine, Circle, Point};
39
40use llimphi_widget_button::{button_view, ButtonPalette};
41use llimphi_widget_progress::{linear_progress_view, radial_progress_view};
42use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
43use llimphi_widget_slider::{slider_view, SliderPalette};
44use llimphi_widget_switch::{switch_view, SwitchPalette};
45
46const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
47
48// ───────────────────────── utilidades ─────────────────────────
49
50/// Color con alpha escalado a `a∈[0,1]` (para fade del overlay vector).
51fn with_alpha(c: Color, a: f32) -> Color {
52 let [r, g, b, _] = c.components;
53 Color::new([r, g, b, a.clamp(0.0, 1.0)])
54}
55
56fn lerp(a: f64, b: f64, t: f64) -> f64 {
57 a + (b - a) * t
58}
59
60/// Reescala `t` desde el subintervalo `[lo,hi]` de la timeline a `[0,1]`,
61/// clampado. Fuera del intervalo devuelve 0 (antes) o 1 (después).
62fn seg(t: f32, lo: f32, hi: f32) -> f32 {
63 ((t - lo) / (hi - lo)).clamp(0.0, 1.0)
64}
65
66// ───────────────────────── tema / paleta ─────────────────────────
67
68#[derive(Clone)]
69struct Skin {
70 theme: llimphi_theme::Theme,
71 accent: Color,
72 panel: Color,
73 panel_hi: Color,
74 border: Color,
75 border_accent: Color,
76 fg: Color,
77 fg_muted: Color,
78 bg: Color,
79}
80
81// ───────────────────────── geometría de las tarjetas ─────────────────────────
82
83#[derive(Clone, Copy)]
84struct CardRect {
85 x: f64,
86 y: f64,
87 w: f64,
88 h: f64,
89}
90
91impl CardRect {
92 fn lerp(self, b: CardRect, t: f64) -> CardRect {
93 CardRect {
94 x: lerp(self.x, b.x, t),
95 y: lerp(self.y, b.y, t),
96 w: lerp(self.w, b.w, t),
97 h: lerp(self.h, b.h, t),
98 }
99 }
100}
101
102const N_CARDS: usize = 6;
103
104/// Disposición A — grilla 3×2 centrada (beat de ensamblado).
105fn layout_grid(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
106 let card_w = 360.0;
107 let card_h = 196.0;
108 let gap = 40.0;
109 let cols = 3.0;
110 let rows = 2.0;
111 let total_w = cols * card_w + (cols - 1.0) * gap;
112 let total_h = rows * card_h + (rows - 1.0) * gap;
113 let x0 = (cw - total_w) / 2.0;
114 let y0 = (ch - total_h) / 2.0;
115 let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: card_h }; N_CARDS];
116 for (i, c) in out.iter_mut().enumerate() {
117 let col = (i % 3) as f64;
118 let row = (i / 3) as f64;
119 c.x = x0 + col * (card_w + gap);
120 c.y = y0 + row * (card_h + gap);
121 }
122 out
123}
124
125/// Disposición B — fila única ancha, alturas escalonadas (beat de morph).
126/// Los MISMOS widgets adentro, otra geometría: "cualquier layout con taffy".
127fn layout_row(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
128 let gap = 22.0;
129 let n = N_CARDS as f64;
130 let card_w = (cw - 2.0 * 90.0 - (n - 1.0) * gap) / n;
131 let x0 = 90.0;
132 let cy = ch / 2.0;
133 // alturas tipo "ecualizador" — silueta dinámica al reacomodar.
134 let hs = [240.0, 300.0, 210.0, 320.0, 260.0, 230.0];
135 let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: 220.0 }; N_CARDS];
136 for (i, c) in out.iter_mut().enumerate() {
137 c.x = x0 + i as f64 * (card_w + gap);
138 c.h = hs[i];
139 c.y = cy - c.h / 2.0;
140 c.w = card_w;
141 }
142 out
143}
144
145// ───────────────────────── contenido de cada card ─────────────────────────
146
147/// Header de card: chip de acento + título.
148fn card_header(title: &str, s: &Skin, accented: bool) -> View<()> {
149 let chip = View::new(Style {
150 size: Size { width: length(28.0), height: length(8.0) },
151 flex_shrink: 0.0,
152 ..Default::default()
153 })
154 .radius(4.0)
155 .fill(if accented { s.accent } else { s.fg_muted });
156 View::new(Style {
157 size: Size { width: percent(1.0_f32), height: length(20.0) },
158 flex_direction: FlexDirection::Row,
159 align_items: Some(AlignItems::CENTER),
160 gap: Size { width: length(10.0), height: length(0.0) },
161 flex_shrink: 0.0,
162 ..Default::default()
163 })
164 .children(vec![
165 chip,
166 View::new(Style { flex_grow: 1.0, ..Default::default() })
167 .text_aligned(title.to_string(), 12.5, s.fg_muted, Alignment::Start)
168 .bold(),
169 ])
170}
171
172/// Línea de "valor" grande (estado legible) bajo el control.
173fn value_line(text: &str, color: Color, size: f32) -> View<()> {
174 View::new(Style {
175 size: Size { width: percent(1.0_f32), height: length(size + 6.0) },
176 flex_shrink: 0.0,
177 ..Default::default()
178 })
179 .text_aligned(text.to_string(), size, color, Alignment::Start)
180 .bold()
181}
182
183/// Cuerpo de una card según índice — cada una hospeda widgets REALES cuyo
184/// estado deriva de `p∈[0,1]` (progreso del beat de widgets).
185fn card_body(i: usize, p: f32, s: &Skin) -> Vec<View<()>> {
186 match i {
187 // ── 0: Switch (off → on) ──────────────────────────────────────
188 0 => {
189 // El thumb se desliza en una rampa centrada del beat.
190 let prog = motion::ease_in_out_cubic(seg(p, 0.15, 0.6));
191 let on = prog > 0.5;
192 let pal = SwitchPalette::from_theme(&s.theme);
193 let sw_row = View::new(Style {
194 size: Size { width: percent(1.0_f32), height: length(26.0) },
195 flex_direction: FlexDirection::Row,
196 align_items: Some(AlignItems::CENTER),
197 gap: Size { width: length(14.0), height: length(0.0) },
198 ..Default::default()
199 })
200 .children(vec![
201 switch_view(prog, (), &pal),
202 View::new(Style { flex_grow: 1.0, ..Default::default() })
203 .text_aligned(
204 "Sincronizar".to_string(),
205 13.0,
206 s.fg,
207 Alignment::Start,
208 ),
209 ]);
210 vec![
211 card_header("switch", s, true),
212 spacer(8.0),
213 sw_row,
214 spacer(10.0),
215 value_line(if on { "ENCENDIDO" } else { "apagado" }, if on { s.accent } else { s.fg_muted }, 22.0),
216 ]
217 }
218 // ── 1: Slider (20% → 75%) ─────────────────────────────────────
219 1 => {
220 let v = lerp(0.2, 0.75, motion::ease_in_out_cubic(seg(p, 0.1, 0.7)) as f64) as f32;
221 let mut pal = SliderPalette::from_theme(&s.theme);
222 pal.track_width = 168.0;
223 pal.label_width = 0.0;
224 pal.value_width = 50.0;
225 pal.track_thickness = 8.0;
226 pal.row_height = 26.0;
227 let sld = slider_view::<(), _>(
228 "",
229 v,
230 0.0,
231 1.0,
232 &pal,
233 |_phase: DragPhase, _dv: f32| None,
234 );
235 vec![
236 card_header("slider", s, false),
237 spacer(10.0),
238 sld,
239 spacer(12.0),
240 value_line(&format!("{:>3.0}%", v * 100.0), s.fg, 26.0),
241 ]
242 }
243 // ── 2: Linear progress (avanza) ───────────────────────────────
244 2 => {
245 let v = motion::ease_out_cubic(seg(p, 0.05, 0.85));
246 let bar = View::new(Style {
247 size: Size { width: percent(1.0_f32), height: length(12.0) },
248 position: Position::Relative,
249 ..Default::default()
250 })
251 .fill(s.theme.bg_button)
252 .radius(6.0)
253 .children(vec![linear_progress_view(
254 v,
255 s.theme.bg_button,
256 s.accent,
257 12.0,
258 )]);
259 vec![
260 card_header("progress", s, true),
261 spacer(14.0),
262 bar,
263 spacer(14.0),
264 value_line(&format!("{:>3.0}% · compilando", v * 100.0), s.fg_muted, 13.0),
265 ]
266 }
267 // ── 3: Segmented control (cambia de pestaña activa) ───────────
268 3 => {
269 // 3 segmentos; el activo recorre 0 → 1 → 2 a lo largo del beat.
270 let phase = seg(p, 0.1, 0.95);
271 let active = ((phase * 3.0).floor() as usize).min(2);
272 let labels = ["Día", "Semana", "Mes"];
273 let pal = SegmentedPalette::from_theme(&s.theme);
274 let seg_ctrl = segmented_view::<(), _>(&labels, active, |_| (), &pal);
275 vec![
276 card_header("segmented", s, false),
277 spacer(14.0),
278 seg_ctrl,
279 spacer(14.0),
280 value_line(labels[active], s.accent, 22.0),
281 ]
282 }
283 // ── 4: Botones (primario teal + ghost) ────────────────────────
284 4 => {
285 // Paleta primaria: fondo teal, texto sobre fondo.
286 let mut prim = ButtonPalette::from_theme(&s.theme);
287 prim.bg = s.accent;
288 prim.bg_hover = s.accent;
289 prim.fg = s.bg; // texto oscuro sobre teal
290 prim.radius = 8.0;
291 let mut ghost = ButtonPalette::from_theme(&s.theme);
292 ghost.bg = s.theme.bg_button;
293 ghost.fg = s.fg;
294 ghost.radius = 8.0;
295 let row = View::new(Style {
296 size: Size { width: percent(1.0_f32), height: length(38.0) },
297 flex_direction: FlexDirection::Row,
298 gap: Size { width: length(12.0), height: length(0.0) },
299 ..Default::default()
300 })
301 .children(vec![
302 View::new(Style {
303 size: Size { width: length(132.0), height: length(38.0) },
304 flex_shrink: 0.0,
305 ..Default::default()
306 })
307 .children(vec![button_view("Regenerar", &prim, ())]),
308 View::new(Style {
309 size: Size { width: length(110.0), height: length(38.0) },
310 flex_shrink: 0.0,
311 ..Default::default()
312 })
313 .children(vec![button_view("Difundir", &ghost, ())]),
314 ]);
315 vec![
316 card_header("button", s, true),
317 spacer(14.0),
318 row,
319 spacer(14.0),
320 value_line("primario · ghost", s.fg_muted, 13.0),
321 ]
322 }
323 // ── 5: Radial progress (anillo que se llena) ──────────────────
324 _ => {
325 let v = motion::ease_out_cubic(seg(p, 0.1, 0.9));
326 let ring = View::new(Style {
327 size: Size { width: length(96.0), height: length(96.0) },
328 position: Position::Relative,
329 ..Default::default()
330 })
331 .children(vec![radial_progress_view(
332 v,
333 s.theme.bg_button,
334 s.accent,
335 0.14,
336 )]);
337 let ring_row = View::new(Style {
338 size: Size { width: percent(1.0_f32), height: length(96.0) },
339 flex_direction: FlexDirection::Row,
340 align_items: Some(AlignItems::CENTER),
341 justify_content: Some(JustifyContent::CENTER),
342 ..Default::default()
343 })
344 .children(vec![ring]);
345 vec![
346 card_header("radial", s, false),
347 spacer(6.0),
348 ring_row,
349 ]
350 }
351 }
352}
353
354/// Espaciador vertical de alto fijo.
355fn spacer(h: f32) -> View<()> {
356 View::new(Style {
357 size: Size { width: percent(1.0_f32), height: length(h) },
358 flex_shrink: 0.0,
359 ..Default::default()
360 })
361}
362
363/// Una card como contenedor absoluto, hospedando widgets reales.
364fn card_view(i: usize, rect: CardRect, alpha: f32, scale: f64, p: f32, s: &Skin) -> View<()> {
365 let accented = i == 0 || i == 2 || i == 4;
366 let border_col = if accented { s.border_accent } else { s.border };
367
368 // Pop de entrada: escala desde el centro de la card.
369 let cx = rect.x + rect.w / 2.0;
370 let cy = rect.y + rect.h / 2.0;
371 let xf = Affine::translate((cx, cy)) * Affine::scale(scale) * Affine::translate((-cx, -cy));
372
373 View::new(Style {
374 position: Position::Absolute,
375 inset: Rect {
376 left: length(rect.x as f32),
377 top: length(rect.y as f32),
378 right: auto(),
379 bottom: auto(),
380 },
381 size: Size { width: length(rect.w as f32), height: length(rect.h as f32) },
382 flex_direction: FlexDirection::Column,
383 gap: Size { width: length(0.0), height: length(0.0) },
384 padding: Rect {
385 left: length(22.0),
386 right: length(22.0),
387 top: length(20.0),
388 bottom: length(18.0),
389 },
390 ..Default::default()
391 })
392 .fill_gradient(
393 Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
394 .with_stops([s.panel_hi, s.panel].as_slice()),
395 )
396 .radius(18.0)
397 .border(if accented { 1.4 } else { 1.0 }, border_col)
398 .shadow(Shadow::soft(120, 26.0).offset(0.0, 12.0))
399 .transform(xf)
400 .alpha(alpha)
401 .children(card_body(i, p, s))
402}
403
404// ───────────────────────── overlays vector (wordmark) ─────────────────────────
405
406/// Dibuja los overlays vector (wordmark + punto firma) sobre un nodo
407/// full-screen, en función de `t`. Los widgets ya están pintados debajo.
408fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64, s: &Skin) {
409 // ── WORDMARK (82–100%) ─────────────────────────────────────────
410 let word_in = seg(t, 0.84, 0.95);
411 let word_a = motion::ease_out_cubic(word_in);
412 if word_a > 0.001 {
413 let size = 132.0_f32;
414 let layout = ts.layout(
415 "Llimphi", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0,
416 );
417 let m = measurement(&layout);
418 let rise = lerp(24.0, 0.0, word_a as f64);
419 let ox = (cw - m.width as f64) / 2.0;
420 let oy = (ch - m.height as f64) / 2.0 - 18.0 + rise;
421 let brush = peniko::Brush::Solid(with_alpha(s.fg, word_a));
422 draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
423
424 let sub_a = motion::ease_out_cubic(seg(t, 0.88, 0.99));
425 if sub_a > 0.001 {
426 let ssz = 26.0_f32;
427 let sub = ts.layout(
428 "a Rust GUI framework", ssz, None, Alignment::Start, 1.0, false, None, 400.0,
429 false, false, 0.0, 0.0,
430 );
431 let sm = measurement(&sub);
432 let dot_r = 6.0;
433 let block_w = sm.width as f64 + dot_r * 2.0 + 14.0;
434 let sx = (cw - block_w) / 2.0;
435 let sy = oy + m.height as f64 + 18.0;
436 scene.fill(
437 peniko::Fill::NonZero,
438 Affine::IDENTITY,
439 with_alpha(s.accent, sub_a),
440 None,
441 &Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r as f64),
442 );
443 let sbrush = peniko::Brush::Solid(with_alpha(s.fg_muted, sub_a));
444 draw_layout_brush_xf(
445 scene,
446 &sub,
447 &sbrush,
448 Affine::translate((sx + dot_r * 2.0 + 14.0, sy)),
449 );
450 }
451 }
452
453 // ── punto teal de firma (esquina inf-der), ancla de marca ───────
454 let corner_a = seg(t, 0.04, 0.12) * (1.0 - seg(t, 0.80, 0.86));
455 if corner_a > 0.001 {
456 let cx = cw - 54.0;
457 let cy = ch - 54.0;
458 scene.fill(
459 peniko::Fill::NonZero,
460 Affine::IDENTITY,
461 with_alpha(s.accent, 0.16 * corner_a),
462 None,
463 &Circle::new(Point::new(cx, cy), 18.0),
464 );
465 scene.fill(
466 peniko::Fill::NonZero,
467 Affine::IDENTITY,
468 with_alpha(s.accent, 0.9 * corner_a),
469 None,
470 &Circle::new(Point::new(cx, cy), 6.0),
471 );
472 }
473}
474
475// ───────────────────────── la escena por frame ─────────────────────────
476
477/// Construye el árbol `View` completo del frame `t`: las cards con widgets
478/// reales (con su estado derivado de t) + un nodo overlay full-screen que
479/// pinta el wordmark encima.
480fn build_view(t: f32, cw: f64, ch: f64, s: &Skin) -> View<()> {
481 let grid = layout_grid(cw, ch);
482 let row = layout_row(cw, ch);
483
484 // Progreso del "estado" de los widgets (toggle/slider/progress/…).
485 let widget_p = seg(t, 0.16, 0.58);
486 // Morph grid → fila (58–80%).
487 let morph = motion::ease_in_out_cubic(seg(t, 0.60, 0.80)) as f64;
488 // Fade-out de las cards antes del wordmark.
489 let cards_fade = 1.0 - seg(t, 0.80, 0.86);
490
491 let mut children: Vec<View<()>> = Vec::new();
492
493 if cards_fade > 0.001 {
494 for i in 0..N_CARDS {
495 // Stagger de entrada: cada card arranca con retraso incremental.
496 let delay = i as f32 * 0.035;
497 let enter = motion::ease_out_back(seg(t, delay, delay + 0.16));
498 if enter <= 0.001 {
499 continue;
500 }
501 let rect = grid[i].lerp(row[i], morph);
502 let scale = lerp(0.88, 1.0, enter.min(1.0) as f64);
503 let alpha = (enter.min(1.0) * cards_fade).clamp(0.0, 1.0);
504 children.push(card_view(i, rect, alpha, scale, widget_p, s));
505 }
506 }
507
508 // Nodo overlay full-screen para el vector (wordmark).
509 let overlay = View::new(Style {
510 position: Position::Absolute,
511 inset: Rect {
512 left: length(0.0),
513 top: length(0.0),
514 right: length(0.0),
515 bottom: length(0.0),
516 },
517 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
518 ..Default::default()
519 })
520 .paint_with({
521 let s = s.clone();
522 move |scene, ts, _rect: PaintRect| {
523 draw_overlays(scene, ts, t, cw, ch, &s);
524 }
525 });
526 children.push(overlay);
527
528 View::new(Style {
529 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
530 position: Position::Relative,
531 ..Default::default()
532 })
533 .fill(s.bg)
534 .children(children)
535}
536
537fn main() {
538 let mut args = std::env::args().skip(1);
539 let out_dir = args.next().unwrap_or_else(|| "showreel_frames".to_string());
540 let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(360);
541 let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
542 let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
543 create_dir_all(&out_dir).expect("mkdir out_dir");
544
545 let theme = llimphi_theme::Theme::by_name("Tawa").expect("tema Tawa");
546 let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF); // teal #2BD9A6 (acento firma)
547 let skin = Skin {
548 accent,
549 panel: theme.bg_panel,
550 panel_hi: theme.bg_button,
551 border: theme.border,
552 border_accent: with_alpha(accent, 0.55),
553 fg: theme.fg_text,
554 fg_muted: theme.fg_muted,
555 bg: theme.bg_app,
556 theme,
557 };
558 let [br, bg, bb, _] = skin.bg.components;
559 let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255);
560
561 // GPU una sola vez; reusar device/renderer/target/buffer para los N frames.
562 let hal = pollster::block_on(Hal::new(None)).expect("hal");
563 let mut renderer = Renderer::new(&hal).expect("renderer");
564 let target = hal.device.create_texture(&wgpu::TextureDescriptor {
565 label: Some("showreel"),
566 size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
567 mip_level_count: 1,
568 sample_count: 1,
569 dimension: wgpu::TextureDimension::D2,
570 format: FMT,
571 usage: wgpu::TextureUsages::STORAGE_BINDING
572 | wgpu::TextureUsages::RENDER_ATTACHMENT
573 | wgpu::TextureUsages::COPY_SRC,
574 view_formats: &[],
575 });
576 let view = target.create_view(&wgpu::TextureViewDescriptor::default());
577
578 let mut ts = Typesetter::new();
579 let cw = w as f64;
580 let ch = h as f64;
581
582 for i in 0..n {
583 let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
584
585 let root = build_view(t, cw, ch, &skin);
586
587 // view → layout (con medición de texto real) → scene — idéntico al eventloop.
588 let mut layout = LayoutTree::new();
589 let mounted = mount(&mut layout, root);
590 let computed = {
591 let tmap = &mounted.text_measures;
592 layout
593 .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
594 match tmap.get(&nid) {
595 Some(tm) => measure_text_node(&mut ts, tm, known, avail),
596 None => taffy::Size::ZERO,
597 }
598 })
599 .expect("layout")
600 };
601 let mut scene = vello::Scene::new();
602 paint(&mut scene, &mounted, &computed, &mut ts, None, None);
603
604 renderer
605 .render_to_view(&hal, &scene, &view, w, h, base)
606 .expect("render_to_view");
607 let path = format!("{out_dir}/frame_{i:04}.png");
608 write_png(&hal, &target, &path, w, h);
609 if i % 30 == 0 || i == n - 1 {
610 eprintln!("showreel: frame {}/{} (t={:.3})", i + 1, n, t);
611 }
612 }
613 eprintln!("showreel: {n} frames en {out_dir}/ ({w}x{h})");
614}
615
616fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
617 let unpadded = (w * 4) as usize;
618 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
619 let padded = unpadded.div_ceil(align) * align;
620 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
621 label: Some("readback"),
622 size: (padded * h as usize) as u64,
623 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
624 mapped_at_creation: false,
625 });
626 let mut enc = hal
627 .device
628 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
629 enc.copy_texture_to_buffer(
630 wgpu::TexelCopyTextureInfo {
631 texture: target,
632 mip_level: 0,
633 origin: wgpu::Origin3d::ZERO,
634 aspect: wgpu::TextureAspect::All,
635 },
636 wgpu::TexelCopyBufferInfo {
637 buffer: &buf,
638 layout: wgpu::TexelCopyBufferLayout {
639 offset: 0,
640 bytes_per_row: Some(padded as u32),
641 rows_per_image: Some(h),
642 },
643 },
644 wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
645 );
646 hal.queue.submit(std::iter::once(enc.finish()));
647 let slice = buf.slice(..);
648 let (tx, rx) = std::sync::mpsc::channel();
649 slice.map_async(wgpu::MapMode::Read, move |r| {
650 let _ = tx.send(r);
651 });
652 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
653 rx.recv().unwrap().unwrap();
654 let data = slice.get_mapped_range();
655 let mut pixels = Vec::with_capacity((w * h * 4) as usize);
656 for r in 0..h as usize {
657 let sidx = r * padded;
658 pixels.extend_from_slice(&data[sidx..sidx + unpadded]);
659 }
660 drop(data);
661 buf.unmap();
662 let file = File::create(path).expect("png");
663 let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
664 enc.set_color(png::ColorType::Rgba);
665 enc.set_depth(png::BitDepth::Eight);
666 let mut wr = enc.write_header().unwrap();
667 wr.write_image_data(&pixels).unwrap();
668}