tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc
llimphi-voxel/src/worldgen.rs
Historia
Quién tocó qué
crudo
feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director
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}