concinnity-engine 0.19.16

Runtime engine for Concinnity: ECS schedule, graphics, spawn, streaming
Documentation
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
// src/gfx/streaming/chunk.rs
//
// The `std`-side driver for infinite-world voxel chunk streaming.
//
// This is the chunk counterpart of `super::mesh`: it owns a
// background generation thread and the channels that carry work to it, and
// wraps the `no_std` policy core in `crate::gfx::chunk_window`. The split is
// the same one the rest of the streaming subsystem uses: `ChunkWindow` decides
// *which* chunks to stream using only `core` + `alloc`; everything OS-coupled
// -- the thread, the channels -- lives here so a future `no_std` client
// runtime only has to replace this file.
//
// `ChunkSource` is the seam. The shipped `ProceduralChunkSource` generates a
// chunk's geometry from a seed on demand, so chunks are never RAM- or
// disk-resident: an evicted chunk is simply regenerated if the camera returns.

use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender};

use super::mesh::DecodedMesh;
use crate::geometry::{
    ChunkBlockType, ChunkGenerator, build_chunk_impostor_mesh, build_chunk_mesh,
};
use crate::gfx::chunk_coord::ChunkCoord;
use crate::gfx::chunk_window::{ChunkDetail, ChunkWindow};
use crate::gfx::mesh_payload::Vertex;

// Generates a streamable chunk's geometry by coordinate and detail.
//
// `Send + Sync` so the background worker thread can own one. Implementors do
// the slow part (terrain generation, meshing); the streamer only ever sees
// the finished [`DecodedMesh`]. `detail` selects full voxel geometry
// ([`ChunkDetail::Near`]) or a coarse distant impostor ([`ChunkDetail::Far`]).
pub(crate) trait ChunkSource: Send + Sync {
    // Build the geometry for chunk `coord` at `detail`, or return a
    // human-readable error. Called off the main thread.
    fn generate(&self, coord: ChunkCoord, detail: ChunkDetail) -> Result<DecodedMesh, String>;
}

// The shipped chunk source: deterministic procedural terrain.
//
// Wraps a [`ChunkGenerator`] and the resolved block palette; `generate` runs
// the generator and meshes the result. Because generation is a pure function
// of the seed and the coordinate, a chunk that streams out and back in is
// regenerated identically -- no RAM or disk copy is kept.
pub(crate) struct ProceduralChunkSource {
    generator: ChunkGenerator,
    palette: Vec<ChunkBlockType>,
    chunk_blocks: [u32; 3],
    block_size: f32,
    // Coarse-grid step (in blocks) for the distant-impostor surface.
    impostor_step: u32,
    // The surface block's atlas UVs, so impostors texture like the full chunks.
    surface_block: ChunkBlockType,
}

impl ProceduralChunkSource {
    // A source for a world with the given seed, chunk dimensions, block size,
    // resolved `BlockType` palette, and distant-impostor coarse step.
    pub(crate) fn new(
        seed: u64,
        chunk_blocks: [u32; 3],
        block_size: f32,
        palette: Vec<ChunkBlockType>,
        impostor_step: u32,
    ) -> Self {
        let generator = ChunkGenerator::new(seed, chunk_blocks, palette.len() as u32);
        // The surface block is the one the generator paints the top of each
        // column with; fall back to the first palette entry for a degenerate
        // (single-entry) palette.
        let surface_idx = generator.surface_palette_index() as usize;
        let surface_block = palette
            .get(surface_idx)
            .or_else(|| palette.first())
            .copied()
            .unwrap_or(ChunkBlockType {
                solid: true,
                uv_top: [0.0, 0.0, 1.0, 1.0],
                uv_bottom: [0.0, 0.0, 1.0, 1.0],
                uv_side: [0.0, 0.0, 1.0, 1.0],
            });
        Self {
            generator,
            palette,
            chunk_blocks,
            block_size,
            impostor_step: impostor_step.max(1),
            surface_block,
        }
    }

