Skip to main content

celestialsim/
chunk_pipeline.rs

1//! The chunk pipeline recording context + render-thread job.
2//!
3//! Mirrors `pipeline.rs` (`Ctx`/`PlanetJob`) for the chunk (grass/foliage)
4//! render path. Two nodes — `chunk-upload` → `chunk-realize` — wired through the
5//! descriptor/instance/verts resources, executed inside one render-thread job.
6
7use std::collections::HashMap;
8
9use celestial_graph::{Graph, NodeId, RecordCtx};
10use godot::classes::{RefCounted, RenderingDevice, RenderingServer};
11use godot::prelude::*;
12
13use crate::chunk_nodes::{build_chunk_pipeline, ChunkNode};
14use crate::descriptors::TerrainGpu;
15use crate::gpu::chunk_gpu::ChunkGpuResources;
16
17/// One chunk batch's CPU-staged data, handed from the main thread to the render
18/// thread. `desc_bytes` is `pack_chunks` output; `instance_bytes` is
19/// `pack_instances` output.
20pub struct ChunkStage {
21    pub desc_bytes: Vec<u8>,
22    pub realize_count: u32,
23    pub instance_bytes: Vec<u8>,
24    pub instance_count: u32,
25    /// Global CPU-surface toggle (1.0 once the provider's base is ready, else 0.0).
26    pub surface_enabled: f32,
27    /// Per-metre displaced-radius factor for CPU-surface heights.
28    pub surface_height_scale: f32,
29    /// Per realized chunk: (slot, rgba8 color [tile_res²·4], elevation metres
30    /// [tile_res²], rgba8-packed world normal [tile_res²·4]).
31    pub surface_patches: Vec<(u32, Vec<u8>, Vec<f32>, Vec<u8>)>,
32    /// CEL-73 scatter: per-realize-chunk `{path_lo, path_hi, face, pad}` aux
33    /// (`pack_scatter_aux`), parallel to `desc_bytes`. Empty when no layers.
34    pub scatter_aux_bytes: Vec<u8>,
35    /// CEL-73 scatter: per-visible-instance `{slot, depth}` list
36    /// (`pack_scatter_vis`) the compact pass gathers from.
37    pub scatter_vis_bytes: Vec<u8>,
38    pub scatter_vis_count: u32,
39    /// CEL-73 scatter: one packed `ScatterParamsGpu` snapshot per layer —
40    /// re-read every stage so density/height edits flow through as pure
41    /// uniform updates.
42    pub scatter_layer_params: Vec<Vec<u8>>,
43}
44
45/// Bytes of one packed `ChunkGpu` descriptor in `desc_bytes`.
46pub const CHUNK_DESC_STRIDE: usize = 96;
47/// Bytes of one packed scatter-aux entry (`ScatterAuxGpu`), parallel to desc.
48pub const SCATTER_AUX_STRIDE: usize = 16;
49
50/// Merge a newly staged batch into a still-pending one instead of replacing it.
51///
52/// The main thread stages every frame while streaming, but the render thread
53/// consumes asynchronously; a plain `stage = Some(new)` DROPPED any unconsumed
54/// batch — those chunks were already marked clean in the cache and kept their
55/// slots, so they were drawn forever with uninitialized pool memory (the giant
56/// garbage-triangle walls). Realize work is cumulative (descriptors + surface
57/// patches + scatter aux append); the instance set, surface globals and scatter
58/// visible/params are snapshots (latest wins). If the merged batch exceeds
59/// `budget_chunks` (render thread stalled for hundreds of frames), the OLDEST
60/// realizes are dropped — they re-dirty naturally.
61pub fn merge_stage(pending: &mut ChunkStage, new: ChunkStage, budget_chunks: usize) {
62    pending.desc_bytes.extend_from_slice(&new.desc_bytes);
63    pending.realize_count += new.realize_count;
64    pending.surface_patches.extend(new.surface_patches);
65    pending.scatter_aux_bytes.extend_from_slice(&new.scatter_aux_bytes);
66    pending.instance_bytes = new.instance_bytes;
67    pending.instance_count = new.instance_count;
68    pending.surface_enabled = new.surface_enabled;
69    pending.surface_height_scale = new.surface_height_scale;
70    // Scatter visible list + per-layer params are per-frame snapshots.
71    pending.scatter_vis_bytes = new.scatter_vis_bytes;
72    pending.scatter_vis_count = new.scatter_vis_count;
73    pending.scatter_layer_params = new.scatter_layer_params;
74
75    let over = (pending.realize_count as usize).saturating_sub(budget_chunks);
76    if over > 0 {
77        pending.desc_bytes.drain(0..over * CHUNK_DESC_STRIDE);
78        // surface_patches / scatter_aux parallel the realize list 1:1.
79        let drop_patches = over.min(pending.surface_patches.len());
80        pending.surface_patches.drain(0..drop_patches);
81        let drop_aux = (over * SCATTER_AUX_STRIDE).min(pending.scatter_aux_bytes.len());
82        pending.scatter_aux_bytes.drain(0..drop_aux);
83        pending.realize_count = budget_chunks as u32;
84    }
85
86    // CEL-91: patches are not always 1:1 with realizes (a realize whose surface
87    // was rejected/missing contributes no patch), so the `over` drain above does
88    // NOT by itself bound them — a stalled render thread could accumulate
89    // arbitrarily many, each ~0.75 MiB at tile_res 256. Cap them explicitly at
90    // one pool's worth: the OLDEST go, exactly as with the realizes. A dropped
91    // patch only means the slot keeps its previous surface until the chunk
92    // re-dirties.
93    if pending.surface_patches.len() > budget_chunks {
94        let excess = pending.surface_patches.len() - budget_chunks;
95        pending.surface_patches.drain(0..excess);
96    }
97}
98
99/// Production recording context for the chunk pipeline: wraps the main
100/// `RenderingDevice`, the chunk GPU resources, and the staged upload for this
101/// execute. Mirrors `Ctx<'a>` from `pipeline.rs`.
102pub struct ChunkCtx<'a> {
103    pub rd: Gd<RenderingDevice>,
104    pub gpu: &'a mut ChunkGpuResources,
105    pub stage: &'a mut Option<ChunkStage>,
106    pub terrain: TerrainGpu,
107    pub res: u32,
108    /// Chunks to realize this execute; set by the upload node from the stage and
109    /// read by the realize node to size its dispatch (`count * verts_per_chunk`).
110    pub realize_count: u32,
111    /// Visible instances this execute (CEL-73); set by the upload node, read by
112    /// the scatter-compact node to size its dispatch (`vis * capacity`).
113    pub vis_count: u32,
114    list: Option<i64>,
115    /// Timestamp labels captured this execute (for GPU timing parity with `Ctx`).
116    pub captured: Vec<&'static str>,
117}
118
119impl<'a> ChunkCtx<'a> {
120    pub fn new(
121        rd: Gd<RenderingDevice>,
122        gpu: &'a mut ChunkGpuResources,
123        stage: &'a mut Option<ChunkStage>,
124        terrain: TerrainGpu,
125        res: u32,
126    ) -> Self {
127        Self {
128            rd,
129            gpu,
130            stage,
131            terrain,
132            res,
133            realize_count: 0,
134            vis_count: 0,
135            list: None,
136            captured: Vec::new(),
137        }
138    }
139
140    pub fn list(&mut self) -> i64 {
141        *self.list.get_or_insert_with(|| self.rd.compute_list_begin())
142    }
143
144    /// Timestamps must sit between compute lists, so close any open list.
145    pub fn close_list(&mut self) {
146        if self.list.take().is_some() {
147            self.rd.compute_list_end();
148        }
149    }
150
151    /// Close the list and capture the end marker; returns the captured labels.
152    pub fn finish(mut self) -> Vec<&'static str> {
153        self.close_list();
154        self.rd.capture_timestamp("celestial/chunk-end");
155        self.captured.push("celestial/chunk-end");
156        self.captured
157    }
158}
159
160impl RecordCtx for ChunkCtx<'_> {
161    fn timestamp(&mut self, label: &str) {
162        self.close_list();
163        self.rd.capture_timestamp(label);
164        if let Some(stat) = STATIC_LABELS.iter().find(|l| **l == label) {
165            self.captured.push(stat);
166        }
167    }
168}
169
170const STATIC_LABELS: [&str; 6] = [
171    "celestial/chunk-upload",
172    "celestial/chunk-surface-custom",
173    "celestial/chunk-realize",
174    "celestial/chunk-bake",
175    "celestial/chunk-scatter-place",
176    "celestial/chunk-scatter-compact",
177];
178
179#[cfg(test)]
180mod stage_tests {
181    use super::*;
182
183    fn stage(chunks: u32, tag: u8) -> ChunkStage {
184        ChunkStage {
185            desc_bytes: vec![tag; chunks as usize * CHUNK_DESC_STRIDE],
186            realize_count: chunks,
187            instance_bytes: vec![tag; 8],
188            instance_count: chunks,
189            surface_enabled: tag as f32,
190            surface_height_scale: tag as f32 * 0.5,
191            surface_patches: (0..chunks).map(|s| (s, vec![tag; 4], vec![tag as f32], vec![tag; 4])).collect(),
192            scatter_aux_bytes: vec![tag; chunks as usize * SCATTER_AUX_STRIDE],
193            scatter_vis_bytes: vec![tag; 8],
194            scatter_vis_count: chunks,
195            scatter_layer_params: vec![vec![tag; 4]],
196        }
197    }
198
199    #[test]
200    fn desc_stride_matches_chunk_gpu() {
201        assert_eq!(
202            CHUNK_DESC_STRIDE,
203            std::mem::size_of::<crate::chunk_descriptors::ChunkGpu>()
204        );
205    }
206
207    #[test]
208    fn merge_appends_realizes_and_snapshots_instances() {
209        // Regression for the dropped-batch bug: merging must KEEP the pending
210        // batch's realize work and only replace the per-frame snapshot parts.
211        let mut pending = stage(3, 1);
212        merge_stage(&mut pending, stage(2, 2), 1024);
213        assert_eq!(pending.realize_count, 5, "realizes accumulate");
214        assert_eq!(pending.desc_bytes.len(), 5 * CHUNK_DESC_STRIDE);
215        assert_eq!(&pending.desc_bytes[..CHUNK_DESC_STRIDE], &[1u8; CHUNK_DESC_STRIDE][..]);
216        assert_eq!(pending.surface_patches.len(), 5);
217        // Snapshot parts take the NEWEST values.
218        assert_eq!(pending.instance_bytes, vec![2u8; 8]);
219        assert_eq!(pending.instance_count, 2);
220        assert_eq!(pending.surface_enabled, 2.0);
221    }
222
223    #[test]
224    fn merge_overflow_drops_oldest_realizes() {
225        let mut pending = stage(3, 1);
226        merge_stage(&mut pending, stage(2, 2), 4); // budget 4 < 3+2
227        assert_eq!(pending.realize_count, 4);
228        assert_eq!(pending.desc_bytes.len(), 4 * CHUNK_DESC_STRIDE);
229        // The oldest (tag 1) descriptor was dropped; the newest survive.
230        assert_eq!(
231            &pending.desc_bytes[3 * CHUNK_DESC_STRIDE..],
232            &[2u8; CHUNK_DESC_STRIDE][..]
233        );
234        assert_eq!(pending.surface_patches.len(), 4);
235    }
236
237    /// CEL-91: a stalled render thread must not let the merged batch accumulate
238    /// surface patches without bound — each is ~0.75 MiB at tile_res 256. The
239    /// cap is one pool's worth (`budget_chunks`), oldest dropped.
240    #[test]
241    fn merge_caps_surface_patches_at_the_budget() {
242        const BUDGET: usize = 4;
243        let mut pending = stage(0, 0);
244        for tag in 1..=50u8 {
245            merge_stage(&mut pending, stage(3, tag), BUDGET);
246            assert!(pending.realize_count as usize <= BUDGET);
247            assert!(
248                pending.surface_patches.len() <= BUDGET,
249                "surface patches ({}) grew past the budget",
250                pending.surface_patches.len()
251            );
252        }
253        assert_eq!(pending.realize_count as usize, BUDGET);
254        assert_eq!(pending.desc_bytes.len(), BUDGET * CHUNK_DESC_STRIDE);
255        assert_eq!(pending.scatter_aux_bytes.len(), BUDGET * SCATTER_AUX_STRIDE);
256    }
257
258    /// Patches are NOT strictly 1:1 with realizes (a stage can carry patches with
259    /// realize_count under budget), so the realize-overflow drain alone does not
260    /// bound them. They must be capped on their own.
261    #[test]
262    fn patches_are_capped_even_when_the_realize_count_is_under_budget() {
263        const BUDGET: usize = 4;
264        let mut pending = stage(0, 0);
265        for tag in 1..=20u8 {
266            let mut s = stage(3, tag);
267            s.realize_count = 0; // no realize overflow — patches only
268            s.desc_bytes.clear();
269            s.scatter_aux_bytes.clear();
270            merge_stage(&mut pending, s, BUDGET);
271        }
272        assert_eq!(pending.realize_count, 0);
273        assert_eq!(pending.surface_patches.len(), BUDGET, "patch memory must stay bounded");
274        // The NEWEST patches survive: the last batch (tag 20) plus one from 19.
275        let tags: Vec<f32> = pending.surface_patches.iter().map(|p| p.2[0]).collect();
276        assert_eq!(tags, vec![19.0, 20.0, 20.0, 20.0]);
277    }
278}
279
280/// Render-thread job owning the chunk graph + GPU resources. (CEL-58 pattern:
281/// a separate `RefCounted` so inline callbacks can't re-enter the node's borrow.)
282#[derive(GodotClass)]
283#[class(base = RefCounted, no_init)]
284pub struct CesChunkJob {
285    base: Base<RefCounted>,
286    pub gpu: ChunkGpuResources,
287    graph: Graph<Box<dyn ChunkNode>>,
288    upload_node: NodeId,
289    /// Staged by the main thread; drained on the render thread.
290    pub stage: Option<ChunkStage>,
291    /// Current terrain params (uploaded with the batch).
292    pub terrain: TerrainGpu,
293    pub res: u32,
294    /// Number of graph executes that actually ran (consumed a stage). Stays flat
295    /// on a stationary camera — the no-readback / cache win the HUD verifies.
296    pub executes: u64,
297    /// Timestamp labels captured last execute (drained the next execute).
298    pending_labels: Vec<&'static str>,
299    /// Per-stage GPU milliseconds from the previous execute: `(node name, ms)`
300    /// for each graph node that ran (`chunk-upload`, `chunk-realize`, and any
301    /// future pass). Read by the node for the HUD breakdown.
302    pub gpu_ms: Vec<(&'static str, f64)>,
303}
304
305impl CesChunkJob {
306    /// Build a job targeting `mm_rid` (or `Rid::Invalid` to self-create the
307    /// multimesh) with capacity `budget` chunks at resolution `res`.
308    pub fn create(
309        mm_rid: Rid,
310        budget: u32,
311        res: u32,
312        tile_res: u32,
313        radius: f32,
314        bump_enable: f32,
315        terrain: TerrainGpu,
316        scatter: Vec<crate::gpu::chunk_gpu::ScatterConfig>,
317    ) -> Gd<Self> {
318        let mut graph: Graph<Box<dyn ChunkNode>> = Graph::new();
319        let reg = build_chunk_pipeline(&mut graph);
320        let mut gpu = ChunkGpuResources::new(mm_rid, budget, res, tile_res, radius);
321        gpu.set_bump_enable(bump_enable);
322        gpu.set_scatter_configs(scatter);
323        Gd::from_init_fn(|base| Self {
324            base,
325            gpu,
326            graph,
327            upload_node: reg.upload,
328            stage: None,
329            terrain,
330            res,
331            executes: 0,
332            pending_labels: Vec::new(),
333            gpu_ms: Vec::new(),
334        })
335    }
336}
337
338#[godot_api]
339impl CesChunkJob {
340    /// Render-thread entry point. Retries silently until resources are ready.
341    #[func]
342    fn run(&mut self) {
343        if self.stage.is_none() {
344            return;
345        }
346        let rs = RenderingServer::singleton();
347        let Some(mut rd) = rs.get_rendering_device() else { return };
348        // Drain the PREVIOUS execute's GPU timestamps into per-stage ms.
349        self.drain_timestamps(&mut rd);
350        if !self.gpu.ensure_ready(&mut rd) {
351            if self.gpu.init_failed() {
352                self.stage = None;
353            }
354            return; // retry next frame
355        }
356        self.graph.mark_dirty(self.upload_node);
357        let mut ctx = ChunkCtx::new(rd, &mut self.gpu, &mut self.stage, self.terrain, self.res);
358        self.graph.execute(&mut ctx);
359        self.pending_labels = ctx.finish();
360        self.executes += 1;
361    }
362
363    /// Convert the previous execute's timestamp pairs into per-stage milliseconds.
364    /// Each consecutive label pair (`chunk-upload`→`chunk-realize`→`chunk-end`,
365    /// plus any future stage) becomes one `(name, ms)` entry. Mirrors
366    /// `PlanetJob::drain_timestamps`. Timestamps are read by name, so this is
367    /// robust even if the device's capture list is shared with other jobs.
368    fn drain_timestamps(&mut self, rd: &mut Gd<RenderingDevice>) {
369        if self.pending_labels.is_empty() {
370            return;
371        }
372        let n = rd.get_captured_timestamps_count();
373        if n == 0 {
374            return;
375        }
376        let mut times: HashMap<String, u64> = HashMap::new();
377        for i in 0..n {
378            let name = rd.get_captured_timestamp_name(i).to_string();
379            times.insert(name, rd.get_captured_timestamp_gpu_time(i));
380        }
381        let labels = std::mem::take(&mut self.pending_labels);
382        let mut out = Vec::new();
383        for pair in labels.windows(2) {
384            if let (Some(&a), Some(&b)) = (times.get(pair[0]), times.get(pair[1])) {
385                out.push((pair[0], (b.saturating_sub(a)) as f64 / 1.0e6));
386            }
387        }
388        if !out.is_empty() {
389            self.gpu_ms = out;
390        }
391    }
392
393    /// TEMP DEBUG (render-thread): read back the vertex pool and print each
394    /// used slot's radius range, to see which chunks sit at inconsistent radii.
395    #[func]
396    fn debug_dump_radii(&mut self, slots: PackedInt32Array) {
397        let rs = RenderingServer::singleton();
398        let Some(mut rd) = rs.get_rendering_device() else { return };
399        let buf = self.gpu.pos_buf();
400        if buf.is_invalid() {
401            return;
402        }
403        let vpc = self.gpu.verts_per_chunk() as usize;
404        let data = rd.buffer_get_data(buf);
405        let bytes = data.as_slice();
406        let floats: &[f32] = bytemuck::cast_slice(bytes);
407        // pos_tex is what the material ACTUALLY draws from — compare per texel.
408        let tex_data = rd.texture_get_data(self.gpu.pos_tex(), 0);
409        let tex_bytes = tex_data.as_slice();
410        let tex: &[f32] = bytemuck::cast_slice(tex_bytes);
411        godot_print!(
412            "[radii] verts_buf {} floats | pos_tex {} floats ({} texels)",
413            floats.len(),
414            tex.len(),
415            tex.len() / 4
416        );
417        // NaN hunt: min/max and |a-b| comparisons are NaN-blind, so count
418        // non-finite components explicitly, per slot, in BOTH sources.
419        let mut nan_slots: Vec<String> = Vec::new();
420        let mut clean = 0u32;
421        for &slot in slots.as_slice() {
422            let s = slot as usize;
423            let base = s * vpc * 4;
424            if base + vpc * 4 > floats.len() {
425                continue;
426            }
427            let mut buf_nan = 0usize;
428            let mut tex_nan = 0usize;
429            let mut first_v = usize::MAX;
430            for v in 0..vpc {
431                let gv = s * vpc + v;
432                let b = &floats[base + v * 4..base + v * 4 + 3];
433                if b.iter().any(|x| !x.is_finite()) {
434                    buf_nan += 1;
435                    if first_v == usize::MAX {
436                        first_v = v;
437                    }
438                }
439                if gv * 4 + 3 <= tex.len()
440                    && tex[gv * 4..gv * 4 + 3].iter().any(|x| !x.is_finite())
441                {
442                    tex_nan += 1;
443                }
444            }
445            if buf_nan > 0 || tex_nan > 0 {
446                if nan_slots.len() < 16 {
447                    nan_slots.push(format!(
448                        "slot {slot}: buf {buf_nan}/{vpc} tex {tex_nan}/{vpc} first v{first_v}"
449                    ));
450                }
451            } else {
452                clean += 1;
453            }
454        }
455        godot_print!(
456            "[radii] NON-FINITE check: {} clean / {} total | {}",
457            clean,
458            slots.len(),
459            nan_slots.join(" | ")
460        );
461
462        // WHERE is each tail slot's geometry: mean interior direction → lat/lon
463        // (the tail of the drawn list is the deepest / nearest chunks). If a
464        // slot's location isn't where the cut says its chunk is, the pool holds
465        // a STALE chunk (realize never landed for the reassignment).
466        let interior = (self.res as usize + 1) * (self.res as usize + 2) / 2;
467        let show = slots.as_slice().len().saturating_sub(10);
468        for &slot in &slots.as_slice()[show..] {
469            let s = slot as usize;
470            let base = s * vpc * 4;
471            if base + vpc * 4 > floats.len() {
472                continue;
473            }
474            let (mut sx, mut sy, mut sz) = (0f64, 0f64, 0f64);
475            let (mut rmin, mut rmax) = (f32::MAX, f32::MIN);
476            for v in 0..interior {
477                let p = &floats[base + v * 4..base + v * 4 + 3];
478                let r = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
479                rmin = rmin.min(r);
480                rmax = rmax.max(r);
481                sx += p[0] as f64;
482                sy += p[1] as f64;
483                sz += p[2] as f64;
484            }
485            let len = (sx * sx + sy * sy + sz * sz).sqrt().max(1e-9);
486            let lat = (sy / len).clamp(-1.0, 1.0).asin().to_degrees();
487            let lon = sz.atan2(sx).to_degrees();
488            godot_print!(
489                "[radii] GPU slot {slot}: lat {lat:.2} lon {lon:.2} r[{rmin:.1},{rmax:.1}]"
490            );
491        }
492
493        // Indirect draw truth: what instance count does the COMMAND BUFFER hold,
494        // and which slots does the GPU-side instance buffer actually carry?
495        let rs2 = RenderingServer::singleton();
496        let mm = self.gpu.mm_rid();
497        let cmd = rs2.multimesh_get_command_buffer_rd_rid(mm);
498        let inst = rs2.multimesh_get_buffer_rd_rid(mm);
499        if cmd.is_valid() {
500            let cb = rd.buffer_get_data(cmd);
501            let c = cb.as_slice();
502            if c.len() >= 20 {
503                let words: Vec<u32> = c[..20]
504                    .chunks(4)
505                    .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
506                    .collect();
507                godot_print!("[radii] indirect cmd words: {:?}", words);
508            }
509        }
510        if inst.is_valid() {
511            let ib = rd.buffer_get_data(inst);
512            let f: &[f32] = bytemuck::cast_slice(ib.as_slice());
513            let stride = 16usize; // 12 transform + 4 custom floats
514            let n = (slots.len() as usize).min(f.len() / stride); // ACTIVE instances
515            let take = |i: usize| (f[i * stride + 12], f[i * stride + 13]);
516            let mut head: Vec<String> = Vec::new();
517            for i in 0..n.min(4) {
518                let (s, m) = take(i);
519                head.push(format!("i{i}=slot{:.0}/m{m:.2}", s));
520            }
521            let mut tail: Vec<String> = Vec::new();
522            for i in n.saturating_sub(6)..n {
523                let (s, m) = take(i);
524                tail.push(format!("i{i}=slot{:.0}/m{m:.2}", s));
525            }
526            godot_print!(
527                "[radii] active instances {} | head {} | tail {}",
528                n,
529                head.join(" "),
530                tail.join(" ")
531            );
532            // Raw transform rows — pack_instances writes identity; anything
533            // else means the renderer reads a different layout.
534            for &i in [0usize, n / 2, n.saturating_sub(1)].iter() {
535                let t = &f[i * stride..i * stride + 12];
536                godot_print!(
537                    "[radii] i{i} xform [{:.2} {:.2} {:.2} {:.2} | {:.2} {:.2} {:.2} {:.2} | {:.2} {:.2} {:.2} {:.2}]",
538                    t[0], t[1], t[2], t[3], t[4], t[5], t[6], t[7], t[8], t[9], t[10], t[11]
539                );
540            }
541        }
542    }
543}