Skip to main content

concinnity_asset/
voxel_world.rs

1// Infinite procedurally generated voxel world schema.
2
3use crate::{AssetId, MaterialHandle, de_opt_material_handle};
4use alloc::vec::Vec;
5
6/// An infinite, procedurally generated voxel world.
7///
8/// Where a [VoxelChunk](#voxelchunk) is one authored chunk compiled to a fixed
9/// mesh at build time, a `VoxelWorld` describes an *unbounded* world: chunks are
10/// generated on demand from `seed` as the camera moves and streamed in and out
11/// around it. The grid is infinite on X/Z and a single chunk tall on Y.
12/// Declaring one opts the world into chunk streaming; with no `VoxelWorld`
13/// present nothing changes.
14///
15/// The `palette` lists [BlockType](#blocktype) assets; the generator uses index
16/// 0 as air, index 1 as the surface block, and index 2 (when present) as the
17/// subsurface block. `material` supplies the textures and lighting shared by
18/// every chunk.
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20#[serde(default)]
21pub struct VoxelWorld {
22    /// Deterministic terrain seed. The same seed always generates the same
23    /// world, so a chunk regenerates identically each time it streams back in.
24    pub seed: u64,
25    /// Blocks per chunk `[dx, dy, dz]`. Y is the world's fixed vertical extent.
26    pub chunk_blocks: [u32; 3],
27    /// World units per block edge.
28    pub block_size: f32,
29    /// Chunk radius streamed around the camera at full voxel detail.
30    pub view_radius: u32,
31    /// Outer chunk radius streamed as cheap coarse impostors. Chunks farther
32    /// than `view_radius` but within `impostor_radius` render as a low-detail
33    /// surface mesh instead of full voxel geometry. `0` (the default) or any
34    /// value `<= view_radius` disables impostors.
35    pub impostor_radius: u32,
36    /// Coarse-grid step (in blocks) for distant-chunk impostors: the surface is
37    /// sampled every `impostor_step` blocks. Higher = cheaper and coarser.
38    pub impostor_step: u32,
39    /// Maximum number of chunks generated and loaded per frame.
40    pub load_budget: u32,
41    /// [BlockType](#blocktype) asset names. Index 0 is air; 1 is the surface
42    /// block; 2, when present, is the subsurface block.
43    pub palette: Vec<AssetId>,
44    /// [Material](#material) shared by every chunk: textures and lighting.
45    #[serde(deserialize_with = "de_opt_material_handle")]
46    pub material: Option<MaterialHandle>,
47}
48
49impl Default for VoxelWorld {
50    fn default() -> Self {
51        Self {
52            seed: 0,
53            chunk_blocks: [16, 24, 16],
54            block_size: 1.0,
55            view_radius: 5,
56            impostor_radius: 0,
57            impostor_step: 4,
58            load_budget: 3,
59            palette: Vec::new(),
60            material: None,
61        }
62    }
63}
64
65// These accessors feed the Metal chunk-streaming path for now
66// (Vulkan / DirectX catch-up is a follow-up).
67impl VoxelWorld {
68    /// Blocks per chunk, each axis floored at 1 so a chunk is never degenerate.
69    pub fn chunk_blocks(&self) -> [u32; 3] {
70        [
71            self.chunk_blocks[0].max(1),
72            self.chunk_blocks[1].max(1),
73            self.chunk_blocks[2].max(1),
74        ]
75    }
76
77    /// World units per block edge, floored at a small positive value.
78    pub fn block_size(&self) -> f32 {
79        self.block_size.max(0.01)
80    }
81
82    /// World-space `(x, z)` size of one chunk.
83    pub fn chunk_world_size(&self) -> (f32, f32) {
84        let b = self.chunk_blocks();
85        let s = self.block_size();
86        (b[0] as f32 * s, b[2] as f32 * s)
87    }
88
89    /// View radius in chunks, floored at 0 and capped so a typo cannot ask for
90    /// a multi-thousand-chunk window.
91    pub fn view_radius(&self) -> i32 {
92        (self.view_radius as i32).clamp(0, 32)
93    }
94
95    /// Effective impostor (far) radius in chunks. Capped well above the
96    /// full-detail cap since impostors are cheap, and floored at `view_radius`
97    /// (a smaller value disables impostors, there is no far band to fill).
98    pub fn impostor_radius(&self) -> i32 {
99        (self.impostor_radius as i32)
100            .clamp(0, 96)
101            .max(self.view_radius())
102    }
103
104    /// Coarse-grid step in blocks for distant impostors, floored at 1 and
105    /// capped so a typo cannot collapse the whole surface to a single quad on a
106    /// huge chunk (still valid, just degenerate).
107    pub fn impostor_step(&self) -> u32 {
108        self.impostor_step.clamp(1, 64)
109    }
110
111    /// Whether the distant-impostor far band is active: an impostor radius
112    /// strictly beyond the full-detail radius.
113    pub fn impostors_enabled(&self) -> bool {
114        self.impostor_radius() > self.view_radius()
115    }
116
117    /// Per-frame chunk load budget as a `usize`, floored at 1 so a stray 0
118    /// cannot wedge streaming permanently.
119    pub fn load_budget(&self) -> usize {
120        (self.load_budget as usize).max(1)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn defaults_stream_a_small_radius_with_impostors_off() {
130        let w = VoxelWorld::default();
131        assert_eq!(w.chunk_blocks(), [16, 24, 16]);
132        assert_eq!(w.block_size(), 1.0);
133        assert_eq!(w.chunk_world_size(), (16.0, 16.0));
134        assert_eq!(w.view_radius(), 5);
135        assert_eq!(w.load_budget(), 3);
136        assert_eq!(w.impostor_step(), 4);
137        // An impostor radius inside the view radius means nothing to impostor.
138        assert!(!w.impostors_enabled());
139        assert_eq!(w.impostor_radius(), w.view_radius());
140        assert!(w.palette.is_empty());
141        assert_eq!(w.material, None);
142    }
143
144    #[test]
145    fn a_degenerate_chunk_size_is_floored_to_something_meshable() {
146        // A zero dimension or block size would produce an empty or infinitely
147        // dense chunk, so both are clamped before the mesher sees them.
148        let w: VoxelWorld =
149            serde_json::from_str(r#"{"chunk_blocks":[0,0,0],"block_size":0.0}"#).unwrap();
150        assert_eq!(w.chunk_blocks(), [1, 1, 1]);
151        assert_eq!(w.block_size(), 0.01);
152        assert_eq!(w.chunk_world_size(), (0.01, 0.01));
153    }
154
155    #[test]
156    fn chunk_world_size_is_the_horizontal_footprint() {
157        // Height is not part of the footprint: chunks tile in X and Z only.
158        let w: VoxelWorld =
159            serde_json::from_str(r#"{"chunk_blocks":[8,64,4],"block_size":0.5}"#).unwrap();
160        assert_eq!(w.chunk_world_size(), (4.0, 2.0));
161    }
162
163    #[test]
164    fn radii_are_clamped_to_what_the_streamer_can_hold() {
165        let w: VoxelWorld =
166            serde_json::from_str(r#"{"view_radius":999,"impostor_radius":999}"#).unwrap();
167        assert_eq!(w.view_radius(), 32);
168        assert_eq!(w.impostor_radius(), 96);
169        assert!(w.impostors_enabled());
170    }
171
172    #[test]
173    fn an_impostor_radius_never_falls_inside_the_view_radius() {
174        // Impostors stand in for chunks beyond the meshed ones, so a smaller
175        // authored radius is raised rather than leaving a hole.
176        let w: VoxelWorld =
177            serde_json::from_str(r#"{"view_radius":10,"impostor_radius":2}"#).unwrap();
178        assert_eq!(w.impostor_radius(), 10);
179        assert!(!w.impostors_enabled());
180    }
181
182    #[test]
183    fn a_zero_impostor_step_or_load_budget_cannot_wedge_streaming() {
184        let w: VoxelWorld = serde_json::from_str(r#"{"impostor_step":0,"load_budget":0}"#).unwrap();
185        assert_eq!(w.impostor_step(), 1);
186        assert_eq!(w.load_budget(), 1);
187        let w: VoxelWorld = serde_json::from_str(r#"{"impostor_step":999}"#).unwrap();
188        assert_eq!(w.impostor_step(), 64);
189    }
190
191    #[test]
192    fn an_authored_world_round_trips_through_postcard() {
193        crate::test_support::install_resolvers();
194        let w: VoxelWorld = serde_json::from_str(
195            r#"{"seed":42,"palette":["stone","dirt"],"material":"voxel_mat",
196                "view_radius":8,"impostor_radius":24}"#,
197        )
198        .unwrap();
199        assert_eq!(w.palette, [AssetId(5), AssetId(4)]);
200        assert_eq!(w.material, Some(MaterialHandle(9)));
201
202        let bytes = postcard::to_allocvec(&w).unwrap();
203        let back: VoxelWorld = postcard::from_bytes(&bytes).unwrap();
204        assert_eq!(back.seed, 42);
205        assert_eq!(back.palette, [AssetId(5), AssetId(4)]);
206        assert_eq!(back.material, Some(MaterialHandle(9)));
207        assert!(back.impostors_enabled());
208    }
209}