1use 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
17pub 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 pub surface_enabled: f32,
27 pub surface_height_scale: f32,
29 pub surface_patches: Vec<(u32, Vec<u8>, Vec<f32>, Vec<u8>)>,
32 pub scatter_aux_bytes: Vec<u8>,
35 pub scatter_vis_bytes: Vec<u8>,
38 pub scatter_vis_count: u32,
39 pub scatter_layer_params: Vec<Vec<u8>>,
43}
44
45pub const CHUNK_DESC_STRIDE: usize = 96;
47pub const SCATTER_AUX_STRIDE: usize = 16;
49
50pub 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 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 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 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
99pub 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 pub realize_count: u32,
111 pub vis_count: u32,
114 list: Option<i64>,
115 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 pub fn close_list(&mut self) {
146 if self.list.take().is_some() {
147 self.rd.compute_list_end();
148 }
149 }
150
151 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 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 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); assert_eq!(pending.realize_count, 4);
228 assert_eq!(pending.desc_bytes.len(), 4 * CHUNK_DESC_STRIDE);
229 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 #[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 #[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; 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 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#[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 pub stage: Option<ChunkStage>,
291 pub terrain: TerrainGpu,
293 pub res: u32,
294 pub executes: u64,
297 pending_labels: Vec<&'static str>,
299 pub gpu_ms: Vec<(&'static str, f64)>,
303}
304
305impl CesChunkJob {
306 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 #[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 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; }
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 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 #[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 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 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 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 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; let n = (slots.len() as usize).min(f.len() / stride); 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 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}