gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 484Markdown 258TOML 131RON 38118 Definitions

⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc llimphi-voxel/src/worldgen.rs Rust · 487 lines · 19.3 KB History Who touched what raw

feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director 333dca1d5 · Sergio ·

1//! **Creador de mundos**: en vez de un terreno hardcodeado (como [`terrain`]
2//! (crate::terrain), que es un mundo de pasto fijo), un [`WorldRecipe`]
3//! *parametriza* el relieve y los materiales — y produce el [`VoxelGrid`]. Un
4//! mundo concreto (desierto, pradera, …) es una **receta**, no una función nueva.
5//!
6//! Dos cosas que pidió el corto del desierto salen de acá:
7//! - **Materiales** ([`Material`]): arena, agua, roca, cactus… cada uno con su
8//!   color. El terreno se pinta por material, no por banda de altura cruda.
9//! - **Receta del desierto** ([`WorldRecipe::desert`]): llano de arena, **pocas
10//!   montañas**, **pocos ríos**, **cactus** ralos.
11//!
12//! La altura sigue siendo **función pura de mundo** ([`WorldRecipe::column_height`]):
13//! mismo `(wx, wz)` → misma columna, así un mundo-receta también podrá streamear
14//! (cuando se cablee a [`WorldStream`](crate::WorldStream)).
15
16use llimphi_3d::VoxelGrid;
17use serde::{Deserialize, Serialize};
18
19use crate::terrain::{fbm, hash2, smooth, world_scale};
20
21/// Un **material** del mundo: la unidad semántica con la que el creador pinta los
22/// voxels (en vez de un color suelto). Da color y solidez; más adelante puede
23/// llevar propiedades físicas (flotabilidad del agua, daño del cactus, etc.).
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum Material {
26    /// Vacío (aire) — no se dibuja.
27    Air,
28    /// Arena del desierto.
29    Sand,
30    /// Tierra/pasto (praderas).
31    Grass,
32    /// Roca de acantilado / montaña.
33    Rock,
34    /// Nieve de cumbre.
35    Snow,
36    /// Agua (superficie de ríos/lagos/mar).
37    Water,
38    /// Carne de cactus (verde).
39    Cactus,
40}
41
42impl Material {
43    /// Color RGB del material.
44    pub fn color(self) -> [u8; 3] {
45        match self {
46            Material::Air => [0, 0, 0],
47            Material::Sand => [214, 188, 130],
48            Material::Grass => [84, 140, 64],
49            Material::Rock => [108, 102, 98],
50            Material::Snow => [236, 240, 250],
51            Material::Water => [54, 118, 158],
52            Material::Cactus => [74, 128, 70],
53        }
54    }
55
56    /// `true` salvo para [`Material::Air`].
57    pub fn is_solid(self) -> bool {
58        !matches!(self, Material::Air)
59    }
60
61    /// Todos los materiales, en orden de catálogo (para que un editor cicle entre
62    /// ellos).
63    pub const ALL: [Material; 7] = [
64        Material::Air,
65        Material::Sand,
66        Material::Grass,
67        Material::Rock,
68        Material::Snow,
69        Material::Water,
70        Material::Cactus,
71    ];
72
73    /// Nombre legible (español) para la UI.
74    pub fn label(self) -> &'static str {
75        match self {
76            Material::Air => "aire",
77            Material::Sand => "arena",
78            Material::Grass => "pasto",
79            Material::Rock => "roca",
80            Material::Snow => "nieve",
81            Material::Water => "agua",
82            Material::Cactus => "cactus",
83        }
84    }
85
86    /// El material siguiente en el catálogo (cicla) — para botones de ciclo.
87    pub fn next(self) -> Material {
88        let i = Material::ALL.iter().position(|&m| m == self).unwrap_or(0);
89        Material::ALL[(i + 1) % Material::ALL.len()]
90    }
91}
92
93/// Qué planta esparce un mundo y con qué forma.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95pub enum Flora {
96    /// Sin vegetación.
97    None,
98    /// Cactus columnar (tronco + algún brazo) — el desierto.
99    Cactus,
100}
101
102impl Flora {
103    /// Todas las opciones de flora (para ciclar en un editor).
104    pub const ALL: [Flora; 2] = [Flora::None, Flora::Cactus];
105
106    /// Nombre legible (español) para la UI.
107    pub fn label(self) -> &'static str {
108        match self {
109            Flora::None => "ninguna",
110            Flora::Cactus => "cactus",
111        }
112    }
113
114    /// La flora siguiente (cicla).
115    pub fn next(self) -> Flora {
116        let i = Flora::ALL.iter().position(|&f| f == self).unwrap_or(0);
117        Flora::ALL[(i + 1) % Flora::ALL.len()]
118    }
119}
120
121/// La **receta de un mundo**: parámetros del relieve + materiales + flora. Producí
122/// el `VoxelGrid` con [`generate`](Self::generate). Presets: [`desert`](Self::desert),
123/// [`grassland`](Self::grassland).
124#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
125pub struct WorldRecipe {
126    /// Semilla del ruido (mismo seed → mismo mundo).
127    pub seed: u32,
128    /// Nivel base del suelo, fracción del alto del mundo `[0,1]` (la llanura).
129    pub base: f32,
130    /// Amplitud de las ondulaciones suaves del suelo (dunas), fracción del alto.
131    pub dune: f32,
132    /// Amplitud de las montañas, fracción del alto.
133    pub relief: f32,
134    /// **Densidad de montañas** `[0,1]`: 0 = casi todo llano (sólo pocos picos
135    /// asoman), 1 = relieve por todos lados.
136    pub mountains: f32,
137    /// Nivel del agua, fracción del alto `[0,1]`: las depresiones por debajo se
138    /// llenan de [`Material::Water`].
139    pub water_level: f32,
140    /// **Densidad de ríos** `[0,1]`: 0 = sin ríos; mayor = canales más anchos/
141    /// frecuentes tallados hacia el agua.
142    pub rivers: f32,
143    /// Material de la superficie sólida (arena en el desierto, pasto en pradera).
144    pub ground: Material,
145    /// Material de acantilados/altura (roca).
146    pub cliff: Material,
147    /// Material de cumbre por encima de `peak_at` (nieve); `Air` = sin cumbre.
148    pub peak: Material,
149    /// Altura normalizada `[0,1]` a partir de la cual aparece `peak`.
150    pub peak_at: f32,
151    /// Flora y su densidad `[0,1]`.
152    pub flora: Flora,
153    pub flora_density: f32,
154    /// **Grano de materia** `[0,1]`: jitter de brillo *por vóxel* sobre el color del
155    /// material. `0` = caras planas de un color liso (el look "textura de Minecraft
156    /// estirada"); `~1` = superficie granular, densa, que parece materia y no bloque.
157    /// Es el **sello del motor**: lo que distancia del cubo plano sin tocar el shader
158    /// (la textura es el propio vóxel). Determinista por `(wx, y, wz, seed)`.
159    #[serde(default)]
160    pub grain: f32,
161}
162
163/// Aplica el **grano de materia** al color base de un vóxel: perturba el brillo
164/// (±`grain`·18%) y mete una pizca de variación por canal, deterministamente por
165/// la posición de mundo. `grain = 0` → color intacto. Función pura → seamless entre
166/// ventanas de streaming.
167fn grained(base: [u8; 3], grain: f32, wx: i32, y: u32, wz: i32, seed: u32) -> [u8; 3] {
168    if grain <= 0.0 {
169        return base;
170    }
171    let g = grain.clamp(0.0, 1.0);
172    // Hash por vóxel (mete `y` en la coordenada para que la columna no sea uniforme).
173    let h = hash2(wx, wz.wrapping_mul(31).wrapping_add(y as i32 * 7), seed ^ 0x6817);
174    let bright = 1.0 + (h - 0.5) * 2.0 * g * 0.18; // ±18% a grano pleno
175    let speck = hash2(wx.wrapping_mul(13).wrapping_add(y as i32), wz, seed ^ 0x51A3);
176    let tilt = (speck - 0.5) * 2.0 * g * 12.0; // ±12 niveles, rompe el liso plano
177    let ch = |c: u8, extra: f32| (c as f32 * bright + extra).clamp(0.0, 255.0) as u8;
178    [ch(base[0], tilt), ch(base[1], tilt * 0.7), ch(base[2], -tilt * 0.5)]
179}
180
181impl WorldRecipe {
182    /// **Desierto llano**: arena, pocas montañas, pocos ríos, cactus ralos. El
183    /// mundo de apertura del corto.
184    pub fn desert(seed: u32) -> Self {
185        Self {
186            seed,
187            base: 0.30,
188            dune: 0.05,    // ondulación suave (dunas bajas)
189            relief: 0.45,  // las pocas montañas que hay, altas
190            mountains: 0.12, // pero MUY ralas
191            water_level: 0.26, // por debajo del suelo base → casi sin agua salvo ríos
192            rivers: 0.18,  // pocos ríos
193            ground: Material::Sand,
194            cliff: Material::Rock,
195            peak: Material::Air, // sin nieve en el desierto
196            peak_at: 1.0,
197            flora: Flora::Cactus,
198            flora_density: 0.010,
199            grain: 0.55, // arena granular, no slab plano
200        }
201    }
202
203    /// **Pradera**: el mundo verde clásico (equivalente conceptual a [`terrain`]
204    /// (crate::terrain)), expresado como receta para mostrar que el creador es
205    /// general.
206    pub fn grassland(seed: u32) -> Self {
207        Self {
208            seed,
209            base: 0.22,
210            dune: 0.10,
211            relief: 0.7,
212            mountains: 0.5,
213            water_level: 0.30,
214            rivers: 0.25,
215            ground: Material::Grass,
216            cliff: Material::Rock,
217            peak: Material::Snow,
218            peak_at: 0.80,
219            flora: Flora::None,
220            flora_density: 0.0,
221            grain: 0.45, // pasto/roca con textura, no color liso
222        }
223    }
224
225    /// **Altura del terreno** (índice `y` del voxel sólido superior) en la columna
226    /// de **mundo** `(wx, wz)`. Función pura: mismo punto → misma altura en
227    /// cualquier ventana (continuidad para el streaming). Combina llanura base +
228    /// dunas suaves + montañas *gated* (sólo asoman donde el fbm supera un umbral,
229    /// así `mountains` bajo deja casi todo plano) + ríos tallados hacia el agua.
230    pub fn column_height(&self, wx: i32, wz: i32, dim: [u32; 3]) -> u32 {
231        let dy = dim[1] as f32;
232        let scale = world_scale(dim);
233        let s = self.seed;
234        let (fx, fz) = (wx as f32 * scale, wz as f32 * scale);
235
236        let base = self.base * dy;
237        // Dunas: ondulación suave centrada en cero.
238        let dunes = (fbm(fx * 1.7, fz * 1.7, 4, s ^ 0x11) - 0.5) * 2.0;
239        let dune_h = dunes * self.dune * dy;
240        // Montañas gated: sólo la parte alta del fbm sobresale. `mountains` baja el
241        // umbral → más raras y aisladas.
242        let c = fbm(fx, fz, 6, s);
243        let thr = 1.0 - self.mountains.clamp(0.0, 1.0);
244        let m = ((c - thr).max(0.0) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
245        let mtn_h = smooth(m) * self.relief * dy;
246
247        let mut h = base + dune_h + mtn_h;
248
249        // Ríos: una "cresta" de ruido (ridged) marca líneas; cerca de la línea el
250        // terreno se hunde hasta un lecho bajo el nivel del agua.
251        if self.rivers > 0.0 {
252            let r = 1.0 - (fbm(fx * 0.8, fz * 0.8, 4, s ^ 0x77) - 0.5).abs() * 2.0;
253            let width = 0.03 + 0.10 * self.rivers.clamp(0.0, 1.0);
254            if r > 1.0 - width {
255                let t = ((r - (1.0 - width)) / width).clamp(0.0, 1.0);
256                let bed = (self.water_level * dy - 2.0).max(1.0);
257                h += (bed - h) * smooth(t);
258            }
259        }
260
261        (h.clamp(1.0, dy - 1.0)) as u32
262    }
263
264    /// Nivel del agua como índice `y`.
265    #[inline]
266    pub fn water_y(&self, dim: [u32; 3]) -> u32 {
267        (self.water_level * dim[1] as f32) as u32
268    }
269
270    /// Material del voxel sólido `(wx, y, wz)` de una columna de altura `h` con
271    /// `slope` (desnivel con vecinos). Decide arena/roca/nieve por altura+pendiente.
272    fn ground_material(&self, wx: i32, wz: i32, y: u32, h: u32, slope: f32, dim: [u32; 3]) -> Material {
273        let dy = dim[1] as f32;
274        let fh = y as f32 / dy;
275        // Acantilado: la cara superior en pendiente fuerte es roca.
276        if y == h && slope > 2.5 {
277            return self.cliff;
278        }
279        // Cumbre nevada (si la receta la tiene).
280        if self.peak.is_solid() && fh > self.peak_at {
281            return self.peak;
282        }
283        // Jitter para que la transición a roca no sea una línea perfecta.
284        let jitter = hash2(wx, wz.wrapping_mul(31).wrapping_add(y as i32), self.seed ^ 0xABCD) * 0.06 - 0.03;
285        // Roca alta general (debajo de la cumbre): más arriba de 0.72 del alto.
286        if fh + jitter > 0.72 {
287            return self.cliff;
288        }
289        self.ground
290    }
291
292    /// `true` si en la columna `(wx, wz)` brota una planta (gate de densidad por
293    /// hash de mundo → determinista y seamless). Sólo en suelo seco y llano.
294    fn has_flora(&self, wx: i32, wz: i32, h: u32, slope: f32, dim: [u32; 3]) -> bool {
295        if self.flora == Flora::None || self.flora_density <= 0.0 {
296            return false;
297        }
298        if h <= self.water_y(dim) + 1 || slope > 1.5 {
299            return false; // ni en el agua/orilla ni en pendiente
300        }
301        hash2(wx.wrapping_mul(7), wz.wrapping_mul(13), self.seed ^ 0xC4C7) < self.flora_density
302    }
303
304    /// **Construye el mundo**: rellena un `VoxelGrid` de tamaño `dim` con la esquina
305    /// en la columna de mundo `origin = [wx, wz]` (usá `[0,0]` para el mundo
306    /// centrado). Terreno + agua + flora, todo por material. Deja el grid limpio
307    /// de *dirty* (se sube entero).
308    pub fn generate_window(&self, dim: [u32; 3], origin: [i32; 2]) -> VoxelGrid {
309        let [dx, dy, dz] = dim;
310        let mut g = VoxelGrid::new(dim);
311        let (ox, oz) = (origin[0], origin[1]);
312        let water_y = self.water_y(dim);
313
314        let h_at = |lx: i32, lz: i32| self.column_height(ox + lx, oz + lz, dim);
315
316        for lz in 0..dz as i32 {
317            for lx in 0..dx as i32 {
318                let (wx, wz) = (ox + lx, oz + lz);
319                let h = h_at(lx, lz);
320                let slope = (h as i32 - h_at(lx - 1, lz) as i32)
321                    .abs()
322                    .max((h as i32 - h_at(lx, lz - 1) as i32).abs()) as f32;
323
324                // Columna sólida (con grano de materia por vóxel — sello del motor).
325                for y in 0..=h.min(dy - 1) {
326                    let m = self.ground_material(wx, wz, y, h, slope, dim);
327                    g.set(lx as u32, y, lz as u32, grained(m.color(), self.grain, wx, y, wz, self.seed));
328                }
329                // Agua: llena por encima del terreno hasta el nivel del agua.
330                if h < water_y {
331                    for y in (h + 1)..=water_y.min(dy - 1) {
332                        g.set(lx as u32, y, lz as u32, Material::Water.color());
333                    }
334                }
335                // Flora.
336                if self.has_flora(wx, wz, h, slope, dim) {
337                    self.place_flora(&mut g, lx as u32, h + 1, lz as u32, wx, wz, dim);
338                }
339            }
340        }
341
342        g.reset_dirty();
343        g
344    }
345
346    /// Mundo centrado en el origen (`origin = [0,0]`).
347    pub fn generate(&self, dim: [u32; 3]) -> VoxelGrid {
348        self.generate_window(dim, [0, 0])
349    }
350
351    /// Coloca una planta con la base en `(x, base_y, z)` (local al grid). Hoy sólo
352    /// el **cactus** columnar: tronco de 3–6 de alto + 0–2 brazos en L.
353    fn place_flora(&self, g: &mut VoxelGrid, x: u32, base_y: u32, z: u32, wx: i32, wz: i32, dim: [u32; 3]) {
354        if self.flora != Flora::Cactus {
355            return;
356        }
357        let dy = dim[1];
358        let col = Material::Cactus.color();
359        // Altura del tronco: 3..6 determinista por la columna.
360        let th = 3 + (hash2(wx, wz, self.seed ^ 0x1357) * 4.0) as u32;
361        for k in 0..th {
362            let y = base_y + k;
363            if y >= dy {
364                break;
365            }
366            g.set(x, y, z, col);
367        }
368        // Brazos: un par de salientes laterales a media altura (forma de candelabro).
369        let arm_seed = hash2(wx.wrapping_add(1), wz, self.seed ^ 0x2468);
370        if th >= 4 && arm_seed > 0.45 {
371            let ay = base_y + th / 2;
372            // Brazo a +x: un voxel al costado y dos hacia arriba.
373            let arm = [(x.wrapping_add(1), ay, z), (x.wrapping_add(1), ay + 1, z)];
374            for &(ax, ya, az) in &arm {
375                if ax < dim[0] && ya < dy {
376                    g.set(ax, ya, az, col);
377                }
378            }
379            if arm_seed > 0.7 && x > 0 {
380                // Brazo opuesto a -x.
381                let arm2 = [(x - 1, ay + 1, z), (x - 1, ay + 2, z)];
382                for &(ax, ya, az) in &arm2 {
383                    if ya < dy {
384                        g.set(ax, ya, az, col);
385                    }
386                }
387            }
388        }
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn altura_es_funcion_pura_de_mundo() {
398        let r = WorldRecipe::desert(7);
399        let dim = [96, 48, 96];
400        // El mismo punto de mundo da la misma altura, lo pidas desde donde lo pidas.
401        assert_eq!(r.column_height(1234, -567, dim), r.column_height(1234, -567, dim));
402    }
403
404    #[test]
405    fn desierto_es_mas_llano_que_pradera() {
406        let dim = [128, 56, 128];
407        let var = |r: &WorldRecipe| {
408            let mut hs = Vec::new();
409            for z in (0..400).step_by(7) {
410                for x in (0..400).step_by(7) {
411                    hs.push(r.column_height(x, z, dim) as f32);
412                }
413            }
414            let mean = hs.iter().sum::<f32>() / hs.len() as f32;
415            hs.iter().map(|h| (h - mean).powi(2)).sum::<f32>() / hs.len() as f32
416        };
417        let desert = var(&WorldRecipe::desert(42));
418        let grass = var(&WorldRecipe::grassland(42));
419        assert!(desert < grass, "el desierto es más llano: var {desert:.1} vs {grass:.1}");
420    }
421
422    #[test]
423    fn el_desierto_pinta_arena_agua_y_cactus() {
424        let dim = [128, 48, 128];
425        let r = WorldRecipe::desert(3);
426        let g = r.generate(dim);
427        let mut seen = [false; 3]; // [arena, agua, cactus]
428        // La arena lleva grano (perturbación por vóxel) → se detecta por cercanía,
429        // no por igualdad exacta. Agua y cactus van sin grano (color exacto).
430        let near = |a: [u8; 3], b: [u8; 3]| {
431            (a[0] as i32 - b[0] as i32).abs() <= 60
432                && (a[1] as i32 - b[1] as i32).abs() <= 60
433                && (a[2] as i32 - b[2] as i32).abs() <= 60
434        };
435        for z in 0..dim[2] {
436            for x in 0..dim[0] {
437                for y in 0..dim[1] {
438                    if let Some(c) = g.get(x, y, z) {
439                        if c[3] == 0 {
440                            continue;
441                        }
442                        let rgb = [c[0], c[1], c[2]];
443                        if rgb == Material::Water.color() {
444                            seen[1] = true;
445                        } else if rgb == Material::Cactus.color() {
446                            seen[2] = true;
447                        } else if near(rgb, Material::Sand.color()) {
448                            seen[0] = true;
449                        }
450                    }
451                }
452            }
453        }
454        assert!(seen[0], "hay arena");
455        assert!(seen[1], "hay agua (ríos)");
456        assert!(seen[2], "hay cactus");
457    }
458
459    #[test]
460    fn grano_de_materia_perturba_pero_es_determinista() {
461        let base = Material::Sand.color();
462        // grain=0 → color intacto (slab plano).
463        assert_eq!(grained(base, 0.0, 5, 3, 9, 1337), base);
464        // grain>0 → el mismo vóxel no es exactamente el color base (tiene grano)…
465        let g1 = grained(base, 0.6, 5, 3, 9, 1337);
466        assert_ne!(g1, base, "con grano el vóxel se aparta del color liso");
467        // …pero es función pura: mismo (pos, seed) → mismo color (seamless/streaming).
468        let g2 = grained(base, 0.6, 5, 3, 9, 1337);
469        assert_eq!(g1, g2);
470        // Vóxeles vecinos difieren → la superficie no es un color uniforme.
471        let gn = grained(base, 0.6, 6, 3, 9, 1337);
472        assert_ne!(g1, gn, "vóxeles contiguos varían → materia granular, no bloque liso");
473        // El desierto trae grano por defecto.
474        assert!(WorldRecipe::desert(1).grain > 0.0);
475    }
476
477    #[test]
478    fn mismo_seed_mismo_mundo() {
479        let dim = [64, 40, 64];
480        let a = WorldRecipe::desert(99).generate(dim);
481        let b = WorldRecipe::desert(99).generate(dim);
482        // Un muestreo basta: deterministas.
483        for (x, y, z) in [(10, 12, 10), (30, 8, 44), (60, 20, 5)] {
484            assert_eq!(a.get(x, y, z), b.get(x, y, z), "({x},{y},{z}) difiere");
485        }
486    }
487}