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}