//! **Showreel de Llimphi** — la vitrina filmada, cuadro a cuadro y sin ventana. //! //! Un guion de 15,5 s, todo pintado por el motor: //! //! 1. Un prompt de terminal (`❯`) donde se tipea `llimphi`. //! 2. La línea se vuelve el input de shuma, sobre un vidrio esmerilado. //! 3. Enter: abajo se despliegan controles REALES, del más simple al más //! complejo (botón → editor de código). //! 4. Scroll: los controles suben y entra un escenario que pasa por el cielo //! de cosmos, un pasillo de supay (el motor 2.5D tipo Doom, sobre un nivel //! sintético sin un solo asset de id) que termina en una fuente, y —por el //! agua— un vuelo sobre el mundo voxel infinito bajo un nevado andino, hasta //! un muñequito que salta celebrando y se va. //! 5. Mientras se va, la mano de ende escribe `llimphi` a mano. //! //! Headless y determinista: el cuadro `i` de `N` es el instante //! `s = i/(N-1)·DURACION`, y todo deriva de `s`. Los widgets se montan con el //! mismo `mount`/`compute_with_measure`/`paint` del eventloop. El voxel es un //! pase wgpu propio: se rinde aparte dos veces (con niebla, y sin ella sobre //! magenta, que da la máscara exacta del cielo), se compone con la cordillera //! pintada por vello y entra a la escena como imagen. //! //! ```text //! cargo run -p llimphi-showreel --release -- \ //! [out_dir] [n_frames] [W] [H] //! ``` //! Defaults: `out_dir=showreel_frames`, `n_frames=465`, `W=1600`, `H=900`. //! `SOLO=,` (segundos) rinde sólo ese tramo. use std::f32::consts::{FRAC_PI_2, PI}; use std::fs::{create_dir_all, File}; use std::io::BufWriter; use std::sync::Arc; use chrono::NaiveDate; use llimphi_theme::{motion, Theme}; use llimphi_ui::llimphi_hal::{wgpu, Hal}; use llimphi_ui::llimphi_layout::taffy; use llimphi_ui::llimphi_layout::taffy::prelude::{ auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style, }; use llimphi_ui::llimphi_layout::taffy::Rect; use llimphi_ui::llimphi_layout::LayoutTree; use llimphi_ui::llimphi_raster::peniko::{ self, Blob, Color, Fill, Gradient, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix, }; use llimphi_ui::llimphi_raster::vello::kurbo::{ Affine, BezPath, Circle, Point, RoundedRect, Stroke, }; use llimphi_ui::llimphi_raster::{vello, Renderer}; use llimphi_ui::llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter, MONOSPACE}; use llimphi_ui::{measure_text_node, mount, paint, DragPhase, PaintRect, Shadow, View}; use llimphi_widget_button::{button_view, ButtonPalette}; use llimphi_widget_calendar::{calendar_view, CalendarPalette, CalendarSpec, WeekStart}; use llimphi_widget_nodegraph::{ nodegraph_view_spec, Camera, NodeSpec, NodegraphMetrics, NodegraphPalette, NodegraphSpec, Wire, }; use llimphi_widget_progress::linear_progress_view; use llimphi_widget_segmented::{segmented_view, SegmentedPalette}; use llimphi_widget_slider::{slider_view, SliderPalette}; use llimphi_widget_switch::{switch_view, SwitchPalette}; use llimphi_widget_table::{table_view, TablePalette}; use llimphi_widget_text_editor::{ text_editor_view_highlighted, Buffer, EditorMetrics, EditorPalette, EditorState, Language, }; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Duración del guion en segundos. Los tramos de abajo están escritos en segundos. const DURACION: f32 = 15.5; // ───────────────────────── utilidades ───────────────────────── fn with_alpha(c: Color, a: f32) -> Color { let [r, g, b, a0] = c.components; Color::new([r, g, b, a0 * a.clamp(0.0, 1.0)]) } fn mix(a: Color, b: Color, t: f32) -> Color { let t = t.clamp(0.0, 1.0); let (x, y) = (a.components, b.components); Color::new([ x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t, x[3] + (y[3] - x[3]) * t, ]) } fn lerp(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t } /// `s` reescalado del tramo `[lo,hi]` a `[0,1]`, clampado. fn seg(s: f32, lo: f32, hi: f32) -> f32 { ((s - lo) / (hi - lo)).clamp(0.0, 1.0) } fn ease(x: f32) -> f32 { motion::ease_in_out_cubic(x) } #[derive(Clone, Copy)] struct Caja { x: f64, y: f64, w: f64, h: f64, } impl Caja { fn lerp(self, b: Caja, t: f64) -> Caja { Caja { x: lerp(self.x, b.x, t), y: lerp(self.y, b.y, t), w: lerp(self.w, b.w, t), h: lerp(self.h, b.h, t) } } fn rr(self, r: f64) -> RoundedRect { RoundedRect::new(self.x, self.y, self.x + self.w, self.y + self.h, r) } } fn absoluto(c: Caja) -> Style { Style { position: Position::Absolute, inset: Rect { left: length(c.x as f32), top: length(c.y as f32), right: auto(), bottom: auto() }, size: Size { width: length(c.w as f32), height: length(c.h as f32) }, ..Default::default() } } fn spacer(h: f32) -> View<()> { View::new(Style { size: Size { width: percent(1.0_f32), height: length(h) }, flex_shrink: 0.0, ..Default::default() }) } // ───────────────────────── paleta ───────────────────────── #[derive(Clone)] struct Skin { theme: Theme, accent: Color, panel: Color, panel_hi: Color, border: Color, fg: Color, fg_muted: Color, bg: Color, /// Los tres colores de las luces de fondo que el vidrio deja ver. luces: [Color; 3], } // ───────────────────────── 1–2. prompt → input de shuma ───────────────────────── const PALABRA: &str = "llimphi"; /// Cuándo cae cada tecla (s). Irregular a propósito: así tipea una persona. const TECLAS: [f32; 7] = [0.42, 0.55, 0.64, 0.80, 0.90, 1.03, 1.12]; fn tipeado(s: f32) -> usize { TECLAS.iter().filter(|&&k| s >= k).count() } /// Las luces que viven detrás del vidrio: tres manchas que derivan despacio. fn luces(s: f32, cw: f64, ch: f64) -> [(Point, f64); 3] { let t = s as f64; [ (Point::new(cw * 0.30 + 80.0 * (t * 0.5).sin(), ch * 0.42 + 40.0 * (t * 0.7).cos()), 340.0), (Point::new(cw * 0.62 + 90.0 * (t * 0.4).cos(), ch * 0.55 + 50.0 * (t * 0.6).sin()), 300.0), (Point::new(cw * 0.48 + 60.0 * (t * 0.9).sin(), ch * 0.30 + 30.0 * (t * 0.5).sin()), 260.0), ] } fn pintar_luces(scene: &mut vello::Scene, s: f32, cw: f64, ch: f64, sk: &Skin, alpha: f32, ensanche: f64) { for ((p, r), col) in luces(s, cw, ch).iter().zip(sk.luces) { let r = r * ensanche; let g = Gradient::new_radial(*p, r as f32).with_stops( [with_alpha(col, 0.55 * alpha), with_alpha(col, 0.18 * alpha), with_alpha(col, 0.0)].as_slice(), ); scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &Circle::new(*p, r)); } } /// El avioncito de enviar del botón de shuma, en una caja de lado `l`. fn avioncito(c: Point, l: f64) -> BezPath { let mut p = BezPath::new(); let k = l / 24.0; let pt = |x: f64, y: f64| Point::new(c.x + (x - 12.0) * k, c.y + (y - 12.0) * k); p.move_to(pt(3.0, 11.0)); p.line_to(pt(21.0, 3.5)); p.line_to(pt(14.5, 21.0)); p.line_to(pt(11.0, 13.0)); p.close_path(); p } /// La línea de comando: de prompt de terminal a input de shuma. Todo en un /// `paint_with`, porque el texto viaja de un lugar a otro sin re-maquetarse. fn input_view(s: f32, cw: f64, ch: f64, sk: &Skin) -> View<()> { let sk = sk.clone(); View::new(absoluto(Caja { x: 0.0, y: 0.0, w: cw, h: ch })).paint_with(move |scene, ts, _r: PaintRect| { let morph = ease(seg(s, 1.65, 2.45)) as f64; let sube = ease(seg(s, 2.75, 3.25)) as f64; let fuera = seg(s, 10.3, 10.6); // el escenario voxel se lleva la pantalla if fuera >= 1.0 { return; } let vivo = 1.0 - fuera; // Las luces de fondo: aparecen con el vidrio y se apagan cuando llegan // los controles (quedan como un brillo tenue). let luz = (morph as f32) * (1.0 - seg(s, 2.9, 3.6)) * vivo; if luz > 0.001 { pintar_luces(scene, s, cw, ch, &sk, luz, 1.0); } let size_t = 46.0_f32; // tamaño del texto en la terminal let size_i = 26.0_f32; // en el input let size = lerp(size_t as f64, size_i as f64, morph) as f32; // La caja final del input (centrada, y después arriba). let iw = 980.0; let ih = 72.0; let centro_y = lerp(ch * 0.5, 74.0, sube); let caja_fin = Caja { x: (cw - iw) / 2.0, y: centro_y - ih / 2.0, w: iw, h: ih }; // Medidas del texto para colocar el caret. let escrito = &PALABRA[..tipeado(s)]; let lay = ts.layout(escrito, size, None, Alignment::Start, 1.0, false, Some(MONOSPACE), 400.0, false, false, 0.0, 0.0); let m = measurement(&lay); let prompt = ts.layout("❯ ", size, None, Alignment::Start, 1.0, false, Some(MONOSPACE), 600.0, false, false, 0.0, 0.0); let pm = measurement(&prompt); // Dónde arranca el texto: en la terminal, a la izquierda tras el prompt; // en el input, adentro con su padding. let x_term = cw * 0.5 - 150.0 + 14.0; let y_term = ch * 0.5 - m.height.max(size * 1.2) as f64 / 2.0; let x_in = caja_fin.x + 26.0; let y_in = caja_fin.y + (ih - size_i as f64 * 1.2) / 2.0; let tx = lerp(x_term, x_in, morph); let ty = lerp(y_term, y_in, morph); // ── el vidrio ── if morph > 0.001 { let a = (morph as f32) * vivo; // La caja crece desde la línea de texto hasta su tamaño. let caja_ini = Caja { x: tx - 20.0, y: ty - 10.0, w: m.width as f64 + 60.0, h: size as f64 * 1.2 + 20.0 }; let c = caja_ini.lerp(caja_fin, morph); let rr = c.rr(16.0); // Sombra suave debajo del vidrio. scene.fill(Fill::NonZero, Affine::translate((0.0, 14.0)), with_alpha(Color::BLACK, 0.35 * a), None, &c.rr(20.0)); scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &rr); // Esmerilado: las mismas luces de atrás, más anchas y lavadas, y un // velo claro encima. Es lo que el ojo lee como vidrio. scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(sk.panel, 0.72), None, &rr); pintar_luces(scene, s, cw, ch, &sk, 0.9, 1.6); let velo = Gradient::new_linear(Point::new(c.x, c.y), Point::new(c.x, c.y + c.h)).with_stops( [with_alpha(Color::WHITE, 0.13), with_alpha(Color::WHITE, 0.04)].as_slice(), ); scene.fill(Fill::NonZero, Affine::IDENTITY, &velo, None, &rr); scene.pop_layer(); // El borde vivo de shuma: un degradé diagonal de tres paradas que gira. let g = s as f64 * 0.6; let borde = Gradient::new_linear( Point::new(c.x + c.w * (0.5 + 0.5 * g.cos()), c.y), Point::new(c.x + c.w * (0.5 - 0.5 * g.cos()), c.y + c.h), ) .with_stops([sk.luces[0], sk.accent, sk.luces[1]].as_slice()); scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &c.rr(17.0)); scene.stroke(&Stroke::new(1.6), Affine::IDENTITY, &borde, None, &rr); // Brillo de canto arriba: el filo del vidrio. scene.stroke( &Stroke::new(1.0), Affine::IDENTITY, with_alpha(Color::WHITE, 0.35), None, &RoundedRect::new(c.x + 14.0, c.y + 1.0, c.x + c.w - 14.0, c.y + 1.5, 0.5), ); scene.pop_layer(); // El botón de enviar (con texto ya es el avioncito). Se hunde al Enter. let b_a = seg(s, 2.05, 2.45) * vivo; if b_a > 0.001 { let golpe = (seg(s, 2.5, 2.6) - seg(s, 2.6, 2.78)) as f64; let r = 22.0 * (1.0 - 0.14 * golpe) * (0.6 + 0.4 * b_a as f64); let bc = Point::new(c.x + c.w - 40.0, c.y + c.h / 2.0); scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(sk.accent, b_a), None, &Circle::new(bc, r)); scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(sk.bg, b_a), None, &avioncito(bc, r * 1.1)); // La onda del Enter. let onda = seg(s, 2.55, 3.0); if onda > 0.0 && onda < 1.0 { scene.stroke( &Stroke::new(2.0), Affine::IDENTITY, with_alpha(sk.accent, (1.0 - onda) * 0.8), None, &Circle::new(bc, r + 30.0 * onda as f64), ); } } } // ── el prompt (sólo en la terminal) ── let prompt_a = (1.0 - morph as f32) * seg(s, 0.1, 0.3); if prompt_a > 0.001 { let brush = peniko::Brush::Solid(with_alpha(sk.accent, prompt_a)); draw_layout_brush_xf(scene, &prompt, &brush, Affine::translate((tx - pm.width as f64 - 14.0, ty))); } // ── el texto: blanco de terminal → color de comando de shuma ── let col = mix(sk.fg, sk.accent, morph as f32); if !escrito.is_empty() { let brush = peniko::Brush::Solid(with_alpha(col, vivo)); draw_layout_brush_xf(scene, &lay, &brush, Affine::translate((tx, ty))); } // ── el caret: parpadea mientras espera, fijo mientras se tipea ── let tipeando = s > TECLAS[0] - 0.05 && s < TECLAS[6] + 0.15; let parpadeo = tipeando || (s * 2.0).fract() < 0.55; if parpadeo && s < 2.6 { let cx = tx + m.width as f64 + 3.0; let ca = if morph > 0.0 { 1.0 } else { 1.0 }; scene.fill( Fill::NonZero, Affine::IDENTITY, with_alpha(mix(sk.fg, sk.accent, morph as f32), ca * vivo), None, &RoundedRect::new(cx, ty + 2.0, cx + lerp(14.0, 2.5, morph), ty + size as f64 * 1.15, 1.0), ); } }) } // ───────────────────────── 3. los controles ───────────────────────── const N_TILES: usize = 9; const TITULOS: [&str; N_TILES] = ["button", "switch", "slider", "progress", "segmented", "calendar", "table", "nodegraph", "editor"]; fn tiles(cw: f64) -> [Caja; N_TILES] { let m = 60.0; let gap = 20.0; let ancho = cw - 2.0 * m; let w1 = (ancho - 4.0 * gap) / 5.0; let mut out = [Caja { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }; N_TILES]; for (i, c) in out.iter_mut().enumerate().take(5) { *c = Caja { x: m + i as f64 * (w1 + gap), y: 136.0, w: w1, h: 172.0 }; } // Abajo los complejos, con anchos a medida de lo que muestran. let anchos = [300.0, 340.0, 400.0, 0.0]; let resto = ancho - anchos[..3].iter().sum::() - 3.0 * gap; let mut x = m; for (k, w) in [anchos[0], anchos[1], anchos[2], resto].into_iter().enumerate() { out[5 + k] = Caja { x, y: 328.0, w, h: 512.0 }; x += w + gap; } out } fn tile_header(title: &str, sk: &Skin) -> View<()> { let chip = View::new(Style { size: Size { width: length(26.0), height: length(7.0) }, flex_shrink: 0.0, ..Default::default() }) .radius(4.0) .fill(sk.accent); View::new(Style { size: Size { width: percent(1.0_f32), height: length(18.0) }, flex_direction: FlexDirection::Row, align_items: Some(AlignItems::CENTER), gap: Size { width: length(10.0), height: length(0.0) }, flex_shrink: 0.0, ..Default::default() }) .children(vec![ chip, View::new(Style { flex_grow: 1.0, ..Default::default() }) .text_aligned(title.to_string(), 12.5, sk.fg_muted, Alignment::Start) .bold(), ]) } fn value_line(text: &str, color: Color, size: f32) -> View<()> { View::new(Style { size: Size { width: percent(1.0_f32), height: length(size + 6.0) }, flex_shrink: 0.0, ..Default::default() }) .text_aligned(text.to_string(), size, color, Alignment::Start) .bold() } fn fila(alto: f32, hijos: Vec>) -> View<()> { View::new(Style { size: Size { width: percent(1.0_f32), height: length(alto) }, flex_direction: FlexDirection::Row, align_items: Some(AlignItems::CENTER), gap: Size { width: length(12.0), height: length(0.0) }, flex_shrink: 0.0, ..Default::default() }) .children(hijos) } fn caja_fija(w: f32, h: f32, hijo: View<()>) -> View<()> { View::new(Style { size: Size { width: length(w), height: length(h) }, flex_shrink: 0.0, ..Default::default() }) .children(vec![hijo]) } const CODIGO: &str = "fn update(m: Model, msg: Msg) -> Model {\n match msg {\n Msg::Sumar => Model { n: m.n + 1, ..m },\n Msg::Restar => Model { n: m.n - 1, ..m },\n Msg::Nombre(s) => Model { nombre: s, ..m },\n }\n}\n"; /// El cuerpo de cada tarjeta. `p∈[0,1]` es el avance de su propia animación. fn tile_body(i: usize, p: f32, s: f32, sk: &Skin) -> Vec> { let th = &sk.theme; match i { 0 => { // Botón: se aprieta (cambia al color de "hover") y vuelve. let apretado = p > 0.35 && p < 0.55; let mut prim = ButtonPalette::from_theme(th); prim.bg = if apretado { mix(sk.accent, Color::WHITE, 0.25) } else { sk.accent }; prim.bg_hover = prim.bg; prim.fg = sk.bg; prim.radius = 8.0; let mut ghost = ButtonPalette::from_theme(th); ghost.bg = th.bg_button; ghost.fg = sk.fg; ghost.radius = 8.0; vec![ tile_header(TITULOS[i], sk), spacer(22.0), fila(38.0, vec![caja_fija(112.0, 38.0, button_view("Publicar", &prim, ())), caja_fija(100.0, 38.0, button_view("Cancelar", &ghost, ()))]), spacer(16.0), value_line(if p > 0.45 { "publicado" } else { "listo" }, sk.fg_muted, 13.0), ] } 1 => { let prog = ease(seg(p, 0.2, 0.55)); let pal = SwitchPalette::from_theme(th); vec![ tile_header(TITULOS[i], sk), spacer(22.0), fila(26.0, vec![switch_view(prog, (), &pal), View::new(Style { flex_grow: 1.0, ..Default::default() }).text_aligned("Sincronizar".to_string(), 13.0, sk.fg, Alignment::Start)]), spacer(18.0), value_line(if prog > 0.5 { "ENCENDIDO" } else { "apagado" }, if prog > 0.5 { sk.accent } else { sk.fg_muted }, 20.0), ] } 2 => { let v = lerp(0.18, 0.74, ease(seg(p, 0.1, 0.7)) as f64) as f32; let mut pal = SliderPalette::from_theme(th); pal.track_width = 170.0; pal.label_width = 0.0; pal.value_width = 0.0; pal.track_thickness = 8.0; pal.row_height = 26.0; vec![ tile_header(TITULOS[i], sk), spacer(24.0), slider_view::<(), _>("", v, 0.0, 1.0, &pal, |_: DragPhase, _: f32| None), spacer(14.0), value_line(&format!("{:>3.0}%", v * 100.0), sk.fg, 22.0), ] } 3 => { let v = motion::ease_out_cubic(seg(p, 0.05, 0.8)); let bar = View::new(Style { size: Size { width: percent(1.0_f32), height: length(12.0) }, position: Position::Relative, flex_shrink: 0.0, ..Default::default() }) .fill(th.bg_button) .radius(6.0) .children(vec![linear_progress_view(v, th.bg_button, sk.accent, 12.0)]); vec![ tile_header(TITULOS[i], sk), spacer(30.0), bar, spacer(18.0), value_line(&if v < 1.0 { format!("{:>3.0}% compilando", v * 100.0) } else { "100% listo".to_string() }, sk.fg_muted, 13.0), ] } 4 => { let active = ((seg(p, 0.1, 0.9) * 3.0).floor() as usize).min(2); let labels = ["Día", "Semana", "Mes"]; let pal = SegmentedPalette::from_theme(th); vec![ tile_header(TITULOS[i], sk), spacer(22.0), segmented_view::<(), _>(&labels, active, |_| (), &pal), spacer(16.0), value_line(labels[active], sk.accent, 20.0), ] } 5 => { // El día elegido salta por el mes. let dias = [12, 16, 23, 29]; let d = dias[((seg(p, 0.15, 0.95) * 4.0).floor() as usize).min(3)]; let cal = calendar_view::<()>(CalendarSpec { view_year: 2026, view_month: 10, selected: NaiveDate::from_ymd_opt(2026, 10, d), today: NaiveDate::from_ymd_opt(2026, 10, 12), week_start: WeekStart::Monday, palette: CalendarPalette::from_theme(th), on_select: Arc::new(|_| ()), on_view_change: Arc::new(|_, _| ()), marcados: vec![NaiveDate::from_ymd_opt(2026, 10, 21).unwrap()], }); vec![tile_header(TITULOS[i], sk), spacer(14.0), cal] } 6 => { // Las filas llegan una a una: la pila real del motor. let todas = [ ["llimphi-ui", "bucle Elm"], ["llimphi-layout", "taffy"], ["llimphi-text", "parley"], ["llimphi-raster", "vello"], ["llimphi-hal", "wgpu"], ["llimphi-voxel", "3D"], ]; let n = 1 + (seg(p, 0.0, 0.8) * 5.0).floor() as usize; let rows: Vec> = todas[..n.min(6)].iter().map(|r| r.iter().map(|c| c.to_string()).collect()).collect(); let headers = vec!["crate".to_string(), "sobre".to_string()]; vec![ tile_header(TITULOS[i], sk), spacer(14.0), table_view::<(), _, _, _>(&headers, &rows, None, None, "+ fila", &TablePalette::from_theme(th), |_, _| (), |_| (), || ()), ] } 7 => { // El bucle de una app Llimphi, como grafo. Los cables llegan de a uno. let n = (seg(p, 0.05, 0.75) * 6.0).floor() as usize; let etapas = ["input", "update", "view", "layout", "raster", "present"]; let nodes: Vec = etapas .iter() .enumerate() .map(|(k, e)| NodeSpec { id: k as u32, label: e.to_string(), x: if k % 2 == 0 { 10.0 } else { 190.0 }, y: 10.0 + k as f32 * 66.0, inputs: if k == 0 { vec![] } else { vec!["in".into()] }, outputs: if k == 5 { vec![] } else { vec!["out".into()] }, }) .collect(); let wires: Vec = (0..n.min(5)) .map(|k| Wire { from_node: k as u32, from_output: 0, to_node: k as u32 + 1, to_input: 0 }) .collect(); let palette = NodegraphPalette::from_theme(th); let mut metrics = NodegraphMetrics::default(); metrics.node_width = 150.0; let g = nodegraph_view_spec(NodegraphSpec { nodes: &nodes, wires: &wires, edges: &[], palette: &palette, metrics: &metrics, camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 0.92 }, on_drag_node: Arc::new(|_, _, _, _| None), on_connect: Arc::new(|_, _, _, _| None), on_right_click_node: None, node_tint: None, wire_tint: None, wire_dash: None, edge_tint: None, edge_dash: None, flujo: s * 1.5, selected: None, on_select_node: None, on_canvas_click: None, on_pan: None, on_zoom: None, cuerpo: None, }); vec![ tile_header(TITULOS[i], sk), spacer(10.0), View::new(Style { size: Size { width: percent(1.0_f32), height: length(440.0) }, flex_shrink: 0.0, ..Default::default() }).children(vec![g]), ] } _ => { // El editor: el código se escribe solo, con resaltado de Rust. let n = (seg(p, 0.0, 0.85) * CODIGO.chars().count() as f32) as usize; let texto: String = CODIGO.chars().take(n).collect(); let mut st = EditorState::new(); st.buffer = Buffer::from_str(&texto); let lineas = texto.matches('\n').count(); let col = texto.rsplit('\n').next().map(|l| l.chars().count()).unwrap_or(0); st.cursor = llimphi_widget_text_editor::Cursor::at(lineas, col); let mut metrics = EditorMetrics::default(); metrics.font_size = 12.0; metrics.line_height = 18.0; metrics.char_width = 7.2; let ed = text_editor_view_highlighted(&st, &EditorPalette::from_theme(th), metrics, 22, Language::Rust, |_| None); vec![ tile_header(TITULOS[i], sk), spacer(10.0), View::new(Style { size: Size { width: percent(1.0_f32), height: length(440.0) }, flex_shrink: 0.0, ..Default::default() }).children(vec![ed]), ] } } } fn tile_view(i: usize, c: Caja, alpha: f32, scale: f64, p: f32, s: f32, sk: &Skin) -> View<()> { let (cx, cy) = (c.x + c.w / 2.0, c.y + c.h / 2.0); let xf = Affine::translate((cx, cy)) * Affine::scale(scale) * Affine::translate((-cx, -cy)); let mut st = absoluto(c); st.flex_direction = FlexDirection::Column; st.padding = Rect { left: length(18.0), right: length(18.0), top: length(16.0), bottom: length(14.0) }; st.overflow = taffy::Point { x: taffy::Overflow::Hidden, y: taffy::Overflow::Hidden }; View::new(st) .fill_gradient(Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0)).with_stops([sk.panel_hi, sk.panel].as_slice())) .radius(16.0) .border(1.0, sk.border) .shadow(Shadow::soft(110, 22.0).offset(0.0, 10.0)) .clip(true) .transform(xf) .alpha(alpha) .children(tile_body(i, p, s, sk)) } // ───────────────────────── 4. el escenario: cosmos → supay → voxel ───────────────────────── fn escenario(s: f32, cw: f64, ch: f64) -> Caja { let chico = Caja { x: 60.0, y: 136.0, w: cw - 120.0, h: ch - 196.0 }; let pantalla = Caja { x: 0.0, y: 0.0, w: cw, h: ch }; chico.lerp(pantalla, ease(seg(s, 10.3, 10.6)) as f64) } /// El cielo de cosmos: la esfera celeste con la Vía Láctea y las /// constelaciones, girando. Es la misma `compose_sphere` de la app. fn cielo_view(cielo: &cosmos_render::RenderModel, s: f32, lado: f32) -> View<()> { let opts = cosmos_render::SphereOpts { size: lado, palette: cosmos_render::Palette::dark(), show_bodies: false, show_signs: false, show_earth: false, show_horizon: false, show_equator: false, show_sky: true, show_constellations: true, show_grid: true, ..Default::default() }; let vista = cosmos_render::SphereView { yaw_deg: 20.0 + (s - 8.0) * 38.0, pitch_deg: -58.0 + 6.0 * ((s - 8.0) * 0.8).sin() }; let cmds = cosmos_render::compose_sphere(cielo, &vista, &opts); cosmos_canvas_llimphi::canvas_view_ex::<()>(cmds, lado, Some(Color::from_rgba8(6, 8, 16, 255)), cosmos_canvas_llimphi::ViewTransform::default()) } fn carta() -> cosmos_model::Chart { use cosmos_model::*; Chart { id: ChartId::new(), contact_id: ContactId::new(), kind: ChartKind::Natal, label: "llimphi".to_string(), birth_data: StoredBirthData { year: 2026, month: 10, day: 12, hour: 21, minute: 0, second: 0.0, tz_offset_minutes: -300, latitude_deg: -13.53, longitude_deg: -71.97, altitude_m: 3399.0, time_certainty: TimeCertainty::Exact, subject_name: None, birthplace_label: None, }, config: StoredChartConfig::default(), related_chart_id: None, created_at_ms: 0, } } /// El pasillo de supay: un sector convexo con antorchas a los costados que /// termina en una fuente. Sin WAD ni atlas: colores planos. const ANCHO_P: f32 = 384.0; const LARGO_P: f32 = 1500.0; /// Dónde está la fuente (centro de la pileta, en unidades de Doom). const FUENTE_Y: f32 = 1180.0; /// Altura de los ojos del jugador. const OJOS: f32 = 41.0; fn jugador_y(u: f32) -> f32 { // Avanza parejo y en el último tramo se acerca a la pileta. 200.0 + (FUENTE_Y - 120.0 - 200.0) * motion::ease_in_out_cubic(u) } fn pasillo(u: f32) -> supay_scene::SceneSnapshot { use supay_scene::*; let wall = |x1, y1, x2, y2| WallSeg { x1, y1, x2, y2, front_sector: 0, back_sector: NO_SECTOR, flags: 0, textures: [[0; 8]; 6], tex_x_offsets: [0.0; 2], tex_y_offsets: [0.0; 2], }; let seg = |x1, y1, x2, y2, linedef| SegSnap { x1, y1, x2, y2, solid: true, linedef }; let tick = (u * 45.0) as u64; let t = tick as f32 * 0.03; let player = PlayerSnap { x: ANCHO_P * 0.5, y: jugador_y(u), z: 0.0, angle: FRAC_PI_2, view_height: OJOS, view_pitch: 0.0 }; let walls = vec![ wall(0.0, 0.0, 0.0, LARGO_P), wall(0.0, LARGO_P, ANCHO_P, LARGO_P), wall(ANCHO_P, LARGO_P, ANCHO_P, 0.0), wall(ANCHO_P, 0.0, 0.0, 0.0), ]; let light = (235.0 + (t * 0.9).sin() * 20.0) as u8; let sectors = vec![SectorSnap { floor_height: 0.0, ceiling_height: 192.0, light_level: light, floor_pic: 0, ceiling_pic: 0 }]; let segs = vec![ seg(ANCHO_P, 0.0, 0.0, 0.0, 3), seg(0.0, 0.0, 0.0, LARGO_P, 0), seg(0.0, LARGO_P, ANCHO_P, LARGO_P, 1), seg(ANCHO_P, LARGO_P, ANCHO_P, 0.0, 2), ]; let fb = |f: u64| ((f / 4 % 4) as u8) | 0x80; let mut sprites = Vec::new(); for k in 0..4 { let y = 420.0 + k as f32 * 300.0; for (x, spr) in [(36.0, 5u16), (ANCHO_P - 36.0, 7u16)] { sprites.push(SpriteSnap { x, y, z: 72.0 + (t * 2.0 + k as f32).sin() * 3.0, angle: 0.0, sprite: spr, frame: fb(tick + k), sector: 0 }); } } SceneSnapshot { tick, player, walls: Arc::from(walls), sectors: Arc::from(sectors), sprites: Arc::from(sprites), subsectors: Arc::from(vec![SubsectorSnap { sector: 0, first_seg: 0, num_segs: 4 }]), segs: Arc::from(segs), nodes: Arc::from(Vec::::new()), sky_pic: NO_SKY_PIC, player_overlays: PlayerOverlays::default(), weapon: WeaponSpriteSnap::default(), weapon_flash: WeaponSpriteSnap::default(), player_stats: PlayerStats { health: 100, armor_points: 0, armor_type: 0, ready_weapon: 1, ammo: [50, 0, 0, 0], max_ammo: [200, 50, 300, 50], cards: [false; 6], }, } } /// La fuente, pintada en la MISMA perspectiva que el pasillo: cada punto se /// proyecta con el foco del renderer de supay (fov vertical de 75°), así que la /// pileta apoya en el piso y crece a medida que el jugador se acerca. fn pintar_fuente(scene: &mut vello::Scene, r: PaintRect, u: f32, s: f32) { let (w, h) = (r.w as f64, r.h as f64); let foco = (h / 2.0) / (37.5f64.to_radians()).tan(); let py = jugador_y(u) as f64; let cx = ANCHO_P as f64 * 0.5; // Proyección de un punto del mundo (x, y, z) a la pantalla. let pr = |x: f64, y: f64, z: f64| -> Point { let d = (y - py).max(4.0); Point::new(r.x as f64 + w / 2.0 + foco * (x - cx) / d, r.y as f64 + h / 2.0 - foco * (z - OJOS as f64) / d) }; let fy = FUENTE_Y as f64; let piedra = Color::from_rgba8(150, 132, 112, 255); let piedra_osc = Color::from_rgba8(96, 82, 70, 255); let agua = Color::from_rgba8(70, 160, 210, 255); let espuma = Color::from_rgba8(225, 244, 255, 255); // Una elipse horizontal (un aro de la fuente) proyectada. let aro = |radio: f64, z: f64| -> BezPath { let mut bp = BezPath::new(); for k in 0..=48 { let a = k as f64 / 48.0 * std::f64::consts::TAU; let p = pr(cx + radio * a.cos(), fy + radio * a.sin(), z); if k == 0 { bp.move_to(p) } else { bp.line_to(p) } } bp.close_path(); bp }; // Una pared cilíndrica vista de frente: la mitad cercana del aro entre dos alturas. let pared = |radio: f64, z0: f64, z1: f64| -> BezPath { let mut bp = BezPath::new(); for k in 0..=24 { let a = std::f64::consts::PI + k as f64 / 24.0 * std::f64::consts::PI; let p = pr(cx + radio * a.cos(), fy + radio * a.sin(), z0); if k == 0 { bp.move_to(p) } else { bp.line_to(p) } } for k in (0..=24).rev() { let a = std::f64::consts::PI + k as f64 / 24.0 * std::f64::consts::PI; bp.line_to(pr(cx + radio * a.cos(), fy + radio * a.sin(), z1)); } bp.close_path(); bp }; // La pileta: pared, borde y espejo de agua con su brillo. scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(Color::BLACK, 0.35), None, &aro(78.0, 0.5)); scene.fill(Fill::NonZero, Affine::IDENTITY, piedra_osc, None, &pared(70.0, 0.0, 24.0)); scene.fill(Fill::NonZero, Affine::IDENTITY, piedra, None, &aro(70.0, 24.0)); scene.fill(Fill::NonZero, Affine::IDENTITY, agua, None, &aro(62.0, 22.0)); let onda = 0.5 + 0.5 * (s as f64 * 9.0).sin(); scene.stroke(&Stroke::new(1.5), Affine::IDENTITY, with_alpha(espuma, 0.5), None, &aro(30.0 + 18.0 * onda, 22.0)); scene.stroke(&Stroke::new(1.2), Affine::IDENTITY, with_alpha(espuma, 0.35), None, &aro(46.0 + 12.0 * (1.0 - onda), 22.0)); // El pie y el plato de arriba. scene.fill(Fill::NonZero, Affine::IDENTITY, piedra_osc, None, &pared(10.0, 22.0, 74.0)); scene.fill(Fill::NonZero, Affine::IDENTITY, piedra_osc, None, &pared(28.0, 74.0, 82.0)); scene.fill(Fill::NonZero, Affine::IDENTITY, piedra, None, &aro(28.0, 82.0)); scene.fill(Fill::NonZero, Affine::IDENTITY, agua, None, &aro(23.0, 81.0)); // El chorro: sube del plato y cae en arcos hacia la pileta. let tope = 132.0; let chorro = |a: f64| -> BezPath { let mut bp = BezPath::new(); for k in 0..=20 { let f = k as f64 / 20.0; let radio = 4.0 + 50.0 * f; let z = 82.0 + (tope - 82.0) * (1.0 - (2.0 * f - 0.35).powi(2)).max(-1.5) * 0.9; let p = pr(cx + radio * a.cos(), fy + radio * a.sin(), z.max(24.0)); if k == 0 { bp.move_to(p) } else { bp.line_to(p) } } bp }; let escala = foco / (fy - py).max(40.0); for k in 0..6 { let a = std::f64::consts::PI + (k as f64 + 0.5) / 6.0 * std::f64::consts::PI; let sc = Stroke::new((3.0 * escala).max(1.0)).with_caps(peniko::kurbo::Cap::Round); scene.stroke(&sc, Affine::IDENTITY, with_alpha(agua, 0.85), None, &chorro(a)); scene.stroke(&Stroke::new((1.2 * escala).max(0.6)), Affine::IDENTITY, with_alpha(espuma, 0.8), None, &chorro(a)); } // El surtidor central y las gotas que caen. let base = pr(cx, fy, 82.0); let cima = pr(cx, fy, tope + 8.0); scene.stroke(&Stroke::new((5.0 * escala).max(1.5)).with_caps(peniko::kurbo::Cap::Round), Affine::IDENTITY, espuma, None, &peniko::kurbo::Line::new(base, cima)); for k in 0..28 { let fase = ((s as f64 * 1.7 + k as f64 * 0.137) % 1.0).abs(); let a = std::f64::consts::PI + ((k * 7) % 13) as f64 / 13.0 * std::f64::consts::PI; let radio = 10.0 + 52.0 * fase; let z = 82.0 + (tope - 82.0) * (1.0 - (2.0 * fase - 0.35).powi(2)) * 0.9; let p = pr(cx + radio * a.cos(), fy + radio * a.sin(), z.max(23.0)); scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(espuma, 0.9), None, &Circle::new(p, (1.6 * escala).max(0.8))); } } fn pasillo_view(u: f32, s: f32, w: f32, h: f32) -> View<()> { View::new(Style { size: Size { width: length(w), height: length(h) }, ..Default::default() }).paint_with(move |scene, ts, r: PaintRect| { let snap = pasillo(u); let cfg = supay_render_llimphi::RenderConfig { atlas: None, crosshair: false, hud: true, sprite_shadows: true, world_lights_enabled: true, far_fog: 6000.0, wall_vertical_bands: 4, wall_vertical_gradient: true, plane_depth_gradient: true, ..supay_render_llimphi::RenderConfig::default() }; let mut sub = vello::Scene::new(); supay_render_llimphi::render_snapshot(&mut sub, ts, r.w, r.h, &snap, &cfg); scene.append(&sub, Some(Affine::translate((r.x as f64, r.y as f64)))); // La fuente va encima del pasillo pero debajo del HUD visualmente // basta: el HUD es la franja de abajo y la fuente vive en el medio. scene.push_layer(Fill::NonZero, Mix::Normal, 1.0, Affine::IDENTITY, &peniko::kurbo::Rect::new(r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h * 0.9) as f64)); pintar_fuente(scene, r, u, s); scene.pop_layer(); }) } /// Rótulo chico del escenario («cosmos», «supay», «voxel»). fn rotulo(texto: &str, a: f32, sk: &Skin) -> View<()> { View::new(Style { position: Position::Absolute, inset: Rect { left: length(22.0), top: length(18.0), right: auto(), bottom: auto() }, size: Size { width: auto(), height: length(26.0) }, padding: Rect { left: length(12.0), right: length(12.0), top: length(4.0), bottom: length(4.0) }, ..Default::default() }) .fill(with_alpha(Color::BLACK, 0.45)) .radius(13.0) .alpha(a) .text_aligned(texto.to_string(), 13.0, with_alpha(sk.fg, 1.0), Alignment::Start) .bold() } // ───────────────────────── 5. la mano escribe «llimphi» ───────────────────────── /// Los nudos de la letra cursiva, en unidades de altura-x (y hacia arriba, /// base en 0). Una curva Catmull-Rom pasa por todos: es un solo trazo, como /// se escribe a mano sin levantar el lápiz. fn nudos_llimphi() -> (Vec<(f32, f32)>, Vec<(f32, f32)>) { let l: &[(f32, f32)] = &[(0.25, 0.8), (0.5, 1.9), (0.45, 2.45), (0.28, 2.4), (0.24, 1.8), (0.26, 0.6), (0.35, 0.05), (0.6, 0.12)]; let i: &[(f32, f32)] = &[(0.25, 0.6), (0.38, 1.0), (0.33, 0.5), (0.36, 0.05), (0.6, 0.15)]; let m: &[(f32, f32)] = &[ (0.2, 0.8), (0.28, 1.0), (0.3, 0.5), (0.27, 0.02), (0.36, 0.72), (0.55, 1.0), (0.7, 0.78), (0.69, 0.02), (0.8, 0.72), (1.0, 1.0), (1.15, 0.78), (1.13, 0.2), (1.25, 0.03), (1.4, 0.15), ]; let p: &[(f32, f32)] = &[(0.2, 0.7), (0.32, 1.0), (0.3, 0.2), (0.27, -1.1), (0.31, -0.3), (0.36, 0.6), (0.58, 1.0), (0.8, 0.72), (0.72, 0.12), (0.45, 0.05), (0.38, 0.25), (0.62, 0.12), (0.85, 0.2)]; let h: &[(f32, f32)] = &[(0.25, 0.8), (0.5, 1.9), (0.45, 2.45), (0.28, 2.4), (0.24, 1.8), (0.25, 0.02), (0.36, 0.72), (0.56, 1.0), (0.72, 0.8), (0.7, 0.2), (0.82, 0.03), (0.95, 0.15)]; let i_fin: &[(f32, f32)] = &[(0.25, 0.6), (0.38, 1.0), (0.33, 0.5), (0.36, 0.05), (0.62, 0.2), (0.9, 0.5)]; let letras: [(&[(f32, f32)], f32); 7] = [(l, 0.6), (l, 0.6), (i, 0.6), (m, 1.4), (p, 0.85), (h, 0.95), (i_fin, 0.9)]; let mut nudos = vec![(-0.2, 0.0), (0.0, 0.1)]; let mut puntos = Vec::new(); let mut x0 = 0.0; for (k, (letra, avance)) in letras.iter().enumerate() { for &(x, y) in letra.iter() { nudos.push((x0 + x, y)); } if k == 2 || k == 6 { puntos.push((x0 + 0.42, 1.5)); } x0 += avance; } (nudos, puntos) } /// Muestrea la Catmull-Rom por los nudos. fn catmull(n: &[(f32, f32)], pasos: usize) -> Vec<(f32, f32)> { let mut out = Vec::new(); for k in 1..n.len() - 2 { let (p0, p1, p2, p3) = (n[k - 1], n[k], n[k + 1], n[k + 2]); for j in 0..pasos { let t = j as f32 / pasos as f32; let (t2, t3) = (t * t, t * t * t); let f = |a: f32, b: f32, c: f32, d: f32| 0.5 * (2.0 * b + (-a + c) * t + (2.0 * a - 5.0 * b + 4.0 * c - d) * t2 + (-a + 3.0 * b - 3.0 * c + d) * t3); out.push((f(p0.0, p1.0, p2.0, p3.0), f(p0.1, p1.1, p2.1, p3.1))); } } out.push(n[n.len() - 2]); out } /// La mano de ende (`ende_pinta::mano::silueta`), posada con la punta del /// marcador en `punta`. Su espacio es de 0..1000 con la punta en el origen. fn pintar_mano(scene: &mut vello::Scene, punta: Point, alto: f64, alpha: f32) { use ende_pinta::figura::{Relleno, Rol}; use tullpu_core::ComandoPath as C; let k = alto / ende_pinta::mano::ALTO_BASE as f64; let xf = Affine::translate((punta.x, punta.y)) * Affine::scale(k); let tinta = with_alpha(Color::from_rgba8(30, 28, 34, 255), alpha); let piel = with_alpha(Color::from_rgba8(232, 188, 152, 255), alpha); let papel = with_alpha(Color::from_rgba8(246, 243, 236, 255), alpha); for pz in ende_pinta::mano::silueta() { let mut bp = BezPath::new(); for c in &pz.comandos { match *c { C::MoverA { x, y } => bp.move_to((x as f64, y as f64)), C::LineaA { x, y } => bp.line_to((x as f64, y as f64)), C::CurvaA { c1x, c1y, c2x, c2y, x, y } => bp.curve_to((c1x as f64, c1y as f64), (c2x as f64, c2y as f64), (x as f64, y as f64)), C::Cerrar => bp.close_path(), #[allow(unreachable_patterns)] _ => {} } } match pz.relleno { Relleno::Piel => scene.fill(Fill::NonZero, xf, piel, None, &bp), Relleno::Fondo => scene.fill(Fill::NonZero, xf, papel, None, &bp), Relleno::Tinta => scene.fill(Fill::NonZero, xf, tinta, None, &bp), _ => {} } let ancho = if pz.rol == Rol::Tinta { 3.2 } else { 2.0 } * pz.grosor as f64; scene.stroke(&Stroke::new(ancho / k), xf, tinta, None, &bp); } } fn firma_view(s: f32, cw: f64, ch: f64) -> View<()> { View::new(absoluto(Caja { x: 0.0, y: 0.0, w: cw, h: ch })).paint_with(move |scene, _ts, _r: PaintRect| { let avance = motion::ease_in_out_cubic(seg(s, 12.9, 14.5)) as f64; if avance <= 0.0 { return; } let (nudos, puntos) = nudos_llimphi(); let curva = catmull(&nudos, 14); // Escala y lugar: «llimphi» a lo ancho del centro, arriba del muñequito. let u = 92.0; // px por altura-x let ancho_total = 6.0 * u; let ox = (cw - ancho_total) / 2.0 - 30.0; let oy = ch * 0.40; let a_px = |(x, y): (f32, f32)| Point::new(ox + x as f64 * u, oy - y as f64 * u); // Largo acumulado, para revelar por distancia (la mano va pareja). let mut acum = vec![0.0f64]; for w in curva.windows(2) { let (a, b) = (a_px(w[0]), a_px(w[1])); acum.push(acum.last().unwrap() + a.distance(b)); } let total = *acum.last().unwrap(); // El trazo principal ocupa el 88 % del tiempo; los puntos de las íes, el resto. let largo = total * (avance / 0.88).min(1.0); let mut bp = BezPath::new(); let mut punta = a_px(curva[0]); bp.move_to(punta); for (k, p) in curva.iter().enumerate().skip(1) { if acum[k] > largo { let a = acum[k - 1]; let f = ((largo - a) / (acum[k] - a)).clamp(0.0, 1.0); let q = a_px(curva[k - 1]).lerp(a_px(*p), f); bp.line_to(q); punta = q; break; } punta = a_px(*p); bp.line_to(punta); } let tinta = Color::from_rgba8(250, 250, 246, 255); let sombra = Stroke::new(11.0).with_caps(peniko::kurbo::Cap::Round).with_join(peniko::kurbo::Join::Round); let trazo = Stroke::new(7.0).with_caps(peniko::kurbo::Cap::Round).with_join(peniko::kurbo::Join::Round); scene.stroke(&sombra, Affine::translate((0.0, 3.0)), with_alpha(Color::BLACK, 0.35), None, &bp); scene.stroke(&trazo, Affine::IDENTITY, tinta, None, &bp); // Los puntos de las íes, con un toque cada uno. let pa = seg(avance as f32, 0.9, 1.0); for (j, &pt) in puntos.iter().enumerate() { if pa > j as f32 * 0.5 { let c = a_px(pt); scene.fill(Fill::NonZero, Affine::translate((0.0, 3.0)), with_alpha(Color::BLACK, 0.35), None, &Circle::new(c, 6.5)); scene.fill(Fill::NonZero, Affine::IDENTITY, tinta, None, &Circle::new(c, 5.0)); if pa < 1.0 { punta = c; } } } // La mano sigue la punta y después se retira hacia abajo a la derecha. let retiro = motion::ease_in_out_cubic(seg(s, 14.55, 15.2)) as f64; let mano_a = 1.0 - seg(s, 14.95, 15.3); if mano_a > 0.0 { let p = Point::new(punta.x + 520.0 * retiro, punta.y + 420.0 * retiro); pintar_mano(scene, p, 260.0, mano_a); } }) } // ───────────────────────── 4.c el voxel: mundo infinito bajo el nevado ───────────────────────── /// La ventana del mundo que vive en la GPU. El terreno es infinito porque sale /// de `column_height(wx, wz)`, una función pura de las coordenadas de MUNDO: /// la ventana se rellena alrededor de la cámara en cada cuadro y encaja sola. const VDIM: [u32; 3] = [192, 80, 192]; const SEMILLA: u32 = 1337; /// Cuándo empieza el tramo voxel. const T_VOX: f32 = 10.45; /// Alto del muñequito, en voxels. const ALTO_SER: f32 = 9.0; /// La bruma del pie de la montaña: es el color hacia el que se desvanece el /// terreno lejano, y el del cielo pintado en el horizonte, para que no haya costura. const BRUMA: [u8; 3] = [214, 226, 238]; fn altura(wx: f32, wz: f32) -> f32 { llimphi_voxel::column_height(wx as i32, wz as i32, VDIM, SEMILLA) as f32 } /// La superficie: el terreno o el agua, lo que esté más alto. El terreno lleno /// de agua hasta el 30 % del alto (el nivel del mar de `fill_terrain_window`), /// y la cámara se mide contra esto: contra el fondo del lago, se mete abajo del agua. fn superficie(wx: f32, wz: f32) -> f32 { altura(wx, wz).max((VDIM[1] as f32 * 0.30).floor()) } /// Un lugar de pasto parejo, sobre el agua, para que el muñequito festeje. fn lugar_del_festejo() -> (f32, f32, f32) { // El lugar con pasto más parejo de una franja del mundo, con dos // condiciones: atrás (por donde llega la cámara, -z) no hay nada más alto // que él, y adelante (por donde se va, +z) el suelo es parejo. Se elige por // puntaje y no por umbral: con esta semilla casi no hay mesetas. let dy = VDIM[1] as f32; let mut mejor: Option<(f32, (f32, f32, f32))> = None; for z in (400..3000).step_by(4) { for x in (-160..=160).step_by(4) { let (x, z) = (x as f32, z as f32); let h = altura(x, z); if h / dy < 0.33 || h / dy > 0.6 { continue; } let despejado = (1..=24).all(|k| { let d = k as f32 * 4.0; (-1..=1).all(|j| altura(x + j as f32 * 6.0, z - d) <= h + 2.0) }); if !despejado { continue; } let rugosidad: f32 = (-1..=8) .flat_map(|k| [(-2.0, k as f32 * 4.0), (2.0, k as f32 * 4.0)]) .map(|(dx, dz)| (altura(x + dx, z + dz) - h).abs()) .sum(); if mejor.map_or(true, |(r, _)| rugosidad < r) { mejor = Some((rugosidad, (x, h, z))); } } } mejor.expect("ninguna columna con pasto despejada en la franja").1 } /// El suelo más alto alrededor de un punto: la cámara nunca baja de ahí. fn techo_cercano(x: f32, z: f32, radio: f32) -> f32 { let mut m = 0.0f32; for i in -2..=2 { for j in -2..=2 { m = m.max(superficie(x + i as f32 * radio * 0.5, z + j as f32 * radio * 0.5)); } } m } struct Voxel { vr: llimphi_3d::VoxelRenderer, grid: llimphi_3d::VoxelGrid, origen: [i32; 2], tex: wgpu::Texture, view: wgpu::TextureView, w: u32, h: u32, festejo: (f32, f32, f32), } /// La cordillera pintada detrás del mundo: cielo de altura, una sierra lejana /// en bruma y el nevado. `horizonte` es la `y` de pantalla del horizonte de la /// cámara: así la montaña se queda quieta en el mundo cuando la cámara cabecea. fn pintar_andes(scene: &mut vello::Scene, w: f64, h: f64, horizonte: f64) { let rgb = |c: [u8; 3]| Color::from_rgba8(c[0], c[1], c[2], 255); let cielo = Gradient::new_linear(Point::new(0.0, horizonte - h * 0.9), Point::new(0.0, horizonte)).with_stops( [Color::from_rgba8(38, 84, 164, 255), Color::from_rgba8(96, 146, 206, 255), rgb(BRUMA)].as_slice(), ); scene.fill(Fill::NonZero, Affine::IDENTITY, &cielo, None, &peniko::kurbo::Rect::new(0.0, 0.0, w, h)); // Un perfil quebrado y determinista: desplazamiento del punto medio. let perfil = |x0: f64, y0: f64, x1: f64, y1: f64, rugosidad: f64, semilla: u32| -> Vec { let mut pts = vec![Point::new(x0, y0), Point::new(x1, y1)]; let mut amp = rugosidad; let mut k = semilla; for _ in 0..7 { let mut nuevos = Vec::with_capacity(pts.len() * 2); for par in pts.windows(2) { nuevos.push(par[0]); k = k.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); let r = (k >> 8) as f64 / (1u64 << 24) as f64 - 0.5; let m = par[0].midpoint(par[1]); nuevos.push(Point::new(m.x, m.y + r * amp)); } nuevos.push(*pts.last().unwrap()); pts = nuevos; amp *= 0.52; } pts }; let poligono = |pts: &[Point], base: f64| -> BezPath { let mut bp = BezPath::new(); bp.move_to(Point::new(pts[0].x, base)); for p in pts { bp.line_to(*p); } bp.line_to(Point::new(pts.last().unwrap().x, base)); bp.close_path(); bp }; // La sierra lejana, en bruma. let lejos = Color::from_rgba8(168, 186, 210, 255); let mut sierra = perfil(-40.0, horizonte - h * 0.10, w * 0.5, horizonte - h * 0.16, h * 0.10, 7); sierra.extend(perfil(w * 0.5, horizonte - h * 0.16, w + 40.0, horizonte - h * 0.08, h * 0.10, 11).into_iter().skip(1)); scene.fill(Fill::NonZero, Affine::IDENTITY, lejos, None, &poligono(&sierra, horizonte + 4.0)); // El nevado: un pico grande, roca en sombra y la nieve arriba. let cumbre = Point::new(w * 0.60, horizonte - h * 0.50); let izq = perfil(w * 0.18, horizonte, cumbre.x, cumbre.y, h * 0.09, 23); let der = perfil(cumbre.x, cumbre.y, w * 1.02, horizonte - h * 0.02, h * 0.09, 29); let mut ladera: Vec = izq.clone(); ladera.extend(der.iter().skip(1).copied()); let roca = Color::from_rgba8(92, 104, 128, 255); scene.fill(Fill::NonZero, Affine::IDENTITY, roca, None, &poligono(&ladera, horizonte + 4.0)); // La línea de nieve: un borde dentado por debajo de la cumbre. let linea = horizonte - h * 0.30; let nieve_de = |pts: &[Point]| -> Vec { pts.iter().copied().filter(|p| p.y < linea).collect() }; let (ni, nd) = (nieve_de(&izq), nieve_de(&der)); if ni.len() > 1 && nd.len() > 1 { let dientes = perfil(nd.last().unwrap().x, linea, ni[0].x, linea, h * 0.07, 41); // Lado al sol (izquierda) y lado en sombra (derecha), partidos por la arista. let mut sol = BezPath::new(); sol.move_to(ni[0]); for p in ni.iter().skip(1) { sol.line_to(*p); } let medio = dientes.len() / 2; for p in dientes.iter().skip(medio) { sol.line_to(*p); } sol.close_path(); let mut sombra = BezPath::new(); sombra.move_to(cumbre); for p in nd.iter().skip(1) { sombra.line_to(*p); } for p in dientes.iter().take(medio + 1) { sombra.line_to(*p); } sombra.close_path(); scene.fill(Fill::NonZero, Affine::IDENTITY, Color::from_rgba8(246, 249, 253, 255), None, &sol); scene.fill(Fill::NonZero, Affine::IDENTITY, Color::from_rgba8(190, 204, 228, 255), None, &sombra); // Las canaletas de los glaciares. for k in 0..5 { let x = cumbre.x + (k as f64 - 2.0) * w * 0.035; let a = Point::new(x, cumbre.y + h * 0.06 + (k % 2) as f64 * h * 0.03); let b = Point::new(x + (k as f64 - 2.0) * 10.0, linea + h * 0.02); scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, Color::from_rgba8(160, 176, 204, 200), None, &peniko::kurbo::Line::new(a, b)); } } // Bruma al pie: la costura con el terreno voxel se funde acá. let pie = Gradient::new_linear(Point::new(0.0, horizonte - h * 0.12), Point::new(0.0, horizonte + 2.0)) .with_stops([with_alpha(rgb(BRUMA), 0.0), rgb(BRUMA)].as_slice()); scene.fill(Fill::NonZero, Affine::IDENTITY, &pie, None, &peniko::kurbo::Rect::new(0.0, horizonte - h * 0.12, w, h)); } impl Voxel { fn new(hal: &Hal, w: u32, h: u32) -> Self { let festejo = lugar_del_festejo(); eprintln!("festejo en {:?} (alto {:.0} % del mundo)", festejo, 100.0 * festejo.1 / VDIM[1] as f32); let mut grid = llimphi_3d::VoxelGrid::new(VDIM); let origen = [-96, festejo.2 as i32 - 600]; llimphi_voxel::fill_terrain_window(&mut grid, origen, SEMILLA); let mut vr = llimphi_3d::VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid); grid.reset_dirty(); let sol = [0.25f32, 0.75, -0.6]; let l = (sol[0] * sol[0] + sol[1] * sol[1] + sol[2] * sol[2]).sqrt(); vr.sun_dir = [sol[0] / l, sol[1] / l, sol[2] / l]; let tex = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("voxel"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: FMT, usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); Voxel { vr, grid, origen, tex, view, w, h, festejo } } /// Un pase voxel sobre `fondo`, con o sin niebla, leído a memoria. fn pase(&mut self, hal: &Hal, renderer: &mut Renderer, cam: &llimphi_3d::Camera3d, niebla: bool, fondo: Color) -> Vec { self.vr.atmosphere = llimphi_3d::Atmosphere { sky_zenith: [52, 98, 170], sky_horizon: BRUMA, fog_density: if niebla { 0.003 } else { 0.0 }, god_rays: 0.0, }; renderer.render_to_view(hal, &vello::Scene::new(), &self.view, self.w, self.h, fondo).expect("fondo voxel"); let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); self.vr.render(&hal.device, &hal.queue, &mut enc, &self.view, (self.w, self.h), cam); hal.queue.submit(std::iter::once(enc.finish())); leer(hal, &self.tex, self.w, self.h) } /// El cuadro del instante `s`: el vuelo rápido sobre el mundo, la llegada /// al muñequito, su festejo y su partida con la cámara subiendo. fn cuadro(&mut self, hal: &Hal, renderer: &mut Renderer, s: f32) -> ImageBrush { use llimphi_3d::glam::Vec3; use llimphi_voxel::{ser_sdf, Clip, Colores, EstiloSer, Pose}; let tv = s - T_VOX; let (xs, hs, zs) = self.festejo; // ── la cámara (coordenadas de MUNDO; y = índice de voxel) ── let llega = motion::ease_out_cubic(seg(tv, 0.0, 2.1)); let zc = zs - 34.0 - 520.0 * (1.0 - llega); // El balanceo corre el ojo Y lo que mira (ver `mira_ser`): con la mirada // apenas torcida en x, la columna de rayos casi paralelos al eje z abre // una grieta recta en el DDA del motor voxel (medido con `raya.py`: 0 // filas con la mirada derecha, ~200 con el ojo corrido medio voxel). let balanceo = 10.0 * ((tv * 1.3).sin()) * (1.0 - llega); let xc = xs + balanceo; let mut adelante = 0.0f32; for k in 0..12 { adelante = adelante.max(superficie(xc, zc + k as f32 * 5.0)); } let aterriza = ease(seg(tv, 1.3, 2.1)); let mut ojo = Vec3::new(xc, (adelante + 13.0) * (1.0 - aterriza) + (hs + 8.0) * aterriza, zc); // Margen de 11: los árboles asoman hasta 9 voxels sobre el suelo. ojo.y = ojo.y.max(techo_cercano(xc, zc, 8.0) + 11.0); let grua = ease(seg(tv, 2.6, 4.9)); ojo.y += 30.0 * grua; ojo.z -= 16.0 * grua; // ── el muñequito: festeja mirando a la cámara, se da vuelta y se va ── let camina = seg(tv, 3.15, 5.0); let zser = zs + 26.0 * ease(camina); let hser = altura(xs, zser); let festejo = Clip::Cheer.pose(tv * 7.0); let paso = Clip::Walk.pose((tv - 3.1).max(0.0) * 9.0); let pose = Pose::lerp(&festejo, &paso, seg(tv, 3.0, 3.25)); let salto_t = seg(tv, 1.85, 3.0); let salto = if salto_t > 0.0 && salto_t < 1.0 { (salto_t * 2.0 * PI).sin().abs() * 3.0 } else { 0.0 }; let yaw = PI * (1.0 - ease(seg(tv, 2.95, 3.25))); let mira_vuelo = ojo + Vec3::new(0.0, -0.24, 1.0) * 60.0; let mira_ser = Vec3::new(xc, hser + ALTO_SER * 0.5, zser); // Al subir, la mirada se levanta hacia el horizonte: el nevado queda a la vista. let mira_lejos = Vec3::new(xc, hs + 26.0, zs + 260.0); let mira = mira_vuelo.lerp(mira_ser, ease(seg(tv, 1.2, 2.1))).lerp(mira_lejos, 0.55 * grua); // ── la ventana del mundo sigue a la cámara ── let b = 8; let origen = [((xc as i32 - 96).div_euclid(b)) * b, ((ojo.z as i32 - 40).div_euclid(b)) * b]; if origen != self.origen { llimphi_voxel::fill_terrain_window(&mut self.grid, origen, SEMILLA); self.vr.sync(&hal.queue, &mut self.grid); self.origen = origen; } let (ox, oz) = (self.origen[0] as f32, self.origen[1] as f32); let mitad = Vec3::new(VDIM[0] as f32, VDIM[1] as f32, VDIM[2] as f32) * 0.5; let local = |p: Vec3| Vec3::new(p.x - ox, p.y, p.z - oz) - mitad; let col = Colores::nuevos([0.92, 0.74, 0.60], [0.17, 0.85, 0.65], [0.20, 0.24, 0.40]); let ser = ser_sdf(&EstiloSer::amable(), &pose, ALTO_SER, col).posado([xs - ox, hser + 1.0 + salto, zser - oz], yaw); self.vr.sers.clear(); self.vr.sers.push(ser); let cam = llimphi_3d::Camera3d { eye: local(ojo), target: local(mira), ..Default::default() }; // ── tres pases: el mundo con niebla, la máscara del cielo, la cordillera ── let magenta = Color::from_rgba8(255, 0, 255, 255); let mundo = self.pase(hal, renderer, &cam, true, Color::BLACK); let mascara = self.pase(hal, renderer, &cam, false, magenta); let d = mira - ojo; let cabeceo = d.y.atan2((d.x * d.x + d.z * d.z).sqrt()) as f64; let (wf, hf) = (self.w as f64, self.h as f64); let foco = (hf / 2.0) / (cam.fovy_rad as f64 / 2.0).tan(); let horizonte = hf / 2.0 - foco * cabeceo.tan(); let mut andes = vello::Scene::new(); pintar_andes(&mut andes, wf, hf, horizonte); renderer.render_to_view(hal, &andes, &self.view, self.w, self.h, Color::BLACK).expect("andes"); let mut px = leer(hal, &self.tex, self.w, self.h); // Donde el rayo no tocó nada va la cordillera; el resto, el mundo. for ((o, m), c) in px.chunks_exact_mut(4).zip(mascara.chunks_exact(4)).zip(mundo.chunks_exact(4)) { let cielo = m[0] == 255 && m[1] == 0 && m[2] == 255; if !cielo { o.copy_from_slice(c); } } tapar_grietas_en_el_agua(&mut px, self.w as usize, self.h as usize); ImageBrush::new(ImageData { data: Blob::new(Arc::new(px)), format: ImageFormat::Rgba8, alpha_type: ImageAlphaType::Alpha, width: self.w, height: self.h, }) } } /// **Parche cosmético**, no arreglo: el render voxel abre a veces una grieta /// vertical punteada de 1–2 px sobre el agua mientras la cámara vuela (no es /// el ser, ni el sol, ni la x entera de la cámara, ni el umbral de la guarda /// del DDA: los cuatro se descartaron midiendo). Hasta encontrar la causa en /// `llimphi-3d`, un píxel oscuro con agua a los dos lados toma el agua de al lado. fn tapar_grietas_en_el_agua(px: &mut [u8], w: usize, h: usize) { let es_agua = |p: &[u8]| p[2] as i32 > p[0] as i32 + 30 && p[2] >= p[1] && p[2] > 70; let lum = |p: &[u8]| p[0] as i32 + p[1] as i32 + p[2] as i32; for y in 0..h { let fila = &mut px[y * w * 4..(y + 1) * w * 4]; let mut x = 1; while x + 8 < w { // ¿Arranca acá una franja oscura de 1 a 6 px con agua clara a los dos lados? let izq = &fila[(x - 1) * 4..(x - 1) * 4 + 4]; let (li, agua_izq) = (lum(izq), es_agua(izq)); let mut ancho = 0; while ancho < 7 && lum(&fila[(x + ancho) * 4..(x + ancho) * 4 + 4]) + 90 < li { ancho += 1; } if agua_izq && (1..=6).contains(&ancho) { let der: [u8; 4] = fila[(x + ancho) * 4..(x + ancho) * 4 + 4].try_into().unwrap(); if es_agua(&der) && lum(&der) > li - 60 { let izq: [u8; 4] = izq.try_into().unwrap(); for k in 0..ancho { let f = (k + 1) as f32 / (ancho + 1) as f32; for c in 0..3 { fila[(x + k) * 4 + c] = (izq[c] as f32 * (1.0 - f) + der[c] as f32 * f) as u8; } } x += ancho; continue; } } x += 1; } } } // ───────────────────────── la escena entera ───────────────────────── fn build_view(s: f32, cw: f64, ch: f64, sk: &Skin, cielo: &cosmos_render::RenderModel, voxel: Option) -> View<()> { let mut hijos: Vec> = Vec::new(); // El scroll: los controles suben y el escenario entra desde abajo. let desliza = ease(seg(s, 7.0, 7.6)) as f64; let fuera = seg(s, 7.6, 7.7); // Los controles: entran en cascada, del más simple al más complejo. if s > 3.0 && fuera < 1.0 { for (i, c) in tiles(cw).iter().enumerate() { let t0 = 3.05 + i as f32 * 0.2; let entra = motion::ease_out_back(seg(s, t0, t0 + 0.45)); if entra <= 0.001 { continue; } let p = seg(s, t0 + 0.2, t0 + 2.4); let escala = lerp(0.86, 1.0, entra.min(1.0) as f64); let alfa = entra.min(1.0); let movida = Caja { y: c.y - desliza * ch, ..*c }; hijos.push(tile_view(i, movida, alfa, escala, p, s, sk)); } } // El escenario. if desliza > 0.0 { let mut e = escenario(s, cw, ch); e.y += (1.0 - desliza) * ch; let radio = lerp(18.0, 0.0, seg(s, 10.3, 10.6) as f64); let mut contenido: Vec> = Vec::new(); let a_doom = seg(s, 9.0, 9.2); let a_vox = seg(s, 10.42, 10.5); let lleno = |st: Style| -> Style { Style { position: Position::Absolute, inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() }, size: Size { width: length(e.w as f32), height: length(e.h as f32) }, ..st } }; // Cosmos. if a_doom < 1.0 { let lado = e.h as f32; contenido.push( View::new(lleno(Style { justify_content: Some(JustifyContent::CENTER), align_items: Some(AlignItems::CENTER), ..Default::default() })) .fill(Color::from_rgba8(6, 8, 16, 255)) .alpha(1.0 - a_doom) .children(vec![View::new(Style { size: Size { width: length(lado), height: length(lado) }, flex_shrink: 0.0, ..Default::default() }).children(vec![cielo_view(cielo, s, lado)])]), ); } // Supay: el pasillo hasta la fuente. if a_doom > 0.0 && a_vox < 1.0 { let u = seg(s, 9.0, 10.45); contenido.push(View::new(lleno(Style::default())).alpha(a_doom * (1.0 - a_vox)).children(vec![pasillo_view(u, s, e.w as f32, e.h as f32)])); } // Voxel (ya rendido a imagen, del tamaño de la pantalla). if let Some(img) = voxel { contenido.push(View::new(lleno(Style::default())).alpha(a_vox).image(img)); } // Rótulos. let r_cosmos = seg(s, 7.4, 7.7) * (1.0 - a_doom); let r_supay = a_doom * (1.0 - seg(s, 10.1, 10.3)); let r_vox = seg(s, 10.8, 11.0) * (1.0 - seg(s, 11.8, 12.1)); if r_cosmos > 0.0 { contenido.push(rotulo("cosmos", r_cosmos, sk)); } if r_supay > 0.0 { contenido.push(rotulo("supay", r_supay, sk)); } if r_vox > 0.0 { contenido.push(rotulo("voxel", r_vox, sk)); } let mut st = absoluto(e); st.overflow = taffy::Point { x: taffy::Overflow::Hidden, y: taffy::Overflow::Hidden }; hijos.push( View::new(st) .fill(Color::from_rgba8(6, 8, 16, 255)) .radius(radio) .shadow(Shadow::soft(140, 30.0).offset(0.0, 14.0)) .clip(true) .children(contenido), ); } // El salto al agua: un destello del color del agua que tapa el cambio al 3D. let destello = seg(s, 10.15, 10.45) * (1.0 - seg(s, 10.5, 10.9)); if destello > 0.0 { hijos.push(View::new(absoluto(Caja { x: 0.0, y: 0.0, w: cw, h: ch })).fill(with_alpha(Color::from_rgba8(196, 232, 250, 255), destello))); } // La línea de comando va encima de todo lo de arriba. hijos.push(input_view(s, cw, ch, sk)); // Y la firma, encima de todo. hijos.push(firma_view(s, cw, ch)); View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, position: Position::Relative, ..Default::default() }) .fill(sk.bg) .children(hijos) } fn main() { let mut args = std::env::args().skip(1); let out_dir = args.next().unwrap_or_else(|| "showreel_frames".to_string()); let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(465); let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600); let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900); // SOLO=, en segundos: rinde sólo ese tramo (para mirar uno sin pagar todos). let solo: Option<(f32, f32)> = std::env::var("SOLO").ok().and_then(|v| { let mut it = v.split(',').filter_map(|x| x.trim().parse().ok()); Some((it.next()?, it.next()?)) }); create_dir_all(&out_dir).expect("mkdir out_dir"); let theme = Theme::by_name("Tawa").expect("tema Tawa"); let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF); let sk = Skin { accent, panel: theme.bg_panel, panel_hi: theme.bg_button, border: theme.border, fg: theme.fg_text, fg_muted: theme.fg_muted, bg: theme.bg_app, luces: [Color::from_rgba8(110, 140, 220, 255), Color::from_rgba8(190, 110, 230, 255), accent], theme, }; let [br, bg, bb, _] = sk.bg.components; let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255); let cielo = cosmos_engine::compute_mock(&carta()); let hal = pollster::block_on(Hal::new(None)).expect("hal"); let mut renderer = Renderer::new(&hal).expect("renderer"); let target = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("showreel"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: FMT, usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = target.create_view(&wgpu::TextureViewDescriptor::default()); let mut voxel = Voxel::new(&hal, w, h); let mut ts = Typesetter::new(); let (cw, ch) = (w as f64, h as f64); for i in 0..n { let s = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) * DURACION }; if let Some((a, b)) = solo { if s < a || s > b { continue; } } let img = if s >= 10.4 { Some(voxel.cuadro(&hal, &mut renderer, s)) } else { None }; let root = build_view(s, cw, ch, &sk, &cielo, img); let mut layout = LayoutTree::new(); let mounted = mount(&mut layout, root); let computed = { let tmap = &mounted.text_measures; layout .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| match tmap.get(&nid) { Some(tm) => measure_text_node(&mut ts, tm, known, avail), None => taffy::Size::ZERO, }) .expect("layout") }; let mut scene = vello::Scene::new(); paint(&mut scene, &mounted, &computed, &mut ts, None, None); renderer.render_to_view(&hal, &scene, &view, w, h, base).expect("render_to_view"); let px = leer(&hal, &target, w, h); escribir_png(&px, &format!("{out_dir}/frame_{i:04}.png"), w, h); if i % 30 == 0 || i == n - 1 { eprintln!("showreel: cuadro {}/{} (s={:.2})", i + 1, n, s); } } eprintln!("showreel: {n} cuadros en {out_dir}/ ({w}x{h})"); } fn leer(hal: &Hal, tex: &wgpu::Texture, w: u32, h: u32) -> Vec { let unpadded = (w * 4) as usize; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; let padded = unpadded.div_ceil(align) * align; let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { label: Some("readback"), size: (padded * h as usize) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(h) }, }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); hal.queue.submit(std::iter::once(enc.finish())); let slice = buf.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().unwrap().unwrap(); let data = slice.get_mapped_range(); let mut px = Vec::with_capacity((w * h * 4) as usize); for r in 0..h as usize { px.extend_from_slice(&data[r * padded..r * padded + unpadded]); } drop(data); buf.unmap(); px } fn escribir_png(px: &[u8], path: &str, w: u32, h: u32) { let file = File::create(path).expect("png"); let mut enc = png::Encoder::new(BufWriter::new(file), w, h); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight); enc.write_header().unwrap().write_image_data(px).unwrap(); }