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