//! **Motas**: materia que se soltó del mundo y anda por el aire. //! //! Es la pieza que faltaba para dos cosas distintas que piden lo mismo: el polvo //! que levanta un golpe (y se vuelve a posar) y la **gravedad líquida** del guion //! —el entorno desprendiéndose hacia arriba como ceniza cuando el héroe está en //! crisis—. Las dos son *desplazamiento dinámico de voxels*: sacar celdas sólidas //! del mundo, moverlas por su cuenta un rato, y devolverlas o dejarlas ir. //! //! No hay motor nuevo: cada mota se dibuja como una **caja analítica** //! ([`Entity3d`](llimphi_3d::Entity3d)), que el ray-march ya interseca en el mismo //! pase que los voxels (con oclusión y sombra). El tope del uniform es 64, así que //! [`Enjambre::entidades`] recorta — y ordena por cercanía al ojo cuando se le dice //! desde dónde se mira, para que lo que se caiga del cupo sea lo que no se ve. //! //! Coordenadas: **de grilla** (`0..dim`, sub-voxel), las mismas de `Entity3d` y de //! la fauna. Todo es determinista por semilla: mismo desprendimiento, misma deriva. use llimphi_3d::glam::Vec3; use llimphi_3d::{Entity3d, VoxelGrid}; /// Una mota en vuelo: dónde está, hacia dónde va, de qué color era el voxel del /// que salió y cuánto le queda de vida. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Mota { /// Posición en coordenadas de grilla (sub-voxel). pub pos: Vec3, /// Velocidad en voxels por segundo. pub vel: Vec3, /// Color heredado del voxel de origen. pub color: [u8; 3], /// Segundos que le quedan antes de disiparse. pub vida: f32, /// Medio tamaño de su caja (`0.5` = un voxel entero). pub tam: f32, } /// Enjambre de motas con su propia gravedad y su propio roce. /// /// La **gravedad se declara**, no se asume: negativa en `y` es polvo que cae, /// positiva es ceniza que sube. Eso es todo lo que separa un efecto del otro. #[derive(Clone, Debug)] pub struct Enjambre { /// Las motas vivas. pub motas: Vec, /// Aceleración constante (voxels/s²). `-9.8` en `y` = caída normal; /// positiva = la gravedad al revés del guion. pub gravedad: Vec3, /// Roce del aire: fracción de velocidad que se pierde por segundo (`0` = vacío, /// `2.0` = frena rápido, como ceniza flotando). pub arrastre: f32, /// Empuje inicial que reciben las motas al desprenderse (voxels/s), radial /// desde el centro del golpe más una pizca de azar. pub impulso: f32, /// Vida base de una mota en segundos (el azar le suma hasta un 60% más). pub vida: f32, /// Si una mota que muere **devuelve su materia** al mundo (polvo que se posa) /// o se disipa sin dejar rastro (ceniza). pub posar: bool, /// Semilla del azar determinista. semilla: u32, /// Contador de tiradas (parte de la semilla de cada mota). tirada: u32, } impl Enjambre { /// Enjambre vacío con gravedad y roce a elección. pub fn nuevo(gravedad: Vec3, arrastre: f32, semilla: u32) -> Self { Self { motas: Vec::new(), gravedad, arrastre, impulso: 2.4, vida: 2.2, posar: false, semilla, tirada: 0, } } /// **Polvo**: cae, se frena poco y **devuelve la materia** al posarse. Lo que /// levanta un pisotón o un derrumbe. pub fn polvo(semilla: u32) -> Self { let mut e = Self::nuevo(Vec3::new(0.0, -9.8, 0.0), 0.6, semilla); e.posar = true; e } /// **Ceniza que sube** (la "gravedad líquida"): el entorno se desprende hacia /// arriba, flota con mucho roce y se disipa sin volver. pub fn ceniza(semilla: u32) -> Self { let mut e = Self::nuevo(Vec3::new(0.0, 1.6, 0.0), 1.4, semilla); e.impulso = 1.2; e.vida = 3.4; e } /// Cuántas motas hay en el aire. pub fn len(&self) -> usize { self.motas.len() } /// `true` si no queda ninguna. pub fn is_empty(&self) -> bool { self.motas.is_empty() } /// Apaga el efecto (las motas en vuelo desaparecen sin posarse). pub fn limpiar(&mut self) { self.motas.clear(); } /// Suelta una mota a mano (para efectos guionados, no arrancados del mundo). pub fn soltar(&mut self, pos: Vec3, vel: Vec3, color: [u8; 3]) { self.motas.push(Mota { pos, vel, color, vida: self.vida, tam: 0.5 }); } /// **Desprende materia del mundo**: saca hasta `cuantos` voxels sólidos de la /// esfera `(centro, radio)` —los **borra del grid**— y los pone a volar con un /// empuje radial. Devuelve cuántos se soltaron. /// /// El muestreo es a paso fijo sobre la lista de candidatos (ordenada por celda), /// así que el hueco queda repartido por toda la esfera y no en una esquina; y es /// determinista: misma semilla y mismo mundo, mismo desprendimiento. /// /// Ojo: esto **cambia el grid**, así que el llamador tiene que sincronizarlo con /// la GPU (`VoxelRenderer::sync`) como con cualquier otra edición. pub fn desprender( &mut self, grid: &mut VoxelGrid, centro: Vec3, radio: f32, cuantos: usize, ) -> usize { if cuantos == 0 || radio <= 0.0 { return 0; } let dim = grid.dim(); let lo = |v: f32| (v - radio).floor().max(0.0) as u32; let hi = |v: f32, d: u32| ((v + radio).ceil() as i64).clamp(0, d as i64) as u32; let mut candidatos: Vec<[u32; 3]> = Vec::new(); for x in lo(centro.x)..hi(centro.x, dim[0]) { for y in lo(centro.y)..hi(centro.y, dim[1]) { for z in lo(centro.z)..hi(centro.z, dim[2]) { let c = Vec3::new(x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5); if (c - centro).length() <= radio && grid.is_solid(x as i32, y as i32, z as i32) { candidatos.push([x, y, z]); } } } } if candidatos.is_empty() { return 0; } let paso = (candidatos.len() as f32 / cuantos as f32).max(1.0); let mut sueltas = 0usize; let mut k = 0usize; while sueltas < cuantos { let idx = (k as f32 * paso) as usize; if idx >= candidatos.len() { break; } let [x, y, z] = candidatos[idx]; k += 1; let Some(c) = grid.get(x, y, z) else { continue }; if c[3] == 0 { continue; } grid.clear(x, y, z); let pos = Vec3::new(x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5); let semilla = self.semilla ^ self.tirada.wrapping_mul(0x9E37_79B9) ^ (sueltas as u32); let radial = (pos - centro).normalize_or_zero(); let jitter = Vec3::new( azar(semilla, 1) - 0.5, azar(semilla, 2) - 0.5, azar(semilla, 3) - 0.5, ); self.motas.push(Mota { pos, vel: (radial + jitter * 0.8) * self.impulso, color: [c[0], c[1], c[2]], vida: self.vida * (1.0 + 0.6 * azar(semilla, 4)), tam: 0.5, }); sueltas += 1; } self.tirada = self.tirada.wrapping_add(1); sueltas } /// Avanza `dt` segundos **sin mundo**: integra gravedad y roce, y descarta las /// que se quedaron sin vida. Devuelve cuántas murieron. pub fn paso(&mut self, dt: f32) -> usize { let antes = self.motas.len(); self.integrar(dt); self.motas.retain(|m| m.vida > 0.0); antes - self.motas.len() } /// Avanza `dt` segundos **contra el mundo**: además de la vida, una mota muere /// al meterse en una celda sólida o al salirse del mapa. Si [`posar`](Self::posar) /// está activo, la que muere **devuelve su color** a la última celda libre que /// ocupó — la materia vuelve al mundo en vez de evaporarse. /// /// Devuelve cuántas murieron. El grid queda sucio: sincronizar con la GPU. pub fn paso_en(&mut self, dt: f32, grid: &mut VoxelGrid) -> usize { let dim = grid.dim(); let solida = |g: &VoxelGrid, p: Vec3| -> bool { let (x, y, z) = (p.x.floor(), p.y.floor(), p.z.floor()); if x < 0.0 || y < 0.0 || z < 0.0 { return true; // el borde de abajo/costado cuenta como suelo } let (x, y, z) = (x as u32, y as u32, z as u32); if x >= dim[0] || y >= dim[1] || z >= dim[2] { return false; // arriba y afuera: sigue volando (y se irá por vida) } g.is_solid(x as i32, y as i32, z as i32) }; let antes: Vec<(Vec3, [u8; 3])> = self.motas.iter().map(|m| (m.pos, m.color)).collect(); self.integrar(dt); let mut muertas: Vec<(Vec3, [u8; 3])> = Vec::new(); let mut viva: Vec = Vec::with_capacity(self.motas.len()); for (m, (prev, color)) in self.motas.iter().zip(&antes) { let choco = solida(grid, m.pos); if m.vida > 0.0 && !choco { viva.push(*m); } else { // La celda donde posarse es la ÚLTIMA libre, no la que la frenó. muertas.push((if choco { *prev } else { m.pos }, *color)); } } self.motas = viva; if self.posar { for (p, color) in &muertas { let (x, y, z) = (p.x.floor(), p.y.floor(), p.z.floor()); if x < 0.0 || y < 0.0 || z < 0.0 { continue; } let (x, y, z) = (x as u32, y as u32, z as u32); if x < dim[0] && y < dim[1] && z < dim[2] && !grid.is_solid(x as i32, y as i32, z as i32) { grid.set(x, y, z, *color); } } } muertas.len() } /// Las motas como cajas para el ray-march, recortadas a `tope` (el uniform del /// motor admite 64). Con `ojo` (coords de grilla) se ordenan por cercanía, así /// lo que no entra en el cupo es lo que está más lejos de la cámara. pub fn entidades(&self, tope: usize, ojo: Option) -> Vec { let mut idx: Vec = (0..self.motas.len()).collect(); if let Some(o) = ojo { idx.sort_by(|&a, &b| { let da = (self.motas[a].pos - o).length_squared(); let db = (self.motas[b].pos - o).length_squared(); da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) }); } idx.truncate(tope); idx.iter() .map(|&i| { let m = &self.motas[i]; // Se achica al final de su vida: desaparecer de golpe se nota. let f = (m.vida / self.vida).clamp(0.15, 1.0); let h = m.tam * f; Entity3d { pos: [m.pos.x, m.pos.y, m.pos.z], half: [h, h, h], color: m.color } }) .collect() } /// Integración común: gravedad, roce y desgaste de vida. fn integrar(&mut self, dt: f32) { let freno = (1.0 - self.arrastre * dt).clamp(0.0, 1.0); for m in &mut self.motas { m.vel += self.gravedad * dt; m.vel *= freno; m.pos += m.vel * dt; m.vida -= dt; } } } /// **Dispara los sucesos de desmoronamiento** que caen en el intervalo `(t0, t1]`: /// cada uno desprende materia del mundo hacia el `enjambre`. Devuelve cuántas motas /// nuevas salieron. /// /// El intervalo es semiabierto a propósito: quien filma llama una vez por cuadro con /// `(t_anterior, t)`, y así un suceso se dispara **exactamente una vez** aunque el /// cuadro dure más que otro. Un salto hacia atrás en el tiempo (rebobinar el preview) /// no dispara nada — el mundo ya está desmoronado. /// /// El grid queda sucio: sincronizar con la GPU. pub fn disparar_sucesos( sucesos: &[crate::Suceso], t0: f32, t1: f32, grid: &mut VoxelGrid, enjambre: &mut Enjambre, ) -> usize { let mut n = 0; for s in sucesos { let crate::TipoSuceso::Desmorona { radio, motas } = s.tipo else { continue }; if s.t > t0 && s.t <= t1 { let centro = Vec3::new(s.pos[0], s.pos[1], s.pos[2]); n += enjambre.desprender(grid, centro, radio, motas as usize); } } n } /// Azar determinista `0..1` a partir de una semilla y una sal (para no repetir el /// mismo número en los tres ejes). fn azar(semilla: u32, sal: u32) -> f32 { let mut h = semilla ^ sal.wrapping_mul(0x85EB_CA6B); h ^= h >> 16; h = h.wrapping_mul(0x7FEB_352D); h ^= h >> 15; h = h.wrapping_mul(0x846C_A68B); h ^= h >> 16; (h % 10_000) as f32 / 10_000.0 } #[cfg(test)] mod tests { use super::*; /// Un bloque macizo de 8×4×8 en el piso de un mundo 16³. fn mundo() -> VoxelGrid { let mut g = VoxelGrid::new([16, 16, 16]); for x in 4..12 { for y in 0..4 { for z in 4..12 { g.set(x, y, z, [120, 100, 80]); } } } g } fn solidos(g: &VoxelGrid) -> usize { let d = g.dim(); let mut n = 0; for x in 0..d[0] { for y in 0..d[1] { for z in 0..d[2] { if g.is_solid(x as i32, y as i32, z as i32) { n += 1; } } } } n } #[test] fn desprender_saca_materia_del_mundo_y_la_pone_a_volar() { let mut g = mundo(); let antes = solidos(&g); let mut e = Enjambre::ceniza(7); let sueltas = e.desprender(&mut g, Vec3::new(8.0, 2.0, 8.0), 3.0, 20); assert_eq!(sueltas, 20, "se soltaron las que se pidieron"); assert_eq!(e.len(), 20); assert_eq!(solidos(&g), antes - 20, "el mundo perdió exactamente esas celdas"); assert!(e.motas.iter().all(|m| m.color == [120, 100, 80]), "heredan el color del voxel"); } #[test] fn no_se_puede_desprender_del_aire() { let mut g = VoxelGrid::new([8, 8, 8]); let mut e = Enjambre::polvo(1); assert_eq!(e.desprender(&mut g, Vec3::new(4.0, 4.0, 4.0), 3.0, 10), 0); assert!(e.is_empty()); } #[test] fn la_ceniza_sube_y_el_polvo_cae() { let alto = |mut e: Enjambre| { e.soltar(Vec3::new(8.0, 8.0, 8.0), Vec3::ZERO, [200, 200, 200]); for _ in 0..20 { e.paso(1.0 / 30.0); } e.motas.first().map(|m| m.pos.y).unwrap_or(f32::NAN) }; let ceniza = alto(Enjambre::ceniza(3)); let polvo = alto(Enjambre::polvo(3)); assert!(ceniza > 8.0, "la ceniza sube: {ceniza}"); assert!(polvo < 8.0, "el polvo cae: {polvo}"); } #[test] fn misma_semilla_mismo_desprendimiento() { let tirada = |s: u32| { let mut g = mundo(); let mut e = Enjambre::ceniza(s); e.desprender(&mut g, Vec3::new(8.0, 2.0, 8.0), 3.0, 12); for _ in 0..10 { e.paso(0.05); } e.motas.clone() }; assert_eq!(tirada(11), tirada(11), "determinista por semilla"); assert_ne!(tirada(11), tirada(12), "otra semilla, otra dispersión"); } #[test] fn el_polvo_devuelve_la_materia_al_posarse() { // Cuatro motas en columnas distintas sobre un piso: al caer, cada una deja // su voxel. La materia se conserva — eso es lo que separa el polvo de la // ceniza (la ceniza se disipa a propósito). let mut g = VoxelGrid::new([16, 16, 16]); for x in 0..16 { for z in 0..16 { g.set(x, 0, z, [80, 80, 80]); } } let antes = solidos(&g); let mut e = Enjambre::polvo(5); for x in [3.5_f32, 6.5, 9.5, 12.5] { e.soltar(Vec3::new(x, 6.0, 8.5), Vec3::ZERO, [200, 40, 40]); } for _ in 0..90 { e.paso_en(1.0 / 30.0, &mut g); } assert!(e.is_empty(), "todas se posaron o se gastaron"); assert_eq!(solidos(&g), antes + 4, "las cuatro devolvieron su voxel"); assert!(g.is_solid(3, 1, 8), "posada justo encima del piso"); } #[test] fn la_ceniza_no_deja_rastro() { let mut g = VoxelGrid::new([8, 8, 8]); for x in 0..8 { for z in 0..8 { g.set(x, 0, z, [80, 80, 80]); } } let antes = solidos(&g); let mut e = Enjambre::ceniza(2); e.soltar(Vec3::new(4.5, 4.0, 4.5), Vec3::new(0.0, -3.0, 0.0), [200, 40, 40]); for _ in 0..120 { e.paso_en(1.0 / 30.0, &mut g); } assert!(e.is_empty()); assert_eq!(solidos(&g), antes, "la ceniza se disipa sin ensuciar el mundo"); } #[test] fn un_suceso_de_desmoronamiento_se_dispara_una_sola_vez() { use crate::{Suceso, TipoSuceso}; let mut g = mundo(); let antes = solidos(&g); let sucesos = vec![ Suceso { t: 1.5, pos: [8.0, 2.0, 8.0], tipo: TipoSuceso::Desmorona { radio: 3.0, motas: 10 }, nota: "se cae el techo".into(), }, // Una marca cualquiera en el mismo instante no debe hacer nada. Suceso { t: 1.5, pos: [8.0, 2.0, 8.0], tipo: TipoSuceso::Marca, nota: String::new() }, ]; let mut e = Enjambre::ceniza(4); // Cuadro anterior al suceso: nada pasa. assert_eq!(disparar_sucesos(&sucesos, 1.0, 1.4, &mut g, &mut e), 0); assert_eq!(solidos(&g), antes); // El cuadro que cruza su instante lo dispara. assert_eq!(disparar_sucesos(&sucesos, 1.4, 1.6, &mut g, &mut e), 10); assert_eq!(solidos(&g), antes - 10); // Los cuadros siguientes NO lo repiten. assert_eq!(disparar_sucesos(&sucesos, 1.6, 2.0, &mut g, &mut e), 0); assert_eq!(e.len(), 10); } #[test] fn las_cajas_respetan_el_tope_y_priorizan_lo_cercano() { let mut e = Enjambre::ceniza(9); for k in 0..200 { e.soltar(Vec3::new(k as f32, 5.0, 5.0), Vec3::ZERO, [10, 20, 30]); } let cajas = e.entidades(64, Some(Vec3::new(0.0, 5.0, 5.0))); assert_eq!(cajas.len(), 64, "no se pasa del cupo del uniform"); assert!(cajas[0].pos[0] < cajas[63].pos[0], "primero lo que está cerca del ojo"); assert!(cajas.iter().all(|c| c.pos[0] < 64.0), "las 64 más cercanas"); } }