    // Sample the terrain surface height on the coarse impostor grid: corner
    // (gx, gz) at the world column its clamped local position maps to. Sampling
    // by world column keeps adjacent impostors watertight along their shared
    // edge (both read the same boundary columns).
    fn impostor_heights(&self, coord: ChunkCoord) -> Vec<i32> {
        let step = self.impostor_step;
        let [dx, _dy, dz] = self.chunk_blocks;
        let nx = dx.div_ceil(step);
        let nz = dz.div_ceil(step);
        let base_x = coord.x * dx as i32;
        let base_z = coord.z * dz as i32;
        let mut heights = Vec::with_capacity(((nx + 1) * (nz + 1)) as usize);
        for gz in 0..=nz {
            let lz = (gz * step).min(dz) as i32;
            for gx in 0..=nx {
                let lx = (gx * step).min(dx) as i32;
                heights.push(
                    self.generator
                        .surface_height_world(base_x + lx, base_z + lz),
                );
            }
        }
        heights
    }
}

impl ChunkSource for ProceduralChunkSource {
    fn generate(&self, coord: ChunkCoord, detail: ChunkDetail) -> Result<DecodedMesh, String> {
        let (vertices, indices) = match detail {
            ChunkDetail::Near => {
                let blocks = self.generator.generate(coord);
                build_chunk_mesh(self.chunk_blocks, self.block_size, &blocks, &self.palette)?
            }
            ChunkDetail::Far => {
                let heights = self.impostor_heights(coord);
                build_chunk_impostor_mesh(
                    self.chunk_blocks,
                    self.block_size,
                    self.impostor_step,
                    &heights,
                    self.surface_block.uv_top,
                    self.surface_block.uv_side,
                )?
            }
        };
        Ok(DecodedMesh { vertices, indices })
    }
}

// Outcome of one background generation, carried back to the main thread.
struct LoadResult {
    coord: ChunkCoord,
    decoded: Result<DecodedMesh, String>,
}

// Drives streaming of an infinite voxel world's chunks.
//
// Owns the [`ChunkWindow`] policy core plus the background generation thread.
// Each frame the renderer calls [`plan_and_dispatch`] then [`drain_completed`].
//
// [`plan_and_dispatch`]: ChunkStreamer::plan_and_dispatch
// [`drain_completed`]: ChunkStreamer::drain_completed
pub(crate) struct ChunkStreamer {
    window: ChunkWindow,
    // World-space size of one chunk on X / Z -- to map a camera position to
    // its chunk coordinate.
    chunk_w: f32,
    chunk_d: f32,
    worker: super::worker::Worker<(ChunkCoord, ChunkDetail)>,
    result_rx: Receiver<LoadResult>,
}

impl ChunkStreamer {
    // Spawn the background worker and build a streamer.
    //
    // `near_radius` is the full-detail chunk radius, `far_radius` the outer
    // impostor radius (equal to `near_radius` disables impostors),
    // `load_budget` caps generations dispatched per frame, and `chunk_w` /
    // `chunk_d` are one chunk's world-space X / Z size (for the
    // camera-to-chunk mapping).
    pub(crate) fn new(
        source: Arc<dyn ChunkSource>,
        near_radius: i32,
        far_radius: i32,
        load_budget: usize,
        chunk_w: f32,
        chunk_d: f32,
    ) -> Self {
        let window = ChunkWindow::new(near_radius, far_radius, load_budget);
        let (request_tx, request_rx) = std::sync::mpsc::channel::<(ChunkCoord, ChunkDetail)>();
        let (result_tx, result_rx) = std::sync::mpsc::channel::<LoadResult>();

        let worker =
            super::worker::Worker::spawn("cn-chunk-stream", request_rx, request_tx, move |rx| {
                worker_loop(source, rx, result_tx)
            });

        Self {
            window,
            chunk_w,
            chunk_d,
            result_rx,
            worker,
        }
    }

    // The chunk a world-space camera position falls in.
    pub(crate) fn camera_chunk(&self, camera: [f32; 3]) -> ChunkCoord {
        ChunkCoord::from_world(camera[0], camera[2], self.chunk_w, self.chunk_d)
    }

    // Run the window policy for a camera in chunk `camera`: dispatch this
    // frame's chunk generations to the worker and return the chunks the
    // caller must remove from the GPU (they have left the view window).
    pub(crate) fn plan_and_dispatch(&mut self, camera: ChunkCoord) -> Vec<ChunkCoord> {
        let plan = self.window.plan(camera);
        for &(coord, detail) in &plan.to_load {
            let sent = self.worker.send((coord, detail));
            if !sent {
                // Worker gone -- forget the chunk so it is retried rather than
                // stuck Pending forever.
                self.window.forget(coord);
            }
        }
        plan.to_evict
    }

