tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
1//! Contenido de la escena: terreno voxel + atmósfera + un "monumento" malla
2//! flotante. Aísla el *qué se muestra* del *cómo se maneja* (`main.rs`), así la
3//! app puede ganar personalidad (más estructuras, entidades, reglas) tocando
4//! sólo aquí, sin reescribir el bucle.
5
6use llimphi_3d::glam::{Mat4, Vec3};
7use llimphi_3d::{
8 Atmosphere, Camera3d, Hud, HudQuad, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer,
9};
10use llimphi_ui::llimphi_hal::wgpu;
11use llimphi_voxel::{
12 multitud_caminando, Bicho, BichoUso, Bioma, BiomaPalette, Caminante, CasaTipo, CasaUso,
13 Critter, DetalleSer, Enjambre, Player, Sembrada, Sembrado,
14};
15
16/// Paleta de bichos (ovejas, marrones, terrosos, celestes…).
17const HERD_PALETTE: [[u8; 3]; 5] = [
18 [236, 236, 232],
19 [205, 170, 125],
20 [120, 92, 70],
21 [96, 132, 176],
22 [206, 198, 96],
23];
24
25/// Formato de la textura intermedia de Llimphi (target de `gpu_paint_with`).
26pub const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
27
28/// Mundo render-able + editable: el motor voxel (terreno) + una malla de
29/// triángulos (el monumento), compuestos por [`Scene3d`] en un depth
30/// compartido. Conserva el [`VoxelGrid`] para editar (romper/colocar) por
31/// raycast y subir incremental con `sync`.
32pub struct World {
33 scene: Scene3d,
34 voxel: VoxelRenderer,
35 monument: Renderer3d,
36 grid: VoxelGrid,
37 dim: [u32; 3],
38 /// Jugador en primera persona (modo "explorar"): camina el terreno con
39 /// gravedad y colisión. En modo órbita simplemente se ignora.
40 player: Player,
41 /// Bichos que deambulan el terreno (misma física que el jugador). Se
42 /// dibujan como cajas en el pase de ray-march (`VoxelRenderer::entities`).
43 critters: Vec<Critter>,
44 /// Overlay screen-space para la mira de primera persona.
45 hud: Hud,
46 /// Si dibujar el monumento-malla flotante (se apaga p.ej. en `--poses`).
47 show_monument: bool,
48 /// **Mapa real** para streaming infinito (modo `--mapa`): si está, la ventana se
49 /// regenera de aquí al caminar, sin borde. `None` en los mundos procedurales.
50 mapa: Option<std::sync::Arc<nahual_geo_voxel::MapaVoxel>>,
51 /// Columna de mundo `[wx, wz]` donde cae la esquina local `(0,0)` de la ventana.
52 origin: [i32; 2],
53 /// Origen de mundo INICIAL de la ventana (al construir). El origen de streaming
54 /// que consume `scroll_to` (que arranca en `[0,0,0]` como el `brick_origin` del
55 /// renderer) es `origin - origin0` → así el ring buffer sube sólo la franja nueva.
56 origin0: [i32; 2],
57 /// **Ceniza**: los voxels que se desprendieron del mundo y andan por el aire.
58 /// Se dibujan como cajas en el mismo pase que los critters.
59 ceniza: Enjambre,
60 /// **La siembra del bioma**: cada animal y cada planta que la ventana estampó,
61 /// con lo necesario para sacarlo y volver a ponerlo. Es lo que deja que el que
62 /// tenés al lado sea un ser analítico y el del fondo siga siendo su estampa de
63 /// voxels. Vacía en los mundos que no vienen de un bioma (mapa real, desierto).
64 siembra: Vec<Sembrado>,
65 /// El bioma que sembró, para volver a estampar lo que se aleja.
66 bioma: Option<(Bioma, BiomaPalette)>,
67 /// Índices de `siembra` que AHORA son ser SDF (y están borrados del grid).
68 en_sdf: Vec<usize>,
69 /// Gente caminando cerca del jugador (la multitud del catálogo).
70 gente: Vec<Caminante>,
71 /// Reloj del mundo: la fase de todo lo que se mueve (paso, viento, aleteo).
72 reloj: f32,
73}
74
75impl World {
76 /// Paso de reubicación de la ventana de streaming (voxels): se regenera sólo al
77 /// cruzar un múltiplo de esto → no se rehace el mundo cada voxel caminado.
78 const STREAM_STEP: i32 = 16;
79
80 /// **Mundo desde un mapa real, STREAMEADO** (modo `--mapa`): la ventana de `dim`
81 /// se desliza sobre un `MapaVoxel` grande a medida que el jugador camina, así el
82 /// mundo **no tiene borde** (Minecraft-style: se explora sin agotarlo; más allá
83 /// del mapa, el relieve del borde/mar se extiende). Reusa física/cámara/HUD.
84 pub fn build_mapa(
85 device: &wgpu::Device,
86 queue: &wgpu::Queue,
87 mapa: nahual_geo_voxel::MapaVoxel,
88 dim: [u32; 3],
89 ) -> Self {
90 // Ventana inicial centrada en el centro del mapa.
91 let origin = [
92 mapa.proj.ancho as i32 / 2 - dim[0] as i32 / 2,
93 mapa.proj.largo as i32 / 2 - dim[2] as i32 / 2,
94 ];
95 let grid = mapa.generar_ventana(dim, origin);
96 let mut voxel = VoxelRenderer::new(device, queue, FMT, &grid);
97 voxel.sun_dir = [0.5, 0.55, 0.42];
98 voxel.atmosphere = Atmosphere {
99 sky_zenith: [72, 124, 198],
100 sky_horizon: [206, 220, 236],
101 // Niebla moderada que difumina el borde lejano de la ventana.
102 fog_density: 1.1 / dim[0] as f32,
103 god_rays: 0.4,
104 };
105 let monument = Renderer3d::new(device, FMT);
106 // Reposar en una CALLE (columna baja), no en un techo.
107 let (cx, cz) = (dim[0] / 2, dim[2] / 2);
108 let (mut sx, mut sz, mut best) = (cx, cz, u32::MAX);
109 let r = (dim[0] / 4).max(8);
110 for z in cz.saturating_sub(r)..(cz + r).min(dim[2] - 1) {
111 for x in cx.saturating_sub(r)..(cx + r).min(dim[0] - 1) {
112 if let Some(h) = grid.height_at(x, z) {
113 if h < best {
114 best = h;
115 sx = x;
116 sz = z;
117 }
118 }
119 }
120 }
121 let player = Player::spawn_on(&grid, sx, sz);
122 let hud = Hud::new(device, FMT);
123 let mut world = Self {
124 scene: Scene3d::new(),
125 voxel,
126 monument,
127 grid,
128 dim,
129 player,
130 critters: Vec::new(),
131 hud,
132 show_monument: false,
133 mapa: Some(std::sync::Arc::new(mapa)),
134 origin,
135 origin0: origin,
136 ceniza: Enjambre::ceniza(0x1CE),
137 siembra: Vec::new(),
138 bioma: None,
139 en_sdf: Vec::new(),
140 gente: Vec::new(),
141 reloj: 0.0,
142 };
143 for _ in 0..40 {
144 world.tick_sin_gpu(1.0 / 30.0);
145 }
146 world
147 }
148
149 /// **Streaming toroidal**: si el jugador se alejó del centro más de un
150 /// [`STREAM_STEP`] (múltiplo de brick), desliza la ventana regenerando el terreno
151 /// del `MapaVoxel` en el origen nuevo y **sube sólo la franja que entra** vía
152 /// [`VoxelRenderer::scroll_to`] (ring buffer, sin re-subir la ventana entera → sin
153 /// tirón). Traslada al jugador el mismo delta → queda quieto en el mundo mientras
154 /// el terreno se re-centra bajo él. Sin borde. No-op fuera de `--mapa`.
155 pub fn follow_mapa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
156 let Some(mapa) = self.mapa.clone() else { return };
157 let step = Self::STREAM_STEP;
158 let cx = self.dim[0] as i32 / 2;
159 let cz = self.dim[2] as i32 / 2;
160 let ddx = ((self.player.pos.x.floor() as i32 - cx) / step) * step;
161 let ddz = ((self.player.pos.z.floor() as i32 - cz) / step) * step;
162 if ddx == 0 && ddz == 0 {
163 return;
164 }
165 self.origin[0] += ddx;
166 self.origin[1] += ddz;
167 self.grid = mapa.generar_ventana(self.dim, self.origin);
168 // Origen de streaming del renderer = delta acumulado desde el arranque
169 // (su `brick_origin` empezó en 0). Sube sólo la franja nueva.
170 let stream = [self.origin[0] - self.origin0[0], 0, self.origin[1] - self.origin0[1]];
171 self.voxel.scroll_to(device, queue, stream, &self.grid);
172 // El jugador retrocede el mismo delta → posición de mundo intacta.
173 self.player.pos.x -= ddx as f32;
174 self.player.pos.z -= ddz as f32;
175 }
176
177 /// Construye el mundo: terreno procedural (de `llimphi-voxel`) subido al
178 /// brick pool, atmósfera diurna, y un cubo-malla coloreado como monumento.
179 /// Necesita `device`/`queue` (de ahí que se construya perezoso, en la 1ª
180 /// pintada GPU).
181 pub fn build(device: &wgpu::Device, queue: &wgpu::Queue, dim_xz: u32, seed: u32) -> Self {
182 let dy = (dim_xz * 4 / 10).max(48);
183 let dim = [dim_xz, dy, dim_xz];
184
185 let grid = llimphi_voxel::terrain(dim, seed);
186 let mut voxel = VoxelRenderer::new(device, queue, FMT, &grid);
187 voxel.sun_dir = [0.55, 0.5, 0.32];
188 voxel.atmosphere = Atmosphere {
189 sky_zenith: [64, 118, 196],
190 sky_horizon: [202, 218, 236],
191 fog_density: 0.5 / dim_xz as f32,
192 god_rays: 0.6,
193 };
194
195 let monument = Renderer3d::new(device, FMT);
196
197 // Jugador posado sobre la columna central del terreno.
198 let player = Player::spawn_on(&grid, dim[0] / 2, dim[2] / 2);
199
200 // Manada esparcida por el interior, en una grilla con jitter
201 // determinista (cada bicho su semilla → rumbos distintos).
202 let margin = 16u32;
203 let (cols, rows) = (7u32, 5u32);
204 let mut critters = Vec::with_capacity((cols * rows) as usize);
205 let mut k: u32 = 0x9e3779b9;
206 let span_x = dim[0].saturating_sub(2 * margin).max(1);
207 let span_z = dim[2].saturating_sub(2 * margin).max(1);
208 for r in 0..rows {
209 for c in 0..cols {
210 // Jitter LCG por celda para que no quede una grilla perfecta.
211 k = k.wrapping_mul(1664525).wrapping_add(1013904223);
212 let jx = (k >> 16) % 9;
213 k = k.wrapping_mul(1664525).wrapping_add(1013904223);
214 let jz = (k >> 16) % 9;
215 let x = margin + span_x * c / (cols - 1) + jx;
216 let z = margin + span_z * r / (rows - 1) + jz;
217 let color = HERD_PALETTE[(r * cols + c) as usize % HERD_PALETTE.len()];
218 critters.push(Critter::spawn_on(&grid, x.min(dim[0] - 1), z.min(dim[2] - 1), color, k));
219 }
220 }
221
222 let hud = Hud::new(device, FMT);
223
224 let mut world = Self {
225 scene: Scene3d::new(),
226 voxel,
227 monument,
228 grid,
229 dim,
230 player,
231 critters,
232 hud,
233 show_monument: true,
234 mapa: None,
235 origin: [0, 0],
236 origin0: [0, 0],
237 ceniza: Enjambre::ceniza(0x1CE),
238 siembra: Vec::new(),
239 bioma: None,
240 en_sdf: Vec::new(),
241 gente: Vec::new(),
242 reloj: 0.0,
243 };
244 // Calentar la manada para que arranque desparramada (no en grilla).
245 for _ in 0..150 {
246 world.tick_sin_gpu(1.0 / 30.0);
247 }
248 world
249 }
250
251 /// Construye el **mundo completo**: TODO lo que se puede crear con el motor,
252 /// junto, para caminarlo — terreno con colinas, **flora** (árboles, arbustos,
253 /// flores, pasto), **minerales** que asoman en los acantilados, un **pueblo
254 /// procedural** (casas de varios estilos + plazas con fuente/bancos/árboles) y
255 /// **fauna** variada (vacas, ovejas, ciervos, zorros, caballos, gallinas,
256 /// mariposas…), todo esparcido por la misma semilla. Sin monumento ni manada
257 /// analítica: la vida está horneada en el propio terreno.
258 pub fn build_todo(device: &wgpu::Device, queue: &wgpu::Queue, dim_xz: u32, seed: u32) -> Self {
259 let dy = (dim_xz * 4 / 10).max(48);
260 let dim = [dim_xz, dy, dim_xz];
261
262 // Partimos de la pradera del proyecto de arranque (ya trae flora +
263 // minerales + fauna) y le sumamos un pueblo procedural encima.
264 let project = llimphi_voxel::Project::starter();
265 let mut bioma = project
266 .biomas
267 .iter()
268 .find(|b| b.name == "pradera")
269 .cloned()
270 .expect("bioma pradera");
271 // Colinas suaves: hay acantilados donde asoman los minerales, pero se
272 // camina cómodo y las casas encuentran dónde asentarse.
273 bioma.relief = 0.4;
274 bioma.mountains = 0.3;
275 bioma.water_level = 0.0;
276 bioma.rivers = 0.0;
277 // Un pueblo mezclado, en manchas conectadas por rutas (campo abierto en
278 // medio) → se nace en pradera y se camina hacia el pueblo.
279 bioma.densidad_urbana = 0.42;
280 bioma.casas = vec![
281 CasaUso { tipo: CasaTipo::Cabana, densidad: 0.3 },
282 CasaUso { tipo: CasaTipo::Moderna, densidad: 0.22 },
283 CasaUso { tipo: CasaTipo::Granja, densidad: 0.2 },
284 CasaUso { tipo: CasaTipo::Adobe, densidad: 0.2 },
285 CasaUso { tipo: CasaTipo::Tienda, densidad: 0.15 },
286 CasaUso { tipo: CasaTipo::Torre, densidad: 0.1 },
287 CasaUso { tipo: CasaTipo::Flintstone, densidad: 0.12 },
288 CasaUso { tipo: CasaTipo::Hobbit, densidad: 0.12 },
289 CasaUso { tipo: CasaTipo::Duplex, densidad: 0.15 },
290 ];
291 // Fauna variada (además de la que ya trae la pradera). Desde que existe el
292 // bestiario SDF, el catálogo del bioma puede ser el catálogo entero: lo que
293 // se ve de cerca es el ser analítico, y lo de lejos su estampa de voxels.
294 bioma.fauna.extend_from_slice(&[
295 BichoUso { bicho: Bicho::Ciervo, densidad: 0.20 },
296 BichoUso { bicho: Bicho::Zorro, densidad: 0.12 },
297 BichoUso { bicho: Bicho::Caballo, densidad: 0.14 },
298 BichoUso { bicho: Bicho::Gallina, densidad: 0.16 },
299 BichoUso { bicho: Bicho::Pato, densidad: 0.12 },
300 BichoUso { bicho: Bicho::Cabra, densidad: 0.14 },
301 BichoUso { bicho: Bicho::Cerdo, densidad: 0.12 },
302 BichoUso { bicho: Bicho::Pajaro, densidad: 0.16 },
303 BichoUso { bicho: Bicho::Perro, densidad: 0.10 },
304 BichoUso { bicho: Bicho::Gato, densidad: 0.08 },
305 BichoUso { bicho: Bicho::Burro, densidad: 0.08 },
306 BichoUso { bicho: Bicho::Llama, densidad: 0.08 },
307 BichoUso { bicho: Bicho::Conejo, densidad: 0.14 },
308 BichoUso { bicho: Bicho::Ardilla, densidad: 0.10 },
309 BichoUso { bicho: Bicho::Mariposa, densidad: 0.14 },
310 BichoUso { bicho: Bicho::Cardenal, densidad: 0.10 },
311 BichoUso { bicho: Bicho::Gorrion, densidad: 0.12 },
312 BichoUso { bicho: Bicho::Cuervo, densidad: 0.08 },
313 BichoUso { bicho: Bicho::Ganso, densidad: 0.08 },
314 BichoUso { bicho: Bicho::Serpiente, densidad: 0.06 },
315 ]);
316 // Y la vegetación. La pradera del catálogo viene MUY tupida (árboles a
317 // 0,008 por columna): con el bosque cerrado no se ve un animal ni a diez
318 // pasos, y la primera toma salió con la cámara metida adentro de un
319 // arbusto. Acá se rehace la lista — menos árboles, y adelante los que la
320 // técnica analítica sabe dibujar bien de cerca (el orden manda: gana el
321 // primero que acierta en la columna).
322 {
323 use llimphi_voxel::{Forma, ObjetoUso};
324 let hoja = bioma.objetos.first().map(|o| o.material).unwrap_or(0);
325 let flor = bioma.objetos.last().map(|o| o.material).unwrap_or(hoja);
326 bioma.objetos = vec![
327 ObjetoUso { material: hoja, densidad: 0.0022, forma: Forma::Ficus },
328 ObjetoUso { material: hoja, densidad: 0.0016, forma: Forma::Sauce },
329 ObjetoUso { material: hoja, densidad: 0.0018, forma: Forma::Jacaranda },
330 ObjetoUso { material: hoja, densidad: 0.0035, forma: Forma::Arbol },
331 ObjetoUso { material: hoja, densidad: 0.0045, forma: Forma::Arbusto },
332 ObjetoUso { material: flor, densidad: 0.0060, forma: Forma::Girasol },
333 ObjetoUso { material: flor, densidad: 0.0070, forma: Forma::Margarita },
334 ObjetoUso { material: flor, densidad: 0.0080, forma: Forma::Lavanda },
335 ObjetoUso { material: hoja, densidad: 0.0060, forma: Forma::Helecho },
336 ObjetoUso { material: hoja, densidad: 0.0300, forma: Forma::Pasto },
337 ];
338 }
339 let palette = project.bioma_palette(&bioma);
340 // Con siembra: la lista de lo que quedó plantado, para que el anillo SDF
341 // pueda cambiar de técnica a los que tengas al lado (`repartir_sdf`).
342 let (grid, siembra) = bioma.generate_window_con_siembra(seed, &palette, dim, [0, 0]);
343 let mut voxel = VoxelRenderer::new(device, queue, FMT, &grid);
344 voxel.sun_dir = [0.5, 0.52, 0.42];
345 voxel.atmosphere = Atmosphere {
346 sky_zenith: [72, 124, 198],
347 sky_horizon: [206, 220, 236],
348 fog_density: 0.45 / dim_xz as f32,
349 god_rays: 0.5,
350 };
351
352 let monument = Renderer3d::new(device, FMT);
353 // Buscamos una columna ABIERTA (fuera de mancha de pueblo) cerca del centro
354 // para nacer en el campo, no dentro de una casa. Espiral de búsqueda.
355 let (mut sx, mut sz) = (dim[0] / 2, dim[2] / 2);
356 'find: for r in 0..(dim[0] / 2) {
357 for &(dx, dz) in &[(r, 0i32), (0, r as i32), (r, r as i32), (r, -(r as i32))] {
358 let x = ((dim[0] / 2) as i32 + dx as i32).clamp(2, dim[0] as i32 - 3) as u32;
359 let z = ((dim[2] / 2) as i32 + dz).clamp(2, dim[2] as i32 - 3) as u32;
360 if !bioma.in_town(seed, x as i32, z as i32, dim) {
361 sx = x;
362 sz = z;
363 break 'find;
364 }
365 }
366 }
367 let player = Player::spawn_on(&grid, sx, sz);
368 let hud = Hud::new(device, FMT);
369
370 // Además de la fauna horneada en el terreno, un puñado de bichos que
371 // DEAMBULAN (misma física que el jugador) para que el mundo tenga
372 // movimiento — se dibujan como cajas en el pase de ray-march.
373 let mut critters = Vec::new();
374 let mut k: u32 = 0x1234_5678 ^ seed;
375 for i in 0..14u32 {
376 k = k.wrapping_mul(1664525).wrapping_add(1013904223);
377 let x = 20 + (k >> 8) % dim[0].saturating_sub(40).max(1);
378 k = k.wrapping_mul(1664525).wrapping_add(1013904223);
379 let z = 20 + (k >> 8) % dim[2].saturating_sub(40).max(1);
380 let color = HERD_PALETTE[i as usize % HERD_PALETTE.len()];
381 critters.push(Critter::spawn_on(&grid, x.min(dim[0] - 1), z.min(dim[2] - 1), color, k));
382 }
383
384 let mut world = Self {
385 scene: Scene3d::new(),
386 voxel,
387 monument,
388 grid,
389 dim,
390 player,
391 critters,
392 hud,
393 show_monument: false,
394 mapa: None,
395 origin: [0, 0],
396 origin0: [0, 0],
397 ceniza: Enjambre::ceniza(0x1CE),
398 siembra,
399 bioma: Some((bioma, palette)),
400 en_sdf: Vec::new(),
401 // Gente caminando por el campo, cerca de donde se nace: la multitud del
402 // catálogo puesta en el mundo. Se posan sobre el relieve cada cuadro.
403 gente: multitud_caminando(
404 seed ^ 0x6E7E,
405 Self::GENTE,
406 Vec3::new(sx as f32 - 14.0, 0.0, sz as f32 - 18.0),
407 28.0,
408 40.0,
409 0.0,
410 ),
411 reloj: 0.0,
412 };
413 for _ in 0..120 {
414 world.tick_sin_gpu(1.0 / 30.0);
415 }
416 world
417 }
418
419 /// Construye un mundo de **desierto** (vía [`WorldRecipe::desert`](llimphi_voxel::WorldRecipe)):
420 /// llano de arena con pocas montañas/ríos y cactus ralos, sin manada (escena
421 /// solemne del corto de nacimiento) y sin el monumento. Atmósfera diurna cálida.
422 /// Pensado para el modo `--born`.
423 pub fn build_desert(device: &wgpu::Device, queue: &wgpu::Queue, dim_xz: u32, seed: u32) -> Self {
424 let dy = (dim_xz * 4 / 10).max(48);
425 let dim = [dim_xz, dy, dim_xz];
426
427 // Desierto **llano** (como lo pidió el corto): sin mesas/relieve ni ríos,
428 // sólo una ondulación de dunas mínima. Así el huevo siempre tiene despeje y
429 // el plano de seguimiento (que retrocede detrás del sujeto) ve arena abierta
430 // en vez de meterse dentro de una columna de terreno.
431 // El bioma de desierto del proyecto de arranque, resuelto a su paleta y
432 // aplanado (sin mesas/relieve ni ríos) — la semilla del CLI manda.
433 let project = llimphi_voxel::Project::starter();
434 let mut bioma = project
435 .biomas
436 .iter()
437 .find(|b| b.name == "desierto")
438 .cloned()
439 .expect("bioma desierto");
440 bioma.mountains = 0.0;
441 bioma.relief = 0.0;
442 bioma.dune = 0.035;
443 bioma.rivers = 0.0;
444 let palette = project.bioma_palette(&bioma);
445 let grid = bioma.generate_window(seed, &palette, dim, [0, 0]);
446 let mut voxel = VoxelRenderer::new(device, queue, FMT, &grid);
447 voxel.sun_dir = [0.5, 0.62, 0.30]; // sol alto y cálido
448 voxel.atmosphere = Atmosphere {
449 sky_zenith: [108, 152, 206], // azul de mediodía
450 sky_horizon: [226, 212, 178], // horizonte arenoso/caluroso
451 fog_density: 0.18 / dim_xz as f32, // baja: hay tomas aéreas (cámara lejos)
452 god_rays: 0.5,
453 };
454
455 let monument = Renderer3d::new(device, FMT);
456 let player = Player::spawn_on(&grid, dim[0] / 2, dim[2] / 2);
457 let hud = Hud::new(device, FMT);
458
459 Self {
460 scene: Scene3d::new(),
461 voxel,
462 monument,
463 grid,
464 dim,
465 player,
466 critters: Vec::new(), // desierto vacío
467 hud,
468 show_monument: false,
469 mapa: None,
470 origin: [0, 0],
471 origin0: [0, 0],
472 ceniza: Enjambre::ceniza(0x1CE),
473 siembra: Vec::new(),
474 bioma: None,
475 en_sdf: Vec::new(),
476 gente: Vec::new(),
477 reloj: 0.0,
478 }
479 }
480
481 /// Avanza un frame de vida: deambula la manada y vuelca sus cajas al
482 /// renderer (la capa de entidades del ray-march). Barato (instancing
483 /// analítico) — llamar cada frame, en cualquier modo.
484 pub fn tick(&mut self, queue: &wgpu::Queue, dt: f32) {
485 self.tick_sin_gpu(dt);
486 // El anillo SDF: quién deja de ser voxel y pasa a ser analítico. Toca el
487 // grid, así que necesita la cola para subir los bricks que cambiaron.
488 self.repartir_sdf(queue);
489 }
490
491 /// La parte del cuadro que no toca la GPU (la usa el constructor para
492 /// «asentar» el mundo antes de que exista un cuadro).
493 pub fn tick_sin_gpu(&mut self, dt: f32) {
494 self.reloj += dt;
495 for c in &mut self.critters {
496 c.step(&self.grid, dt);
497 }
498 self.ceniza.paso(dt);
499 self.voxel.entities.clear();
500 self.voxel.entities.extend(self.critters.iter().map(|c| c.entity()));
501 // Lo que sobra del cupo de cajas del motor (64) es para la ceniza, lo más
502 // cerca del ojo primero: la que no entra es la que no se ve.
503 let resto = 64usize.saturating_sub(self.voxel.entities.len());
504 if resto > 0 && !self.ceniza.is_empty() {
505 let ojo = self.player.eye();
506 self.voxel.entities.extend(self.ceniza.entidades(resto, Some(ojo)));
507 }
508 }
509
510 /// Cuántas personas caminan por el mundo.
511 const GENTE: usize = 9;
512 /// Hasta dónde llega el anillo SDF de la FAUNA (voxels). Generoso: un animal
513 /// mide un par de metros, así que a cincuenta pasos todavía se le ve la forma.
514 const RADIO_FAUNA: f32 = 62.0;
515 /// Y el de la FLORA. Corto a propósito: los árboles son cientos y a diez metros
516 /// la estampa de voxels ya cuenta lo mismo. El anillo chico es lo que deja el
517 /// presupuesto libre para los animales y la gente, que es donde se nota.
518 const RADIO_FLORA: f32 = 26.0;
519 /// Histéresis: se entra al anillo a `RADIO` y se sale a `RADIO · SALIDA`. Sin
520 /// esto, el que queda justo en el borde alterna de técnica todos los cuadros —
521 /// y cada alternancia re-sube bricks a la GPU.
522 const SALIDA: f32 = 1.18;
523
524 /// **El reparto entre las dos técnicas de graficación**, cuadro a cuadro.
525 ///
526 /// Lo que el bioma sembró en voxels sigue en voxels **salvo lo que tenés
527 /// cerca**: eso se borra del grid y se dibuja como ser analítico (silueta lisa,
528 /// cara, andar). Al alejarse vuelve a su estampa. El mundo NO se regenera: se
529 /// re-suben los pocos bricks que cambiaron (`VoxelRenderer::sync`).
530 ///
531 /// El presupuesto del uniform (40 seres / 640 primitivas) se reparte por
532 /// cercanía, y el detalle de cada uno baja con la distancia: de cerca la cara,
533 /// de lejos la silueta.
534 fn repartir_sdf(&mut self, queue: &wgpu::Queue) {
535 let Some((bioma, _)) = self.bioma.as_ref() else {
536 return;
537 };
538 let ojo = self.player.eye();
539 let dist = |s: &Sembrado| {
540 let c = Vec3::new(
541 s.base[0] as f32 + 0.5,
542 s.base[1] as f32 + s.alto() * 0.5,
543 s.base[2] as f32 + 0.5,
544 );
545 ojo.distance(c)
546 };
547
548 // 1) Quién califica: por clase, por distancia, y con histéresis para los
549 // que ya están adentro.
550 let mut candidatos: Vec<(f32, usize)> = Vec::new();
551 for (i, s) in self.siembra.iter().enumerate() {
552 let radio = match s.clase {
553 Sembrada::Fauna { .. } => Self::RADIO_FAUNA,
554 Sembrada::Flora { .. } => Self::RADIO_FLORA,
555 };
556 let radio = if self.en_sdf.contains(&i) { radio * Self::SALIDA } else { radio };
557 let d = dist(s);
558 if d <= radio {
559 candidatos.push((d, i));
560 }
561 }
562 // La FAUNA primero y la flora después, cada grupo por cercanía. Con un
563 // orden puramente por distancia, las flores y los arbustos que tenés a dos
564 // pasos se comen el presupuesto y no queda un solo animal analítico en el
565 // cuadro — que es justo lo que se vino a ver. Una toma salió así: un
566 // girasol precioso y ni una vaca.
567 candidatos.sort_by(|a, b| {
568 let clase = |i: usize| match self.siembra[i].clase {
569 Sembrada::Fauna { .. } => 0u8,
570 Sembrada::Flora { .. } => 1,
571 };
572 clase(a.1).cmp(&clase(b.1)).then(a.0.total_cmp(&b.0))
573 });
574
575 // 2) El presupuesto del uniform. La gente entra primero (es lo que más se
576 // mira) y el resto se reparte por el orden de arriba, bajando el detalle
577 // antes de descartar a nadie.
578 let cupo_gente = self.gente.len().min(llimphi_3d::VOXEL_MAX_SERS / 3);
579 let mut sers: Vec<llimphi_3d::SdfSer> = Vec::with_capacity(llimphi_3d::VOXEL_MAX_SERS);
580 let mut gastado = 0usize;
581 let mut gente: Vec<(f32, usize)> = self
582 .gente
583 .iter()
584 .enumerate()
585 .map(|(i, c)| (ojo.distance(c.pos), i))
586 .collect();
587 gente.sort_by(|a, b| a.0.total_cmp(&b.0));
588 for (d, i) in gente.into_iter().take(cupo_gente) {
589 let c = &self.gente[i];
590 let detalle = DetalleSer::por_distancia(d, c.persona.altura);
591 if gastado + detalle.costo() > llimphi_3d::VOXEL_MAX_SER_PRIMS {
592 break;
593 }
594 let ser = c.persona.posada(c.clip, c.fase, c.pos, c.yaw, None, detalle);
595 gastado += ser.prims.len();
596 sers.push(ser);
597 }
598
599 // 3) Los del anillo: los que YA están sin estampar se dibujan sí o sí (si
600 // no, quedarían de agujero en el mundo); los nuevos entran mientras
601 // alcance el presupuesto.
602 let mut quiero: Vec<(f32, usize, bool)> = candidatos
603 .iter()
604 .map(|&(d, i)| (d, i, self.en_sdf.contains(&i)))
605 .collect();
606 quiero.sort_by(|a, b| b.2.cmp(&a.2).then(a.0.total_cmp(&b.0)));
607
608 let mut dibujar: Vec<(f32, usize)> = Vec::new();
609 for (d, i, ya) in quiero {
610 if sers.len() + dibujar.len() >= llimphi_3d::VOXEL_MAX_SERS {
611 break;
612 }
613 let s = &self.siembra[i];
614 let mut detalle = DetalleSer::por_distancia(d, s.alto());
615 while gastado + detalle.costo() > llimphi_3d::VOXEL_MAX_SER_PRIMS {
616 detalle = match detalle {
617 DetalleSer::Hero => DetalleSer::Medio,
618 DetalleSer::Medio => DetalleSer::Lejos,
619 DetalleSer::Lejos => break,
620 };
621 }
622 if gastado + detalle.costo() > llimphi_3d::VOXEL_MAX_SER_PRIMS {
623 if ya {
624 continue; // sin cupo, pero sigue borrado: se re-estampa abajo
625 }
626 break;
627 }
628 let ser = s.ser_sdf(self.reloj, detalle);
629 gastado += ser.prims.len();
630 sers.push(ser);
631 dibujar.push((d, i));
632 }
633
634 // 4) Cambios de técnica, **a cuentagotas**: `VoxelGrid::take_dirty` devuelve
635 // UNA caja que envuelve todo lo tocado y `sync` re-sube sus bricks.
636 // Cambiando dos sujetos en esquinas opuestas del anillo, esa caja es
637 // media ventana. Con tope por cuadro la caja queda chica, y lo que no
638 // entró pasa al siguiente — gratis, porque la histéresis ya evita que
639 // nadie oscile en el borde.
640 const POR_CUADRO: usize = 3;
641 let elegidos: Vec<usize> = dibujar.iter().map(|&(_, i)| i).collect();
642 let mut cambios = 0usize;
643 let previos = std::mem::take(&mut self.en_sdf);
644 for i in previos {
645 if !elegidos.contains(&i) && cambios < POR_CUADRO {
646 // Se aleja: vuelve a ser su estampa de voxels.
647 bioma.estampar(&mut self.grid, &self.siembra[i], self.dim);
648 cambios += 1;
649 } else {
650 self.en_sdf.push(i);
651 }
652 }
653 for &i in &elegidos {
654 if !self.en_sdf.contains(&i) {
655 if cambios >= POR_CUADRO {
656 // No entra este cuadro: tampoco se dibuja, o saldría encimado
657 // con su propio fantasma de voxels.
658 if let Some(p) = dibujar.iter().position(|&(_, j)| j == i) {
659 sers.remove(cupo_gente.min(sers.len()) + p);
660 dibujar.remove(p);
661 }
662 continue;
663 }
664 bioma.desestampar(&mut self.grid, &self.siembra[i], self.dim);
665 self.en_sdf.push(i);
666 cambios += 1;
667 }
668 }
669 // Y el que sigue borrado pero no entró en el cupo del uniform: se dibuja
670 // igual con el detalle más barato, o quedaría un hueco en el terreno.
671 for &i in &self.en_sdf.clone() {
672 if !dibujar.iter().any(|&(_, j)| j == i) {
673 let s = &self.siembra[i];
674 sers.push(s.ser_sdf(self.reloj, DetalleSer::Lejos));
675 }
676 }
677 // Válvula de comparación: `SDF_RING=0` deja el mundo ENTERO en voxels (se
678 // vuelve a estampar lo que se había sacado y no se dibuja ningún ser
679 // analítico). Es para sacar las dos tomas del mismo sitio, con la misma
680 // cámara, y ver de qué se trata el cambio de técnica — el "antes" no se
681 // puede reconstruir de memoria.
682 let anillo = std::env::var("SDF_RING").map(|v| v != "0").unwrap_or(true);
683 if !anillo {
684 for &i in &self.en_sdf {
685 bioma.estampar(&mut self.grid, &self.siembra[i], self.dim);
686 }
687 sers.clear();
688 cambios += 1;
689 }
690 if cambios > 0 {
691 self.voxel.sync(queue, &mut self.grid);
692 }
693 self.voxel.sers = sers;
694
695 // 5) La gente sigue caminando y pisando el relieve.
696 self.caminar_gente();
697 }
698
699 /// Avanza la multitud: cada uno a su paso, y **posado sobre el terreno** (la
700 /// vereda del catálogo era plana; un campo con lomas, no).
701 fn caminar_gente(&mut self) {
702 for c in &mut self.gente {
703 let vel = c.persona.altura * 0.62;
704 let paso = if c.yaw.abs() < 0.1 { vel } else { -vel };
705 c.pos.z += paso * (1.0 / 60.0);
706 c.fase += vel * 2.2 / 60.0;
707 // Rebote en los bordes de la ventana (el mundo tiene borde).
708 let margen = 6.0;
709 if c.pos.z < margen || c.pos.z > self.dim[2] as f32 - margen {
710 c.yaw = if c.yaw.abs() < 0.1 { std::f32::consts::PI } else { 0.0 };
711 c.pos.z = c.pos.z.clamp(margen, self.dim[2] as f32 - margen);
712 }
713 c.pos.x = c.pos.x.clamp(margen, self.dim[0] as f32 - margen);
714 // Pisar el relieve: la altura de la columna de abajo.
715 if let Some(h) = self.grid.height_at(c.pos.x as u32, c.pos.z as u32) {
716 let objetivo = h as f32 + 1.0;
717 // Suavizado: subir de golpe un escalón se ve como un salto.
718 c.pos.y += (objetivo - c.pos.y).clamp(-0.35, 0.35);
719 }
720 }
721 }
722
723 /// El punto que mirar para VER el catálogo: el centro del ser analítico más
724 /// cercano al ojo que no sea el propio pie del jugador.
725 ///
726 /// **En coordenadas CENTRADAS**, como todo lo que consume [`Camera3d`]: la
727 /// siembra vive en coords de grilla y devolverla cruda dejaba la cámara mirando
728 /// media ventana más allá — la toma salía de puro cielo y no había nada que
729 /// delatara por qué.
730 pub fn mirar_sdf(&self, ojo: Vec3) -> Option<Vec3> {
731 let centro = self.world_center();
732 // Un animal antes que una planta: es lo que cuenta la historia del cambio
733 // de técnica (una flor también, pero un ciervo se entiende de una).
734 let hay_fauna = self
735 .en_sdf
736 .iter()
737 .any(|i| matches!(self.siembra[*i].clase, Sembrada::Fauna { .. }));
738 self.siembra
739 .iter()
740 .enumerate()
741 .filter(|(i, _)| self.en_sdf.contains(i))
742 .filter(|(_, s)| !hay_fauna || matches!(s.clase, Sembrada::Fauna { .. }))
743 .map(|(_, s)| {
744 Vec3::new(
745 s.base[0] as f32 + 0.5,
746 s.base[1] as f32 + s.alto() * 0.45,
747 s.base[2] as f32 + 0.5,
748 ) - centro
749 })
750 .filter(|c| ojo.distance(*c) > 2.5)
751 .min_by(|a, b| ojo.distance(*a).total_cmp(&ojo.distance(*b)))
752 }
753
754 /// Altura del terreno en una columna, **en coordenadas centradas** (para no
755 /// plantar la cámara adentro de una loma).
756 pub fn altura_en(&self, x: f32, z: f32) -> Option<f32> {
757 let centro = self.world_center();
758 let (gx, gz) = (x + centro.x, z + centro.z);
759 if gx < 0.0 || gz < 0.0 || gx >= self.dim[0] as f32 || gz >= self.dim[2] as f32 {
760 return None;
761 }
762 self.grid
763 .height_at(gx as u32, gz as u32)
764 .map(|h| h as f32 + 1.0 - centro.y)
765 }
766
767 /// Cuántos seres analíticos hay en el cuadro y cuántas primitivas gastan — el
768 /// número que dice si el anillo entra en el presupuesto.
769 pub fn cuenta_sdf(&self) -> (usize, usize) {
770 (
771 self.voxel.sers.len(),
772 self.voxel.sers.iter().map(|s| s.prims.len()).sum(),
773 )
774 }
775
776 /// **Desmorona** el mundo donde apunta el rayo: arranca un puñado de voxels y
777 /// los pone a subir como ceniza (gravedad al revés). Devuelve `false` si el rayo
778 /// no pegó en nada.
779 pub fn desprender_ceniza(&mut self, queue: &wgpu::Queue, origin: [f32; 3], dir: [f32; 3]) -> bool {
780 let max = self.dim[0] as f32 * 3.0;
781 let Some(hit) = llimphi_voxel::raycast(&self.grid, origin, dir, max) else {
782 return false;
783 };
784 let [cx, cy, cz] = hit.cell;
785 let centro = Vec3::new(cx as f32 + 0.5, cy as f32 + 0.5, cz as f32 + 0.5);
786 let n = self.ceniza.desprender(&mut self.grid, centro, 3.5, 28);
787 if n > 0 {
788 self.voxel.sync(queue, &mut self.grid);
789 }
790 n > 0
791 }
792
793 /// Avanza la física del jugador `dt` segundos con la caminata deseada
794 /// (`wish`, horizontal, espacio de grilla) y `jump`. Devuelve el ojo del
795 /// jugador en **mundo** (centrado en el origen, listo para la cámara).
796 pub fn step_player(&mut self, wish: Vec3, jump: bool, dt: f32) -> Vec3 {
797 self.player.step(&self.grid, wish, jump, dt);
798 self.player.eye() - self.world_center()
799 }
800
801 /// Ojo del jugador en coords centradas (para la cámara), sin avanzar la física.
802 /// Se usa tras [`follow_mapa`](Self::follow_mapa), que puede reubicar al jugador.
803 pub fn eye(&self) -> Vec3 {
804 self.player.eye() - self.world_center()
805 }
806
807 /// Niebla base del mapa (la que difumina el borde de la ventana a ras de suelo).
808 fn fog_base(&self) -> f32 {
809 1.1 / self.dim[0] as f32
810 }
811
812 /// Altura del jugador SOBRE el terreno de su columna (voxels), `0` si está al ras.
813 fn altitud(&self) -> f32 {
814 let px = (self.player.pos.x.max(0.0) as u32).min(self.dim[0] - 1);
815 let pz = (self.player.pos.z.max(0.0) as u32).min(self.dim[2] - 1);
816 let suelo = self.grid.height_at(px, pz).unwrap_or(0) as f32;
817 (self.player.pos.y - suelo).max(0.0)
818 }
819
820 /// **Vuelo libre** (creativo): mueve al jugador en `wish` (con vertical), sin
821 /// gravedad ni colisión. La velocidad **escala con la altitud** (más alto → más
822 /// rápido, para cubrir territorio) y la **niebla se abre** con la altura (más
823 /// alto → ves más lejos, hasta el borde de la ventana). Devuelve el ojo centrado.
824 pub fn fly_player(&mut self, wish: Vec3, dt: f32) -> Vec3 {
825 let alt = self.altitud();
826 let boost = (1.0 + alt / 14.0).min(16.0);
827 self.player.volar_veloz(wish, dt, boost);
828 // Niebla que se disipa al subir → vista lejana.
829 self.voxel.atmosphere.fog_density = self.fog_base() / (1.0 + alt / 30.0);
830 self.player.eye() - self.world_center()
831 }
832
833 /// Restaura la niebla base (al volver a caminar tras haber volado alto).
834 pub fn reset_mapa_fog(&mut self) {
835 if self.mapa.is_some() {
836 self.voxel.atmosphere.fog_density = self.fog_base();
837 }
838 }
839
840 /// Dimensión `[dx, dy, dz]` de la ventana actual.
841 pub fn dim(&self) -> [u32; 3] {
842 self.dim
843 }
844
845 /// Origen de la ventana de streaming (columna de mundo de la esquina). Debug.
846 pub fn origin_debug(&self) -> [i32; 2] {
847 self.origin
848 }
849
850 /// Reposa al jugador sobre la columna central (al entrar a modo explorar,
851 /// por si el terreno cambió por ediciones).
852 pub fn respawn_player(&mut self) {
853 self.player = Player::spawn_on(&self.grid, self.dim[0] / 2, self.dim[2] / 2);
854 }
855
856 /// Reposa al jugador sobre una columna concreta `(x, z)` (clamp al grid).
857 /// Útil para encuadrar una vista en primera persona desde un mirador.
858 pub fn spawn_player_at(&mut self, x: u32, z: u32) {
859 let x = x.min(self.dim[0] - 1);
860 let z = z.min(self.dim[2] - 1);
861 self.player = Player::spawn_on(&self.grid, x, z);
862 }
863
864 /// Centro (en **mundo**) del bicho más cercano a `eye_world`, o `None` si no
865 /// hay manada. Para encuadrar un bicho en una toma de primera persona.
866 pub fn nearest_critter(&self, eye_world: Vec3) -> Option<Vec3> {
867 let center = self.world_center();
868 self.critters
869 .iter()
870 .map(|c| {
871 let e = c.entity();
872 Vec3::new(e.pos[0], e.pos[1], e.pos[2]) - center
873 })
874 .min_by(|a, b| {
875 a.distance_squared(eye_world)
876 .total_cmp(&b.distance_squared(eye_world))
877 })
878 }
879
880 /// Medio-`dim`: el offset entre espacio de grilla (`[0,dim]`) y mundo
881 /// (centrado en el origen). `mundo = grilla - centro`.
882 fn world_center(&self) -> Vec3 {
883 Vec3::new(self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32) * 0.5
884 }
885
886 /// Edita el mundo por **raycast** desde un origen/dirección (espacio de
887 /// grilla = `eye_mundo + dim/2`): `build=false` cava un cráter en el voxel
888 /// golpeado; `build=true` deposita un bloque en la celda vacía adyacente.
889 /// Sube sólo lo tocado (`sync` incremental). Devuelve `true` si pegó algo.
890 /// Se llama desde el hilo GPU (necesita `queue`).
891 pub fn apply_edit(&mut self, queue: &wgpu::Queue, origin: [f32; 3], dir: [f32; 3], build: bool) -> bool {
892 let max = self.dim[0] as f32 * 3.0;
893 let Some(hit) = llimphi_voxel::raycast(&self.grid, origin, dir, max) else {
894 return false;
895 };
896 if build {
897 let [px, py, pz] = hit.place;
898 let r = 1i32;
899 for dz in -r..=r {
900 for dy in -r..=r {
901 for dx in -r..=r {
902 let (x, y, z) = (px + dx, py + dy, pz + dz);
903 if x >= 0 && y >= 0 && z >= 0 {
904 self.grid.set(x as u32, y as u32, z as u32, [222, 184, 96]);
905 }
906 }
907 }
908 }
909 } else {
910 let [cx, cy, cz] = hit.cell;
911 let r = 4i32;
912 for dz in -r..=r {
913 for dy in -r..=r {
914 for dx in -r..=r {
915 if dx * dx + dy * dy + dz * dz <= r * r {
916 let (x, y, z) = (cx + dx, cy + dy, cz + dz);
917 if x >= 0 && y >= 0 && z >= 0 {
918 self.grid.clear(x as u32, y as u32, z as u32);
919 }
920 }
921 }
922 }
923 }
924 }
925 self.voxel.sync(queue, &mut self.grid);
926 true
927 }
928
929 /// Posiciona el monumento: flota sobre el centro del mundo y gira con
930 /// `angle` (rad). El mundo voxel ocupa `y ∈ [-dy/2, dy/2]`; lo dejamos a
931 /// ~`0.45·dy` para que asome entre los picos (y se ocluya con ellos — prueba
932 /// la convivencia voxel+malla en vivo).
933 pub fn animate(&mut self, angle: f32) {
934 let dy = self.dim[1] as f32;
935 let size = self.dim[0] as f32 * 0.11;
936 let y = dy * 0.45;
937 let m = Mat4::from_translation(Vec3::new(0.0, y, 0.0))
938 * Mat4::from_rotation_y(angle)
939 * Mat4::from_scale(Vec3::splat(size));
940 self.monument.set_model(m);
941 }
942
943 /// Compone terreno + monumento sobre `target` (depth compartido). Firma
944 /// compatible con la closure de `gpu_paint_with` (más la cámara).
945 pub fn render(
946 &mut self,
947 device: &wgpu::Device,
948 queue: &wgpu::Queue,
949 encoder: &mut wgpu::CommandEncoder,
950 target: &wgpu::TextureView,
951 viewport: (u32, u32),
952 camera: &Camera3d,
953 ) {
954 self.render_with(device, queue, encoder, target, viewport, camera, &[]);
955 }
956
957 /// Como [`render`](Self::render) pero agrega **mallas extra** al pase (mismo
958 /// depth compartido → se ocluyen con el terreno y el monumento). Es el gancho
959 /// para los **actores** de una escena filmada: la app posa cada actor en su
960 /// propio [`Renderer3d`](llimphi_3d::Renderer3d) y los pasa aquí.
961 #[allow(clippy::too_many_arguments)]
962 pub fn render_with(
963 &mut self,
964 device: &wgpu::Device,
965 queue: &wgpu::Queue,
966 encoder: &mut wgpu::CommandEncoder,
967 target: &wgpu::TextureView,
968 viewport: (u32, u32),
969 camera: &Camera3d,
970 extra: &[&Renderer3d],
971 ) {
972 let mut meshes: Vec<&Renderer3d> = Vec::with_capacity(1 + extra.len());
973 if self.show_monument {
974 meshes.push(&self.monument);
975 }
976 meshes.extend_from_slice(extra);
977 self.scene.render(
978 device,
979 queue,
980 encoder,
981 target,
982 viewport,
983 camera,
984 Some(&self.voxel),
985 &meshes,
986 );
987 }
988
989 /// Enciende/apaga el dibujo del monumento-malla flotante (se apaga en
990 /// `--poses` para no tapar la fila de actores).
991 pub fn show_monument(&mut self, v: bool) {
992 self.show_monument = v;
993 }
994
995 /// Posición en **mundo** (centrado en el origen) del **suelo** sobre la
996 /// columna de grilla `(gx, gz)`: pies un voxel por encima del terreno (o del
997 /// piso `y=0` si la columna está vacía). Para posar un actor sobre el relieve.
998 pub fn ground_at(&self, gx: u32, gz: u32) -> Vec3 {
999 let gx = gx.min(self.dim[0] - 1);
1000 let gz = gz.min(self.dim[2] - 1);
1001 let top = self.grid.height_at(gx, gz).map(|y| y as f32 + 1.0).unwrap_or(0.0);
1002 Vec3::new(gx as f32 + 0.5, top, gz as f32 + 0.5) - self.world_center()
1003 }
1004
1005 /// Cota del suelo en un punto de **mundo** (el mismo espacio que devuelve
1006 /// [`ground_at`](Self::ground_at)). Es lo que necesita el IK de pies de un actor:
1007 /// muestrear bajo cada pie por separado, no bajo el centro del cuerpo.
1008 pub fn ground_y(&self, wx: f32, wz: f32) -> f32 {
1009 let c = self.world_center();
1010 let gx = (wx + c.x).max(0.0) as u32;
1011 let gz = (wz + c.z).max(0.0) as u32;
1012 self.ground_at(gx, gz).y
1013 }
1014
1015 /// Pinta el HUD de primera persona encima de la escena (pase screen-space):
1016 /// la **mira** centrada + un panel con el **modo** y las **coordenadas** del
1017 /// jugador (lectura del propio `Player`, espacio de grilla). Llamar *después*
1018 /// de [`render`](Self::render), en modo explorar.
1019 pub fn draw_hud(
1020 &mut self,
1021 device: &wgpu::Device,
1022 queue: &wgpu::Queue,
1023 encoder: &mut wgpu::CommandEncoder,
1024 target: &wgpu::TextureView,
1025 viewport: (u32, u32),
1026 ) {
1027 let mut quads = HudQuad::crosshair(viewport, 9.0, 2.0, [1.0, 1.0, 1.0, 0.85]).to_vec();
1028
1029 // Texto: modo + coordenadas. La grilla es entera, redondeamos a voxel.
1030 let p = self.player.pos;
1031 let coords = format!("X {} Y {} Z {}", p.x as i32, p.y as i32, p.z as i32);
1032 let px = 2.0; // pixel de glifo (10×14 por carácter)
1033 let (tx, ty) = (16.0, 14.0);
1034 let lh = 7.0 * px + 6.0; // alto de línea (glifo + interlínea)
1035
1036 // Panel de fondo translúcido para legibilidad sobre cualquier terreno.
1037 let pw = HudQuad::text_width(&coords, px).max(HudQuad::text_width("EXPLORAR", px));
1038 quads.push(HudQuad {
1039 x: tx - 8.0,
1040 y: ty - 8.0,
1041 w: pw + 16.0,
1042 h: lh * 2.0 + 8.0,
1043 color: [0.0, 0.0, 0.0, 0.45],
1044 });
1045 quads.extend(HudQuad::text("EXPLORAR", tx, ty, px, [0.62, 0.95, 0.78, 0.95]));
1046 quads.extend(HudQuad::text(&coords, tx, ty + lh, px, [0.95, 0.97, 1.0, 0.95]));
1047
1048 self.hud.render(device, queue, encoder, target, viewport, &quads);
1049 }
1050}