pub mod addr;
pub mod billboard;
pub mod cavegen;
pub mod chunks;
pub mod edit;
pub mod lod;
pub mod render;
pub mod snapshot;
pub mod streaming;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use glam::{DQuat, DVec3, IVec3, UVec3};
use roxlap_formats::vxl::Vxl;
use serde::{Deserialize, Serialize};
pub use addr::{grid_local_to_world, voxel_global, voxel_split, world_to_grid_local, GridLocalPos};
pub use billboard::{canonical_viewpoints, BillboardCache, BillboardSnapshot};
pub use edit::SpanOp;
pub use lod::{select_lod, Lod, LodThresholds};
pub use streaming::{ChunkGenerator, StreamRadius};
pub const CHUNK_SIZE_XY: u32 = 128;
pub const CHUNK_SIZE_Z: u32 = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct GridId(u32);
impl GridId {
#[must_use]
pub const fn raw(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RayHit {
pub grid: GridId,
pub voxel: IVec3,
pub world: DVec3,
pub t: f64,
pub color: Option<u32>,
}
fn voxel_dda(grid: &Grid, lo: DVec3, ld: DVec3, max_t: f64) -> Option<(IVec3, f64)> {
#[allow(clippy::cast_possible_truncation)]
let mut p = IVec3::new(
lo.x.floor() as i32,
lo.y.floor() as i32,
lo.z.floor() as i32,
);
if grid.voxel_solid(p) {
return Some((p, 0.0)); }
let sign = |d: f64| -> i32 {
if d > 0.0 {
1
} else if d < 0.0 {
-1
} else {
0
}
};
let step = IVec3::new(sign(ld.x), sign(ld.y), sign(ld.z));
let t_delta = DVec3::new(
if ld.x == 0.0 {
f64::INFINITY
} else {
(1.0 / ld.x).abs()
},
if ld.y == 0.0 {
f64::INFINITY
} else {
(1.0 / ld.y).abs()
},
if ld.z == 0.0 {
f64::INFINITY
} else {
(1.0 / ld.z).abs()
},
);
let boundary = |o: f64, d: f64| -> f64 {
if d > 0.0 {
(o.floor() + 1.0 - o) / d
} else if d < 0.0 {
(o - o.floor()) / -d
} else {
f64::INFINITY
}
};
let mut t_max = DVec3::new(
boundary(lo.x, ld.x),
boundary(lo.y, ld.y),
boundary(lo.z, ld.z),
);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let max_steps = (max_t * 3.0) as u64 + 8;
for _ in 0..max_steps {
let t = if t_max.x <= t_max.y && t_max.x <= t_max.z {
p.x += step.x;
let t = t_max.x;
t_max.x += t_delta.x;
t
} else if t_max.y <= t_max.z {
p.y += step.y;
let t = t_max.y;
t_max.y += t_delta.y;
t
} else {
p.z += step.z;
let t = t_max.z;
t_max.z += t_delta.z;
t
};
if t > max_t {
return None;
}
if grid.voxel_solid(p) {
return Some((p, t));
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct GridTransform {
pub origin: DVec3,
pub rotation: DQuat,
}
impl GridTransform {
#[must_use]
pub fn identity() -> Self {
Self {
origin: DVec3::ZERO,
rotation: DQuat::IDENTITY,
}
}
#[must_use]
pub fn at(origin: DVec3) -> Self {
Self {
origin,
rotation: DQuat::IDENTITY,
}
}
}
impl Default for GridTransform {
fn default() -> Self {
Self::identity()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GridAddr {
pub grid: GridId,
pub chunk: IVec3,
pub voxel: UVec3,
}
#[derive(Debug)]
pub struct Grid {
pub transform: GridTransform,
pub chunks: HashMap<IVec3, Vxl>,
pub render_sky: bool,
pub mip_levels_override: Option<u32>,
pub lod_thresholds: LodThresholds,
pub billboards: Option<BillboardCache>,
pub generator: Option<Arc<dyn ChunkGenerator>>,
pub stream_radius: StreamRadius,
pub chunk_versions: HashMap<IVec3, u64>,
pub pending_gen: HashSet<IVec3>,
}
impl Grid {
#[must_use]
pub fn new(transform: GridTransform) -> Self {
Self {
transform,
chunks: HashMap::new(),
render_sky: true,
mip_levels_override: None,
lod_thresholds: LodThresholds::always_near(),
billboards: None,
generator: None,
stream_radius: StreamRadius::DISABLED,
chunk_versions: HashMap::new(),
pending_gen: HashSet::new(),
}
}
#[must_use]
pub fn chunk_version(&self, chunk_idx: IVec3) -> u64 {
self.chunk_versions.get(&chunk_idx).copied().unwrap_or(0)
}
pub fn bump_chunk_version(&mut self, chunk_idx: IVec3) {
let entry = self.chunk_versions.entry(chunk_idx).or_insert(0);
*entry = entry.saturating_add(1);
}
pub fn set_generator(&mut self, generator: Option<Arc<dyn ChunkGenerator>>) {
self.generator = generator;
}
pub fn ensure_chunk_generated(&mut self, chunk_idx: IVec3) -> bool {
if self.chunks.contains_key(&chunk_idx) {
return false;
}
let Some(generator) = self.generator.as_ref() else {
return false;
};
if !generator.should_generate(chunk_idx) {
return false;
}
let chunk = generator.generate(chunk_idx);
self.chunks.insert(chunk_idx, chunk);
self.billboards = None;
true
}
#[must_use]
pub fn bounding_radius(&self) -> f64 {
if self.chunks.is_empty() {
return 0.0;
}
let mut min = IVec3::splat(i32::MAX);
let mut max = IVec3::splat(i32::MIN);
for &idx in self.chunks.keys() {
min = min.min(idx);
max = max.max(idx);
}
let sx = f64::from(CHUNK_SIZE_XY);
let sz = f64::from(CHUNK_SIZE_Z);
let lo = DVec3::new(
f64::from(min.x) * sx,
f64::from(min.y) * sx,
f64::from(min.z) * sz,
);
let hi = DVec3::new(
f64::from(max.x + 1) * sx,
f64::from(max.y + 1) * sx,
f64::from(max.z + 1) * sz,
);
let half_extent = (hi - lo) * 0.5;
half_extent.length()
}
#[must_use]
pub fn select_lod(&self, camera_world_pos: DVec3) -> Lod {
select_lod(camera_world_pos, &self.transform, self.lod_thresholds)
}
}
#[derive(Debug, Default)]
pub struct Scene {
grids: HashMap<GridId, Grid>,
next_grid_id: u32,
#[cfg(not(target_arch = "wasm32"))]
streaming: streaming::StreamingState,
}
impl Scene {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn grid_count(&self) -> usize {
self.grids.len()
}
pub fn add_grid(&mut self, transform: GridTransform) -> GridId {
let id = GridId(self.next_grid_id);
self.next_grid_id += 1;
self.grids.insert(id, Grid::new(transform));
id
}
pub fn remove_grid(&mut self, id: GridId) -> Option<Grid> {
self.grids.remove(&id)
}
#[must_use]
pub fn grid(&self, id: GridId) -> Option<&Grid> {
self.grids.get(&id)
}
pub fn grid_mut(&mut self, id: GridId) -> Option<&mut Grid> {
self.grids.get_mut(&id)
}
pub fn grids(&self) -> impl Iterator<Item = (GridId, &Grid)> {
self.grids.iter().map(|(id, g)| (*id, g))
}
pub fn grids_mut(&mut self) -> impl Iterator<Item = (GridId, &mut Grid)> {
self.grids.iter_mut().map(|(id, g)| (*id, g))
}
#[must_use]
pub fn resolve_voxel(&self, world: DVec3, ray_dir: DVec3) -> Option<(GridId, IVec3)> {
let len = ray_dir.length();
if len < 1e-9 {
return None;
}
let inside = world + ray_dir * (0.5 / len); for (id, grid) in self.grids() {
let glp = addr::world_to_grid_local(inside, &grid.transform);
let v = addr::voxel_global(glp.chunk, glp.voxel);
if grid.voxel_solid(v) {
return Some((id, v));
}
}
None
}
#[must_use]
pub fn raycast(&self, origin: DVec3, dir: DVec3, max_dist: f64) -> Option<RayHit> {
let len = dir.length();
if len < 1e-12 || max_dist <= 0.0 {
return None;
}
let dn = dir / len; let mut best: Option<RayHit> = None;
for (id, grid) in self.grids() {
let inv = grid.transform.rotation.inverse();
let lo = inv * (origin - grid.transform.origin);
let ld = inv * dn;
if let Some((voxel, t)) = voxel_dda(grid, lo, ld, max_dist) {
if best.as_ref().map_or(true, |b| t < b.t) {
best = Some(RayHit {
grid: id,
voxel,
world: origin + dn * t,
t,
color: grid.voxel_color(voxel),
});
}
}
}
best
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_streaming_threads(&mut self, n: usize) {
self.streaming.set_thread_count(n);
}
#[cfg(target_arch = "wasm32")]
pub fn set_streaming_threads(&mut self, _n: usize) {
}
pub fn pump_streaming(&mut self, camera_world_pos: DVec3) {
#[cfg(target_arch = "wasm32")]
{
self.pump_streaming_sync(camera_world_pos);
}
#[cfg(not(target_arch = "wasm32"))]
{
self.pump_streaming_native(camera_world_pos);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_native(&mut self, camera_world_pos: DVec3) {
while let Ok(result) = self.streaming.rx.try_recv() {
let Some(grid) = self.grids.get_mut(&result.grid_id) else {
continue;
};
let was_pending = grid.pending_gen.remove(&result.chunk_idx);
if !was_pending {
continue;
}
if grid.chunks.contains_key(&result.chunk_idx) {
continue;
}
if grid.chunk_version(result.chunk_idx) != result.version_at_dispatch {
continue;
}
grid.chunks.insert(result.chunk_idx, result.vxl);
grid.billboards = None;
}
self.streaming.ensure_pool();
let pool: &rayon::ThreadPool = self.streaming.pool.as_ref().expect("ensure_pool just ran");
let tx_template = &self.streaming.tx;
for (grid_id, grid) in &mut self.grids {
evict_grid_chunks(grid, camera_world_pos);
dispatch_grid_async(*grid_id, grid, camera_world_pos, pool, tx_template);
}
}
pub fn pump_streaming_sync(&mut self, camera_world_pos: DVec3) {
for grid in self.grids.values_mut() {
pump_grid_streaming_sync(grid, camera_world_pos);
}
}
}
fn pump_grid_streaming_sync(grid: &mut Grid, camera_world_pos: DVec3) {
let radius = grid.stream_radius;
if radius.is_disabled() {
return;
}
let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
if radius.r_active > 0.0 && grid.generator.is_some() {
for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
grid.ensure_chunk_generated(idx);
});
}
evict_grid_chunks_with_cam(grid, cam_local);
}
#[cfg(not(target_arch = "wasm32"))]
fn evict_grid_chunks(grid: &mut Grid, camera_world_pos: DVec3) {
let radius = grid.stream_radius;
if radius.is_disabled() {
return;
}
let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
evict_grid_chunks_with_cam(grid, cam_local);
}
fn evict_grid_chunks_with_cam(grid: &mut Grid, cam_local: DVec3) {
let radius = grid.stream_radius;
if !radius.r_evict.is_finite() {
return;
}
let r_sq = radius.r_evict * radius.r_evict;
let to_evict: Vec<IVec3> = grid
.chunks
.keys()
.filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
.copied()
.collect();
let to_evict_pending: Vec<IVec3> = grid
.pending_gen
.iter()
.filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
.copied()
.collect();
if to_evict.is_empty() && to_evict_pending.is_empty() {
return;
}
for idx in &to_evict {
grid.chunks.remove(idx);
grid.chunk_versions.remove(idx);
grid.pending_gen.remove(idx);
}
for idx in &to_evict_pending {
grid.pending_gen.remove(idx);
}
if !to_evict.is_empty() {
grid.billboards = None;
}
}
fn for_each_chunk_in_radius<F>(cam_local: DVec3, r_active: f64, mut f: F)
where
F: FnMut(IVec3),
{
let r_sq = r_active * r_active;
let sxy = f64::from(CHUNK_SIZE_XY);
let sz = f64::from(CHUNK_SIZE_Z);
#[allow(clippy::cast_possible_truncation)]
let r_chunks_xy = (r_active / sxy).ceil() as i32 + 1;
#[allow(clippy::cast_possible_truncation)]
let r_chunks_z = (r_active / sz).ceil() as i32 + 1;
#[allow(clippy::cast_possible_truncation)]
let cx_chunk = (cam_local.x / sxy).floor() as i32;
#[allow(clippy::cast_possible_truncation)]
let cy_chunk = (cam_local.y / sxy).floor() as i32;
#[allow(clippy::cast_possible_truncation)]
let cz_chunk = (cam_local.z / sz).floor() as i32;
for chz in (cz_chunk - r_chunks_z)..=(cz_chunk + r_chunks_z) {
for chy in (cy_chunk - r_chunks_xy)..=(cy_chunk + r_chunks_xy) {
for chx in (cx_chunk - r_chunks_xy)..=(cx_chunk + r_chunks_xy) {
let idx = IVec3::new(chx, chy, chz);
if streaming::chunk_aabb_dist_sq(cam_local, idx) <= r_sq {
f(idx);
}
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn dispatch_grid_async(
grid_id: GridId,
grid: &mut Grid,
camera_world_pos: DVec3,
pool: &rayon::ThreadPool,
tx: &crossbeam_channel::Sender<streaming::ChunkResult>,
) {
let radius = grid.stream_radius;
if radius.is_disabled() || radius.r_active <= 0.0 {
return;
}
let Some(generator) = grid.generator.as_ref().map(Arc::clone) else {
return;
};
let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
if grid.chunks.contains_key(&idx) {
return; }
if grid.pending_gen.contains(&idx) {
return; }
if !generator.should_generate(idx) {
return;
}
grid.pending_gen.insert(idx);
let version_at_dispatch = grid.chunk_version(idx);
let tx_clone = tx.clone();
let gen_clone = Arc::clone(&generator);
pool.spawn(move || {
let vxl = gen_clone.generate(idx);
let _ = tx_clone.send(streaming::ChunkResult {
grid_id,
chunk_idx: idx,
version_at_dispatch,
vxl,
});
});
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_scene_has_no_grids() {
let scene = Scene::new();
assert_eq!(scene.grid_count(), 0);
assert!(scene.grids().next().is_none());
}
#[test]
fn raycast_hits_axis_aligned_voxel() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
scene
.grid_mut(id)
.unwrap()
.set_voxel(IVec3::new(5, 5, 10), Some(0x80_aa_bb_cc));
let hit = scene
.raycast(DVec3::new(5.5, 5.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.expect("ray hits the voxel");
assert_eq!(hit.grid, id);
assert_eq!(hit.voxel, IVec3::new(5, 5, 10));
assert!((hit.t - 10.0).abs() < 1e-6, "t≈10, got {}", hit.t);
assert!(hit.color.is_some(), "textured voxel has a colour");
assert!(
scene
.raycast(DVec3::new(0.5, 0.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.is_none(),
"empty column → no hit",
);
}
#[test]
fn raycast_respects_grid_transform() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
scene
.grid_mut(id)
.unwrap()
.set_voxel(IVec3::new(5, 5, 10), Some(0x80_11_22_33));
let hit = scene
.raycast(DVec3::new(105.5, 5.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.expect("ray hits the translated voxel");
assert_eq!(hit.voxel, IVec3::new(5, 5, 10), "grid-local voxel");
assert!((hit.world.x - 105.5).abs() < 1e-6, "world x preserved");
assert!((hit.t - 10.0).abs() < 1e-6, "t≈10, got {}", hit.t);
}
#[test]
fn raycast_picks_nearest_grid() {
let mut scene = Scene::new();
let near = scene.add_grid(GridTransform::identity());
let far = scene.add_grid(GridTransform::identity());
scene
.grid_mut(near)
.unwrap()
.set_voxel(IVec3::new(1, 1, 20), Some(0x80_00_ff_00));
scene
.grid_mut(far)
.unwrap()
.set_voxel(IVec3::new(1, 1, 40), Some(0x80_ff_00_00));
let hit = scene
.raycast(DVec3::new(1.5, 1.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.expect("hits the nearer voxel");
assert_eq!(hit.grid, near);
assert_eq!(hit.voxel, IVec3::new(1, 1, 20));
}
#[test]
fn add_grid_returns_fresh_ids() {
let mut scene = Scene::new();
let a = scene.add_grid(GridTransform::identity());
let b = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
assert_ne!(a, b);
assert_eq!(a.raw(), 0);
assert_eq!(b.raw(), 1);
assert_eq!(scene.grid_count(), 2);
}
#[test]
fn grid_lookup_round_trips() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
let g = scene.grid(id).expect("grid registered");
assert_eq!(g.transform.origin, DVec3::new(10.0, 20.0, 30.0));
assert_eq!(g.transform.rotation, DQuat::IDENTITY);
assert!(g.chunks.is_empty());
}
#[test]
fn remove_grid_drops_it_from_scene() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let removed = scene.remove_grid(id);
assert!(removed.is_some());
assert_eq!(scene.grid_count(), 0);
assert!(scene.grid(id).is_none());
let id2 = scene.add_grid(GridTransform::identity());
assert_ne!(id, id2);
assert_eq!(id2.raw(), 1);
}
#[test]
fn remove_unknown_grid_is_none() {
let mut scene = Scene::new();
let bogus = GridId(999);
assert!(scene.remove_grid(bogus).is_none());
}
#[test]
fn grid_mut_can_modify_transform() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
scene.grid_mut(id).unwrap().transform.origin = DVec3::new(1.0, 2.0, 3.0);
assert_eq!(
scene.grid(id).unwrap().transform.origin,
DVec3::new(1.0, 2.0, 3.0)
);
}
#[test]
fn chunk_size_constants_match_plan() {
assert_eq!(CHUNK_SIZE_XY, 128);
assert_eq!(CHUNK_SIZE_Z, 256);
}
#[test]
fn new_grid_defaults_to_always_near_lod() {
let g = Grid::new(GridTransform::identity());
assert_eq!(g.lod_thresholds.r_near, f64::INFINITY);
assert_eq!(g.lod_thresholds.r_mid, f64::INFINITY);
assert_eq!(g.select_lod(DVec3::new(1e9, 0.0, 0.0)), Lod::Near);
}
#[test]
fn bounding_radius_empty_grid_is_zero() {
let g = Grid::new(GridTransform::identity());
assert_eq!(g.bounding_radius(), 0.0);
}
#[test]
fn bounding_radius_single_chunk_at_origin() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_88_88_88));
let r = g.bounding_radius();
let expected = ((64.0_f64).powi(2) * 2.0 + (128.0_f64).powi(2)).sqrt();
assert!(
(r - expected).abs() < 1e-9,
"bounding_radius={r} expected={expected}"
);
}
#[test]
fn bounding_radius_grows_with_chunk_extent() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_88_88_88));
g.set_voxel(IVec3::new(384, 0, 0), Some(0x80_88_88_88));
assert_eq!(g.chunks.len(), 2);
let r = g.bounding_radius();
let expected = (256.0_f64.powi(2) + 64.0_f64.powi(2) + 128.0_f64.powi(2)).sqrt();
assert!(
(r - expected).abs() < 1e-9,
"bounding_radius={r} expected={expected}"
);
}
#[test]
fn grid_select_lod_respects_lod_thresholds_field() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
let g = scene.grid_mut(id).unwrap();
g.lod_thresholds = LodThresholds {
r_near: 50.0,
r_mid: 200.0,
..LodThresholds::always_near()
};
assert_eq!(g.select_lod(DVec3::new(125.0, 0.0, 0.0)), Lod::Near);
assert_eq!(g.select_lod(DVec3::new(200.0, 0.0, 0.0)), Lod::Mid);
assert_eq!(g.select_lod(DVec3::new(600.0, 0.0, 0.0)), Lod::Far);
}
}