Skip to main content

concinnity_core/components/
voxel_world.rs

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