tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! **Motas**: materia que se soltó del mundo y anda por el aire.
2//!
3//! Es la pieza que faltaba para dos cosas distintas que piden lo mismo: el polvo
4//! que levanta un golpe (y se vuelve a posar) y la **gravedad líquida** del guion
5//! —el entorno desprendiéndose hacia arriba como ceniza cuando el héroe está en
6//! crisis—. Las dos son *desplazamiento dinámico de voxels*: sacar celdas sólidas
7//! del mundo, moverlas por su cuenta un rato, y devolverlas o dejarlas ir.
8//!
9//! No hay motor nuevo: cada mota se dibuja como una **caja analítica**
10//! ([`Entity3d`](llimphi_3d::Entity3d)), que el ray-march ya interseca en el mismo
11//! pase que los voxels (con oclusión y sombra). El tope del uniform es 64, así que
12//! [`Enjambre::entidades`] recorta — y ordena por cercanía al ojo cuando se le dice
13//! desde dónde se mira, para que lo que se caiga del cupo sea lo que no se ve.
14//!
15//! Coordenadas: **de grilla** (`0..dim`, sub-voxel), las mismas de `Entity3d` y de
16//! la fauna. Todo es determinista por semilla: mismo desprendimiento, misma deriva.
17
18use llimphi_3d::glam::Vec3;
19use llimphi_3d::{Entity3d, VoxelGrid};
20
21/// Una mota en vuelo: dónde está, hacia dónde va, de qué color era el voxel del
22/// que salió y cuánto le queda de vida.
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct Mota {
25 /// Posición en coordenadas de grilla (sub-voxel).
26 pub pos: Vec3,
27 /// Velocidad en voxels por segundo.
28 pub vel: Vec3,
29 /// Color heredado del voxel de origen.
30 pub color: [u8; 3],
31 /// Segundos que le quedan antes de disiparse.
32 pub vida: f32,
33 /// Medio tamaño de su caja (`0.5` = un voxel entero).
34 pub tam: f32,
35}
36
37/// Enjambre de motas con su propia gravedad y su propio roce.
38///
39/// La **gravedad se declara**, no se asume: negativa en `y` es polvo que cae,
40/// positiva es ceniza que sube. Eso es todo lo que separa un efecto del otro.
41#[derive(Clone, Debug)]
42pub struct Enjambre {
43 /// Las motas vivas.
44 pub motas: Vec<Mota>,
45 /// Aceleración constante (voxels/s²). `-9.8` en `y` = caída normal;
46 /// positiva = la gravedad al revés del guion.
47 pub gravedad: Vec3,
48 /// Roce del aire: fracción de velocidad que se pierde por segundo (`0` = vacío,
49 /// `2.0` = frena rápido, como ceniza flotando).
50 pub arrastre: f32,
51 /// Empuje inicial que reciben las motas al desprenderse (voxels/s), radial
52 /// desde el centro del golpe más una pizca de azar.
53 pub impulso: f32,
54 /// Vida base de una mota en segundos (el azar le suma hasta un 60% más).
55 pub vida: f32,
56 /// Si una mota que muere **devuelve su materia** al mundo (polvo que se posa)
57 /// o se disipa sin dejar rastro (ceniza).
58 pub posar: bool,
59 /// Semilla del azar determinista.
60 semilla: u32,
61 /// Contador de tiradas (parte de la semilla de cada mota).
62 tirada: u32,
63}
64
65impl Enjambre {
66 /// Enjambre vacío con gravedad y roce a elección.
67 pub fn nuevo(gravedad: Vec3, arrastre: f32, semilla: u32) -> Self {
68 Self {
69 motas: Vec::new(),
70 gravedad,
71 arrastre,
72 impulso: 2.4,
73 vida: 2.2,
74 posar: false,
75 semilla,
76 tirada: 0,
77 }
78 }
79
80 /// **Polvo**: cae, se frena poco y **devuelve la materia** al posarse. Lo que
81 /// levanta un pisotón o un derrumbe.
82 pub fn polvo(semilla: u32) -> Self {
83 let mut e = Self::nuevo(Vec3::new(0.0, -9.8, 0.0), 0.6, semilla);
84 e.posar = true;
85 e
86 }
87
88 /// **Ceniza que sube** (la "gravedad líquida"): el entorno se desprende hacia
89 /// arriba, flota con mucho roce y se disipa sin volver.
90 pub fn ceniza(semilla: u32) -> Self {
91 let mut e = Self::nuevo(Vec3::new(0.0, 1.6, 0.0), 1.4, semilla);
92 e.impulso = 1.2;
93 e.vida = 3.4;
94 e
95 }
96
97 /// Cuántas motas hay en el aire.
98 pub fn len(&self) -> usize {
99 self.motas.len()
100 }
101
102 /// `true` si no queda ninguna.
103 pub fn is_empty(&self) -> bool {
104 self.motas.is_empty()
105 }
106
107 /// Apaga el efecto (las motas en vuelo desaparecen sin posarse).
108 pub fn limpiar(&mut self) {
109 self.motas.clear();
110 }
111
112 /// Suelta una mota a mano (para efectos guionados, no arrancados del mundo).
113 pub fn soltar(&mut self, pos: Vec3, vel: Vec3, color: [u8; 3]) {
114 self.motas.push(Mota { pos, vel, color, vida: self.vida, tam: 0.5 });
115 }
116
117 /// **Desprende materia del mundo**: saca hasta `cuantos` voxels sólidos de la
118 /// esfera `(centro, radio)` —los **borra del grid**— y los pone a volar con un
119 /// empuje radial. Devuelve cuántos se soltaron.
120 ///
121 /// El muestreo es a paso fijo sobre la lista de candidatos (ordenada por celda),
122 /// así que el hueco queda repartido por toda la esfera y no en una esquina; y es
123 /// determinista: misma semilla y mismo mundo, mismo desprendimiento.
124 ///
125 /// Ojo: esto **cambia el grid**, así que el llamador tiene que sincronizarlo con
126 /// la GPU (`VoxelRenderer::sync`) como con cualquier otra edición.
127 pub fn desprender(
128 &mut self,
129 grid: &mut VoxelGrid,
130 centro: Vec3,
131 radio: f32,
132 cuantos: usize,
133 ) -> usize {
134 if cuantos == 0 || radio <= 0.0 {
135 return 0;
136 }
137 let dim = grid.dim();
138 let lo = |v: f32| (v - radio).floor().max(0.0) as u32;
139 let hi = |v: f32, d: u32| ((v + radio).ceil() as i64).clamp(0, d as i64) as u32;
140 let mut candidatos: Vec<[u32; 3]> = Vec::new();
141 for x in lo(centro.x)..hi(centro.x, dim[0]) {
142 for y in lo(centro.y)..hi(centro.y, dim[1]) {
143 for z in lo(centro.z)..hi(centro.z, dim[2]) {
144 let c = Vec3::new(x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5);
145 if (c - centro).length() <= radio && grid.is_solid(x as i32, y as i32, z as i32)
146 {
147 candidatos.push([x, y, z]);
148 }
149 }
150 }
151 }
152 if candidatos.is_empty() {
153 return 0;
154 }
155 let paso = (candidatos.len() as f32 / cuantos as f32).max(1.0);
156 let mut sueltas = 0usize;
157 let mut k = 0usize;
158 while sueltas < cuantos {
159 let idx = (k as f32 * paso) as usize;
160 if idx >= candidatos.len() {
161 break;
162 }
163 let [x, y, z] = candidatos[idx];
164 k += 1;
165 let Some(c) = grid.get(x, y, z) else { continue };
166 if c[3] == 0 {
167 continue;
168 }
169 grid.clear(x, y, z);
170 let pos = Vec3::new(x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5);
171 let semilla = self.semilla ^ self.tirada.wrapping_mul(0x9E37_79B9) ^ (sueltas as u32);
172 let radial = (pos - centro).normalize_or_zero();
173 let jitter = Vec3::new(
174 azar(semilla, 1) - 0.5,
175 azar(semilla, 2) - 0.5,
176 azar(semilla, 3) - 0.5,
177 );
178 self.motas.push(Mota {
179 pos,
180 vel: (radial + jitter * 0.8) * self.impulso,
181 color: [c[0], c[1], c[2]],
182 vida: self.vida * (1.0 + 0.6 * azar(semilla, 4)),
183 tam: 0.5,
184 });
185 sueltas += 1;
186 }
187 self.tirada = self.tirada.wrapping_add(1);
188 sueltas
189 }
190
191 /// Avanza `dt` segundos **sin mundo**: integra gravedad y roce, y descarta las
192 /// que se quedaron sin vida. Devuelve cuántas murieron.
193 pub fn paso(&mut self, dt: f32) -> usize {
194 let antes = self.motas.len();
195 self.integrar(dt);
196 self.motas.retain(|m| m.vida > 0.0);
197 antes - self.motas.len()
198 }
199
200 /// Avanza `dt` segundos **contra el mundo**: además de la vida, una mota muere
201 /// al meterse en una celda sólida o al salirse del mapa. Si [`posar`](Self::posar)
202 /// está activo, la que muere **devuelve su color** a la última celda libre que
203 /// ocupó — la materia vuelve al mundo en vez de evaporarse.
204 ///
205 /// Devuelve cuántas murieron. El grid queda sucio: sincronizar con la GPU.
206 pub fn paso_en(&mut self, dt: f32, grid: &mut VoxelGrid) -> usize {
207 let dim = grid.dim();
208 let solida = |g: &VoxelGrid, p: Vec3| -> bool {
209 let (x, y, z) = (p.x.floor(), p.y.floor(), p.z.floor());
210 if x < 0.0 || y < 0.0 || z < 0.0 {
211 return true; // el borde de abajo/costado cuenta como suelo
212 }
213 let (x, y, z) = (x as u32, y as u32, z as u32);
214 if x >= dim[0] || y >= dim[1] || z >= dim[2] {
215 return false; // arriba y afuera: sigue volando (y se irá por vida)
216 }
217 g.is_solid(x as i32, y as i32, z as i32)
218 };
219
220 let antes: Vec<(Vec3, [u8; 3])> = self.motas.iter().map(|m| (m.pos, m.color)).collect();
221 self.integrar(dt);
222 let mut muertas: Vec<(Vec3, [u8; 3])> = Vec::new();
223 let mut viva: Vec<Mota> = Vec::with_capacity(self.motas.len());
224 for (m, (prev, color)) in self.motas.iter().zip(&antes) {
225 let choco = solida(grid, m.pos);
226 if m.vida > 0.0 && !choco {
227 viva.push(*m);
228 } else {
229 // La celda donde posarse es la ÚLTIMA libre, no la que la frenó.
230 muertas.push((if choco { *prev } else { m.pos }, *color));
231 }
232 }
233 self.motas = viva;
234 if self.posar {
235 for (p, color) in &muertas {
236 let (x, y, z) = (p.x.floor(), p.y.floor(), p.z.floor());
237 if x < 0.0 || y < 0.0 || z < 0.0 {
238 continue;
239 }
240 let (x, y, z) = (x as u32, y as u32, z as u32);
241 if x < dim[0] && y < dim[1] && z < dim[2] && !grid.is_solid(x as i32, y as i32, z as i32)
242 {
243 grid.set(x, y, z, *color);
244 }
245 }
246 }
247 muertas.len()
248 }
249
250 /// Las motas como cajas para el ray-march, recortadas a `tope` (el uniform del
251 /// motor admite 64). Con `ojo` (coords de grilla) se ordenan por cercanía, así
252 /// lo que no entra en el cupo es lo que está más lejos de la cámara.
253 pub fn entidades(&self, tope: usize, ojo: Option<Vec3>) -> Vec<Entity3d> {
254 let mut idx: Vec<usize> = (0..self.motas.len()).collect();
255 if let Some(o) = ojo {
256 idx.sort_by(|&a, &b| {
257 let da = (self.motas[a].pos - o).length_squared();
258 let db = (self.motas[b].pos - o).length_squared();
259 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
260 });
261 }
262 idx.truncate(tope);
263 idx.iter()
264 .map(|&i| {
265 let m = &self.motas[i];
266 // Se achica al final de su vida: desaparecer de golpe se nota.
267 let f = (m.vida / self.vida).clamp(0.15, 1.0);
268 let h = m.tam * f;
269 Entity3d { pos: [m.pos.x, m.pos.y, m.pos.z], half: [h, h, h], color: m.color }
270 })
271 .collect()
272 }
273
274 /// Integración común: gravedad, roce y desgaste de vida.
275 fn integrar(&mut self, dt: f32) {
276 let freno = (1.0 - self.arrastre * dt).clamp(0.0, 1.0);
277 for m in &mut self.motas {
278 m.vel += self.gravedad * dt;
279 m.vel *= freno;
280 m.pos += m.vel * dt;
281 m.vida -= dt;
282 }
283 }
284}
285
286/// **Dispara los sucesos de desmoronamiento** que caen en el intervalo `(t0, t1]`:
287/// cada uno desprende materia del mundo hacia el `enjambre`. Devuelve cuántas motas
288/// nuevas salieron.
289///
290/// El intervalo es semiabierto a propósito: quien filma llama una vez por cuadro con
291/// `(t_anterior, t)`, y así un suceso se dispara **exactamente una vez** aunque el
292/// cuadro dure más que otro. Un salto hacia atrás en el tiempo (rebobinar el preview)
293/// no dispara nada — el mundo ya está desmoronado.
294///
295/// El grid queda sucio: sincronizar con la GPU.
296pub fn disparar_sucesos(
297 sucesos: &[crate::Suceso],
298 t0: f32,
299 t1: f32,
300 grid: &mut VoxelGrid,
301 enjambre: &mut Enjambre,
302) -> usize {
303 let mut n = 0;
304 for s in sucesos {
305 let crate::TipoSuceso::Desmorona { radio, motas } = s.tipo else { continue };
306 if s.t > t0 && s.t <= t1 {
307 let centro = Vec3::new(s.pos[0], s.pos[1], s.pos[2]);
308 n += enjambre.desprender(grid, centro, radio, motas as usize);
309 }
310 }
311 n
312}
313
314/// Azar determinista `0..1` a partir de una semilla y una sal (para no repetir el
315/// mismo número en los tres ejes).
316fn azar(semilla: u32, sal: u32) -> f32 {
317 let mut h = semilla ^ sal.wrapping_mul(0x85EB_CA6B);
318 h ^= h >> 16;
319 h = h.wrapping_mul(0x7FEB_352D);
320 h ^= h >> 15;
321 h = h.wrapping_mul(0x846C_A68B);
322 h ^= h >> 16;
323 (h % 10_000) as f32 / 10_000.0
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 /// Un bloque macizo de 8×4×8 en el piso de un mundo 16³.
331 fn mundo() -> VoxelGrid {
332 let mut g = VoxelGrid::new([16, 16, 16]);
333 for x in 4..12 {
334 for y in 0..4 {
335 for z in 4..12 {
336 g.set(x, y, z, [120, 100, 80]);
337 }
338 }
339 }
340 g
341 }
342
343 fn solidos(g: &VoxelGrid) -> usize {
344 let d = g.dim();
345 let mut n = 0;
346 for x in 0..d[0] {
347 for y in 0..d[1] {
348 for z in 0..d[2] {
349 if g.is_solid(x as i32, y as i32, z as i32) {
350 n += 1;
351 }
352 }
353 }
354 }
355 n
356 }
357
358 #[test]
359 fn desprender_saca_materia_del_mundo_y_la_pone_a_volar() {
360 let mut g = mundo();
361 let antes = solidos(&g);
362 let mut e = Enjambre::ceniza(7);
363 let sueltas = e.desprender(&mut g, Vec3::new(8.0, 2.0, 8.0), 3.0, 20);
364 assert_eq!(sueltas, 20, "se soltaron las que se pidieron");
365 assert_eq!(e.len(), 20);
366 assert_eq!(solidos(&g), antes - 20, "el mundo perdió exactamente esas celdas");
367 assert!(e.motas.iter().all(|m| m.color == [120, 100, 80]), "heredan el color del voxel");
368 }
369
370 #[test]
371 fn no_se_puede_desprender_del_aire() {
372 let mut g = VoxelGrid::new([8, 8, 8]);
373 let mut e = Enjambre::polvo(1);
374 assert_eq!(e.desprender(&mut g, Vec3::new(4.0, 4.0, 4.0), 3.0, 10), 0);
375 assert!(e.is_empty());
376 }
377
378 #[test]
379 fn la_ceniza_sube_y_el_polvo_cae() {
380 let alto = |mut e: Enjambre| {
381 e.soltar(Vec3::new(8.0, 8.0, 8.0), Vec3::ZERO, [200, 200, 200]);
382 for _ in 0..20 {
383 e.paso(1.0 / 30.0);
384 }
385 e.motas.first().map(|m| m.pos.y).unwrap_or(f32::NAN)
386 };
387 let ceniza = alto(Enjambre::ceniza(3));
388 let polvo = alto(Enjambre::polvo(3));
389 assert!(ceniza > 8.0, "la ceniza sube: {ceniza}");
390 assert!(polvo < 8.0, "el polvo cae: {polvo}");
391 }
392
393 #[test]
394 fn misma_semilla_mismo_desprendimiento() {
395 let tirada = |s: u32| {
396 let mut g = mundo();
397 let mut e = Enjambre::ceniza(s);
398 e.desprender(&mut g, Vec3::new(8.0, 2.0, 8.0), 3.0, 12);
399 for _ in 0..10 {
400 e.paso(0.05);
401 }
402 e.motas.clone()
403 };
404 assert_eq!(tirada(11), tirada(11), "determinista por semilla");
405 assert_ne!(tirada(11), tirada(12), "otra semilla, otra dispersión");
406 }
407
408 #[test]
409 fn el_polvo_devuelve_la_materia_al_posarse() {
410 // Cuatro motas en columnas distintas sobre un piso: al caer, cada una deja
411 // su voxel. La materia se conserva — eso es lo que separa el polvo de la
412 // ceniza (la ceniza se disipa a propósito).
413 let mut g = VoxelGrid::new([16, 16, 16]);
414 for x in 0..16 {
415 for z in 0..16 {
416 g.set(x, 0, z, [80, 80, 80]);
417 }
418 }
419 let antes = solidos(&g);
420 let mut e = Enjambre::polvo(5);
421 for x in [3.5_f32, 6.5, 9.5, 12.5] {
422 e.soltar(Vec3::new(x, 6.0, 8.5), Vec3::ZERO, [200, 40, 40]);
423 }
424 for _ in 0..90 {
425 e.paso_en(1.0 / 30.0, &mut g);
426 }
427 assert!(e.is_empty(), "todas se posaron o se gastaron");
428 assert_eq!(solidos(&g), antes + 4, "las cuatro devolvieron su voxel");
429 assert!(g.is_solid(3, 1, 8), "posada justo encima del piso");
430 }
431
432 #[test]
433 fn la_ceniza_no_deja_rastro() {
434 let mut g = VoxelGrid::new([8, 8, 8]);
435 for x in 0..8 {
436 for z in 0..8 {
437 g.set(x, 0, z, [80, 80, 80]);
438 }
439 }
440 let antes = solidos(&g);
441 let mut e = Enjambre::ceniza(2);
442 e.soltar(Vec3::new(4.5, 4.0, 4.5), Vec3::new(0.0, -3.0, 0.0), [200, 40, 40]);
443 for _ in 0..120 {
444 e.paso_en(1.0 / 30.0, &mut g);
445 }
446 assert!(e.is_empty());
447 assert_eq!(solidos(&g), antes, "la ceniza se disipa sin ensuciar el mundo");
448 }
449
450 #[test]
451 fn un_suceso_de_desmoronamiento_se_dispara_una_sola_vez() {
452 use crate::{Suceso, TipoSuceso};
453 let mut g = mundo();
454 let antes = solidos(&g);
455 let sucesos = vec![
456 Suceso {
457 t: 1.5,
458 pos: [8.0, 2.0, 8.0],
459 tipo: TipoSuceso::Desmorona { radio: 3.0, motas: 10 },
460 nota: "se cae el techo".into(),
461 },
462 // Una marca cualquiera en el mismo instante no debe hacer nada.
463 Suceso { t: 1.5, pos: [8.0, 2.0, 8.0], tipo: TipoSuceso::Marca, nota: String::new() },
464 ];
465 let mut e = Enjambre::ceniza(4);
466 // Cuadro anterior al suceso: nada pasa.
467 assert_eq!(disparar_sucesos(&sucesos, 1.0, 1.4, &mut g, &mut e), 0);
468 assert_eq!(solidos(&g), antes);
469 // El cuadro que cruza su instante lo dispara.
470 assert_eq!(disparar_sucesos(&sucesos, 1.4, 1.6, &mut g, &mut e), 10);
471 assert_eq!(solidos(&g), antes - 10);
472 // Los cuadros siguientes NO lo repiten.
473 assert_eq!(disparar_sucesos(&sucesos, 1.6, 2.0, &mut g, &mut e), 0);
474 assert_eq!(e.len(), 10);
475 }
476
477 #[test]
478 fn las_cajas_respetan_el_tope_y_priorizan_lo_cercano() {
479 let mut e = Enjambre::ceniza(9);
480 for k in 0..200 {
481 e.soltar(Vec3::new(k as f32, 5.0, 5.0), Vec3::ZERO, [10, 20, 30]);
482 }
483 let cajas = e.entidades(64, Some(Vec3::new(0.0, 5.0, 5.0)));
484 assert_eq!(cajas.len(), 64, "no se pasa del cupo del uniform");
485 assert!(cajas[0].pos[0] < cajas[63].pos[0], "primero lo que está cerca del ojo");
486 assert!(cajas.iter().all(|c| c.pos[0] < 64.0), "las 64 más cercanas");
487 }
488}