tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-voxel/src/world_stream.rs
Historia
Quién tocó qué
crudo
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
1//! `WorldStream` — ventana voxel acotada que se desliza por un mundo procedural
2//! **ilimitado** (M6: streaming). En vez de un grid fijo centrado en el origen,
3//! mantenemos una ventana de `dim` voxels cuya esquina cae en una columna de
4//! **mundo** `origin`; al caminar, [`WorldStream::follow`] reubica la ventana
5//! (en pasos de `step`) y regenera el terreno con [`fill_terrain_window`]. Como
6//! el relieve es función pura de mundo ([`column_height`]), las costuras encajan
7//! y se puede caminar indefinidamente sin "muro" ni repetición.
8//!
9//! **Alcance (MVP):** la regeneración es de la ventana **entera** por cada paso
10//! cruzado (no un *shift* parcial que sólo rellene el borde nuevo). Es O(ventana)
11//! por reubicación, no por frame — sólo al cruzar un múltiplo de `step`. El grid
12//! queda **dirty completo** para que `VoxelRenderer::sync` re-suba (o se
13//! reconstruya el renderer). El *shift* incremental (copiar la zona común y
14//! generar sólo la franja nueva) y el LOD del horizonte quedan como optimización
15//! futura — ver `MOTOR-VOXEL.md` §7.
16
17use llimphi_3d::VoxelGrid;
18
19use std::collections::HashMap;
20
21use crate::terrain::fill_terrain_window;
22
23/// Ventana de mundo que sigue a un foco (la cámara/jugador) por un terreno
24/// procedural ilimitado.
25pub struct WorldStream {
26 grid: VoxelGrid,
27 dim: [u32; 3],
28 seed: u32,
29 /// Columna de mundo `[wx, wz]` donde cae la esquina local `(0,0)`.
30 origin: [i32; 2],
31 /// Granularidad de reubicación (voxels). Recentrar sólo cuando el foco se
32 /// aleja del centro más de medio paso evita regenerar cada voxel caminado.
33 step: i32,
34 /// **Ediciones persistentes** por coordenada de **mundo** (`[wx, wy, wz]` →
35 /// RGBA; `a = 0` = voxel cavado/aire). Como el terreno se regenera desde la
36 /// semilla cada vez que la ventana vuelve a una zona, sin esto los cambios del
37 /// jugador se perderían al alejarse y volver. Se re-aplican sobre el terreno
38 /// fresco en cada `follow` (overlay). Es el estado a serializar para la
39 /// persistencia CAS a disco (futuro): `mundo → BLAKE3(postcard(patch))`.
40 edits: HashMap<[i32; 3], [u8; 4]>,
41}
42
43impl WorldStream {
44 /// Crea una ventana de `dim` centrada en la columna de mundo `(center_x,
45 /// center_z)` y la genera. `step` = granularidad de reubicación en voxels
46 /// (típicamente el lado de un *brick*, p.ej. 8).
47 pub fn new(dim: [u32; 3], seed: u32, center_x: i32, center_z: i32, step: u32) -> Self {
48 let step = step.max(1) as i32;
49 let origin = Self::origin_for(dim, center_x, center_z, step);
50 let mut grid = VoxelGrid::new(dim);
51 fill_terrain_window(&mut grid, origin, seed);
52 Self { grid, dim, seed, origin, step, edits: HashMap::new() }
53 }
54
55 /// Origen (esquina) de ventana que centra la columna de mundo `(cx, cz)`,
56 /// **snappeado** a `step` para que sólo cambie a saltos discretos.
57 fn origin_for(dim: [u32; 3], cx: i32, cz: i32, step: i32) -> [i32; 2] {
58 let half_x = dim[0] as i32 / 2;
59 let half_z = dim[2] as i32 / 2;
60 [
61 snap(cx - half_x, step),
62 snap(cz - half_z, step),
63 ]
64 }
65
66 /// Reubica la ventana para centrar la columna de mundo `(cx, cz)` y, si el
67 /// origen snappeado cambió, **regenera** el terreno. Devuelve `true` si hubo
68 /// regeneración (el grid quedó dirty → el caller debe `sync`/reconstruir).
69 pub fn follow(&mut self, cx: i32, cz: i32) -> bool {
70 let want = Self::origin_for(self.dim, cx, cz, self.step);
71 if want == self.origin {
72 return false;
73 }
74 self.origin = want;
75 fill_terrain_window(&mut self.grid, want, self.seed);
76 // Re-aplicar las ediciones persistentes que caen en la ventana nueva
77 // (overlay sobre el terreno fresco): así un cráter/estructura sobrevive
78 // alejarse y volver.
79 self.reapply_edits();
80 true
81 }
82
83 /// Edita un voxel en coordenadas de **mundo** y lo **persiste**: `Some(rgb)`
84 /// coloca un bloque sólido, `None` lo cava (aire). El cambio se registra en
85 /// `edits` (sobrevive el regen del streaming) y, si el voxel cae en la ventana
86 /// actual, se aplica al grid (que queda dirty → el caller hace `sync`/scroll).
87 pub fn edit(&mut self, wx: i32, wy: i32, wz: i32, block: Option<[u8; 3]>) {
88 let v = match block {
89 Some(rgb) => [rgb[0], rgb[1], rgb[2], 255],
90 None => [0, 0, 0, 0],
91 };
92 self.edits.insert([wx, wy, wz], v);
93 self.apply_voxel(wx, wy, wz, v);
94 }
95
96 /// Cantidad de voxels editados persistidos (para reportar/serializar).
97 pub fn edit_count(&self) -> usize {
98 self.edits.len()
99 }
100
101 /// Serializa las ediciones a un blob **postcard** (lista de `([wx,wy,wz],
102 /// RGBA)`), apto para guardar en la CAS de tawasuyu direccionado por su
103 /// **BLAKE3** (`mundo → BLAKE3(blob)`) y recargar entre ejecuciones. Es la
104 /// **persistencia a disco** del estado in-memory de [`Self::edit`].
105 pub fn export_edits(&self) -> Vec<u8> {
106 let mut v: Vec<([i32; 3], [u8; 4])> = self.edits.iter().map(|(&k, &val)| (k, val)).collect();
107 // Orden canónico (el HashMap no es determinista) → mismas ediciones dan el
108 // mismo blob y, por ende, la misma dirección BLAKE3 (dedup/integridad CAS).
109 v.sort_unstable_by_key(|(k, _)| *k);
110 postcard::to_allocvec(&v).expect("postcard ediciones")
111 }
112
113 /// Carga ediciones desde un blob de [`Self::export_edits`], las fusiona en el
114 /// mapa persistente y las **re-aplica** sobre la ventana actual. Devuelve la
115 /// cantidad cargada, o `None` si el blob no decodifica.
116 pub fn import_edits(&mut self, bytes: &[u8]) -> Option<usize> {
117 let v: Vec<([i32; 3], [u8; 4])> = postcard::from_bytes(bytes).ok()?;
118 let n = v.len();
119 for (k, val) in v {
120 self.edits.insert(k, val);
121 }
122 self.reapply_edits();
123 Some(n)
124 }
125
126 /// Aplica un voxel `v` (RGBA, `a=0`=aire) al grid si su coordenada de mundo
127 /// cae en la ventana actual. No-op si está afuera (ya quedó en `edits`).
128 fn apply_voxel(&mut self, wx: i32, wy: i32, wz: i32, v: [u8; 4]) {
129 let lx = wx - self.origin[0];
130 let lz = wz - self.origin[1];
131 if lx < 0 || wy < 0 || lz < 0 {
132 return;
133 }
134 let (lx, ly, lz) = (lx as u32, wy as u32, lz as u32);
135 if lx < self.dim[0] && ly < self.dim[1] && lz < self.dim[2] {
136 if v[3] > 0 {
137 self.grid.set(lx, ly, lz, [v[0], v[1], v[2]]);
138 } else {
139 self.grid.clear(lx, ly, lz);
140 }
141 }
142 }
143
144 /// Re-aplica todas las ediciones que caen en la ventana actual sobre el grid.
145 fn reapply_edits(&mut self) {
146 // Split de borrows: iterar `edits` (inmutable) y mutar `grid`.
147 let Self { edits, grid, origin, dim, .. } = self;
148 for (&[wx, wy, wz], &v) in edits.iter() {
149 let lx = wx - origin[0];
150 let lz = wz - origin[1];
151 if lx < 0 || wy < 0 || lz < 0 {
152 continue;
153 }
154 let (lx, ly, lz) = (lx as u32, wy as u32, lz as u32);
155 if lx < dim[0] && ly < dim[1] && lz < dim[2] {
156 if v[3] > 0 {
157 grid.set(lx, ly, lz, [v[0], v[1], v[2]]);
158 } else {
159 grid.clear(lx, ly, lz);
160 }
161 }
162 }
163 }
164
165 /// Grid actual (para renderizar / `VoxelRenderer::new`).
166 pub fn grid(&self) -> &VoxelGrid {
167 &self.grid
168 }
169
170 /// Grid mutable (para `VoxelRenderer::sync`, que toma `&mut`).
171 pub fn grid_mut(&mut self) -> &mut VoxelGrid {
172 &mut self.grid
173 }
174
175 /// Columna de mundo de la esquina local `(0,0)`.
176 pub fn origin(&self) -> [i32; 2] {
177 self.origin
178 }
179
180 /// Origen en **voxels 3D** (`y = 0`: el mundo no scrollea en vertical), para
181 /// pasar a [`VoxelRenderer::scroll_to`](llimphi_3d::VoxelRenderer::scroll_to).
182 /// Si `step` es múltiplo de [`llimphi_3d::VOXEL_BRICK`] queda alineado a brick.
183 pub fn origin_voxel(&self) -> [i32; 3] {
184 [self.origin[0], 0, self.origin[1]]
185 }
186
187 /// Mapea una columna de **mundo** a coordenada **local** de ventana, o `None`
188 /// si cae afuera. Para ubicar al jugador/entidades dentro del grid actual.
189 pub fn world_to_local(&self, wx: i32, wz: i32) -> Option<(u32, u32)> {
190 let lx = wx - self.origin[0];
191 let lz = wz - self.origin[1];
192 if lx >= 0 && lz >= 0 && lx < self.dim[0] as i32 && lz < self.dim[2] as i32 {
193 Some((lx as u32, lz as u32))
194 } else {
195 None
196 }
197 }
198
199 /// Mapea una coordenada **local** de ventana a su columna de **mundo**.
200 pub fn local_to_world(&self, lx: u32, lz: u32) -> (i32, i32) {
201 (self.origin[0] + lx as i32, self.origin[1] + lz as i32)
202 }
203}
204
205/// Redondea `v` al múltiplo de `step` más cercano hacia abajo (floor con signo).
206#[inline]
207fn snap(v: i32, step: i32) -> i32 {
208 (v.div_euclid(step)) * step
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn snap_redondea_con_signo() {
217 assert_eq!(snap(0, 8), 0);
218 assert_eq!(snap(7, 8), 0);
219 assert_eq!(snap(8, 8), 8);
220 assert_eq!(snap(-1, 8), -8);
221 assert_eq!(snap(-8, 8), -8);
222 assert_eq!(snap(-9, 8), -16);
223 }
224
225 /// Caminar menos de un paso NO regenera; cruzar un paso SÍ.
226 #[test]
227 fn follow_regenera_solo_al_cruzar_un_paso() {
228 let dim = [48, 32, 48];
229 let mut s = WorldStream::new(dim, 9, 0, 0, 8);
230 let o0 = s.origin();
231 // Moverse dentro del mismo bloque de 8: sin regen.
232 assert!(!s.follow(3, 2));
233 assert_eq!(s.origin(), o0);
234 // Cruzar varios pasos: regen y el origen siguió al foco.
235 assert!(s.follow(64, 0));
236 assert_ne!(s.origin(), o0);
237 // El centro de ventana quedó cerca del foco (dentro de un paso).
238 let cx = s.origin()[0] + dim[0] as i32 / 2;
239 assert!((cx - 64).abs() <= 8, "ventana centrada en el foco (cx={cx})");
240 }
241
242 /// Tras seguir el foco, el contenido coincide con generar esa ventana de cero
243 /// (la regeneración es completa y determinista por origen).
244 #[test]
245 fn contenido_tras_follow_igual_a_generacion_directa() {
246 let dim = [40, 28, 40];
247 let seed = 555;
248 let mut s = WorldStream::new(dim, seed, 0, 0, 8);
249 s.follow(120, -80);
250 let origin = s.origin();
251
252 let mut directo = VoxelGrid::new(dim);
253 fill_terrain_window(&mut directo, origin, seed);
254
255 for z in 0..dim[2] {
256 for y in 0..dim[1] {
257 for x in 0..dim[0] {
258 assert_eq!(
259 s.grid().get(x, y, z),
260 directo.get(x, y, z),
261 "discrepa en ({x},{y},{z})"
262 );
263 }
264 }
265 }
266 }
267
268 /// Una edición sobrevive alejarse y volver: el terreno se regenera desde la
269 /// semilla, pero el overlay de `edits` la re-aplica.
270 #[test]
271 fn una_edicion_sobrevive_alejarse_y_volver() {
272 let dim = [48, 40, 48];
273 let seed = 7;
274 let mut s = WorldStream::new(dim, seed, 0, 0, 8);
275
276 // Bloque magenta en lo alto del aire sobre la columna de mundo (0,0):
277 // el terreno nunca pone magenta ahí (es aire), así el origen del píxel es
278 // inequívocamente la edición.
279 let (wx, wy, wz) = (0, dim[1] as i32 - 3, 0);
280 let magenta = [255, 0, 255];
281 s.edit(wx, wy, wz, Some(magenta));
282 assert_eq!(s.edit_count(), 1);
283
284 let read = |s: &WorldStream| {
285 let (lx, lz) = s.world_to_local(wx, wz).expect("en ventana");
286 s.grid().get(lx, wy as u32, lz)
287 };
288 assert_eq!(read(&s), Some([255, 0, 255, 255]), "presente recién editada");
289
290 // Alejarse MUCHO (el voxel sale de la ventana → terreno regenerado, sin él).
291 assert!(s.follow(2000, 2000));
292 assert!(s.world_to_local(wx, wz).is_none(), "fuera de la ventana lejana");
293 assert_eq!(s.edit_count(), 1, "la edición sigue persistida");
294
295 // Volver al origen: el terreno se regenera Y el overlay re-aplica la edición.
296 assert!(s.follow(0, 0));
297 assert_eq!(read(&s), Some([255, 0, 255, 255]), "sobrevivió al regen");
298
299 // Y sin la edición, ese voxel sería aire (prueba que no es terreno).
300 let mut limpio = VoxelGrid::new(dim);
301 fill_terrain_window(&mut limpio, s.origin(), seed);
302 let (lx, lz) = s.world_to_local(wx, wz).unwrap();
303 assert_eq!(limpio.get(lx, wy as u32, lz), Some([0, 0, 0, 0]), "terreno solo = aire");
304 }
305
306 /// Cavar (edit con `None`) también persiste: un voxel sólido del terreno
307 /// queda vacío tras alejarse y volver.
308 #[test]
309 fn cavar_persiste() {
310 let dim = [48, 40, 48];
311 let seed = 13;
312 let mut s = WorldStream::new(dim, seed, 0, 0, 8);
313
314 // Buscar un voxel SÓLIDO del terreno en la columna central y cavarlo.
315 let (lx0, lz0) = s.world_to_local(0, 0).unwrap();
316 let h = s.grid().height_at(lx0, lz0).expect("columna con terreno");
317 let (wx, wy, wz) = (0, h as i32, 0);
318 assert!(s.grid().is_solid(lx0 as i32, wy, lz0 as i32), "sólido antes");
319
320 s.edit(wx, wy, wz, None); // cavar
321 let local_solid = |s: &WorldStream| {
322 let (lx, lz) = s.world_to_local(wx, wz).unwrap();
323 s.grid().is_solid(lx as i32, wy, lz as i32)
324 };
325 assert!(!local_solid(&s), "cavado tras editar");
326
327 s.follow(3000, -3000);
328 s.follow(0, 0);
329 assert!(!local_solid(&s), "sigue cavado tras volver");
330 }
331
332 /// CAS a disco (simulada en memoria): exportar las ediciones de un mundo y
333 /// re-importarlas en otro recién creado las restaura; el blob es canónico
334 /// (misma dirección BLAKE3) sin importar el orden de edición.
335 #[test]
336 fn ediciones_round_trip_por_blob() {
337 let dim = [48, 40, 48];
338 let seed = 99;
339
340 // Mundo A: unas cuantas ediciones (en distinto orden que B).
341 let mut a = WorldStream::new(dim, seed, 0, 0, 8);
342 a.edit(0, 30, 0, Some([10, 20, 30]));
343 a.edit(-5, 12, 7, None);
344 a.edit(3, 25, -2, Some([200, 100, 50]));
345 let blob_a = a.export_edits();
346
347 // Mundo B: las MISMAS ediciones en otro orden → mismo blob canónico.
348 let mut b = WorldStream::new(dim, seed, 0, 0, 8);
349 b.edit(3, 25, -2, Some([200, 100, 50]));
350 b.edit(0, 30, 0, Some([10, 20, 30]));
351 b.edit(-5, 12, 7, None);
352 assert_eq!(a.export_edits(), b.export_edits(), "blob canónico (orden-indep.)");
353 assert_eq!(blake3::hash(&blob_a), blake3::hash(&b.export_edits()), "misma dirección CAS");
354
355 // Mundo C: vacío → importa el blob de A → recupera las 3 ediciones.
356 let mut c = WorldStream::new(dim, seed, 0, 0, 8);
357 assert_eq!(c.edit_count(), 0);
358 assert_eq!(c.import_edits(&blob_a), Some(3));
359 assert_eq!(c.edit_count(), 3);
360 // Y el contenido coincide con A en la ventana (la edición magenta arriba).
361 let (lx, lz) = c.world_to_local(0, 0).unwrap();
362 assert_eq!(c.grid().get(lx, 30, lz), Some([10, 20, 30, 255]), "edición restaurada");
363
364 // Blob inválido → None, sin romper.
365 assert_eq!(c.import_edits(&[0xff, 0xff, 0xff]), None);
366 }
367}