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// =============================================================================
2// llimphi-voxel :: apertura — la atmósfera y la caída de una escena
3// -----------------------------------------------------------------------------
4// Las herramientas de autoría de la APERTURA de una escena:
5//
6// * [`Atmosfera`] — la BRUMA (densidad + color) y el FUNDIDO DESDE NEGRO con
7// que abre la escena. "Mucha bruma", "entra lento desde el negro".
8// * [`Caida`] — la cámara PURA (sin cuerpo) que cae del cielo hacia una
9// columna del mundo, mirando abajo y enderezándose al acercarse: "ve el
10// mundo acercarse hasta caer". Reusa `Camera3d`/`cam_lerp` de `camera_rig`
11// (la caída de `BirthSequence`, pero SIN sujeto).
12//
13// Ambas son deterministas por tiempo (reproducibles cuadro a cuadro), serializan
14// (viajan en el archivo de la escena) y se editan desde el estudio. El primer
15// uso: la escena del NACIMIENTO del protagonista.
16// =============================================================================
17
18use llimphi_3d::glam::Vec3;
19use llimphi_3d::Camera3d;
20use serde::{Deserialize, Serialize};
21
22use crate::camera_rig::cam_lerp;
23
24/// Suavizado Hermite `3t²−2t³` (deriva nula en los extremos).
25fn smoothstep(x: f32) -> f32 {
26 let x = x.clamp(0.0, 1.0);
27 x * x * (3.0 - 2.0 * x)
28}
29
30/// La ATMÓSFERA de una escena: cuánta bruma hay, de qué color, y cuánto dura el
31/// fundido desde el negro al abrir. Autorable desde el estudio.
32#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
33pub struct Atmosfera {
34 /// Densidad de la bruma: un COEFICIENTE (`0` = aire limpio; ~1 = niebla de
35 /// tarde; ~2.5 = todo se lo traga la bruma). El render lo divide por el ancho
36 /// del mundo → `fog_density`.
37 pub bruma: f32,
38 /// Color de la bruma / horizonte hacia el que funden los lejos (RGB `0..1`).
39 pub color: [f32; 3],
40 /// Segundos de fundido DESDE NEGRO al abrir la escena (`0` = sin fundido).
41 pub desde_negro: f32,
42}
43
44impl Atmosfera {
45 /// El aire del nacimiento: bruma espesa gris-perla que abre lento desde el
46 /// negro.
47 pub fn nacimiento() -> Self {
48 Self { bruma: 2.5, color: [0.66, 0.65, 0.70], desde_negro: 3.0 }
49 }
50
51 /// Aire despejado, sin fundido — el neutro para escenas normales.
52 pub fn despejada() -> Self {
53 Self { bruma: 0.3, color: [0.62, 0.70, 0.85], desde_negro: 0.0 }
54 }
55
56 /// Cuánto NEGRO cubre la escena en `t`: `1` = negro total (t≈0), `0` = ya
57 /// visible (tras `desde_negro`). Un fundido suave. Sin fundido ⟹ siempre 0.
58 pub fn negro_en(&self, t: f32) -> f32 {
59 if self.desde_negro <= 0.0 {
60 return 0.0;
61 }
62 1.0 - smoothstep(t / self.desde_negro)
63 }
64
65 /// El COLOR de la niebla/horizonte en `t` (RGB u8): de NEGRO (t≈0, mientras
66 /// dura `desde_negro`) al `color` de la bruma. Es lo que hace "empezar en
67 /// negro y entrar a la bruma": como la cámara arranca alto entre niebla
68 /// densa, todo se funde a este color — negro al principio, gris-perla después.
69 /// Puro y determinista (el render sólo lo aplica).
70 pub fn color_niebla_en(&self, t: f32) -> [u8; 3] {
71 let visible = 1.0 - self.negro_en(t);
72 let tinte = |c: f32| (c * visible * 255.0).round().clamp(0.0, 255.0) as u8;
73 [tinte(self.color[0]), tinte(self.color[1]), tinte(self.color[2])]
74 }
75}
76
77/// La CAÍDA de apertura: la cámara pura cae desde `altura` hacia la columna
78/// `aterrizaje` del mundo, mirando abajo y enderezándose al acercarse. Sin
79/// cuerpo — es la cámara la que cae. Determinista por tiempo.
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81pub struct Caida {
82 /// Altura de partida, en el cielo entre la bruma.
83 pub altura: f32,
84 /// Duración de la caída (seg).
85 pub duracion: f32,
86 /// Columna `(x, z)` del mundo hacia la que cae y aterriza.
87 pub aterrizaje: [f32; 2],
88 /// Altura del suelo en la columna de aterrizaje; el ojo termina [`Self::OJO`]
89 /// por encima.
90 pub suelo: f32,
91}
92
93impl Caida {
94 /// Altura del ojo al aterrizar (a la altura de una mirada humana).
95 pub const OJO: f32 = 1.6;
96
97 /// Una caída larga y lenta desde muy alto sobre una columna del mundo — el
98 /// nacimiento.
99 pub fn nacimiento(aterrizaje: [f32; 2], suelo: f32) -> Self {
100 Self { altura: 90.0, duracion: 7.0, aterrizaje, suelo }
101 }
102
103 /// La cámara al inicio: muy alto, mirando casi recto hacia abajo a la columna.
104 fn cielo(&self) -> Camera3d {
105 let [ax, az] = self.aterrizaje;
106 let eye = Vec3::new(ax, self.altura, az);
107 // Objetivo muy por debajo (mira abajo), con un pelín de +Z para no
108 // degenerar el vector "up" (mirar recto abajo lo dejaría indefinido).
109 let target = Vec3::new(ax, self.suelo, az + 0.01);
110 Camera3d { eye, target, ..Camera3d::default() }
111 }
112
113 /// La cámara al aterrizar: a ras del suelo, mirando al horizonte (+Z).
114 fn piso(&self) -> Camera3d {
115 let [ax, az] = self.aterrizaje;
116 let eye = Vec3::new(ax, self.suelo + Self::OJO, az);
117 let target = Vec3::new(ax, self.suelo + Self::OJO, az + 10.0);
118 Camera3d { eye, target, ..Camera3d::default() }
119 }
120
121 /// La cámara en el instante `t` de la caída: de "alto mirando abajo" a "a ras
122 /// mirando al frente", con desaceleración (el `smoothstep` de `cam_lerp`).
123 /// Coordenadas ABSOLUTAS del mundo (usa `aterrizaje`/`suelo`).
124 pub fn camara(&self, t: f32) -> Camera3d {
125 let u = (t / self.duracion.max(1e-3)).clamp(0.0, 1.0);
126 cam_lerp(&self.cielo(), &self.piso(), u)
127 }
128
129 /// Igual que [`camara`](Self::camara) pero anclada a un `centro` dado (el
130 /// punto de aterrizaje **en el frame del render**, que está centrado en el
131 /// origen). Reusa `altura`/`duracion` como offsets relativos; ignora
132 /// `aterrizaje`/`suelo` (que son coords absolutas). Es la que consume el
133 /// preview, donde la geometría vive desplazada `-half`.
134 pub fn camara_desde(&self, centro: Vec3, t: f32) -> Camera3d {
135 let cielo = Camera3d {
136 eye: centro + Vec3::new(0.0, self.altura, 0.0),
137 target: centro + Vec3::new(0.0, 0.0, 0.01), // mira abajo, al centro
138 ..Camera3d::default()
139 };
140 let piso = Camera3d {
141 eye: centro + Vec3::new(0.0, Self::OJO, 0.0),
142 target: centro + Vec3::new(0.0, Self::OJO, 10.0), // al frente (+Z)
143 ..Camera3d::default()
144 };
145 let u = (t / self.duracion.max(1e-3)).clamp(0.0, 1.0);
146 cam_lerp(&cielo, &piso, u)
147 }
148
149 /// ¿Ya terminó de caer en `t`?
150 pub fn aterrizo(&self, t: f32) -> bool {
151 t >= self.duracion
152 }
153}
154
155impl crate::studio::Project {
156 /// El proyecto de la escena del NACIMIENTO: un desierto vacío (sin
157 /// vegetación, sin agua, pocas montañas), envuelto en un mundo, y la escena
158 /// de la cámara PURA que cae desde el negro entre mucha bruma hasta aterrizar
159 /// —todavía sin cuerpo—. El primer artefacto autorable de la colaboración.
160 pub fn nacimiento() -> crate::studio::Project {
161 use crate::studio::{world_dim, MaterialDef, Mundo, Project, SceneSpec, PREVIEW_DIM_XZ};
162 use crate::worldgen::{Bioma, Material};
163
164 let mut p = Project { next_id: 1, ..Default::default() };
165
166 // Materiales mínimos: arena (suelo) y roca (acantilado). Nada más.
167 let arena = p.alloc_id();
168 p.materiales.push(MaterialDef::from_builtin(arena, Material::Sand));
169 let roca = p.alloc_id();
170 p.materiales.push(MaterialDef::from_builtin(roca, Material::Rock));
171
172 // Bioma "vacío puro": desierto sin vegetación, sin agua, pocas montañas.
173 let desierto = p.alloc_id();
174 p.biomas.push(Bioma {
175 id: desierto,
176 name: "vacío".into(),
177 base: 0.30,
178 dune: 0.06,
179 relief: 0.30, // relieve suave
180 mountains: 0.06, // "no tantas montañas"
181 water_level: 0.0, // sin agua
182 rivers: 0.0, // sin ríos
183 peak_at: 1.0, // sin picos nevados
184 ground: arena,
185 cliff: roca,
186 peak: None,
187 objetos: vec![], // sin vegetación ni objetos
188 minerales: vec![], // el vacío no tiene minerales
189 densidad_urbana: 0.4,
190 casas: Vec::new(),
191 fauna: vec![],
192 seres: vec![], // todavía no hay cuerpo
193 });
194
195 // El mundo que lo envuelve.
196 let seed = 20_260_702;
197 let mundo = p.alloc_id();
198 p.mundos.push(Mundo { id: mundo, name: "el vacío".into(), seed, biomas: vec![desierto], mapa: None });
199
200 // La caída aterriza en el centro del mundo; el suelo sale del terreno real.
201 let dim = world_dim(PREVIEW_DIM_XZ);
202 let cx = dim[0] as f32 * 0.5;
203 let cz = dim[2] as f32 * 0.5;
204 let suelo = crate::terrain::column_height(cx as i32, cz as i32, dim, seed) as f32;
205
206 let caida = Caida::nacimiento([cx, cz], suelo);
207 let atmosfera = Atmosfera::nacimiento();
208
209 // La escena: cámara PURA (sin actores), la caída + un respiro final.
210 let escena_id = p.alloc_id();
211 p.escenas.push(SceneSpec {
212 distancia: None,
213 id: escena_id,
214 name: "el nacimiento".into(),
215 mundo,
216 duration: caida.duracion + 1.5,
217 actors: vec![], // no hay cuerpo: es la cámara la que cae
218 shots: vec![],
219 handheld: 0.25, // un pulso leve: la cámara respira al caer, no es trípode
220 atmosfera: Some(atmosfera),
221 caida: Some(caida),
222 sucesos: Vec::new(),
223 utileria: Vec::new(),
224 cielo: None,
225 decorado: Vec::new(),
226 jardin: None,
227 });
228
229 p
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn el_fundido_va_de_negro_a_visible() {
239 let a = Atmosfera::nacimiento();
240 assert_eq!(a.negro_en(0.0), 1.0, "arranca en negro total");
241 assert_eq!(a.negro_en(a.desde_negro), 0.0, "al final ya se ve");
242 // Monótono decreciente.
243 let mut prev = a.negro_en(0.0);
244 for i in 1..=20 {
245 let t = a.desde_negro * i as f32 / 20.0;
246 let n = a.negro_en(t);
247 assert!(n <= prev + 1e-6, "el negro sólo se disipa");
248 prev = n;
249 }
250 }
251
252 #[test]
253 fn el_aire_despejado_no_funde() {
254 let a = Atmosfera::despejada();
255 assert_eq!(a.negro_en(0.0), 0.0);
256 assert!(a.bruma < Atmosfera::nacimiento().bruma, "despejada trae menos bruma");
257 }
258
259 #[test]
260 fn la_niebla_va_de_negro_al_color() {
261 let a = Atmosfera::nacimiento();
262 assert_eq!(a.color_niebla_en(0.0), [0, 0, 0], "arranca negro puro");
263 let fin = a.color_niebla_en(a.desde_negro);
264 // Al final = el color de la bruma en u8 (gris-perla), no negro.
265 assert!(fin.iter().all(|&c| c > 100), "termina en la bruma clara: {fin:?}");
266 }
267
268 #[test]
269 fn camara_desde_ancla_la_caida_a_un_centro() {
270 let c = Caida::nacimiento([0.0, 0.0], 0.0);
271 let centro = Vec3::new(5.0, 2.0, -3.0);
272 let alto = c.camara_desde(centro, 0.0);
273 let bajo = c.camara_desde(centro, c.duracion);
274 // Arranca `altura` sobre el centro; termina `OJO` sobre el centro.
275 assert!((alto.eye - (centro + Vec3::new(0.0, c.altura, 0.0))).length() < 1e-3);
276 assert!((bajo.eye - (centro + Vec3::new(0.0, Caida::OJO, 0.0))).length() < 1e-3);
277 assert!(alto.eye.y > bajo.eye.y, "desciende");
278 }
279
280 #[test]
281 fn la_caida_desciende_del_cielo_al_suelo() {
282 let c = Caida::nacimiento([64.0, 64.0], 12.0);
283 let alto = c.camara(0.0);
284 let bajo = c.camara(c.duracion);
285 assert!((alto.eye.y - c.altura).abs() < 1e-3, "arranca en la altura del cielo");
286 assert!((bajo.eye.y - (c.suelo + Caida::OJO)).abs() < 1e-3, "termina a ras del suelo");
287 assert!(alto.eye.y > bajo.eye.y, "cae: baja de altura");
288 }
289
290 #[test]
291 fn arranca_mirando_abajo_y_termina_mirando_al_frente() {
292 let c = Caida::nacimiento([64.0, 64.0], 12.0);
293 let alto = c.camara(0.0);
294 // Al inicio el objetivo está MUY por debajo del ojo → mira hacia abajo.
295 assert!(alto.target.y < alto.eye.y - 10.0, "mira abajo al caer");
296 let bajo = c.camara(c.duracion);
297 // Al final mira al frente (+Z), casi a la misma altura del ojo.
298 assert!(bajo.target.z > bajo.eye.z + 1.0, "termina mirando al frente");
299 assert!((bajo.target.y - bajo.eye.y).abs() < 1e-3, "mirada nivelada al horizonte");
300 }
301
302 #[test]
303 fn el_descenso_es_monotono() {
304 let c = Caida::nacimiento([64.0, 64.0], 12.0);
305 let mut prev = c.camara(0.0).eye.y;
306 for i in 1..=40 {
307 let t = c.duracion * i as f32 / 40.0;
308 let y = c.camara(t).eye.y;
309 assert!(y <= prev + 1e-4, "el ojo sólo desciende");
310 prev = y;
311 }
312 assert!(c.aterrizo(c.duracion));
313 assert!(!c.aterrizo(c.duracion * 0.5));
314 }
315}