tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 124Markdown 82TOML 78Shell 12149 Definitions

⎇ main llimphi-voxel-render/src/escenario.rs Rust · 317 lines · 13.2 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1//! El **escenario**: una escena voxel montada y lista para dar cuadros.
2//!
3//! Es el bucle que estaba adentro de `export_scene` del studio, sacado a
4//! donde lo pueda usar cualquiera. La forma importa más que el contenido:
5//!
6//! ```text
7//!   preparar(project, scene)   ── una vez: mundo, reparto, ventana
8//!        │
9//!        └─ cuadro(t)          ── por cuadro: posar, encuadrar, componer
10//! ```
11//!
12//! Esa separación es lo que vuelve viable filmar: generar el terreno cuesta
13//! bastante más que dibujarlo, y adentro del bucle se pagaría una vez por
14//! cuadro. Lo único que `cuadro` hace caro es **regenerar la ventana cuando el
15//! reparto cruza un paso**, que es justo lo que permite que una escena viva en
16//! un mundo infinito en vez de una caja de 128³.
17//!
18//! No escribe archivos ni muxea: compone sobre la textura que le den. Quien
19//! filma decide la resolución, el aspecto y qué hace con el resultado.
20
21use llimphi_3d::glam::Vec3;
22use llimphi_3d::Camera3d;
23use llimphi_hal::wgpu;
24use llimphi_voxel::{
25    reparto_mixto_con, window_origin_for_cast, world_dim, ActorScript, CharSpec, EnCuadro, Enjambre,
26    MundoRender, Project, SceneSpec, PREVIEW_DIM_XZ,
27};
28
29use crate::preview::WorldPreview;
30
31/// El **cielo** con el que hay que limpiar el cuadro antes de componer. El
32/// ray-march pinta su propia atmósfera sobre los rayos que no pegan, pero el
33/// clear evita un borde frío si la niebla afloja.
34pub const CIELO: [u8; 4] = [150, 186, 224, 255];
35
36/// **La luz de una escena**: sol, cielo y bruma.
37///
38/// Vive acá y no en el `SceneSpec` porque no es de la escena: es de **quien
39/// filma**. La misma escena rodada por dos marcas distintas tiene que salir con
40/// dos luces distintas, o las dos marcas se leen como un solo canal. En `ende`
41/// esto sale del sello.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct Ambiente {
44    /// Dirección **hacia** el sol, sin normalizar.
45    pub sol: [f32; 3],
46    pub cenit: [u8; 3],
47    pub horizonte: [u8; 3],
48    /// Densidad de bruma como numerador: la real es esto sobre el ancho de la
49    /// ventana.
50    pub bruma: f32,
51    /// Haces de sol cruzando la bruma, `0..1`.
52    pub haces: f32,
53}
54
55/// Una escena montada sobre la GPU, lista para entregar cuadros.
56pub struct Escenario {
57    preview: WorldPreview,
58    mundo: MundoRender,
59    escena: SceneSpec,
60    /// Lo que la escena le pone al mundo (jardín, corredores, decorado). Se arma
61    /// una vez: compararlo cada cuadro es barato, reconstruirlo no.
62    puesta: llimphi_voxel::Puesta,
63    guiones: Vec<ActorScript>,
64    reparto: Vec<CharSpec>,
65    dim: [u32; 3],
66    /// La luz de quien filma. `None` = la del estudio.
67    ambiente: Option<Ambiente>,
68    /// La materia desprendida que anda por el aire (los sucesos «desmorona» la
69    /// alimentan; ver [`llimphi_voxel::disparar_sucesos`]).
70    ceniza: Enjambre,
71    /// Instante del cuadro anterior: el intervalo `(t_previo, t]` es lo que decide
72    /// qué sucesos se disparan en ESTE cuadro, y por qué se disparan una sola vez.
73    t_previo: Option<f32>,
74}
75
76impl Escenario {
77    /// Monta la escena `escena` de `proyecto` con el tamaño de mundo por
78    /// defecto.
79    pub fn preparar(
80        device: &wgpu::Device,
81        queue: &wgpu::Queue,
82        proyecto: &Project,
83        escena: &SceneSpec,
84    ) -> Result<Escenario, String> {
85        Escenario::preparar_con(device, queue, proyecto, escena, world_dim(PREVIEW_DIM_XZ))
86    }
87
88    /// Igual, eligiendo el tamaño de la ventana voxel. Más grande se ve más
89    /// lejos y cuesta más; el ray-march es por píxel, así que lo que pesa es
90    /// la profundidad del volumen, no su ancho en pantalla.
91    pub fn preparar_con(
92        device: &wgpu::Device,
93        queue: &wgpu::Queue,
94        proyecto: &Project,
95        escena: &SceneSpec,
96        dim: [u32; 3],
97    ) -> Result<Escenario, String> {
98        let mundo = proyecto
99            .render_mundo(escena.mundo)
100            .ok_or("la escena no tiene un mundo de fondo válido")?;
101        let preview = WorldPreview::build(device, queue, &mundo, dim, 1, None);
102        let guiones = escena.scripts();
103        let reparto = escena
104            .actors
105            .iter()
106            .map(|a| proyecto.character_or_default(a.character))
107            .collect();
108        Ok(Escenario {
109            preview,
110            mundo,
111            escena: escena.clone(),
112            puesta: escena.puesta(),
113            guiones,
114            reparto,
115            dim,
116            ambiente: None,
117            ceniza: Enjambre::ceniza(0x1CE),
118            t_previo: None,
119        })
120    }
121
122    /// Duración de la escena, en segundos de su propio guion.
123    pub fn duracion(&self) -> f32 {
124        self.escena.duration
125    }
126
127    /// Fija la luz con que se filma. Sin esto se usa la del estudio, que es la
128    /// misma para todo el mundo.
129    pub fn set_ambiente(&mut self, a: Ambiente) {
130        self.ambiente = Some(a);
131    }
132
133    /// Fija el **temblor de cámara en mano**. Es la perilla que hace que un
134    /// plano filmado y un trazo dibujado se lean como la misma persona, así
135    /// que la decide quien filma y no la escena.
136    pub fn set_temblor(&mut self, amt: f32) {
137        self.escena.handheld = amt.max(0.0);
138    }
139
140    /// El color con que hay que limpiar el cuadro antes de componer: el
141    /// horizonte de la noche si la escena es de noche, si no el del ambiente,
142    /// o el cielo del estudio.
143    pub fn cielo(&self) -> [u8; 4] {
144        if let Some(c) = self.escena.cielo.as_ref() {
145            return c.fondo();
146        }
147        match self.ambiente {
148            Some(a) => [a.horizonte[0], a.horizonte[1], a.horizonte[2], 255],
149            None => CIELO,
150        }
151    }
152
153    /// Los instantes que merecen un acento musical: cortes de cámara y gestos.
154    pub fn acentos(&self) -> Vec<f32> {
155        self.escena.beat_times()
156    }
157
158    /// Compone el cuadro del segundo `t` sobre `target`.
159    ///
160    /// `rect` es el sub-rect del viewport donde va la escena (para montarla en
161    /// un panel); para un cuadro entero, `(0, 0, w, h)`.
162    ///
163    /// **Coordenadas**: el shader del voxel espera la cámara y las posiciones
164    /// en espacio CENTRADO (`mundo = grilla − dim/2`), porque hace
165    /// `ro = cam_eye + dim/2`. `ground_at_world` devuelve coords de ventana, así
166    /// que hay que restarle `half` — si no, en los planos cercanos el terreno
167    /// queda fuera de cuadro y el reparto parece flotar en el cielo. Costó dos
168    /// re-renders descubrirlo la primera vez; queda escrito.
169    #[allow(clippy::too_many_arguments)]
170    pub fn cuadro(
171        &mut self,
172        device: &wgpu::Device,
173        queue: &wgpu::Queue,
174        encoder: &mut wgpu::CommandEncoder,
175        target: &wgpu::TextureView,
176        viewport: (u32, u32),
177        rect: (f32, f32, f32, f32),
178        t: f32,
179    ) {
180        // Mundo infinito: la ventana voxel sigue al reparto. Sólo se regenera
181        // al cruzar un paso, así que por cuadro esto es casi gratis.
182        let origen = window_origin_for_cast(&self.guiones, t, self.dim);
183        self.preview
184            .ensure_window_con(device, queue, &self.mundo, 1, origen, &self.puesta);
185
186        // La luz de quien filma, DESPUÉS de la ventana (que al regenerarse
187        // repone la atmósfera del estudio) y ANTES del fundido de apertura,
188        // que si existe tiene que ganar.
189        if let Some(a) = self.ambiente {
190            self.preview.set_lighting(
191                a.sol,
192                llimphi_3d::Atmosphere {
193                    sky_zenith: a.cenit,
194                    sky_horizon: a.horizonte,
195                    fog_density: a.bruma / self.dim[0] as f32,
196                    god_rays: a.haces,
197                },
198            );
199        }
200
201        // El cielo de noche, si la escena lo declara: reemplaza la luz de día de
202        // quien filma (que sea de noche lo dice el cuento), pero conserva su
203        // bruma y sus haces. Después del ambiente y antes de la apertura.
204        if let Some(c) = self.escena.cielo.as_ref() {
205            self.preview.set_cielo(Some(c));
206        }
207
208        // La atmósfera de apertura (bruma + fundido desde negro), si la escena
209        // la declara. Va antes de componer: sólo toca uniforms.
210        if let Some(atm) = self.escena.atmosfera.as_ref() {
211            self.preview.set_atmosfera_escena(atm, t);
212        }
213
214        let half = Vec3::new(self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32) * 0.5;
215
216        // Lo que la escena dicta que se desmorona: los sucesos que caen entre el
217        // cuadro anterior y éste desprenden materia, y lo que ya volaba avanza.
218        // Va antes de encuadrar porque cambia el mundo que la cámara va a ver.
219        let t0 = self.t_previo.unwrap_or(t - 1e-4);
220        let sucesos = self.escena.sucesos.clone();
221        self.preview.desmoronar(queue, &sucesos, t0, t, &mut self.ceniza);
222        if !self.ceniza.is_empty() {
223            self.ceniza.paso((t - t0).max(0.0));
224        }
225        self.t_previo = Some(t);
226
227        let mut poses = Vec::with_capacity(self.guiones.len());
228        let mut centroide = Vec3::ZERO;
229        for (guion, quien) in self.guiones.iter().zip(&self.reparto) {
230            // Tiempo cuantizado a la tasa propia del actor: el Héroe a 12 fps
231            // se mueve y posa en doses; los demás, fluidos. Es un sello de
232            // animación, no una optimización.
233            let at = guion.quantize(t);
234            let s = guion.sample(at);
235            let pos = self.preview.pie_en_mundo(s.gx, s.gz) - half;
236            centroide += pos;
237            poses.push((pos, s, quien, at));
238        }
239        let mirar = if poses.is_empty() {
240            Vec3::new(0.0, self.dim[1] as f32 * (0.32 - 0.5), 0.0)
241        } else {
242            centroide / poses.len() as f32 + Vec3::new(0.0, 1.0, 0.0)
243        };
244        // La escena manda si lo dice; si no, la distancia sale del tamaño del
245        // reparto, que es lo que sirve cuando lo filmado es una escena y no un
246        // objeto que se muestra.
247        let distancia = self
248            .escena
249            .distancia
250            .unwrap_or(6.0 + poses.len() as f32 * 1.2);
251        let camara = self.escena.camera_at(mirar, distancia, t);
252
253        let mut mallas = Vec::with_capacity(poses.len());
254        let mut en_cuadro: Vec<EnCuadro> = Vec::new();
255        for (pos, s, quien, at) in &poses {
256            if quien.es_sdf() {
257                // Humanoide o bicho del bestiario → ser SDF (toon + cara por decals), en coords de
258                // grilla: el ray-marcher los traza.
259                en_cuadro.push(EnCuadro {
260                    spec: quien,
261                    pos: *pos + half,
262                    yaw: s.facing,
263                    clip: s.clip,
264                    fase: *at,
265                });
266            } else {
267                // Rig → malla de su andar.
268                mallas.push(quien.to_meta(*pos, s.facing, s.clip, *at, Some(camara.eye)));
269            }
270        }
271        // Por el REPARTO y no crudo: el uniform tiene lugar para unas pocas
272        // primitivas, así que los de cerca se llevan la cara entera y los del
273        // fondo la pierden. Pasarle la lista cruda haría que los que sobran se
274        // caigan del cuadro sin aviso. Y al que ni degradado entra, el reparto
275        // MIXTO lo baja a malla de cajas en vez de borrarlo del plano.
276        // La utilería va primero y el reparto se reparte lo que ella deja: comparten
277        // el uniform, y una estrella que se come el lugar de una cara se nota menos
278        // que una estrella que no aparece cuando el guion la nombra.
279        let en_mano: Vec<(Vec3, f32, Vec3)> =
280            poses.iter().map(|(pos, s, quien, at)| (*pos + half, s.facing, quien.mano(s.clip, *at))).collect();
281        let utileria = self.escena.utileria_en(t, &en_mano);
282        // La estrella que alumbra: de noche, la utilería con luz propia ilumina
283        // lo que tiene cerca (la cara de quien la lleva).
284        self.preview.set_luces(self.escena.luces_en(t, &en_mano));
285        let rep = reparto_mixto_con(&en_cuadro, camara.eye + half, true, half, &utileria);
286        mallas.extend(rep.mallas);
287        let mut seres = utileria;
288        seres.extend(rep.sers);
289        // Las flores de los canteros, como seres, con lo que el reparto dejó.
290        let mira = (camara.target - camara.eye).normalize_or_zero();
291        llimphi_voxel::sumar_macizos(&mut seres, self.preview.macizos(), (camara.eye + half).into(), mira.into());
292        self.preview.set_motas(&self.ceniza, camara.eye + half);
293
294        self.preview.render_scene(
295            device, queue, encoder, target, viewport, rect, &camara, &mallas, seres,
296        );
297    }
298
299    /// La cámara de la escena en `t` — para que el caller pueda encuadrar algo
300    /// más (un cartel anclado al mundo, por ejemplo) sin re-derivarla.
301    pub fn camara_en(&self, t: f32) -> Camera3d {
302        let half = Vec3::new(self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32) * 0.5;
303        let mut centroide = Vec3::ZERO;
304        let mut n = 0.0f32;
305        for guion in &self.guiones {
306            let s = guion.sample(guion.quantize(t));
307            centroide += self.preview.pie_en_mundo(s.gx, s.gz) - half;
308            n += 1.0;
309        }
310        let mirar = if n == 0.0 {
311            Vec3::new(0.0, self.dim[1] as f32 * (0.32 - 0.5), 0.0)
312        } else {
313            centroide / n + Vec3::new(0.0, 1.0, 0.0)
314        };
315        self.escena.camera_at(mirar, 6.0 + n * 1.2, t)
316    }
317}