Skip to main content

celestialsim/
async_bake.rs

1//! The async GDScript bake contract (CEL-86): pure helpers + the submission
2//! queue a [`crate::builder::CesBuilder`] hands results back through.
3//!
4//! A GDScript builder that defines `_bake_requested(requests)` is baked
5//! ASYNCHRONOUSLY: the planet hands it a batch of chunks each frame and the
6//! builder submits surfaces back — from any thread, at any time, any number of
7//! times per chunk — via `submit_chunk`. Submissions land in [`SubmitQueue`],
8//! which the planet drains on the main thread into the SAME `ready_surfaces`
9//! map the Rust [`crate::bake_pool::BakePool`] fills. Admission gating, coarse
10//! ancestor stand-ins and per-frame throttling therefore apply unchanged — they
11//! never knew where a surface came from.
12//!
13//! Re-submitting an already-resident chunk marks it dirty and re-realizes it,
14//! which is exactly the streaming refinement path (a coarse tile now, a finer
15//! one when the download lands) with no extra API.
16//!
17//! Everything here is pure and unit-tested; the Godot glue lives in
18//! `builder.rs` (the `#[func]`s) and `celestial.rs` (drain / request /
19//! gate).
20
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::Mutex;
23
24use godot::builtin::{Color, Vector3};
25
26use celestial_algo::quadtree::ChunkId;
27
28/// Deepest chunk depth a handle can encode. `ChunkId::path` spends 2 bits per
29/// level, so `depth <= 20` fits the 40 path bits below.
30pub const MAX_HANDLE_DEPTH: u8 = 20;
31
32/// Widths of the handle bit-fields, low to high: path | depth | face | tag.
33const PATH_BITS: u32 = 40;
34const DEPTH_BITS: u32 = 5;
35const FACE_BITS: u32 = 5;
36/// Per-planet tag, so two planets sharing one builder `.tres` cannot have their
37/// submissions cross-wired: a planet ignores any handle not bearing its tag.
38pub const TAG_BITS: u32 = 12;
39
40const DEPTH_SHIFT: u32 = PATH_BITS;
41const FACE_SHIFT: u32 = DEPTH_SHIFT + DEPTH_BITS;
42const TAG_SHIFT: u32 = FACE_SHIFT + FACE_BITS;
43
44const PATH_MASK: u64 = (1 << PATH_BITS) - 1;
45const DEPTH_MASK: u64 = (1 << DEPTH_BITS) - 1;
46const FACE_MASK: u64 = (1 << FACE_BITS) - 1;
47/// Mask a raw planet tag down to the bits a handle can carry.
48pub const TAG_MASK: u64 = (1 << TAG_BITS) - 1;
49
50/// Pack `(tag, id)` into the opaque `i64` handle GDScript sees. Returns `None`
51/// for a chunk too deep to encode (`depth > MAX_HANDLE_DEPTH`) — such a chunk is
52/// simply never handed to an async builder.
53///
54/// Total width is `40 + 5 + 5 + 12 = 62` bits, so the result is always a
55/// non-negative `i64` (GDScript ints are signed).
56pub fn handle_encode(tag: u16, id: ChunkId) -> Option<i64> {
57    if id.depth > MAX_HANDLE_DEPTH || id.face as u64 > FACE_MASK {
58        return None;
59    }
60    if id.path & !PATH_MASK != 0 {
61        return None;
62    }
63    let bits = (id.path & PATH_MASK)
64        | ((id.depth as u64 & DEPTH_MASK) << DEPTH_SHIFT)
65        | ((id.face as u64 & FACE_MASK) << FACE_SHIFT)
66        | ((tag as u64 & TAG_MASK) << TAG_SHIFT);
67    Some(bits as i64)
68}
69
70/// Unpack a handle, rejecting anything not stamped with `tag` (a submission
71/// meant for a different planet) and anything negative or over-wide.
72pub fn handle_decode(tag: u16, handle: i64) -> Option<ChunkId> {
73    if handle < 0 {
74        return None;
75    }
76    let bits = handle as u64;
77    if (bits >> TAG_SHIFT) & TAG_MASK != (tag as u64 & TAG_MASK) {
78        return None;
79    }
80    let depth = ((bits >> DEPTH_SHIFT) & DEPTH_MASK) as u8;
81    if depth > MAX_HANDLE_DEPTH {
82        return None;
83    }
84    Some(ChunkId {
85        face: ((bits >> FACE_SHIFT) & FACE_MASK) as u8,
86        depth,
87        path: bits & PATH_MASK,
88    })
89}
90
91/// The texel-centre world directions of a chunk, row-major, `tile_res²` of them.
92///
93/// The same mapping the shaders use: barycentric texel centres over the chunk
94/// triangle, folded across the diagonal (`u + v > 1`) so the whole square holds
95/// valid data, then gnomonically projected onto the unit sphere by normalizing.
96/// Pure — safe to call from a worker thread.
97pub fn chunk_dirs(corners: [Vector3; 3], tile_res: u32) -> Vec<Vector3> {
98    let n = tile_res as usize;
99    let res_f = tile_res as f32;
100    let (d0, d1, d2) =
101        (corners[0].normalized(), corners[1].normalized(), corners[2].normalized());
102    let mut dirs = Vec::with_capacity(n * n);
103    for ty in 0..n {
104        for tx in 0..n {
105            let mut u = (tx as f32 + 0.5) / res_f;
106            let mut v = (ty as f32 + 0.5) / res_f;
107            if u + v > 1.0 {
108                let s = u + v;
109                u /= s;
110                v /= s;
111            }
112            dirs.push((d0 * (1.0 - u - v) + d1 * u + d2 * v).normalized());
113        }
114    }
115    dirs
116}
117
118/// Pack a signed unit component into the rgba8 normal encoding.
119fn pack_component(c: f32) -> u8 {
120    ((c * 0.5 + 0.5).clamp(0.0, 1.0) * 255.0).round() as u8
121}
122
123/// Pack a chunk's rgba8 world normals: each texel uses `over[i]` when the
124/// builder supplied one, else a curvature-correct finite difference of the
125/// height grid — the same construction `NoiseProvider` uses (the chunk's edge
126/// vectors are projected perpendicular to the radial direction before the height
127/// gradient is added along it).
128///
129/// `corners` are the chunk's UNNORMALIZED corners (their length carries the
130/// world radius scale); `dirs` is [`chunk_dirs`] for the same chunk.
131pub fn pack_normals(
132    corners: [Vector3; 3],
133    dirs: &[Vector3],
134    height: &[f32],
135    tile_res: u32,
136    over: Option<&[Vector3]>,
137) -> Vec<u8> {
138    let n = tile_res as usize;
139    let res_f = tile_res as f32;
140    let ni = n as i32;
141    let k = corners[0].length().max(1.0); // world radius scale
142    let ex = (corners[1] - corners[0]) / res_f;
143    let ey = (corners[2] - corners[0]) / res_f;
144
145    let mut normal = vec![0u8; n * n * 4];
146    for ty in 0..n {
147        for tx in 0..n {
148            let idx = ty * n + tx;
149            let dir = dirs[idx];
150            let nrm = if let Some(o) = over.and_then(|s| s.get(idx)) {
151                o.normalized()
152            } else {
153                let sample = |x: i32, y: i32| -> f32 {
154                    let x = x.clamp(0, ni - 1) as usize;
155                    let y = y.clamp(0, ni - 1) as usize;
156                    height[y * n + x]
157                };
158                let (xi, yi) = (tx as i32, ty as i32);
159                let exl = ex - dir * dir.dot(ex);
160                let eyl = ey - dir * dir.dot(ey);
161                let dpx = exl * 2.0 + dir * (k * (sample(xi + 1, yi) - sample(xi - 1, yi)));
162                let dpy = eyl * 2.0 + dir * (k * (sample(xi, yi + 1) - sample(xi, yi - 1)));
163                let mut nrm = dpx.cross(dpy).normalized();
164                if nrm.dot(dir) < 0.0 {
165                    nrm = -nrm;
166                }
167                nrm
168            };
169            normal[idx * 4] = pack_component(nrm.x);
170            normal[idx * 4 + 1] = pack_component(nrm.y);
171            normal[idx * 4 + 2] = pack_component(nrm.z);
172            normal[idx * 4 + 3] = 255;
173        }
174    }
175    normal
176}
177
178/// [`pack_normals`] with no builder-supplied normals: pure finite difference.
179pub fn fd_normals(
180    corners: [Vector3; 3],
181    dirs: &[Vector3],
182    height: &[f32],
183    tile_res: u32,
184) -> Vec<u8> {
185    pack_normals(corners, dirs, height, tile_res, None)
186}
187
188/// Why a `submit_chunk` payload was rejected.
189#[derive(Clone, Copy, PartialEq, Eq, Debug)]
190pub enum SubmitError {
191    /// `heights.len() != tile_res²`.
192    HeightLen { got: usize, want: usize },
193    /// `colors.len() != tile_res²`.
194    ColorLen { got: usize, want: usize },
195    /// `normals` was non-empty but not `tile_res²` long.
196    NormalLen { got: usize, want: usize },
197}
198
199impl std::fmt::Display for SubmitError {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            SubmitError::HeightLen { got, want } => {
203                write!(f, "heights has {got} entries, expected tile_res² = {want}")
204            }
205            SubmitError::ColorLen { got, want } => {
206                write!(f, "colors has {got} entries, expected tile_res² = {want}")
207            }
208            SubmitError::NormalLen { got, want } => {
209                write!(f, "normals has {got} entries, expected 0 or tile_res² = {want}")
210            }
211        }
212    }
213}
214
215/// A raw `submit_chunk` payload as GDScript handed it over. Held as plain
216/// `Vec`s (not `PackedArray`s, which are not `Send`) so the queue can cross
217/// threads. Validated at drain time, where `tile_res` is known.
218#[derive(Clone, Debug)]
219pub struct RawSubmission {
220    pub handle: i64,
221    pub heights: Vec<f32>,
222    pub colors: Vec<Color>,
223    pub normals: Vec<Vector3>,
224}
225
226/// A validated surface. `normal == None` ⇒ finite-difference it (the planet has
227/// the chunk geometry; the builder may not).
228#[derive(Clone, Debug, PartialEq)]
229pub struct Submission {
230    pub handle: i64,
231    pub color: Vec<u8>,
232    pub height: Vec<f32>,
233    pub normal: Option<Vec<u8>>,
234}
235
236/// Validate and convert a raw `submit_chunk` payload.
237///
238/// Non-finite heights (a NaN from a bad division, an inf from a failed fetch)
239/// are sanitized to `0.0`: a NaN reaching LOD selection poisons it in a way that
240/// is near-impossible to trace back from the symptom.
241///
242/// An EMPTY `normals` means "finite-difference it for me" and yields
243/// `normal: None`; any other length mismatch is an error.
244pub fn validate_submission(
245    raw: &RawSubmission,
246    tile_res: u32,
247) -> Result<Submission, SubmitError> {
248    let (heights, colors, normals) = (&raw.heights, &raw.colors, &raw.normals);
249    let want = (tile_res as usize) * (tile_res as usize);
250    if heights.len() != want {
251        return Err(SubmitError::HeightLen { got: heights.len(), want });
252    }
253    if colors.len() != want {
254        return Err(SubmitError::ColorLen { got: colors.len(), want });
255    }
256    if !normals.is_empty() && normals.len() != want {
257        return Err(SubmitError::NormalLen { got: normals.len(), want });
258    }
259
260    let height: Vec<f32> =
261        heights.iter().map(|h| if h.is_finite() { *h } else { 0.0 }).collect();
262
263    let mut color = vec![255u8; want * 4];
264    for (i, c) in colors.iter().enumerate() {
265        color[i * 4] = (c.r.clamp(0.0, 1.0) * 255.0) as u8;
266        color[i * 4 + 1] = (c.g.clamp(0.0, 1.0) * 255.0) as u8;
267        color[i * 4 + 2] = (c.b.clamp(0.0, 1.0) * 255.0) as u8;
268        color[i * 4 + 3] = 255;
269    }
270
271    let normal = if normals.is_empty() {
272        None
273    } else {
274        let mut packed = vec![0u8; want * 4];
275        for (i, v) in normals.iter().enumerate() {
276            let nrm = v.normalized();
277            packed[i * 4] = pack_component(nrm.x);
278            packed[i * 4 + 1] = pack_component(nrm.y);
279            packed[i * 4 + 2] = pack_component(nrm.z);
280            packed[i * 4 + 3] = 255;
281        }
282        Some(packed)
283    };
284
285    Ok(Submission { handle: raw.handle, color, height, normal })
286}
287
288/// The mutex-guarded hand-back channel owned by a `CesBuilder`.
289///
290/// `submit_chunk` pushes from ANY thread; the planet drains on the main thread
291/// each frame. The builder owns it, so there is no back-reference to the planet
292/// and no reference cycle. A worker that outlives `teardown_job` just pushes
293/// into a queue nobody drains — harmless.
294#[derive(Default)]
295pub struct SubmitQueue {
296    items: Mutex<Vec<RawSubmission>>,
297    /// A bad payload inside a bake loop would otherwise print thousands of
298    /// identical errors per second; report the first and stay quiet after.
299    reported_error: AtomicBool,
300}
301
302impl SubmitQueue {
303    /// Push a raw submission (any thread).
304    pub fn push(&self, s: RawSubmission) {
305        if let Ok(mut q) = self.items.lock() {
306            q.push(s);
307        }
308    }
309
310    /// Take everything queued (main thread, once per frame).
311    pub fn drain(&self) -> Vec<RawSubmission> {
312        match self.items.lock() {
313            Ok(mut q) => std::mem::take(&mut *q),
314            Err(_) => Vec::new(),
315        }
316    }
317
318    /// Drop every queued submission (a live param edit invalidated them).
319    pub fn clear(&self) {
320        if let Ok(mut q) = self.items.lock() {
321            q.clear();
322        }
323    }
324
325    /// `true` the FIRST time a payload is rejected, `false` forever after — so
326    /// the caller prints one error, not one per frame.
327    pub fn should_report_error(&self) -> bool {
328        !self.reported_error.swap(true, Ordering::Relaxed)
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn id(face: u8, depth: u8, path: u64) -> ChunkId {
337        ChunkId { face, depth, path }
338    }
339
340    #[test]
341    fn handle_roundtrips_over_every_face_and_depth() {
342        for face in 0..20u8 {
343            for depth in 0..=MAX_HANDLE_DEPTH {
344                // A full path for this depth: alternating quadrant bits.
345                let path = 0xAAAA_AAAA_AAu64 & ((1u64 << (2 * depth as u32)) - 1).max(0);
346                let want = id(face, depth, path);
347                let h = handle_encode(0x5A5, want).expect("encodable");
348                assert!(h >= 0, "handle must be a non-negative GDScript int");
349                assert_eq!(handle_decode(0x5A5, h), Some(want));
350            }
351        }
352    }
353
354    #[test]
355    fn handle_rejects_a_foreign_planet_tag() {
356        let h = handle_encode(7, id(3, 9, 0b1101)).unwrap();
357        assert_eq!(handle_decode(7, h), Some(id(3, 9, 0b1101)));
358        assert_eq!(handle_decode(8, h), None, "another planet's tag must not decode");
359    }
360
361    #[test]
362    fn handle_rejects_too_deep_and_negative() {
363        assert_eq!(handle_encode(0, id(0, MAX_HANDLE_DEPTH + 1, 0)), None);
364        assert_eq!(handle_decode(0, -1), None);
365    }
366
367    #[test]
368    fn handle_tag_is_masked_not_truncated_into_the_sign_bit() {
369        // Widest legal everything: still positive.
370        let h = handle_encode(TAG_MASK as u16, id(31, MAX_HANDLE_DEPTH, PATH_MASK)).unwrap();
371        assert!(h > 0);
372        assert_eq!(handle_decode(TAG_MASK as u16, h).unwrap().face, 31);
373    }
374
375    fn raw(handle: i64, heights: &[f32], colors: &[Color], normals: &[Vector3]) -> RawSubmission {
376        RawSubmission {
377            handle,
378            heights: heights.to_vec(),
379            colors: colors.to_vec(),
380            normals: normals.to_vec(),
381        }
382    }
383
384    #[test]
385    fn validate_rejects_wrong_lengths() {
386        let c = vec![Color::from_rgba(1.0, 1.0, 1.0, 1.0); 4];
387        assert_eq!(
388            validate_submission(&raw(0, &[0.0; 3], &c, &[]), 2),
389            Err(SubmitError::HeightLen { got: 3, want: 4 })
390        );
391        assert_eq!(
392            validate_submission(&raw(0, &[0.0; 4], &c[..3], &[]), 2),
393            Err(SubmitError::ColorLen { got: 3, want: 4 })
394        );
395        assert_eq!(
396            validate_submission(&raw(0, &[0.0; 4], &c, &[Vector3::UP; 2]), 2),
397            Err(SubmitError::NormalLen { got: 2, want: 4 })
398        );
399    }
400
401    #[test]
402    fn validate_sanitizes_non_finite_heights() {
403        let c = vec![Color::from_rgba(0.0, 0.0, 0.0, 1.0); 4];
404        let h = [1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY];
405        let s = validate_submission(&raw(0, &h, &c, &[]), 2).unwrap();
406        assert_eq!(s.height, vec![1.0, 0.0, 0.0, 0.0]);
407        assert!(s.height.iter().all(|v| v.is_finite()));
408    }
409
410    #[test]
411    fn validate_empty_normals_defers_to_finite_difference() {
412        let c = vec![Color::from_rgba(1.0, 0.5, 0.25, 1.0); 4];
413        let s = validate_submission(&raw(9, &[0.0; 4], &c, &[]), 2).unwrap();
414        assert_eq!(s.handle, 9);
415        assert!(s.normal.is_none(), "no normals ⇒ FD at drain time");
416        assert_eq!(&s.color[0..4], &[255, 127, 63, 255]);
417    }
418
419    #[test]
420    fn validate_packs_supplied_normals() {
421        let c = vec![Color::from_rgba(0.0, 0.0, 0.0, 1.0); 4];
422        // Un-normalized on purpose: the packer must normalize.
423        let s =
424            validate_submission(&raw(0, &[0.0; 4], &c, &[Vector3::new(0.0, 5.0, 0.0); 4]), 2)
425                .unwrap();
426        let n = s.normal.expect("packed");
427        assert_eq!(&n[0..4], &[128, 255, 128, 255]);
428    }
429
430    #[test]
431    fn chunk_dirs_are_unit_and_row_major() {
432        let corners = [Vector3::new(1.0, 0.0, 0.0), Vector3::new(0.0, 1.0, 0.0), Vector3::new(0.0, 0.0, 1.0)];
433        let dirs = chunk_dirs(corners, 8);
434        assert_eq!(dirs.len(), 64);
435        assert!(dirs.iter().all(|d| (d.length() - 1.0).abs() < 1.0e-5));
436    }
437
438    #[test]
439    fn fd_normals_of_a_flat_chunk_point_outward() {
440        let corners =
441            [Vector3::new(1.0, 0.0, 0.0), Vector3::new(0.0, 1.0, 0.0), Vector3::new(0.0, 0.0, 1.0)];
442        let dirs = chunk_dirs(corners, 4);
443        let n = fd_normals(corners, &dirs, &[0.0; 16], 4);
444        for i in 0..16 {
445            let v = Vector3::new(
446                n[i * 4] as f32 / 255.0 * 2.0 - 1.0,
447                n[i * 4 + 1] as f32 / 255.0 * 2.0 - 1.0,
448                n[i * 4 + 2] as f32 / 255.0 * 2.0 - 1.0,
449            );
450            assert!(v.dot(dirs[i]) > 0.0, "normal must face away from the planet centre");
451        }
452    }
453
454    #[test]
455    fn queue_drains_once_and_reports_one_error() {
456        let q = SubmitQueue::default();
457        q.push(raw(1, &[], &[], &[]));
458        q.push(raw(2, &[], &[], &[]));
459        let got = q.drain();
460        assert_eq!(got.len(), 2);
461        assert!(q.drain().is_empty(), "drain takes everything");
462
463        assert!(q.should_report_error(), "first bad payload reports");
464        assert!(!q.should_report_error(), "subsequent ones stay quiet");
465    }
466
467    #[test]
468    fn queue_clear_drops_stale_submissions() {
469        let q = SubmitQueue::default();
470        q.push(raw(1, &[], &[], &[]));
471        q.clear();
472        assert!(q.drain().is_empty());
473    }
474
475    #[test]
476    fn queue_push_is_safe_from_many_threads() {
477        let q = std::sync::Arc::new(SubmitQueue::default());
478        let hs: Vec<_> = (0..8)
479            .map(|t| {
480                let q = std::sync::Arc::clone(&q);
481                std::thread::spawn(move || {
482                    for i in 0..32 {
483                        q.push(raw(t * 32 + i, &[], &[], &[]));
484                    }
485                })
486            })
487            .collect();
488        for h in hs {
489            h.join().unwrap();
490        }
491        assert_eq!(q.drain().len(), 8 * 32);
492    }
493}