// ============================================================================= // llimphi-voxel :: apertura — la atmósfera y la caída de una escena // ----------------------------------------------------------------------------- // Las herramientas de autoría de la APERTURA de una escena: // // * [`Atmosfera`] — la BRUMA (densidad + color) y el FUNDIDO DESDE NEGRO con // que abre la escena. "Mucha bruma", "entra lento desde el negro". // * [`Caida`] — la cámara PURA (sin cuerpo) que cae del cielo hacia una // columna del mundo, mirando abajo y enderezándose al acercarse: "ve el // mundo acercarse hasta caer". Reusa `Camera3d`/`cam_lerp` de `camera_rig` // (la caída de `BirthSequence`, pero SIN sujeto). // // Ambas son deterministas por tiempo (reproducibles cuadro a cuadro), serializan // (viajan en el archivo de la escena) y se editan desde el estudio. El primer // uso: la escena del NACIMIENTO del protagonista. // ============================================================================= use llimphi_3d::glam::Vec3; use llimphi_3d::Camera3d; use serde::{Deserialize, Serialize}; use crate::camera_rig::cam_lerp; /// Suavizado Hermite `3t²−2t³` (deriva nula en los extremos). fn smoothstep(x: f32) -> f32 { let x = x.clamp(0.0, 1.0); x * x * (3.0 - 2.0 * x) } /// La ATMÓSFERA de una escena: cuánta bruma hay, de qué color, y cuánto dura el /// fundido desde el negro al abrir. Autorable desde el estudio. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Atmosfera { /// Densidad de la bruma: un COEFICIENTE (`0` = aire limpio; ~1 = niebla de /// tarde; ~2.5 = todo se lo traga la bruma). El render lo divide por el ancho /// del mundo → `fog_density`. pub bruma: f32, /// Color de la bruma / horizonte hacia el que funden los lejos (RGB `0..1`). pub color: [f32; 3], /// Segundos de fundido DESDE NEGRO al abrir la escena (`0` = sin fundido). pub desde_negro: f32, } impl Atmosfera { /// El aire del nacimiento: bruma espesa gris-perla que abre lento desde el /// negro. pub fn nacimiento() -> Self { Self { bruma: 2.5, color: [0.66, 0.65, 0.70], desde_negro: 3.0 } } /// Aire despejado, sin fundido — el neutro para escenas normales. pub fn despejada() -> Self { Self { bruma: 0.3, color: [0.62, 0.70, 0.85], desde_negro: 0.0 } } /// Cuánto NEGRO cubre la escena en `t`: `1` = negro total (t≈0), `0` = ya /// visible (tras `desde_negro`). Un fundido suave. Sin fundido ⟹ siempre 0. pub fn negro_en(&self, t: f32) -> f32 { if self.desde_negro <= 0.0 { return 0.0; } 1.0 - smoothstep(t / self.desde_negro) } /// El COLOR de la niebla/horizonte en `t` (RGB u8): de NEGRO (t≈0, mientras /// dura `desde_negro`) al `color` de la bruma. Es lo que hace "empezar en /// negro y entrar a la bruma": como la cámara arranca alto entre niebla /// densa, todo se funde a este color — negro al principio, gris-perla después. /// Puro y determinista (el render sólo lo aplica). pub fn color_niebla_en(&self, t: f32) -> [u8; 3] { let visible = 1.0 - self.negro_en(t); let tinte = |c: f32| (c * visible * 255.0).round().clamp(0.0, 255.0) as u8; [tinte(self.color[0]), tinte(self.color[1]), tinte(self.color[2])] } } /// La CAÍDA de apertura: la cámara pura cae desde `altura` hacia la columna /// `aterrizaje` del mundo, mirando abajo y enderezándose al acercarse. Sin /// cuerpo — es la cámara la que cae. Determinista por tiempo. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Caida { /// Altura de partida, en el cielo entre la bruma. pub altura: f32, /// Duración de la caída (seg). pub duracion: f32, /// Columna `(x, z)` del mundo hacia la que cae y aterriza. pub aterrizaje: [f32; 2], /// Altura del suelo en la columna de aterrizaje; el ojo termina [`Self::OJO`] /// por encima. pub suelo: f32, } impl Caida { /// Altura del ojo al aterrizar (a la altura de una mirada humana). pub const OJO: f32 = 1.6; /// Una caída larga y lenta desde muy alto sobre una columna del mundo — el /// nacimiento. pub fn nacimiento(aterrizaje: [f32; 2], suelo: f32) -> Self { Self { altura: 90.0, duracion: 7.0, aterrizaje, suelo } } /// La cámara al inicio: muy alto, mirando casi recto hacia abajo a la columna. fn cielo(&self) -> Camera3d { let [ax, az] = self.aterrizaje; let eye = Vec3::new(ax, self.altura, az); // Objetivo muy por debajo (mira abajo), con un pelín de +Z para no // degenerar el vector "up" (mirar recto abajo lo dejaría indefinido). let target = Vec3::new(ax, self.suelo, az + 0.01); Camera3d { eye, target, ..Camera3d::default() } } /// La cámara al aterrizar: a ras del suelo, mirando al horizonte (+Z). fn piso(&self) -> Camera3d { let [ax, az] = self.aterrizaje; let eye = Vec3::new(ax, self.suelo + Self::OJO, az); let target = Vec3::new(ax, self.suelo + Self::OJO, az + 10.0); Camera3d { eye, target, ..Camera3d::default() } } /// La cámara en el instante `t` de la caída: de "alto mirando abajo" a "a ras /// mirando al frente", con desaceleración (el `smoothstep` de `cam_lerp`). /// Coordenadas ABSOLUTAS del mundo (usa `aterrizaje`/`suelo`). pub fn camara(&self, t: f32) -> Camera3d { let u = (t / self.duracion.max(1e-3)).clamp(0.0, 1.0); cam_lerp(&self.cielo(), &self.piso(), u) } /// Igual que [`camara`](Self::camara) pero anclada a un `centro` dado (el /// punto de aterrizaje **en el frame del render**, que está centrado en el /// origen). Reusa `altura`/`duracion` como offsets relativos; ignora /// `aterrizaje`/`suelo` (que son coords absolutas). Es la que consume el /// preview, donde la geometría vive desplazada `-half`. pub fn camara_desde(&self, centro: Vec3, t: f32) -> Camera3d { let cielo = Camera3d { eye: centro + Vec3::new(0.0, self.altura, 0.0), target: centro + Vec3::new(0.0, 0.0, 0.01), // mira abajo, al centro ..Camera3d::default() }; let piso = Camera3d { eye: centro + Vec3::new(0.0, Self::OJO, 0.0), target: centro + Vec3::new(0.0, Self::OJO, 10.0), // al frente (+Z) ..Camera3d::default() }; let u = (t / self.duracion.max(1e-3)).clamp(0.0, 1.0); cam_lerp(&cielo, &piso, u) } /// ¿Ya terminó de caer en `t`? pub fn aterrizo(&self, t: f32) -> bool { t >= self.duracion } } impl crate::studio::Project { /// El proyecto de la escena del NACIMIENTO: un desierto vacío (sin /// vegetación, sin agua, pocas montañas), envuelto en un mundo, y la escena /// de la cámara PURA que cae desde el negro entre mucha bruma hasta aterrizar /// —todavía sin cuerpo—. El primer artefacto autorable de la colaboración. pub fn nacimiento() -> crate::studio::Project { use crate::studio::{world_dim, MaterialDef, Mundo, Project, SceneSpec, PREVIEW_DIM_XZ}; use crate::worldgen::{Bioma, Material}; let mut p = Project { next_id: 1, ..Default::default() }; // Materiales mínimos: arena (suelo) y roca (acantilado). Nada más. let arena = p.alloc_id(); p.materiales.push(MaterialDef::from_builtin(arena, Material::Sand)); let roca = p.alloc_id(); p.materiales.push(MaterialDef::from_builtin(roca, Material::Rock)); // Bioma "vacío puro": desierto sin vegetación, sin agua, pocas montañas. let desierto = p.alloc_id(); p.biomas.push(Bioma { id: desierto, name: "vacío".into(), base: 0.30, dune: 0.06, relief: 0.30, // relieve suave mountains: 0.06, // "no tantas montañas" water_level: 0.0, // sin agua rivers: 0.0, // sin ríos peak_at: 1.0, // sin picos nevados ground: arena, cliff: roca, peak: None, objetos: vec![], // sin vegetación ni objetos minerales: vec![], // el vacío no tiene minerales densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![], seres: vec![], // todavía no hay cuerpo }); // El mundo que lo envuelve. let seed = 20_260_702; let mundo = p.alloc_id(); p.mundos.push(Mundo { id: mundo, name: "el vacío".into(), seed, biomas: vec![desierto], mapa: None }); // La caída aterriza en el centro del mundo; el suelo sale del terreno real. let dim = world_dim(PREVIEW_DIM_XZ); let cx = dim[0] as f32 * 0.5; let cz = dim[2] as f32 * 0.5; let suelo = crate::terrain::column_height(cx as i32, cz as i32, dim, seed) as f32; let caida = Caida::nacimiento([cx, cz], suelo); let atmosfera = Atmosfera::nacimiento(); // La escena: cámara PURA (sin actores), la caída + un respiro final. let escena_id = p.alloc_id(); p.escenas.push(SceneSpec { distancia: None, id: escena_id, name: "el nacimiento".into(), mundo, duration: caida.duracion + 1.5, actors: vec![], // no hay cuerpo: es la cámara la que cae shots: vec![], handheld: 0.25, // un pulso leve: la cámara respira al caer, no es trípode atmosfera: Some(atmosfera), caida: Some(caida), sucesos: Vec::new(), utileria: Vec::new(), cielo: None, decorado: Vec::new(), jardin: None, }); p } } #[cfg(test)] mod tests { use super::*; #[test] fn el_fundido_va_de_negro_a_visible() { let a = Atmosfera::nacimiento(); assert_eq!(a.negro_en(0.0), 1.0, "arranca en negro total"); assert_eq!(a.negro_en(a.desde_negro), 0.0, "al final ya se ve"); // Monótono decreciente. let mut prev = a.negro_en(0.0); for i in 1..=20 { let t = a.desde_negro * i as f32 / 20.0; let n = a.negro_en(t); assert!(n <= prev + 1e-6, "el negro sólo se disipa"); prev = n; } } #[test] fn el_aire_despejado_no_funde() { let a = Atmosfera::despejada(); assert_eq!(a.negro_en(0.0), 0.0); assert!(a.bruma < Atmosfera::nacimiento().bruma, "despejada trae menos bruma"); } #[test] fn la_niebla_va_de_negro_al_color() { let a = Atmosfera::nacimiento(); assert_eq!(a.color_niebla_en(0.0), [0, 0, 0], "arranca negro puro"); let fin = a.color_niebla_en(a.desde_negro); // Al final = el color de la bruma en u8 (gris-perla), no negro. assert!(fin.iter().all(|&c| c > 100), "termina en la bruma clara: {fin:?}"); } #[test] fn camara_desde_ancla_la_caida_a_un_centro() { let c = Caida::nacimiento([0.0, 0.0], 0.0); let centro = Vec3::new(5.0, 2.0, -3.0); let alto = c.camara_desde(centro, 0.0); let bajo = c.camara_desde(centro, c.duracion); // Arranca `altura` sobre el centro; termina `OJO` sobre el centro. assert!((alto.eye - (centro + Vec3::new(0.0, c.altura, 0.0))).length() < 1e-3); assert!((bajo.eye - (centro + Vec3::new(0.0, Caida::OJO, 0.0))).length() < 1e-3); assert!(alto.eye.y > bajo.eye.y, "desciende"); } #[test] fn la_caida_desciende_del_cielo_al_suelo() { let c = Caida::nacimiento([64.0, 64.0], 12.0); let alto = c.camara(0.0); let bajo = c.camara(c.duracion); assert!((alto.eye.y - c.altura).abs() < 1e-3, "arranca en la altura del cielo"); assert!((bajo.eye.y - (c.suelo + Caida::OJO)).abs() < 1e-3, "termina a ras del suelo"); assert!(alto.eye.y > bajo.eye.y, "cae: baja de altura"); } #[test] fn arranca_mirando_abajo_y_termina_mirando_al_frente() { let c = Caida::nacimiento([64.0, 64.0], 12.0); let alto = c.camara(0.0); // Al inicio el objetivo está MUY por debajo del ojo → mira hacia abajo. assert!(alto.target.y < alto.eye.y - 10.0, "mira abajo al caer"); let bajo = c.camara(c.duracion); // Al final mira al frente (+Z), casi a la misma altura del ojo. assert!(bajo.target.z > bajo.eye.z + 1.0, "termina mirando al frente"); assert!((bajo.target.y - bajo.eye.y).abs() < 1e-3, "mirada nivelada al horizonte"); } #[test] fn el_descenso_es_monotono() { let c = Caida::nacimiento([64.0, 64.0], 12.0); let mut prev = c.camara(0.0).eye.y; for i in 1..=40 { let t = c.duracion * i as f32 / 40.0; let y = c.camara(t).eye.y; assert!(y <= prev + 1e-4, "el ojo sólo desciende"); prev = y; } assert!(c.aterrizo(c.duracion)); assert!(!c.aterrizo(c.duracion * 0.5)); } }