    // Apply every completed background generation via `upload`, which
    // receives the chunk's geometry by value so it can carry it into a
    // recorded backend op. Returns the number of chunks brought resident this
    // call.
    //
    // A chunk evicted while its generation was still in flight is dropped --
    // the window no longer tracks it, so its mesh is discarded rather than
    // uploaded into a chunk the camera has already left behind.
    pub(crate) fn drain_completed(
        &mut self,
        mut upload: impl FnMut(ChunkCoord, Vec<Vertex>, Vec<u16>),
    ) -> usize {
        let mut applied = 0;
        while let Ok(result) = self.result_rx.try_recv() {
            if !self.window.is_tracked(result.coord) {
                continue; // evicted mid-flight -- discard
            }
            match result.decoded {
                Ok(mesh) => {
                    // Resident GPU footprint: the decoded vertex + index buffers.
                    let bytes = mesh.vertices.len() * std::mem::size_of::<Vertex>()
                        + mesh.indices.len() * std::mem::size_of::<u16>();
                    upload(result.coord, mesh.vertices, mesh.indices);
                    self.window.mark_resident(result.coord, bytes as u64);
                    applied += 1;
                }
                Err(e) => {
                    tracing::warn!(
                        "chunk stream: generation of chunk ({},{}) failed: {}",
                        result.coord.x,
                        result.coord.z,
                        e
                    );
                    // Terminally resident (0 bytes -- nothing uploaded) so the
                    // planner stops retrying a chunk whose generation
                    // deterministically fails.
                    self.window.mark_resident(result.coord, 0);
                }
            }
        }
        applied
    }

    // `(resident, pending)` chunk counts -- for diagnostics.
    pub(crate) fn stats(&self) -> (usize, usize) {
        self.window.counts()
    }

    // `(near_resident, far_resident)` chunk counts -- resident full chunks vs
    // resident distant impostors, for diagnostics.
    pub(crate) fn detail_counts(&self) -> (usize, usize) {
        self.window.counts_by_detail()
    }

    // Set (or clear with `None`) the resident-chunk-byte budget. When set, the
    // window clamps its effective view radius down to hold resident chunk bytes
    // at or under the budget, shedding the far impostor band before the near
    // full-detail band.
    pub(crate) fn set_byte_budget(&mut self, budget: Option<u64>) {
        self.window.set_byte_budget(budget);
    }

    // Total resident chunk bytes, for diagnostics.
    pub(crate) fn resident_bytes(&self) -> u64 {
        self.window.resident_bytes()
    }

    // The active resident-byte budget, or `None` when byte accounting is off.
    pub(crate) fn byte_budget(&self) -> Option<u64> {
        self.window.byte_budget()
    }
}

