gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-voxel/src/world_stream.rs Rust · 367 líneas · 15.2 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//! `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}