tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-voxel-render/src/escenario.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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, 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 guiones: Vec<ActorScript>,
61 reparto: Vec<CharSpec>,
62 dim: [u32; 3],
63 /// La luz de quien filma. `None` = la del estudio.
64 ambiente: Option<Ambiente>,
65 /// La materia desprendida que anda por el aire (los sucesos «desmorona» la
66 /// alimentan; ver [`llimphi_voxel::disparar_sucesos`]).
67 ceniza: Enjambre,
68 /// Instante del cuadro anterior: el intervalo `(t_previo, t]` es lo que decide
69 /// qué sucesos se disparan en ESTE cuadro, y por qué se disparan una sola vez.
70 t_previo: Option<f32>,
71}
72
73impl Escenario {
74 /// Monta la escena `escena` de `proyecto` con el tamaño de mundo por
75 /// defecto.
76 pub fn preparar(
77 device: &wgpu::Device,
78 queue: &wgpu::Queue,
79 proyecto: &Project,
80 escena: &SceneSpec,
81 ) -> Result<Escenario, String> {
82 Escenario::preparar_con(device, queue, proyecto, escena, world_dim(PREVIEW_DIM_XZ))
83 }
84
85 /// Igual, eligiendo el tamaño de la ventana voxel. Más grande se ve más
86 /// lejos y cuesta más; el ray-march es por píxel, así que lo que pesa es
87 /// la profundidad del volumen, no su ancho en pantalla.
88 pub fn preparar_con(
89 device: &wgpu::Device,
90 queue: &wgpu::Queue,
91 proyecto: &Project,
92 escena: &SceneSpec,
93 dim: [u32; 3],
94 ) -> Result<Escenario, String> {
95 let mundo = proyecto
96 .render_mundo(escena.mundo)
97 .ok_or("la escena no tiene un mundo de fondo válido")?;
98 let preview = WorldPreview::build(device, queue, &mundo, dim, 1, None);
99 let guiones = escena.scripts();
100 let reparto = escena
101 .actors
102 .iter()
103 .map(|a| proyecto.character_or_default(a.character))
104 .collect();
105 Ok(Escenario {
106 preview,
107 mundo,
108 escena: escena.clone(),
109 guiones,
110 reparto,
111 dim,
112 ambiente: None,
113 ceniza: Enjambre::ceniza(0x1CE),
114 t_previo: None,
115 })
116 }
117
118 /// Duración de la escena, en segundos de su propio guion.
119 pub fn duracion(&self) -> f32 {
120 self.escena.duration
121 }
122
123 /// Fija la luz con que se filma. Sin esto se usa la del estudio, que es la
124 /// misma para todo el mundo.
125 pub fn set_ambiente(&mut self, a: Ambiente) {
126 self.ambiente = Some(a);
127 }
128
129 /// Fija el **temblor de cámara en mano**. Es la perilla que hace que un
130 /// plano filmado y un trazo dibujado se lean como la misma persona, así
131 /// que la decide quien filma y no la escena.
132 pub fn set_temblor(&mut self, amt: f32) {
133 self.escena.handheld = amt.max(0.0);
134 }
135
136 /// El color con que hay que limpiar el cuadro antes de componer: el
137 /// horizonte del ambiente, o el cielo del estudio.
138 pub fn cielo(&self) -> [u8; 4] {
139 match self.ambiente {
140 Some(a) => [a.horizonte[0], a.horizonte[1], a.horizonte[2], 255],
141 None => CIELO,
142 }
143 }
144
145 /// Los instantes que merecen un acento musical: cortes de cámara y gestos.
146 pub fn acentos(&self) -> Vec<f32> {
147 self.escena.beat_times()
148 }
149
150 /// Compone el cuadro del segundo `t` sobre `target`.
151 ///
152 /// `rect` es el sub-rect del viewport donde va la escena (para montarla en
153 /// un panel); para un cuadro entero, `(0, 0, w, h)`.
154 ///
155 /// **Coordenadas**: el shader del voxel espera la cámara y las posiciones
156 /// en espacio CENTRADO (`mundo = grilla − dim/2`), porque hace
157 /// `ro = cam_eye + dim/2`. `ground_at_world` devuelve coords de ventana, así
158 /// que hay que restarle `half` — si no, en los planos cercanos el terreno
159 /// queda fuera de cuadro y el reparto parece flotar en el cielo. Costó dos
160 /// re-renders descubrirlo la primera vez; queda escrito.
161 #[allow(clippy::too_many_arguments)]
162 pub fn cuadro(
163 &mut self,
164 device: &wgpu::Device,
165 queue: &wgpu::Queue,
166 encoder: &mut wgpu::CommandEncoder,
167 target: &wgpu::TextureView,
168 viewport: (u32, u32),
169 rect: (f32, f32, f32, f32),
170 t: f32,
171 ) {
172 // Mundo infinito: la ventana voxel sigue al reparto. Sólo se regenera
173 // al cruzar un paso, así que por cuadro esto es casi gratis.
174 let origen = window_origin_for_cast(&self.guiones, t, self.dim);
175 self.preview
176 .ensure_window(device, queue, &self.mundo, 1, origen);
177
178 // La luz de quien filma, DESPUÉS de la ventana (que al regenerarse
179 // repone la atmósfera del estudio) y ANTES del fundido de apertura,
180 // que si existe tiene que ganar.
181 if let Some(a) = self.ambiente {
182 self.preview.set_lighting(
183 a.sol,
184 llimphi_3d::Atmosphere {
185 sky_zenith: a.cenit,
186 sky_horizon: a.horizonte,
187 fog_density: a.bruma / self.dim[0] as f32,
188 god_rays: a.haces,
189 },
190 );
191 }
192
193 // La atmósfera de apertura (bruma + fundido desde negro), si la escena
194 // la declara. Va antes de componer: sólo toca uniforms.
195 if let Some(atm) = self.escena.atmosfera.as_ref() {
196 self.preview.set_atmosfera_escena(atm, t);
197 }
198
199 let half = Vec3::new(self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32) * 0.5;
200
201 // Lo que la escena dicta que se desmorona: los sucesos que caen entre el
202 // cuadro anterior y éste desprenden materia, y lo que ya volaba avanza.
203 // Va antes de encuadrar porque cambia el mundo que la cámara va a ver.
204 let t0 = self.t_previo.unwrap_or(t - 1e-4);
205 let sucesos = self.escena.sucesos.clone();
206 self.preview.desmoronar(queue, &sucesos, t0, t, &mut self.ceniza);
207 if !self.ceniza.is_empty() {
208 self.ceniza.paso((t - t0).max(0.0));
209 }
210 self.t_previo = Some(t);
211
212 let mut poses = Vec::with_capacity(self.guiones.len());
213 let mut centroide = Vec3::ZERO;
214 for (guion, quien) in self.guiones.iter().zip(&self.reparto) {
215 // Tiempo cuantizado a la tasa propia del actor: el Héroe a 12 fps
216 // se mueve y posa en doses; los demás, fluidos. Es un sello de
217 // animación, no una optimización.
218 let at = guion.quantize(t);
219 let s = guion.sample(at);
220 let pos = self.preview.ground_at_world(s.gx as i32, s.gz as i32) - half;
221 centroide += pos;
222 poses.push((pos, s, quien, at));
223 }
224 let mirar = if poses.is_empty() {
225 Vec3::new(0.0, self.dim[1] as f32 * (0.32 - 0.5), 0.0)
226 } else {
227 centroide / poses.len() as f32 + Vec3::new(0.0, 1.0, 0.0)
228 };
229 let distancia = 6.0 + poses.len() as f32 * 1.2;
230 let camara = self.escena.camera_at(mirar, distancia, t);
231
232 let mut mallas = Vec::with_capacity(poses.len());
233 let mut en_cuadro: Vec<EnCuadro> = Vec::new();
234 for (pos, s, quien, at) in &poses {
235 if quien.cuerpo.is_none() {
236 // Humanoide → ser SDF (toon + cara por decals), en coords de
237 // grilla: el ray-marcher los traza.
238 en_cuadro.push(EnCuadro {
239 spec: quien,
240 pos: *pos + half,
241 yaw: s.facing,
242 clip: s.clip,
243 fase: *at,
244 });
245 } else {
246 // Rig → malla de su andar.
247 mallas.push(quien.to_meta(*pos, s.facing, s.clip, *at, Some(camara.eye)));
248 }
249 }
250 // Por el REPARTO y no crudo: el uniform tiene lugar para unas pocas
251 // primitivas, así que los de cerca se llevan la cara entera y los del
252 // fondo la pierden. Pasarle la lista cruda haría que los que sobran se
253 // caigan del cuadro sin aviso. Y al que ni degradado entra, el reparto
254 // MIXTO lo baja a malla de cajas en vez de borrarlo del plano.
255 let rep = reparto_mixto(&en_cuadro, camara.eye + half, true, half);
256 mallas.extend(rep.mallas);
257 let seres = rep.sers;
258 self.preview.set_motas(&self.ceniza, camara.eye + half);
259
260 self.preview.render_scene(
261 device, queue, encoder, target, viewport, rect, &camara, &mallas, seres,
262 );
263 }
264
265 /// La cámara de la escena en `t` — para que el caller pueda encuadrar algo
266 /// más (un cartel anclado al mundo, por ejemplo) sin re-derivarla.
267 pub fn camara_en(&self, t: f32) -> Camera3d {
268 let half = Vec3::new(self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32) * 0.5;
269 let mut centroide = Vec3::ZERO;
270 let mut n = 0.0f32;
271 for guion in &self.guiones {
272 let s = guion.sample(guion.quantize(t));
273 centroide += self.preview.ground_at_world(s.gx as i32, s.gz as i32) - half;
274 n += 1.0;
275 }
276 let mirar = if n == 0.0 {
277 Vec3::new(0.0, self.dim[1] as f32 * (0.32 - 0.5), 0.0)
278 } else {
279 centroide / n + Vec3::new(0.0, 1.0, 0.0)
280 };
281 self.escena.camera_at(mirar, 6.0 + n * 1.2, t)
282 }
283}