tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4
llimphi-voxel/src/worldgen.rs
Historia
Quién tocó qué
crudo
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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}