// Background worker: generate each requested chunk and ship the result back.
// Exits when the request channel closes (the streamer was dropped).
fn worker_loop(
    source: Arc<dyn ChunkSource>,
    requests: Receiver<(ChunkCoord, ChunkDetail)>,
    results: Sender<LoadResult>,
) {
    while let Ok((coord, detail)) = requests.recv() {
        let decoded = source.generate(coord, detail);
        if results.send(LoadResult { coord, decoded }).is_err() {
            break;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cc(x: i32, z: i32) -> ChunkCoord {
        ChunkCoord::new(x, z)
    }

    fn mk_vertex(x: f32) -> Vertex {
        Vertex {
            pos: [x, 0.0, 0.0],
            normal: [0.0, 1.0, 0.0],
            tangent: [1.0, 0.0, 0.0],
            color: [1.0, 1.0, 1.0],
            uv: [0.0, 0.0],
        }
    }

    // A source yielding a fixed 1-triangle mesh for any chunk + detail.
    struct ConstSource;
    impl ChunkSource for ConstSource {
        fn generate(
            &self,
            _coord: ChunkCoord,
            _detail: ChunkDetail,
        ) -> Result<DecodedMesh, String> {
            Ok(DecodedMesh {
                vertices: vec![mk_vertex(0.0), mk_vertex(1.0), mk_vertex(2.0)],
                indices: vec![0, 1, 2],
            })
        }
    }

    fn drain_until(streamer: &mut ChunkStreamer, want: usize) -> Vec<ChunkCoord> {
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
        let mut uploaded = Vec::new();
        while std::time::Instant::now() < deadline {
            streamer.drain_completed(|coord, _, _| uploaded.push(coord));
            if streamer.stats().0 >= want {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(1));
        }
        uploaded
    }

    #[test]
    fn procedural_source_generates_a_non_empty_chunk() {
        let palette = vec![
            ChunkBlockType {
                solid: false,
                uv_top: [0.0; 4],
                uv_bottom: [0.0; 4],
                uv_side: [0.0; 4],
            },
            ChunkBlockType {
                solid: true,
                uv_top: [0.0, 0.0, 1.0, 1.0],
                uv_bottom: [0.0, 0.0, 1.0, 1.0],
                uv_side: [0.0, 0.0, 1.0, 1.0],
            },
        ];
        let source = ProceduralChunkSource::new(42, [8, 16, 8], 1.0, palette, 4);
        let mesh = source
            .generate(cc(0, 0), ChunkDetail::Near)
            .expect("generate ok");
        assert!(!mesh.vertices.is_empty());
        assert!(!mesh.indices.is_empty());
        // The Far impostor is a non-empty, far smaller mesh than the full chunk.
        let full = source
            .generate(cc(0, 0), ChunkDetail::Near)
            .expect("full ok");
        let impostor = source
            .generate(cc(0, 0), ChunkDetail::Far)
            .expect("impostor ok");
        assert!(!impostor.vertices.is_empty());
        assert!(
            impostor.vertices.len() < full.vertices.len(),
            "impostor ({}) should be cheaper than full ({})",
            impostor.vertices.len(),
            full.vertices.len()
        );
    }

    #[test]
    fn camera_chunk_maps_world_position_to_a_chunk() {
        let streamer = ChunkStreamer::new(Arc::new(ConstSource), 2, 2, 4, 16.0, 16.0);
        assert_eq!(streamer.camera_chunk([0.0, 5.0, 0.0]), cc(0, 0));
        assert_eq!(streamer.camera_chunk([20.0, 5.0, -1.0]), cc(1, -1));
    }

    #[test]
    fn plan_dispatches_chunks_and_drain_uploads_them() {
        let mut streamer = ChunkStreamer::new(Arc::new(ConstSource), 1, 1, 100, 16.0, 16.0);
        // radius 1 -> a 3x3 window of 9 chunks dispatched at once.
        let evict = streamer.plan_and_dispatch(cc(0, 0));
        assert!(evict.is_empty());
        let uploaded = drain_until(&mut streamer, 9);
        assert_eq!(uploaded.len(), 9);
        assert!(uploaded.contains(&cc(0, 0)));
        assert_eq!(streamer.stats(), (9, 0));
    }

    // Chunks within the near radius carry full geometry; the band out to the
    // far radius is filled with cheap impostors instead.
    #[test]
    fn detail_counts_split_the_near_and_far_bands() {
        // near 0 -> only the camera's own chunk is full detail; the rest of the
        // 3x3 far window streams as impostors.
        let mut streamer = ChunkStreamer::new(Arc::new(ConstSource), 0, 1, 100, 16.0, 16.0);
        streamer.plan_and_dispatch(cc(0, 0));
        drain_until(&mut streamer, 9);
        assert_eq!(streamer.detail_counts(), (1, 8));
    }

    // A generation that deterministically fails.
    struct FailingSource;
    impl ChunkSource for FailingSource {
        fn generate(
            &self,
            _coord: ChunkCoord,
            _detail: ChunkDetail,
        ) -> Result<DecodedMesh, String> {
            Err("no terrain".to_string())
        }
    }

    // A chunk whose generation fails is marked terminally resident with no
    // geometry, so the window stops re-dispatching it every frame.
    #[test]
    fn a_failed_generation_is_not_retried() {
        let mut streamer = ChunkStreamer::new(Arc::new(FailingSource), 0, 0, 4, 16.0, 16.0);
        streamer.plan_and_dispatch(cc(0, 0));

        let uploaded = drain_until(&mut streamer, 1);
        assert!(uploaded.is_empty(), "a failed generation uploads nothing");
        assert_eq!(streamer.stats(), (1, 0));
        assert_eq!(streamer.resident_bytes(), 0);
    }

    #[test]
    fn moving_far_evicts_the_old_window() {
        let mut streamer = ChunkStreamer::new(Arc::new(ConstSource), 1, 1, 100, 16.0, 16.0);
        streamer.plan_and_dispatch(cc(0, 0));
        drain_until(&mut streamer, 9);
        // Jump far enough that the whole origin window leaves the evict band.
        let evict = streamer.plan_and_dispatch(cc(50, 0));
        assert!(evict.contains(&cc(0, 0)));
        assert_eq!(evict.len(), 9);
    }
}