Skip to main content

celestialsim/
bake_pool.rs

1//! Background chunk-surface baking (the fast-flight stutter fix), generic over
2//! any [`CpuSurfaceProvider`].
3//!
4//! Baking a chunk's surface costs several ms (a CPU fBm evaluation, …);
5//! running it on the main thread capped fast flight to a handful
6//! of admissions per frame. This pool moves the work onto worker threads: the
7//! planet enqueues bake jobs for chunks it wants to admit, and only admits a
8//! chunk into the GPU cache once its surface is READY — ancestor stand-ins cover
9//! the ground in the meantime, so delayed admission is invisible.
10//!
11//! The pool is provider-agnostic: every worker calls
12//! [`CpuSurfaceProvider::bake`], and all source-specific state (tile caches,
13//! streaming, height scaling) lives behind the provider.
14
15use std::collections::HashSet;
16use std::sync::mpsc;
17use std::sync::{Arc, Mutex, RwLock};
18use std::thread::JoinHandle;
19
20use celestial_algo::clipmap::FaceFrame;
21use celestial_algo::quadtree::{Chunk, ChunkId};
22
23use crate::surface::{ChunkSurface, CpuSurfaceProvider};
24
25/// One bake outcome. `surface == None` means the job was CANCELLED (its chunk
26/// left the wanted set before a worker got to it) — the in-flight entry is
27/// released so the chunk re-queues if it comes back into view.
28pub struct BakeResult {
29    pub chunk: Chunk,
30    pub surface: Option<ChunkSurface>,
31    /// The provider generation at the time the bake was REQUESTED. Private:
32    /// only the pool constructs results, and only [`BakePool::poll`] compares
33    /// it. Stamping the REQUEST (not the bake) means a job queued before a
34    /// param edit is rejected even if a worker happens to run it after the
35    /// edit — "request time < last parameter update time ⇒ reject".
36    req_gen: u64,
37}
38
39struct BakeJob {
40    frame: FaceFrame,
41    chunk: Chunk,
42    /// Generation current when this job was requested (see [`BakeResult::req_gen`]).
43    req_gen: u64,
44}
45
46/// The provider plus the generation it belongs to. A live param edit installs a
47/// new provider AND bumps `gen`; workers read both under one guard, so a result
48/// is always stamped with exactly the params it was baked from.
49struct ProviderSlot {
50    gen: u64,
51    provider: Arc<dyn CpuSurfaceProvider>,
52}
53
54/// Fallback bound for the result channel when the caller passes 0 (see
55/// [`BakePool::new`]).
56const DEFAULT_RESULT_CAP: usize = 128;
57
58/// Worker pool resampling chunk surfaces off the main thread.
59pub struct BakePool {
60    /// `Option` so `Drop` can close the channel and stop the workers.
61    req_tx: Option<mpsc::Sender<BakeJob>>,
62    /// BOUNDED (CEL-91). Each `BakeResult` owns a full `ChunkSurface`
63    /// (`12 × tile_res²` bytes = 0.75 MiB at tile_res 256); an unbounded channel
64    /// let finished bakes pile up on the heap whenever the main thread drained
65    /// slower than the workers produced. With a `sync_channel` the workers BLOCK
66    /// on `send` once `cap` results are undrained, which is the backpressure that
67    /// actually caps the memory. Shutdown is safe: `Drop` closes the request
68    /// channel and the receiver is dropped with the pool, so a blocked `send`
69    /// returns `Err` and the worker exits (never deadlocking the join).
70    ///
71    /// `Option` for exactly that reason: `Drop` must DROP the receiver BEFORE it
72    /// joins the workers. A struct field is only dropped after `Drop::drop`
73    /// returns, so leaving it in place would keep a `send`-blocked worker blocked
74    /// forever and hang the join.
75    res_rx: Option<mpsc::Receiver<BakeResult>>,
76    /// Chunks queued or being baked (dedup; cleared by [`poll`](Self::poll)).
77    in_flight: HashSet<ChunkId>,
78    /// Camera-driven cancellation: workers skip chunks not in this set (the
79    /// current cut). `None` (initial) means everything is wanted.
80    wanted: Arc<RwLock<Option<HashSet<ChunkId>>>>,
81    /// Shared, SWAPPABLE provider + its generation. A live param edit installs a
82    /// fresh provider here ([`set_provider`](Self::set_provider)); workers read it
83    /// per bake, so edits take effect without tearing down + respawning worker
84    /// threads (which would block the caller on in-flight bakes).
85    provider: Arc<RwLock<ProviderSlot>>,
86    /// Main-thread mirror of the current generation. [`poll`](Self::poll) drops
87    /// any result stamped with an older one (it was baked from stale params).
88    generation: u64,
89    workers: Vec<JoinHandle<()>>,
90}
91
92impl BakePool {
93    /// Spawn `n_workers` baking `tile_res × tile_res` surfaces via `provider`.
94    ///
95    /// `result_cap` bounds the number of FINISHED-but-undrained results held on
96    /// the heap (each one a full `ChunkSurface`); pass the caller's in-flight
97    /// bound. 0 means "use [`DEFAULT_RESULT_CAP`]".
98    pub fn new(
99        provider: Arc<dyn CpuSurfaceProvider>,
100        tile_res: u32,
101        n_workers: usize,
102        result_cap: usize,
103    ) -> Self {
104        let cap = if result_cap == 0 { DEFAULT_RESULT_CAP } else { result_cap };
105        let (req_tx, req_rx) = mpsc::channel::<BakeJob>();
106        // Bounded: workers block on `send` rather than growing the heap.
107        let (res_tx, res_rx) = mpsc::sync_channel::<BakeResult>(cap);
108        let req_rx = Arc::new(Mutex::new(req_rx));
109        let wanted: Arc<RwLock<Option<HashSet<ChunkId>>>> = Arc::new(RwLock::new(None));
110        let provider: Arc<RwLock<ProviderSlot>> =
111            Arc::new(RwLock::new(ProviderSlot { gen: 0, provider }));
112
113        let mut workers = Vec::with_capacity(n_workers.max(1));
114        for _ in 0..n_workers.max(1) {
115            let req_rx = Arc::clone(&req_rx);
116            let res_tx = res_tx.clone();
117            let provider = Arc::clone(&provider);
118            let wanted = Arc::clone(&wanted);
119            workers.push(std::thread::spawn(move || {
120                loop {
121                    // Hold the queue lock only across the blocking recv.
122                    let job = {
123                        let guard = match req_rx.lock() {
124                            Ok(g) => g,
125                            Err(_) => break,
126                        };
127                        guard.recv()
128                    };
129                    let job = match job {
130                        Ok(j) => j,
131                        Err(_) => break, // channel closed
132                    };
133                    // Read the CURRENT provider (a live edit may have swapped
134                    // it). The result is stamped with the job's REQUEST-time
135                    // generation, so `poll` drops any bake whose request
136                    // predates the latest param edit — regardless of which
137                    // provider the worker happened to read here.
138                    let prov = match provider.read() {
139                        Ok(g) => Arc::clone(&g.provider),
140                        Err(_) => break,
141                    };
142                    // Cancel stale jobs: if the chunk left the wanted set while
143                    // queued, release it without baking so fresh work never
144                    // waits behind flown-past terrain.
145                    let stale = match wanted.read() {
146                        Ok(w) => w.as_ref().map(|w| !w.contains(&job.chunk.id)).unwrap_or(false),
147                        Err(_) => false,
148                    };
149                    if stale {
150                        let out =
151                            BakeResult { chunk: job.chunk, surface: None, req_gen: job.req_gen };
152                        if res_tx.send(out).is_err() {
153                            break;
154                        }
155                        continue;
156                    }
157                    let surface = prov.bake(&job.frame, &job.chunk, tile_res);
158                    let out =
159                        BakeResult { chunk: job.chunk, surface: Some(surface), req_gen: job.req_gen };
160                    if res_tx.send(out).is_err() {
161                        break; // receiver gone
162                    }
163                }
164            }));
165        }
166
167        BakePool {
168            req_tx: Some(req_tx),
169            res_rx: Some(res_rx),
170            in_flight: HashSet::new(),
171            wanted,
172            provider,
173            generation: 0,
174            workers,
175        }
176    }
177
178    /// Install a fresh provider (e.g. after a live editor param edit) and open a
179    /// new GENERATION. Combine with a cache `invalidate_all` + clearing any ready
180    /// surfaces so every chunk re-bakes with the new params.
181    ///
182    /// Bumping the generation is what makes a live edit atomic across the planet.
183    /// Without it, the bakes ALREADY RUNNING when the edit lands finish against the
184    /// OLD params and are applied anyway, so those chunks keep old-param terrain
185    /// (e.g. the old `water_height`) while every other chunk shows the new value —
186    /// the planet ends up remembering two different values at once. Releasing
187    /// `in_flight` matters just as much: otherwise `request`'s dedup sees those
188    /// chunks as still pending and SILENTLY SKIPS their re-bake, so they are never
189    /// refreshed at all.
190    pub fn set_provider(&mut self, provider: Arc<dyn CpuSurfaceProvider>) {
191        if let Ok(mut g) = self.provider.write() {
192            g.gen += 1;
193            g.provider = provider;
194            self.generation = g.gen;
195        }
196        // Results for these are now stale and will be dropped by `poll`; forget
197        // them so the caller's re-request actually queues a fresh bake.
198        self.in_flight.clear();
199    }
200
201    /// Replace the wanted-chunk set (the current cut) workers use to cancel
202    /// stale queued bakes. `None` initial state means everything is wanted.
203    pub fn set_wanted(&self, ids: HashSet<ChunkId>) {
204        if let Ok(mut w) = self.wanted.write() {
205            *w = Some(ids);
206        }
207    }
208
209    /// Queue a bake for `chunk` unless one is already queued/running. The job
210    /// is stamped with the CURRENT generation (its "request time"): if a param
211    /// edit lands before the result comes back, [`poll`](Self::poll) rejects it.
212    pub fn request(&mut self, frame: &FaceFrame, chunk: &Chunk) {
213        if !self.in_flight.insert(chunk.id) {
214            return;
215        }
216        if let Some(tx) = &self.req_tx {
217            let job = BakeJob { frame: *frame, chunk: *chunk, req_gen: self.generation };
218            if tx.send(job).is_err() {
219                self.in_flight.remove(&chunk.id);
220            }
221        }
222    }
223
224    /// The current generation (bumped by every [`set_provider`](Self::set_provider)).
225    /// The planet stamps its own per-chunk bookkeeping (ready surfaces) with this
226    /// so a surface can also be rejected at RENDER time, not just on arrival.
227    pub fn generation(&self) -> u64 {
228        self.generation
229    }
230
231    /// Is a bake for `id` queued or running?
232    pub fn in_flight(&self, id: ChunkId) -> bool {
233        self.in_flight.contains(&id)
234    }
235
236    /// Number of queued/running bakes (backpressure signal).
237    pub fn in_flight_len(&self) -> usize {
238        self.in_flight.len()
239    }
240
241    /// Drain completed bakes non-blocking.
242    ///
243    /// Results whose REQUEST predates the current generation (a param edit has
244    /// landed since they were queued) are DROPPED, never handed back. The chunk
245    /// was already released from `in_flight` by [`set_provider`], so the
246    /// caller's re-request re-bakes it with the current params.
247    pub fn poll(&mut self) -> Vec<BakeResult> {
248        let mut raw = Vec::new();
249        if let Some(rx) = self.res_rx.as_ref() {
250            while let Ok(r) = rx.try_recv() {
251                raw.push(r);
252            }
253        }
254        let mut out = Vec::new();
255        for r in raw {
256            self.in_flight.remove(&r.chunk.id);
257            if r.req_gen != self.generation {
258                continue; // requested before the last param edit — discard
259            }
260            out.push(r);
261        }
262        out
263    }
264}
265
266impl Drop for BakePool {
267    fn drop(&mut self) {
268        self.req_tx = None; // close the request channel; idle workers exit
269        // The result channel is BOUNDED, so a worker can be blocked in `send`
270        // right now. Dropping the receiver BEFORE the join makes that `send`
271        // return `Err` and the worker break out of its loop; leaving it alive
272        // (as a plain field would, since fields drop only after this returns)
273        // would hang the join forever.
274        self.res_rx = None;
275        for h in self.workers.drain(..) {
276            let _ = h.join();
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::noise_provider::{NoiseParams, NoiseProvider};
285    use celestial_algo::quadtree::{base_face_frames, select_chunks};
286    use godot::builtin::Vector3;
287    use std::time::Duration;
288
289    /// A built-in [`NoiseProvider`] (CPU fBm, no network), for driving the
290    /// generic pool with a real provider in tests.
291    fn surface_provider() -> Arc<dyn CpuSurfaceProvider> {
292        Arc::new(NoiseProvider::new(NoiseParams::default()))
293    }
294
295    /// A provider that bakes a CONSTANT height, slowly. `value` identifies which
296    /// params produced a surface, so a test can tell a stale bake from a fresh one.
297    struct ConstProvider {
298        value: f32,
299        delay: Duration,
300    }
301
302    impl CpuSurfaceProvider for ConstProvider {
303        fn bake(&self, _f: &FaceFrame, _c: &Chunk, tile_res: u32) -> ChunkSurface {
304            std::thread::sleep(self.delay);
305            let n = (tile_res * tile_res) as usize;
306            ChunkSurface {
307                color: vec![255u8; n * 4],
308                height: vec![self.value; n],
309                normal: vec![128u8; n * 4],
310            }
311        }
312        fn height_scale(&self) -> f32 {
313            1.0
314        }
315    }
316
317    /// A live param edit swaps the provider. Any bake ALREADY RUNNING was started
318    /// against the OLD params, so its result is stale and must never be applied —
319    /// otherwise the chunks that happened to be mid-bake keep old-param terrain
320    /// while everything else updates (the "only some chunks update" bug).
321    ///
322    /// Equally, `set_provider` must release those chunks from `in_flight`, or
323    /// `request`'s dedup silently SUPPRESSES the re-bake and the chunk is never
324    /// refreshed at all.
325    #[test]
326    fn provider_swap_discards_in_flight_stale_bakes() {
327        const STALE: f32 = 1.0;
328        const FRESH: f32 = 2.0;
329
330        let mut pool = BakePool::new(
331            Arc::new(ConstProvider { value: STALE, delay: Duration::from_millis(300) }),
332            16,
333            1,
334            16,
335        );
336
337        let frames = base_face_frames(6371.0);
338        let cam = Vector3::new(0.0, 0.0, 6373.0);
339        let cut = select_chunks(&frames, cam, 0.05, 20, 6, None);
340        let chunk = cut[0];
341        let frame = &frames[chunk.id.face as usize];
342
343        // Start a bake, then let the worker actually pick it up and read the OLD
344        // provider before we swap.
345        pool.request(frame, &chunk);
346        std::thread::sleep(Duration::from_millis(60));
347
348        // Live edit: new params.
349        pool.set_provider(Arc::new(ConstProvider { value: FRESH, delay: Duration::ZERO }));
350
351        // The in-flight (now stale) bake must not block a re-request.
352        assert!(
353            !pool.in_flight(chunk.id),
354            "set_provider must release in-flight chunks so they can be re-baked"
355        );
356        pool.request(frame, &chunk);
357
358        // Drain for a while. The STALE surface must NEVER be handed back.
359        let mut fresh_seen = false;
360        for _ in 0..400 {
361            for r in pool.poll() {
362                if let Some(s) = r.surface {
363                    assert_ne!(
364                        s.height[0], STALE,
365                        "a bake started with the OLD provider leaked through as a surface"
366                    );
367                    if s.height[0] == FRESH {
368                        fresh_seen = true;
369                    }
370                }
371            }
372            if fresh_seen {
373                break;
374            }
375            std::thread::sleep(Duration::from_millis(5));
376        }
377        assert!(fresh_seen, "the chunk must be re-baked with the NEW provider");
378    }
379
380    /// "Request time < last parameter update time ⇒ reject": a job QUEUED
381    /// before an edit must be dropped even when a worker only dequeues it
382    /// AFTER the swap (and so bakes it with the new provider). One worker,
383    /// slow first job: the second job is guaranteed still queued at swap time.
384    #[test]
385    fn results_requested_before_an_edit_are_rejected_even_if_baked_after() {
386        let mut pool = BakePool::new(
387            Arc::new(ConstProvider { value: 1.0, delay: Duration::from_millis(200) }),
388            16,
389            1,
390            16,
391        );
392
393        let frames = base_face_frames(6371.0);
394        let cut = select_chunks(&frames, Vector3::new(0.0, 0.0, 6373.0), 0.05, 20, 6, None);
395        let (a, b) = (cut[0], cut[1]);
396
397        pool.request(&frames[a.id.face as usize], &a); // worker picks this up
398        pool.request(&frames[b.id.face as usize], &b); // still queued...
399        std::thread::sleep(Duration::from_millis(60));
400        // ...when the edit lands. `b` will be baked with the NEW provider, but
401        // its REQUEST predates the edit — it must still be rejected.
402        pool.set_provider(Arc::new(ConstProvider { value: 2.0, delay: Duration::ZERO }));
403
404        let deadline = std::time::Instant::now() + Duration::from_secs(2);
405        while std::time::Instant::now() < deadline {
406            assert!(
407                pool.poll().is_empty(),
408                "a result requested before the param edit was handed back"
409            );
410            std::thread::sleep(Duration::from_millis(5));
411        }
412    }
413
414    #[test]
415    fn pool_bakes_off_thread_and_dedups() {
416        let provider = surface_provider();
417        let mut pool = BakePool::new(provider, 32, 2, 16);
418
419        let frames = base_face_frames(6371.0);
420        let cam = Vector3::new(0.0, 0.0, 6373.0);
421        let cut = select_chunks(&frames, cam, 0.05, 20, 6, None);
422        let chunk = cut[0];
423        let frame = &frames[chunk.id.face as usize];
424
425        pool.request(frame, &chunk);
426        pool.request(frame, &chunk); // dedup
427        assert_eq!(pool.in_flight_len(), 1);
428
429        let mut results = Vec::new();
430        for _ in 0..500 {
431            results.extend(pool.poll());
432            if !results.is_empty() {
433                break;
434            }
435            std::thread::sleep(Duration::from_millis(2));
436        }
437        assert_eq!(results.len(), 1, "one deduped bake result");
438        let r = &results[0];
439        assert_eq!(r.chunk.id, chunk.id);
440        // Surface carries the baked CPU-noise height field (finite; may be
441        // negative where the seabed dips below sea level).
442        let surface = r.surface.as_ref().expect("baked, not cancelled");
443        assert!(surface.height[0].is_finite());
444        assert!(!pool.in_flight(chunk.id), "in-flight cleared after poll");
445    }
446
447    #[test]
448    fn stale_jobs_are_cancelled_not_baked() {
449        // set_wanted(empty) BEFORE requesting: whenever the worker dequeues the
450        // job, the chunk is already unwanted → cancelled outcome, in-flight
451        // released, and the chunk is re-requestable.
452        let provider = surface_provider();
453        let mut pool = BakePool::new(provider, 32, 1, 16);
454        let frames = base_face_frames(6371.0);
455        let cut = select_chunks(&frames, Vector3::new(0.0, 0.0, 6373.0), 0.05, 20, 6, None);
456        let chunk = cut[0];
457        let frame = &frames[chunk.id.face as usize];
458
459        pool.set_wanted(HashSet::new()); // nothing is wanted
460        pool.request(frame, &chunk);
461        let mut results = Vec::new();
462        for _ in 0..500 {
463            results.extend(pool.poll());
464            if !results.is_empty() {
465                break;
466            }
467            std::thread::sleep(Duration::from_millis(2));
468        }
469        assert_eq!(results.len(), 1);
470        assert!(results[0].surface.is_none(), "stale job must be cancelled, not baked");
471        assert!(!pool.in_flight(chunk.id), "cancelled job re-requestable");
472
473        // Wanted again → the re-request bakes for real.
474        let mut wanted = HashSet::new();
475        wanted.insert(chunk.id);
476        pool.set_wanted(wanted);
477        pool.request(frame, &chunk);
478        let mut results = Vec::new();
479        for _ in 0..500 {
480            results.extend(pool.poll());
481            if !results.is_empty() {
482                break;
483            }
484            std::thread::sleep(Duration::from_millis(2));
485        }
486        assert!(results[0].surface.is_some(), "wanted-again chunk bakes normally");
487    }
488
489    /// CEL-91 shutdown safety: the result channel is BOUNDED, so workers can be
490    /// blocked in `send` when the pool is dropped. `Drop` must drop the receiver
491    /// BEFORE joining, or the join hangs forever. Queue far more work than the
492    /// (cap-1) channel can hold, never poll, then drop — the drop must return.
493    #[test]
494    fn drop_does_not_deadlock_with_workers_blocked_on_a_full_result_channel() {
495        let (done_tx, done_rx) = mpsc::channel::<()>();
496        let h = std::thread::spawn(move || {
497            let mut pool = BakePool::new(
498                Arc::new(ConstProvider { value: 1.0, delay: Duration::from_millis(1) }),
499                8,
500                2,
501                1, // capacity 1: workers block on the 2nd undrained result
502            );
503            let frames = base_face_frames(6371.0);
504            let cut = select_chunks(&frames, Vector3::new(0.0, 0.0, 6373.0), 0.05, 20, 6, None);
505            for chunk in cut.iter().take(64) {
506                pool.request(&frames[chunk.id.face as usize], chunk);
507            }
508            // Give the workers time to fill the channel and BLOCK in `send`.
509            std::thread::sleep(Duration::from_millis(120));
510            drop(pool); // must not hang
511            let _ = done_tx.send(());
512        });
513        assert!(
514            done_rx.recv_timeout(Duration::from_secs(10)).is_ok(),
515            "BakePool::drop deadlocked against a worker blocked on the bounded channel"
516        );
517        h.join().unwrap();
518    }
519
520    /// The bounded channel is backpressure, not data loss: with a small cap the
521    /// results still all arrive, one drain at a time.
522    #[test]
523    fn a_small_result_cap_still_delivers_every_result() {
524        let mut pool = BakePool::new(surface_provider(), 8, 2, 1);
525        let frames = base_face_frames(6371.0);
526        let cut = select_chunks(&frames, Vector3::new(0.0, 0.0, 6373.0), 0.05, 20, 6, None);
527        let wanted: HashSet<ChunkId> = cut.iter().take(8).map(|c| c.id).collect();
528        pool.set_wanted(wanted);
529        for chunk in cut.iter().take(8) {
530            pool.request(&frames[chunk.id.face as usize], chunk);
531        }
532
533        let mut n = 0;
534        for _ in 0..2000 {
535            n += pool.poll().len();
536            if n == 8 {
537                break;
538            }
539            std::thread::sleep(Duration::from_millis(2));
540        }
541        assert_eq!(n, 8, "every requested bake must come back despite the cap of 1");
542        assert_eq!(pool.in_flight_len(), 0);
543    }
544}