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//! 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}