gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 llimphi-voxel/src/worldgen.rs Rust · 435 líneas · 16.3 KB Historia Quién tocó qué crudo

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · 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}
155
156impl WorldRecipe {
157    /// **Desierto llano**: arena, pocas montañas, pocos ríos, cactus ralos. El
158    /// mundo de apertura del corto.
159    pub fn desert(seed: u32) -> Self {
160        Self {
161            seed,
162            base: 0.30,
163            dune: 0.05,    // ondulación suave (dunas bajas)
164            relief: 0.45,  // las pocas montañas que hay, altas
165            mountains: 0.12, // pero MUY ralas
166            water_level: 0.26, // por debajo del suelo base → casi sin agua salvo ríos
167            rivers: 0.18,  // pocos ríos
168            ground: Material::Sand,
169            cliff: Material::Rock,
170            peak: Material::Air, // sin nieve en el desierto
171            peak_at: 1.0,
172            flora: Flora::Cactus,
173            flora_density: 0.010,
174        }
175    }
176
177    /// **Pradera**: el mundo verde clásico (equivalente conceptual a [`terrain`]
178    /// (crate::terrain)), expresado como receta para mostrar que el creador es
179    /// general.
180    pub fn grassland(seed: u32) -> Self {
181        Self {
182            seed,
183            base: 0.22,
184            dune: 0.10,
185            relief: 0.7,
186            mountains: 0.5,
187            water_level: 0.30,
188            rivers: 0.25,
189            ground: Material::Grass,
190            cliff: Material::Rock,
191            peak: Material::Snow,
192            peak_at: 0.80,
193            flora: Flora::None,
194            flora_density: 0.0,
195        }
196    }
197
198    /// **Altura del terreno** (índice `y` del voxel sólido superior) en la columna
199    /// de **mundo** `(wx, wz)`. Función pura: mismo punto → misma altura en
200    /// cualquier ventana (continuidad para el streaming). Combina llanura base +
201    /// dunas suaves + montañas *gated* (sólo asoman donde el fbm supera un umbral,
202    /// así `mountains` bajo deja casi todo plano) + ríos tallados hacia el agua.
203    pub fn column_height(&self, wx: i32, wz: i32, dim: [u32; 3]) -> u32 {
204        let dy = dim[1] as f32;
205        let scale = world_scale(dim);
206        let s = self.seed;
207        let (fx, fz) = (wx as f32 * scale, wz as f32 * scale);
208
209        let base = self.base * dy;
210        // Dunas: ondulación suave centrada en cero.
211        let dunes = (fbm(fx * 1.7, fz * 1.7, 4, s ^ 0x11) - 0.5) * 2.0;
212        let dune_h = dunes * self.dune * dy;
213        // Montañas gated: sólo la parte alta del fbm sobresale. `mountains` baja el
214        // umbral → más raras y aisladas.
215        let c = fbm(fx, fz, 6, s);
216        let thr = 1.0 - self.mountains.clamp(0.0, 1.0);
217        let m = ((c - thr).max(0.0) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
218        let mtn_h = smooth(m) * self.relief * dy;
219
220        let mut h = base + dune_h + mtn_h;
221
222        // Ríos: una "cresta" de ruido (ridged) marca líneas; cerca de la línea el
223        // terreno se hunde hasta un lecho bajo el nivel del agua.
224        if self.rivers > 0.0 {
225            let r = 1.0 - (fbm(fx * 0.8, fz * 0.8, 4, s ^ 0x77) - 0.5).abs() * 2.0;
226            let width = 0.03 + 0.10 * self.rivers.clamp(0.0, 1.0);
227            if r > 1.0 - width {
228                let t = ((r - (1.0 - width)) / width).clamp(0.0, 1.0);
229                let bed = (self.water_level * dy - 2.0).max(1.0);
230                h += (bed - h) * smooth(t);
231            }
232        }
233
234        (h.clamp(1.0, dy - 1.0)) as u32
235    }
236
237    /// Nivel del agua como índice `y`.
238    #[inline]
239    pub fn water_y(&self, dim: [u32; 3]) -> u32 {
240        (self.water_level * dim[1] as f32) as u32
241    }
242
243    /// Material del voxel sólido `(wx, y, wz)` de una columna de altura `h` con
244    /// `slope` (desnivel con vecinos). Decide arena/roca/nieve por altura+pendiente.
245    fn ground_material(&self, wx: i32, wz: i32, y: u32, h: u32, slope: f32, dim: [u32; 3]) -> Material {
246        let dy = dim[1] as f32;
247        let fh = y as f32 / dy;
248        // Acantilado: la cara superior en pendiente fuerte es roca.
249        if y == h && slope > 2.5 {
250            return self.cliff;
251        }
252        // Cumbre nevada (si la receta la tiene).
253        if self.peak.is_solid() && fh > self.peak_at {
254            return self.peak;
255        }
256        // Jitter para que la transición a roca no sea una línea perfecta.
257        let jitter = hash2(wx, wz.wrapping_mul(31).wrapping_add(y as i32), self.seed ^ 0xABCD) * 0.06 - 0.03;
258        // Roca alta general (debajo de la cumbre): más arriba de 0.72 del alto.
259        if fh + jitter > 0.72 {
260            return self.cliff;
261        }
262        self.ground
263    }
264
265    /// `true` si en la columna `(wx, wz)` brota una planta (gate de densidad por
266    /// hash de mundo → determinista y seamless). Sólo en suelo seco y llano.
267    fn has_flora(&self, wx: i32, wz: i32, h: u32, slope: f32, dim: [u32; 3]) -> bool {
268        if self.flora == Flora::None || self.flora_density <= 0.0 {
269            return false;
270        }
271        if h <= self.water_y(dim) + 1 || slope > 1.5 {
272            return false; // ni en el agua/orilla ni en pendiente
273        }
274        hash2(wx.wrapping_mul(7), wz.wrapping_mul(13), self.seed ^ 0xC4C7) < self.flora_density
275    }
276
277    /// **Construye el mundo**: rellena un `VoxelGrid` de tamaño `dim` con la esquina
278    /// en la columna de mundo `origin = [wx, wz]` (usá `[0,0]` para el mundo
279    /// centrado). Terreno + agua + flora, todo por material. Deja el grid limpio
280    /// de *dirty* (se sube entero).
281    pub fn generate_window(&self, dim: [u32; 3], origin: [i32; 2]) -> VoxelGrid {
282        let [dx, dy, dz] = dim;
283        let mut g = VoxelGrid::new(dim);
284        let (ox, oz) = (origin[0], origin[1]);
285        let water_y = self.water_y(dim);
286
287        let h_at = |lx: i32, lz: i32| self.column_height(ox + lx, oz + lz, dim);
288
289        for lz in 0..dz as i32 {
290            for lx in 0..dx as i32 {
291                let (wx, wz) = (ox + lx, oz + lz);
292                let h = h_at(lx, lz);
293                let slope = (h as i32 - h_at(lx - 1, lz) as i32)
294                    .abs()
295                    .max((h as i32 - h_at(lx, lz - 1) as i32).abs()) as f32;
296
297                // Columna sólida.
298                for y in 0..=h.min(dy - 1) {
299                    let m = self.ground_material(wx, wz, y, h, slope, dim);
300                    g.set(lx as u32, y, lz as u32, m.color());
301                }
302                // Agua: llena por encima del terreno hasta el nivel del agua.
303                if h < water_y {
304                    for y in (h + 1)..=water_y.min(dy - 1) {
305                        g.set(lx as u32, y, lz as u32, Material::Water.color());
306                    }
307                }
308                // Flora.
309                if self.has_flora(wx, wz, h, slope, dim) {
310                    self.place_flora(&mut g, lx as u32, h + 1, lz as u32, wx, wz, dim);
311                }
312            }
313        }
314
315        g.reset_dirty();
316        g
317    }
318
319    /// Mundo centrado en el origen (`origin = [0,0]`).
320    pub fn generate(&self, dim: [u32; 3]) -> VoxelGrid {
321        self.generate_window(dim, [0, 0])
322    }
323
324    /// Coloca una planta con la base en `(x, base_y, z)` (local al grid). Hoy sólo
325    /// el **cactus** columnar: tronco de 3–6 de alto + 0–2 brazos en L.
326    fn place_flora(&self, g: &mut VoxelGrid, x: u32, base_y: u32, z: u32, wx: i32, wz: i32, dim: [u32; 3]) {
327        if self.flora != Flora::Cactus {
328            return;
329        }
330        let dy = dim[1];
331        let col = Material::Cactus.color();
332        // Altura del tronco: 3..6 determinista por la columna.
333        let th = 3 + (hash2(wx, wz, self.seed ^ 0x1357) * 4.0) as u32;
334        for k in 0..th {
335            let y = base_y + k;
336            if y >= dy {
337                break;
338            }
339            g.set(x, y, z, col);
340        }
341        // Brazos: un par de salientes laterales a media altura (forma de candelabro).
342        let arm_seed = hash2(wx.wrapping_add(1), wz, self.seed ^ 0x2468);
343        if th >= 4 && arm_seed > 0.45 {
344            let ay = base_y + th / 2;
345            // Brazo a +x: un voxel al costado y dos hacia arriba.
346            let arm = [(x.wrapping_add(1), ay, z), (x.wrapping_add(1), ay + 1, z)];
347            for &(ax, ya, az) in &arm {
348                if ax < dim[0] && ya < dy {
349                    g.set(ax, ya, az, col);
350                }
351            }
352            if arm_seed > 0.7 && x > 0 {
353                // Brazo opuesto a -x.
354                let arm2 = [(x - 1, ay + 1, z), (x - 1, ay + 2, z)];
355                for &(ax, ya, az) in &arm2 {
356                    if ya < dy {
357                        g.set(ax, ya, az, col);
358                    }
359                }
360            }
361        }
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn altura_es_funcion_pura_de_mundo() {
371        let r = WorldRecipe::desert(7);
372        let dim = [96, 48, 96];
373        // El mismo punto de mundo da la misma altura, lo pidas desde donde lo pidas.
374        assert_eq!(r.column_height(1234, -567, dim), r.column_height(1234, -567, dim));
375    }
376
377    #[test]
378    fn desierto_es_mas_llano_que_pradera() {
379        let dim = [128, 56, 128];
380        let var = |r: &WorldRecipe| {
381            let mut hs = Vec::new();
382            for z in (0..400).step_by(7) {
383                for x in (0..400).step_by(7) {
384                    hs.push(r.column_height(x, z, dim) as f32);
385                }
386            }
387            let mean = hs.iter().sum::<f32>() / hs.len() as f32;
388            hs.iter().map(|h| (h - mean).powi(2)).sum::<f32>() / hs.len() as f32
389        };
390        let desert = var(&WorldRecipe::desert(42));
391        let grass = var(&WorldRecipe::grassland(42));
392        assert!(desert < grass, "el desierto es más llano: var {desert:.1} vs {grass:.1}");
393    }
394
395    #[test]
396    fn el_desierto_pinta_arena_agua_y_cactus() {
397        let dim = [128, 48, 128];
398        let r = WorldRecipe::desert(3);
399        let g = r.generate(dim);
400        let mut seen = [false; 3]; // [arena, agua, cactus]
401        for z in 0..dim[2] {
402            for x in 0..dim[0] {
403                for y in 0..dim[1] {
404                    if let Some(c) = g.get(x, y, z) {
405                        if c[3] == 0 {
406                            continue;
407                        }
408                        let rgb = [c[0], c[1], c[2]];
409                        if rgb == Material::Sand.color() {
410                            seen[0] = true;
411                        } else if rgb == Material::Water.color() {
412                            seen[1] = true;
413                        } else if rgb == Material::Cactus.color() {
414                            seen[2] = true;
415                        }
416                    }
417                }
418            }
419        }
420        assert!(seen[0], "hay arena");
421        assert!(seen[1], "hay agua (ríos)");
422        assert!(seen[2], "hay cactus");
423    }
424
425    #[test]
426    fn mismo_seed_mismo_mundo() {
427        let dim = [64, 40, 64];
428        let a = WorldRecipe::desert(99).generate(dim);
429        let b = WorldRecipe::desert(99).generate(dim);
430        // Un muestreo basta: deterministas.
431        for (x, y, z) in [(10, 12, 10), (30, 8, 44), (60, 20, 5)] {
432            assert_eq!(a.get(x, y, z), b.get(x, y, z), "({x},{y},{z}) difiere");
433        }
434    }
435}