roxlap_scene/lib.rs
1//! roxlap scene-graph layer — many independent chunked voxel
2//! grids in a single 3D scene.
3//!
4//! See `PORTING-SCENE.md` at the workspace root for the substage
5//! roadmap. This crate is the layer **above** voxlap's per-chunk
6//! renderer (`roxlap-core`): a [`Scene`] holds a sparse set of
7//! [`Grid`]s, each with its own f64 world position + arbitrary 3D
8//! rotation. Future stages will add per-grid raycast composition
9//! (S3), cross-chunk gline within a grid (S4), per-grid rotation
10//! (S5), far-LOD billboards / planet proxies (S6), and streaming +
11//! procedural generation (S7).
12//!
13//! S2.0 lands the **type skeleton + grid registration only**.
14//! S2.1 adds the [`addr`] module — world ↔ grid-local ↔ chunk +
15//! voxel-in-chunk decomposition, the canonical f64↔i32 boundary
16//! helper called out by risk R5 in `PORTING-SCENE.md`. S2.2 adds
17//! the [`chunks`] module (sparse storage with on-demand chunk
18//! allocation) and the [`Grid`] edit API ([`Grid::set_voxel`],
19//! [`Grid::set_rect`], [`Grid::set_sphere`]) which decompose
20//! multi-chunk operations and delegate to
21//! [`roxlap_formats::edit`]. S2.3 adds the [`snapshot`] module —
22//! a serde-friendly view of the scene that round-trips through
23//! `Serialize` + `Deserialize` (chunks encode via
24//! [`roxlap_formats::vxl::serialize`] / [`parse`]). Rendering
25//! composition is still owed (S3+).
26//!
27//! [`parse`]: roxlap_formats::vxl::parse
28
29pub mod addr;
30pub mod billboard;
31pub mod cavegen;
32pub mod chunks;
33pub mod edit;
34pub mod lod;
35pub mod render;
36pub mod snapshot;
37pub mod streaming;
38
39use std::collections::{HashMap, HashSet};
40use std::sync::Arc;
41
42use glam::{DQuat, DVec3, IVec3, UVec3};
43use roxlap_formats::vxl::Vxl;
44use serde::{Deserialize, Serialize};
45
46pub use addr::{grid_local_to_world, voxel_global, voxel_split, world_to_grid_local, GridLocalPos};
47pub use billboard::{canonical_viewpoints, BillboardCache, BillboardSnapshot};
48pub use lod::{select_lod, Lod, LodThresholds};
49pub use streaming::{ChunkGenerator, StreamRadius};
50
51/// XY size of one chunk in voxels. The plan locks 128 — keeps
52/// chunks compact (~2 MB worst-case dense-slab footprint inside
53/// each `Vxl`) and divides cleanly into voxlap's 2048 reference
54/// world size.
55pub const CHUNK_SIZE_XY: u32 = 128;
56
57/// Z size of one chunk in voxels. Locked at 256 to preserve
58/// voxlap's existing slab byte format unchanged inside each chunk
59/// — the per-chunk renderer doesn't need to know it's living
60/// inside a scene-graph.
61pub const CHUNK_SIZE_Z: u32 = 256;
62
63/// Stable identifier for a grid registered in a [`Scene`]. Issued
64/// by [`Scene::add_grid`]; persists across edits but a removed
65/// grid's id is not reissued.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
67pub struct GridId(u32);
68
69impl GridId {
70 /// The integer wire form. Useful for serde / debug output.
71 #[must_use]
72 pub const fn raw(self) -> u32 {
73 self.0
74 }
75}
76
77/// f64 world placement of one grid: position + orientation.
78///
79/// `origin` is the grid's local-space origin in world coords —
80/// chunk `(0, 0, 0)`'s `(0, 0, 0)` voxel maps to
81/// `origin + rotation * vec3(0, 0, 0)` (i.e. just `origin`).
82/// Voxel size is fixed at 1 world unit / voxel for v1.
83#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
84pub struct GridTransform {
85 pub origin: DVec3,
86 pub rotation: DQuat,
87}
88
89impl GridTransform {
90 /// Identity transform at world origin. Useful as a default for
91 /// the first grid added to an otherwise empty scene.
92 #[must_use]
93 pub fn identity() -> Self {
94 Self {
95 origin: DVec3::ZERO,
96 rotation: DQuat::IDENTITY,
97 }
98 }
99
100 /// Axis-aligned grid placed at `origin` with no rotation.
101 #[must_use]
102 pub fn at(origin: DVec3) -> Self {
103 Self {
104 origin,
105 rotation: DQuat::IDENTITY,
106 }
107 }
108}
109
110impl Default for GridTransform {
111 fn default() -> Self {
112 Self::identity()
113 }
114}
115
116/// Address of one voxel inside a scene: which grid it belongs to,
117/// which chunk within that grid, and the voxel's offset inside
118/// that chunk.
119///
120/// `chunk` is signed (`IVec3`) because chunks are centred on the
121/// grid's local origin and may extend in either direction. `voxel`
122/// is unsigned and must satisfy
123/// `(voxel.x, voxel.y) < CHUNK_SIZE_XY` and `voxel.z < CHUNK_SIZE_Z`.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
125pub struct GridAddr {
126 pub grid: GridId,
127 pub chunk: IVec3,
128 pub voxel: UVec3,
129}
130
131/// One independent voxel grid in a scene. Holds its world placement
132/// and a sparse map of populated chunks. Empty chunk slots are
133/// implicit air and skipped during rendering / raycasts.
134///
135/// Each chunk is internally a [`Vxl`] with `vsid = CHUNK_SIZE_XY`
136/// — the existing per-chunk renderer (opticast + grouscan +
137/// sprites + lighting in `roxlap-core`) runs on each chunk
138/// unchanged. Vertical worlds are built by stacking chunks along
139/// grid-local `+z`.
140#[derive(Debug)]
141pub struct Grid {
142 /// World placement (origin + rotation).
143 pub transform: GridTransform,
144 /// Sparse chunk storage keyed by `(chx, chy, chz)` chunk
145 /// coordinates. A missing entry means the chunk is fully air.
146 pub chunks: HashMap<IVec3, Vxl>,
147 /// Whether sky pixels rendered for this grid should be
148 /// composited into the final framebuffer. `true` is the
149 /// historical "grid owns its own sky" behaviour: ray misses
150 /// inside this grid's frustum paint sky_color into the temp
151 /// buffer. Set `false` for grids that are a foreground object
152 /// (e.g. a ship) — the sky is owned by a single "world" grid
153 /// (the ground) and other grids should not contribute sky
154 /// pixels, otherwise their grid-local-frame sky lookup
155 /// rotates with the grid and visibly fights the world's sky
156 /// during compose. See [`crate::render::render_scene_composed`]
157 /// for the masking implementation.
158 pub render_sky: bool,
159 /// Override [`roxlap_core::opticast::OpticastSettings::mip_levels`]
160 /// for this grid. `None` ⇒ use the caller's value. `Some(n)`
161 /// ⇒ cap at `n` (clamped to `[1, settings.mip_levels]`). Use
162 /// to disable multi-mip on a per-grid basis — small grids
163 /// (rotating ships, billboards) don't benefit from deep mips
164 /// and CAN trigger the
165 /// `[[project_axis_aligned_mip_beams]]`-style cf-cancellation
166 /// artifact when near-axis-aligned rays hit the rotated grid.
167 /// `Some(1)` = mip-0 only, byte-stable to single-mip.
168 pub mip_levels_override: Option<u32>,
169 /// World-distance thresholds for per-grid LOD tier selection
170 /// (S6.0). Defaults to [`LodThresholds::always_near`], so a
171 /// freshly-constructed grid always renders at full voxel (the
172 /// S5-and-earlier byte-stable behaviour). S6.1 plugs `Mid` into
173 /// the existing multi-mip path; S6.3 plugs `Far` into the
174 /// billboard impostor cache. See [`crate::lod`].
175 pub lod_thresholds: LodThresholds,
176 /// Lazy [`BillboardCache`] for the `Lod::Far` tier (S6.2).
177 /// `None` until the first time S6.3's render dispatch needs
178 /// it; populated then via [`BillboardCache::build`] and
179 /// cleared by edits ([`Self::set_voxel`] / [`Self::set_rect`]
180 /// / [`Self::set_sphere`]) to force a rebuild on next Far use.
181 /// Callers may also force-invalidate via direct assignment.
182 pub billboards: Option<BillboardCache>,
183 /// Optional procedural generator (S7.0). When set,
184 /// [`Self::ensure_chunk_generated`] uses it to materialise
185 /// chunks that are still absent from [`Self::chunks`].
186 ///
187 /// Streaming layers (S7.1+) walk the active radius around the
188 /// camera and call `ensure_chunk_generated` for missing chunks;
189 /// later stages dispatch this onto a background rayon pool. The
190 /// trait bound is `Send + Sync` (needed for S7.3 async
191 /// dispatch) + `Debug` (needed so [`Grid`] keeps deriving
192 /// `Debug`).
193 ///
194 /// `None` is the default — a grid without a generator behaves
195 /// exactly like the pre-S7 grids: absent chunks stay absent.
196 ///
197 /// `Arc` (not `Box`) so S7.3's async dispatch can clone the
198 /// generator into background rayon tasks without moving it out
199 /// of the grid. Trait bound `Send + Sync` (required at S7.0)
200 /// already makes `Arc<dyn ChunkGenerator>` `Send + Sync`.
201 pub generator: Option<Arc<dyn ChunkGenerator>>,
202 /// Streaming activity / eviction radii used by
203 /// [`Scene::pump_streaming_sync`] (S7.1). Defaults to
204 /// [`StreamRadius::DISABLED`] so existing grids see no change
205 /// in behaviour until the caller opts in.
206 pub stream_radius: StreamRadius,
207 /// Per-chunk edit version counter (S7.2). Each user edit
208 /// through [`Self::set_voxel`] / [`Self::set_rect`] /
209 /// [`Self::set_sphere`] bumps the counter for every chunk it
210 /// actually wrote to. [`Self::ensure_chunk_generated`] does
211 /// NOT bump — a freshly generated chunk has no edits and
212 /// reads as version 0.
213 ///
214 /// Wired up here so the S7.3 async dispatch can detect "an
215 /// edit happened while a chunk was being generated in the
216 /// background" and discard the now-stale result: each
217 /// background task captures the dispatch-time version and
218 /// only installs its result iff the current version still
219 /// matches.
220 ///
221 /// Missing entries read as `0` via [`Self::chunk_version`].
222 /// Evictions in [`Scene::pump_streaming_sync`] drop the
223 /// corresponding entry so the map stays bounded.
224 pub chunk_versions: HashMap<IVec3, u64>,
225 /// In-flight background generation tasks (S7.3).
226 ///
227 /// Populated by [`Scene::pump_streaming`] when it dispatches a
228 /// generator call onto the streaming rayon pool, drained when
229 /// the corresponding [`ChunkResult`] is received and processed
230 /// (either installed or discarded). The set is consulted to
231 /// avoid re-dispatching the same chunk while a previous task
232 /// is still running.
233 ///
234 /// Stays empty when only the synchronous
235 /// [`Scene::pump_streaming_sync`] is used — that path generates
236 /// inline on the calling thread.
237 ///
238 /// [`ChunkResult`]: streaming::ChunkResult
239 pub pending_gen: HashSet<IVec3>,
240}
241
242impl Grid {
243 /// New empty grid at the given transform — no chunks populated,
244 /// `render_sky = true`, LOD thresholds default to
245 /// [`LodThresholds::always_near`], no billboard cache.
246 #[must_use]
247 pub fn new(transform: GridTransform) -> Self {
248 Self {
249 transform,
250 chunks: HashMap::new(),
251 render_sky: true,
252 mip_levels_override: None,
253 lod_thresholds: LodThresholds::always_near(),
254 billboards: None,
255 generator: None,
256 stream_radius: StreamRadius::DISABLED,
257 chunk_versions: HashMap::new(),
258 pending_gen: HashSet::new(),
259 }
260 }
261
262 /// Current per-chunk edit version (S7.2). Returns `0` for any
263 /// chunk that hasn't been edited yet (including absent chunks
264 /// and chunks materialised only via
265 /// [`Self::ensure_chunk_generated`]).
266 ///
267 /// Used by S7.3's async generation dispatch to detect "edit
268 /// happened while we were generating" — the dispatcher
269 /// snapshots this value, the background task carries it, and
270 /// the result is discarded on install if the live counter has
271 /// since moved.
272 #[must_use]
273 pub fn chunk_version(&self, chunk_idx: IVec3) -> u64 {
274 self.chunk_versions.get(&chunk_idx).copied().unwrap_or(0)
275 }
276
277 /// Bump the edit version of `chunk_idx` (S7.2). Saturating add
278 /// at `u64::MAX` — a chunk would need 10^11 edits per second
279 /// for ~5 years to wrap, so saturation is a defensive cap, not
280 /// a realistic concern.
281 ///
282 /// Called by the edit API ([`Self::set_voxel`] /
283 /// [`Self::set_rect`] / [`Self::set_sphere`]) after a chunk
284 /// has actually been written to. Pure no-op edit paths
285 /// (carving from an air chunk that doesn't exist yet) skip
286 /// the bump.
287 ///
288 /// Exposed as `pub` (vs the historical `pub(crate)`) so hosts
289 /// that mutate `grid.chunks` directly — e.g.
290 /// `roxlap-scene-demo`'s `StreamingBakeTracker` writing
291 /// lightmode-1 alphas via `apply_lighting_with_cache` — can
292 /// signal "this chunk's slab changed" to downstream consumers
293 /// like the GPU dirty-chunk poller.
294 pub fn bump_chunk_version(&mut self, chunk_idx: IVec3) {
295 let entry = self.chunk_versions.entry(chunk_idx).or_insert(0);
296 *entry = entry.saturating_add(1);
297 }
298
299 /// Attach (or detach) the procedural generator used by
300 /// [`Self::ensure_chunk_generated`] (S7.0).
301 ///
302 /// Pass `Some(Arc::new(generator))` to enable on-demand chunk
303 /// generation; pass `None` to revert to the "absent stays
304 /// absent" behaviour. Replacing an existing generator drops the
305 /// previous `Arc` clone without touching already-materialised
306 /// chunks. Any background tasks dispatched by a prior
307 /// [`Scene::pump_streaming`] hold their own clones of the old
308 /// generator and finish naturally.
309 pub fn set_generator(&mut self, generator: Option<Arc<dyn ChunkGenerator>>) {
310 self.generator = generator;
311 }
312
313 /// Materialise the chunk at `chunk_idx` by running [`Self::generator`]
314 /// if (a) the chunk is not already present and (b) a generator
315 /// is attached. Returns `true` iff a chunk was newly generated.
316 ///
317 /// No-ops in all other cases:
318 /// - chunk already present (caller edits / a previous
319 /// `ensure_chunk_generated` call already populated it),
320 /// - no generator attached (the chunk stays implicit-air per
321 /// the existing convention — does NOT fall through to
322 /// [`Self::ensure_chunk`]'s empty-chunk constructor).
323 ///
324 /// This is the synchronous S7.0 path. S7.3 will add an async
325 /// counterpart that dispatches the generator call to a
326 /// dedicated rayon pool and installs the result on the next
327 /// `pump_streaming` call.
328 pub fn ensure_chunk_generated(&mut self, chunk_idx: IVec3) -> bool {
329 if self.chunks.contains_key(&chunk_idx) {
330 return false;
331 }
332 let Some(generator) = self.generator.as_ref() else {
333 return false;
334 };
335 // S7.6+: generator may decline specific indices (e.g. a
336 // single-z-layer generator skipping placeholder bedrock
337 // chunks at chz != 0). Respect the filter so we don't
338 // materialise an unwanted chunk.
339 if !generator.should_generate(chunk_idx) {
340 return false;
341 }
342 let chunk = generator.generate(chunk_idx);
343 self.chunks.insert(chunk_idx, chunk);
344 // S7.4: a fresh chunk grows the populated AABB → the
345 // bounding sphere shifts/expands → existing impostor
346 // projections become wrong. Match the eviction (S7.1) +
347 // edit (S6.2) invalidation contract and drop the cache.
348 // Next Far-tier render rebuilds lazily.
349 self.billboards = None;
350 true
351 }
352
353 /// Bounding-sphere radius of the populated chunk set in
354 /// grid-local space.
355 ///
356 /// Walks the sparse chunk map once, computes the chunk-index
357 /// AABB, converts to voxel-space half-extent, returns its
358 /// Euclidean length. Empty grid → `0.0`.
359 ///
360 /// Conservative — bounds the full chunk volume, not just its
361 /// populated voxels (a chunk containing one voxel still
362 /// contributes `CHUNK_SIZE_XY × CHUNK_SIZE_XY × CHUNK_SIZE_Z`
363 /// to the bbox). For LOD picking that's fine: an over-bound
364 /// sphere errs on the side of `Near`.
365 ///
366 /// Cost: `O(chunks.len())`; recomputed on every call. Callers
367 /// who need this every frame should memoize at the
368 /// [`Scene`]-level cache (added when S6.2 needs it).
369 #[must_use]
370 pub fn bounding_radius(&self) -> f64 {
371 if self.chunks.is_empty() {
372 return 0.0;
373 }
374 let mut min = IVec3::splat(i32::MAX);
375 let mut max = IVec3::splat(i32::MIN);
376 for &idx in self.chunks.keys() {
377 min = min.min(idx);
378 max = max.max(idx);
379 }
380 // Chunk-index bbox → voxel-space half-extent. `+1` on max
381 // converts inclusive chunk index to exclusive voxel upper
382 // bound (chunk `idx` covers voxels `[idx*size, (idx+1)*size)`).
383 let sx = f64::from(CHUNK_SIZE_XY);
384 let sz = f64::from(CHUNK_SIZE_Z);
385 let lo = DVec3::new(
386 f64::from(min.x) * sx,
387 f64::from(min.y) * sx,
388 f64::from(min.z) * sz,
389 );
390 let hi = DVec3::new(
391 f64::from(max.x + 1) * sx,
392 f64::from(max.y + 1) * sx,
393 f64::from(max.z + 1) * sz,
394 );
395 let half_extent = (hi - lo) * 0.5;
396 half_extent.length()
397 }
398
399 /// Pick this grid's LOD tier for the given world-space camera
400 /// position. Convenience wrapper around [`crate::select_lod`]
401 /// that pulls [`Self::lod_thresholds`] from the grid.
402 #[must_use]
403 pub fn select_lod(&self, camera_world_pos: DVec3) -> Lod {
404 select_lod(camera_world_pos, &self.transform, self.lod_thresholds)
405 }
406}
407
408/// Top-level scene container. Holds a flat collection of grids
409/// keyed by [`GridId`].
410///
411/// S2.0 only exposes registration / removal / lookup. Address math
412/// helpers (S2.x), edit API (S2.x), and rendering composition (S3)
413/// land in later sub-substages.
414#[derive(Debug, Default)]
415pub struct Scene {
416 grids: HashMap<GridId, Grid>,
417 next_grid_id: u32,
418 /// S7.3: per-scene streaming pool + result channel. Stored on
419 /// the `Scene` so `pump_streaming` can dispatch background
420 /// tasks and drain results across pump calls. `cfg`-gated out
421 /// on wasm32 where `pump_streaming` short-circuits to
422 /// `pump_streaming_sync` (no rayon pool there).
423 #[cfg(not(target_arch = "wasm32"))]
424 streaming: streaming::StreamingState,
425}
426
427impl Scene {
428 /// New empty scene — no grids.
429 #[must_use]
430 pub fn new() -> Self {
431 Self::default()
432 }
433
434 /// Number of grids currently registered.
435 #[must_use]
436 pub fn grid_count(&self) -> usize {
437 self.grids.len()
438 }
439
440 /// Register a new grid. Returns its fresh, unique [`GridId`].
441 pub fn add_grid(&mut self, transform: GridTransform) -> GridId {
442 let id = GridId(self.next_grid_id);
443 self.next_grid_id += 1;
444 self.grids.insert(id, Grid::new(transform));
445 id
446 }
447
448 /// Remove a grid by id. Returns the removed [`Grid`] (so the
449 /// caller can reclaim its chunks) or `None` if the id wasn't
450 /// registered. Removed ids are not reissued.
451 pub fn remove_grid(&mut self, id: GridId) -> Option<Grid> {
452 self.grids.remove(&id)
453 }
454
455 /// Borrow a registered grid.
456 #[must_use]
457 pub fn grid(&self, id: GridId) -> Option<&Grid> {
458 self.grids.get(&id)
459 }
460
461 /// Mutably borrow a registered grid.
462 pub fn grid_mut(&mut self, id: GridId) -> Option<&mut Grid> {
463 self.grids.get_mut(&id)
464 }
465
466 /// Iterator over all `(id, grid)` pairs in registration order
467 /// is **not** guaranteed — the underlying map is a `HashMap`.
468 /// Callers that need a stable order must sort by [`GridId`].
469 pub fn grids(&self) -> impl Iterator<Item = (GridId, &Grid)> {
470 self.grids.iter().map(|(id, g)| (*id, g))
471 }
472
473 /// Mutable iterator over all `(id, grid)` pairs. Yield order
474 /// is not guaranteed (HashMap-backed).
475 pub fn grids_mut(&mut self) -> impl Iterator<Item = (GridId, &mut Grid)> {
476 self.grids.iter_mut().map(|(id, g)| (*id, g))
477 }
478
479 /// Configure the number of worker threads in the dedicated
480 /// streaming pool (S7.3).
481 ///
482 /// Lazily applied — the pool itself is constructed on the first
483 /// [`Self::pump_streaming`] call. If the pool was already built
484 /// (i.e. a previous `pump_streaming` already dispatched at
485 /// least one task), it gets dropped and rebuilt. Dropping the
486 /// old pool blocks until all of its in-flight tasks finish
487 /// (rayon's contract); any results those tasks sent are still
488 /// drained by the next `pump_streaming` because the channel
489 /// survives the rebuild.
490 ///
491 /// The streaming pool is separate from rayon's global pool
492 /// (which R12 multicore rendering uses), so chunk generation
493 /// doesn't compete with render threads. Sensible values are 1
494 /// to ~4 — generation work is CPU-bound but should leave most
495 /// of the box for everything else.
496 ///
497 /// On wasm32 this is a no-op (no rayon pool available);
498 /// `pump_streaming` runs synchronously there.
499 ///
500 /// # Panics
501 /// Panics on native if `n == 0` (zero-thread pools are not
502 /// supported; the scene crate's S7.1 helper already disallows
503 /// the equivalent for `StreamRadius::r_active < 0`).
504 #[cfg(not(target_arch = "wasm32"))]
505 pub fn set_streaming_threads(&mut self, n: usize) {
506 self.streaming.set_thread_count(n);
507 }
508
509 /// wasm32 no-op companion of [`Self::set_streaming_threads`].
510 /// Lets cross-target code call this unconditionally.
511 #[cfg(target_arch = "wasm32")]
512 pub fn set_streaming_threads(&mut self, _n: usize) {
513 // No streaming pool on wasm32 — see `pump_streaming` docs.
514 }
515
516 /// Asynchronous streaming pump (S7.3).
517 ///
518 /// On native, dispatches missing-chunk generations onto a
519 /// dedicated rayon pool, drains any results that arrived since
520 /// the last pump, runs the eviction pass, and tracks in-flight
521 /// tasks in each grid's [`Grid::pending_gen`] set. The drain
522 /// uses the per-chunk version counter from S7.2 to discard
523 /// results whose chunk was edited mid-generation.
524 ///
525 /// On wasm32 this short-circuits to [`Self::pump_streaming_sync`]
526 /// — no thread pool is available there, but the same per-grid
527 /// stream-in / evict semantics apply.
528 ///
529 /// Call once per frame from the render thread. Cheap when
530 /// nothing changed (early-exit on disabled grids, try_recv
531 /// loops empty fast).
532 pub fn pump_streaming(&mut self, camera_world_pos: DVec3) {
533 #[cfg(target_arch = "wasm32")]
534 {
535 self.pump_streaming_sync(camera_world_pos);
536 }
537 #[cfg(not(target_arch = "wasm32"))]
538 {
539 self.pump_streaming_native(camera_world_pos);
540 }
541 }
542
543 /// Native implementation of [`Self::pump_streaming`].
544 #[cfg(not(target_arch = "wasm32"))]
545 fn pump_streaming_native(&mut self, camera_world_pos: DVec3) {
546 // 1. Drain inbox — install fresh results, drop stale.
547 while let Ok(result) = self.streaming.rx.try_recv() {
548 let Some(grid) = self.grids.get_mut(&result.grid_id) else {
549 // Grid was removed while a generation task was
550 // in-flight. Drop silently.
551 continue;
552 };
553 // Clearing pending_gen here both for "result delivered"
554 // and "we shouldn't try to re-dispatch this chunk just
555 // because it's missing".
556 let was_pending = grid.pending_gen.remove(&result.chunk_idx);
557 if !was_pending {
558 // Either the chunk was evicted (pending cleared in
559 // the eviction pass below in some prior call), or a
560 // duplicate result for an already-handled chunk.
561 continue;
562 }
563 if grid.chunks.contains_key(&result.chunk_idx) {
564 // Some other path (e.g. `ensure_chunk_generated`
565 // sync helper, or a manual edit's `ensure_chunk`)
566 // already populated the slot. Don't overwrite.
567 continue;
568 }
569 if grid.chunk_version(result.chunk_idx) != result.version_at_dispatch {
570 // S7.2 stale-result discard: chunk was edited mid-
571 // generation.
572 continue;
573 }
574 grid.chunks.insert(result.chunk_idx, result.vxl);
575 // S7.4: same invalidation contract as the sync
576 // `ensure_chunk_generated` path — installing a new
577 // chunk can grow the bounding sphere, so the
578 // billboard impostor cache must be rebuilt on next
579 // Far entry. Lazy: only one cache wipe per drain
580 // batch, the Far render rebuilds afterwards.
581 grid.billboards = None;
582 }
583
584 // 2. Per-grid: eviction first, then dispatch. Doing evict
585 // before dispatch means a chunk that's just left
586 // r_active doesn't get re-dispatched on the same pump.
587 self.streaming.ensure_pool();
588 // Disjoint sub-field borrows: pool/tx via `&self.streaming.*`,
589 // grids via `&mut self.grids`. Hold both at once.
590 let pool: &rayon::ThreadPool = self.streaming.pool.as_ref().expect("ensure_pool just ran");
591 let tx_template = &self.streaming.tx;
592 for (grid_id, grid) in &mut self.grids {
593 evict_grid_chunks(grid, camera_world_pos);
594 dispatch_grid_async(*grid_id, grid, camera_world_pos, pool, tx_template);
595 }
596 }
597
598 /// Synchronous streaming pump (S7.1).
599 ///
600 /// For each grid with a non-[`StreamRadius::DISABLED`] policy:
601 /// 1. Project the world-space camera into grid-local coords
602 /// (inverse rotation + origin subtract).
603 /// 2. Stream in any chunk whose AABB-to-camera distance is
604 /// `<= r_active`, calling [`Grid::ensure_chunk_generated`].
605 /// No-ops gracefully if the grid has no generator attached
606 /// (so callers can use the eviction half of streaming on a
607 /// purely-edited grid).
608 /// 3. Evict any chunk whose AABB-to-camera distance exceeds
609 /// `r_evict` from the grid's chunk map. Eviction also
610 /// clears the cached [`BillboardCache`] (the bounding sphere
611 /// may shrink, invalidating impostor projections; the next
612 /// Far-tier render rebuilds lazily).
613 ///
614 /// Both passes use the f64 grid-local position so rotation
615 /// + non-axis-aligned grids stream and evict correctly. The
616 /// generate path is blocking — S7.3 will move it to a
617 /// background rayon pool with `pump_streaming` (non-blocking).
618 /// Callers that want the async variant in S7.0/S7.1 stages
619 /// should keep `r_active` small.
620 pub fn pump_streaming_sync(&mut self, camera_world_pos: DVec3) {
621 for grid in self.grids.values_mut() {
622 pump_grid_streaming_sync(grid, camera_world_pos);
623 }
624 }
625}
626
627/// S7.1 helper — drives one grid's synchronous streaming pass.
628/// Stream-in pass uses [`Grid::ensure_chunk_generated`] (blocking
629/// inline generation); eviction pass shared with the S7.3 async
630/// path through [`evict_grid_chunks`].
631fn pump_grid_streaming_sync(grid: &mut Grid, camera_world_pos: DVec3) {
632 let radius = grid.stream_radius;
633 if radius.is_disabled() {
634 return;
635 }
636 let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
637
638 // --- Pass 1: stream in active chunks (sync) ---------------
639 if radius.r_active > 0.0 && grid.generator.is_some() {
640 for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
641 grid.ensure_chunk_generated(idx);
642 });
643 }
644
645 // --- Pass 2: evict chunks past r_evict --------------------
646 evict_grid_chunks_with_cam(grid, cam_local);
647}
648
649/// Eviction pass shared by [`pump_grid_streaming_sync`] and the
650/// S7.3 async path. Public-ish so the async driver can call it
651/// before dispatching to avoid generating chunks that are about
652/// to be evicted. `cfg`-gated to native: on wasm32 the only
653/// caller (`pump_streaming_native`) doesn't compile, so this
654/// helper would warn as dead code.
655#[cfg(not(target_arch = "wasm32"))]
656fn evict_grid_chunks(grid: &mut Grid, camera_world_pos: DVec3) {
657 let radius = grid.stream_radius;
658 if radius.is_disabled() {
659 return;
660 }
661 let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
662 evict_grid_chunks_with_cam(grid, cam_local);
663}
664
665/// Eviction inner — assumes `cam_local` is already computed (the
666/// dispatcher and sync pump both have it on hand).
667fn evict_grid_chunks_with_cam(grid: &mut Grid, cam_local: DVec3) {
668 let radius = grid.stream_radius;
669 if !radius.r_evict.is_finite() {
670 return;
671 }
672 let r_sq = radius.r_evict * radius.r_evict;
673 let to_evict: Vec<IVec3> = grid
674 .chunks
675 .keys()
676 .filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
677 .copied()
678 .collect();
679 // S7.3: also evict pending in-flight tasks past r_evict so the
680 // drain pass doesn't install a chunk that's no longer wanted.
681 // We don't have a way to cancel the rayon task, but we can
682 // drop the pending_gen entry so the result is dropped on
683 // arrival.
684 let to_evict_pending: Vec<IVec3> = grid
685 .pending_gen
686 .iter()
687 .filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
688 .copied()
689 .collect();
690 if to_evict.is_empty() && to_evict_pending.is_empty() {
691 return;
692 }
693 for idx in &to_evict {
694 grid.chunks.remove(idx);
695 // S7.2: keep chunk_versions in sync with chunks so the
696 // map stays bounded. A future re-stream of the same idx
697 // restarts at 0 — that's fine because any in-flight
698 // gen-result tagged with the pre-eviction version is
699 // unreachable (no chunk to install onto) and gets
700 // discarded by the new "version still 0" check anyway.
701 grid.chunk_versions.remove(idx);
702 // S7.3: drop pending entry for the same chunk too. If a
703 // background task is still running, its result will be
704 // dropped on arrival (was_pending = false).
705 grid.pending_gen.remove(idx);
706 }
707 for idx in &to_evict_pending {
708 grid.pending_gen.remove(idx);
709 }
710 if !to_evict.is_empty() {
711 // Bounding sphere can shrink → impostor projections would
712 // be wrong on next Far render. Clear lazily; the next
713 // Far-tier pass repopulates via BillboardCache::build.
714 grid.billboards = None;
715 }
716}
717
718/// Walk every chunk index whose AABB falls within `r_active` of
719/// `cam_local` and invoke `f` on it. Shared between the S7.1 sync
720/// stream-in and the S7.3 async dispatch.
721fn for_each_chunk_in_radius<F>(cam_local: DVec3, r_active: f64, mut f: F)
722where
723 F: FnMut(IVec3),
724{
725 let r_sq = r_active * r_active;
726 let sxy = f64::from(CHUNK_SIZE_XY);
727 let sz = f64::from(CHUNK_SIZE_Z);
728 // Half-extent in chunk units; ceil to be conservative so any
729 // chunk whose AABB clips the radius gets considered. `+1`
730 // covers the half-open chunk-AABB upper edge plus the case
731 // where the camera sits exactly on a chunk boundary and the
732 // closest chunk is one index off.
733 #[allow(clippy::cast_possible_truncation)]
734 let r_chunks_xy = (r_active / sxy).ceil() as i32 + 1;
735 #[allow(clippy::cast_possible_truncation)]
736 let r_chunks_z = (r_active / sz).ceil() as i32 + 1;
737 #[allow(clippy::cast_possible_truncation)]
738 let cx_chunk = (cam_local.x / sxy).floor() as i32;
739 #[allow(clippy::cast_possible_truncation)]
740 let cy_chunk = (cam_local.y / sxy).floor() as i32;
741 #[allow(clippy::cast_possible_truncation)]
742 let cz_chunk = (cam_local.z / sz).floor() as i32;
743 for chz in (cz_chunk - r_chunks_z)..=(cz_chunk + r_chunks_z) {
744 for chy in (cy_chunk - r_chunks_xy)..=(cy_chunk + r_chunks_xy) {
745 for chx in (cx_chunk - r_chunks_xy)..=(cx_chunk + r_chunks_xy) {
746 let idx = IVec3::new(chx, chy, chz);
747 if streaming::chunk_aabb_dist_sq(cam_local, idx) <= r_sq {
748 f(idx);
749 }
750 }
751 }
752 }
753}
754
755/// S7.3 async dispatch — schedule generation for every chunk in
756/// `r_active` that's not already present and not already in
757/// flight. Each dispatch clones the grid's generator `Arc` and a
758/// sender clone, then spawns the closure on the streaming rayon
759/// pool. The closure does the generate + send; the main thread
760/// drains results on the next pump.
761#[cfg(not(target_arch = "wasm32"))]
762fn dispatch_grid_async(
763 grid_id: GridId,
764 grid: &mut Grid,
765 camera_world_pos: DVec3,
766 pool: &rayon::ThreadPool,
767 tx: &crossbeam_channel::Sender<streaming::ChunkResult>,
768) {
769 let radius = grid.stream_radius;
770 if radius.is_disabled() || radius.r_active <= 0.0 {
771 return;
772 }
773 let Some(generator) = grid.generator.as_ref().map(Arc::clone) else {
774 return;
775 };
776 let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
777 for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
778 if grid.chunks.contains_key(&idx) {
779 return; // already present
780 }
781 if grid.pending_gen.contains(&idx) {
782 return; // already in flight
783 }
784 // S7.6+: respect the generator's per-chunk filter — same
785 // contract as `Grid::ensure_chunk_generated` (sync helper).
786 // Lets a generator decline to materialise specific indices
787 // (e.g. `HillsChunkGenerator` skipping placeholder bedrock
788 // chunks at chz != 0 so the camera-above-grid path doesn't
789 // create chz < 0 entries that would shift `origin_chunk_z`
790 // and trigger the S4B.6.j cross-chunk look-down bug).
791 if !generator.should_generate(idx) {
792 return;
793 }
794 grid.pending_gen.insert(idx);
795 let version_at_dispatch = grid.chunk_version(idx);
796 let tx_clone = tx.clone();
797 let gen_clone = Arc::clone(&generator);
798 pool.spawn(move || {
799 let vxl = gen_clone.generate(idx);
800 // Send is non-blocking on unbounded channel; if the
801 // receiver was dropped (Scene drop), the send fails
802 // silently — that's fine.
803 let _ = tx_clone.send(streaming::ChunkResult {
804 grid_id,
805 chunk_idx: idx,
806 version_at_dispatch,
807 vxl,
808 });
809 });
810 });
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816
817 #[test]
818 fn empty_scene_has_no_grids() {
819 let scene = Scene::new();
820 assert_eq!(scene.grid_count(), 0);
821 assert!(scene.grids().next().is_none());
822 }
823
824 #[test]
825 fn add_grid_returns_fresh_ids() {
826 let mut scene = Scene::new();
827 let a = scene.add_grid(GridTransform::identity());
828 let b = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
829 assert_ne!(a, b);
830 assert_eq!(a.raw(), 0);
831 assert_eq!(b.raw(), 1);
832 assert_eq!(scene.grid_count(), 2);
833 }
834
835 #[test]
836 fn grid_lookup_round_trips() {
837 let mut scene = Scene::new();
838 let id = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
839 let g = scene.grid(id).expect("grid registered");
840 assert_eq!(g.transform.origin, DVec3::new(10.0, 20.0, 30.0));
841 assert_eq!(g.transform.rotation, DQuat::IDENTITY);
842 assert!(g.chunks.is_empty());
843 }
844
845 #[test]
846 fn remove_grid_drops_it_from_scene() {
847 let mut scene = Scene::new();
848 let id = scene.add_grid(GridTransform::identity());
849 let removed = scene.remove_grid(id);
850 assert!(removed.is_some());
851 assert_eq!(scene.grid_count(), 0);
852 assert!(scene.grid(id).is_none());
853 // Re-adding does NOT reuse the dropped id.
854 let id2 = scene.add_grid(GridTransform::identity());
855 assert_ne!(id, id2);
856 assert_eq!(id2.raw(), 1);
857 }
858
859 #[test]
860 fn remove_unknown_grid_is_none() {
861 let mut scene = Scene::new();
862 let bogus = GridId(999);
863 assert!(scene.remove_grid(bogus).is_none());
864 }
865
866 #[test]
867 fn grid_mut_can_modify_transform() {
868 let mut scene = Scene::new();
869 let id = scene.add_grid(GridTransform::identity());
870 scene.grid_mut(id).unwrap().transform.origin = DVec3::new(1.0, 2.0, 3.0);
871 assert_eq!(
872 scene.grid(id).unwrap().transform.origin,
873 DVec3::new(1.0, 2.0, 3.0)
874 );
875 }
876
877 #[test]
878 fn chunk_size_constants_match_plan() {
879 // Plan locks these values; bumping either breaks the slab
880 // byte format (Z) or the worst-case chunk footprint budget
881 // (XY). Pin them so a future refactor that drifts them
882 // shows up in CI.
883 assert_eq!(CHUNK_SIZE_XY, 128);
884 assert_eq!(CHUNK_SIZE_Z, 256);
885 }
886
887 // ---- S6.0: bounding_radius + Grid::select_lod ----
888
889 #[test]
890 fn new_grid_defaults_to_always_near_lod() {
891 // Byte-identity contract for the staged S6 rollout: a
892 // grid built through `new` must never trigger the Mid/Far
893 // branches by accident, even when bounding_radius would
894 // imply otherwise.
895 let g = Grid::new(GridTransform::identity());
896 assert_eq!(g.lod_thresholds.r_near, f64::INFINITY);
897 assert_eq!(g.lod_thresholds.r_mid, f64::INFINITY);
898 assert_eq!(g.select_lod(DVec3::new(1e9, 0.0, 0.0)), Lod::Near);
899 }
900
901 #[test]
902 fn bounding_radius_empty_grid_is_zero() {
903 let g = Grid::new(GridTransform::identity());
904 assert_eq!(g.bounding_radius(), 0.0);
905 }
906
907 #[test]
908 fn bounding_radius_single_chunk_at_origin() {
909 // One chunk at (0, 0, 0): bbox is [0, 128) × [0, 128) × [0, 256).
910 // Half-extent = (64, 64, 128); length = sqrt(64² + 64² + 128²)
911 // = sqrt(4096 + 4096 + 16384) = sqrt(24576) ≈ 156.7747...
912 let mut scene = Scene::new();
913 let id = scene.add_grid(GridTransform::identity());
914 let g = scene.grid_mut(id).unwrap();
915 // Populate chunk (0, 0, 0) via the edit API.
916 g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_88_88_88));
917 let r = g.bounding_radius();
918 let expected = ((64.0_f64).powi(2) * 2.0 + (128.0_f64).powi(2)).sqrt();
919 assert!(
920 (r - expected).abs() < 1e-9,
921 "bounding_radius={r} expected={expected}"
922 );
923 }
924
925 #[test]
926 fn bounding_radius_grows_with_chunk_extent() {
927 // Two chunks at (0,0,0) and (3,0,0): x extent is 4 chunks =
928 // 512 voxels; y/z are 1 chunk each. Half-extent = (256, 64, 128);
929 // length = sqrt(256² + 64² + 128²) = sqrt(65536+4096+16384)
930 // = sqrt(86016) ≈ 293.2848.
931 let mut scene = Scene::new();
932 let id = scene.add_grid(GridTransform::identity());
933 let g = scene.grid_mut(id).unwrap();
934 // Stamp one voxel in chunk (0,0,0).
935 g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_88_88_88));
936 // Stamp one voxel in chunk (3,0,0): grid-local x = 3*128 = 384.
937 g.set_voxel(IVec3::new(384, 0, 0), Some(0x80_88_88_88));
938 assert_eq!(g.chunks.len(), 2);
939 let r = g.bounding_radius();
940 let expected = (256.0_f64.powi(2) + 64.0_f64.powi(2) + 128.0_f64.powi(2)).sqrt();
941 assert!(
942 (r - expected).abs() < 1e-9,
943 "bounding_radius={r} expected={expected}"
944 );
945 }
946
947 #[test]
948 fn grid_select_lod_respects_lod_thresholds_field() {
949 // Set a non-default threshold and verify the helper picks
950 // the right tier for known distances.
951 let mut scene = Scene::new();
952 let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
953 let g = scene.grid_mut(id).unwrap();
954 g.lod_thresholds = LodThresholds {
955 r_near: 50.0,
956 r_mid: 200.0,
957 ..LodThresholds::always_near()
958 };
959 // Camera 25 units from grid origin → Near.
960 assert_eq!(g.select_lod(DVec3::new(125.0, 0.0, 0.0)), Lod::Near);
961 // 100 units → Mid.
962 assert_eq!(g.select_lod(DVec3::new(200.0, 0.0, 0.0)), Lod::Mid);
963 // 500 units → Far.
964 assert_eq!(g.select_lod(DVec3::new(600.0, 0.0, 0.0)), Lod::Far);
965 }
966}