Skip to main content

celestialsim/
chunk_gpu_test.rs

1//! Task 10 verification: run the committed `ChunkRealize.spv` on a real Vulkan
2//! `RenderingDevice` and prove the realized vertex positions match the CPU
3//! reference `quadtree::chunk_subvertex_base` (terrain OFF), plus a terrain-ON
4//! envelope check.
5//!
6//! Mirrors `conform_gpu_test`: a throwaway `Node` whose `run()` drives the GPU
7//! on a **local** `RenderingDevice` (so we own `submit`/`sync` and can read the
8//! pool back deterministically — the main device's submit is owned by the
9//! renderer). It exercises the real production [`ChunkGpuResources`] compute path
10//! (pipeline build, descriptor/params upload, `record_realize`, vertex-pool
11//! readback); only the `MultiMesh` (a main-device, render-only concern) is
12//! skipped via `new_compute_only`. A scene calls `run()` and prints
13//! `CHUNK_GPU_TEST: PASS/FAIL ...`.
14
15use bytemuck::Zeroable;
16use godot::classes::{Node, RenderingDevice, RenderingServer};
17use godot::prelude::*;
18
19use celestial_algo::clipmap::FaceFrame;
20use celestial_algo::quadtree::{base_face_frames, chunk_subvertex_base, Bary, Chunk, ChunkId};
21
22use crate::chunk_descriptors::{pack_chunks, pack_instances, verts_per_chunk};
23use crate::descriptors::{assemble, HeightGpu, TerrainGpu, TextureGpu};
24use crate::gpu::chunk_gpu::ChunkGpuResources;
25
26#[derive(GodotClass)]
27#[class(base = Node, init, internal)]
28pub struct ChunkRealizeGpuTest {
29    base: Base<Node>,
30}
31
32#[godot_api]
33impl ChunkRealizeGpuTest {
34    /// Run the chunk-realize GPU verification; returns a PASS/FAIL line.
35    #[func]
36    fn run(&self) -> GString {
37        match run_check() {
38            Ok(msg) => GString::from(format!("CHUNK_GPU_TEST: PASS — {msg}").as_str()),
39            Err(msg) => GString::from(format!("CHUNK_GPU_TEST: FAIL — {msg}").as_str()),
40        }
41    }
42
43    /// Run the CEL-73 scatter-place GPU verification: dispatch the committed
44    /// `ScatterPlace.spv` on a local device and compare every pool record
45    /// (validity, hash01, full transform) against the `celestial_algo::scatter`
46    /// CPU reference — including the gnomonic projection. PASS/FAIL line.
47    #[func]
48    fn run_scatter(&self) -> GString {
49        match scatter_check() {
50            Ok(msg) => GString::from(format!("SCATTER_GPU_TEST: PASS — {msg}").as_str()),
51            Err(msg) => GString::from(format!("SCATTER_GPU_TEST: FAIL — {msg}").as_str()),
52        }
53    }
54
55    /// Debug: bake ONE chunk's detail tile at `tile_res` (terrain ON) and save the
56    /// colour + normal atlases as `<out_dir>/bake_color_<tile_res>.png` /
57    /// `bake_normal_<tile_res>.png`. Isolates the bake shader's output (noise
58    /// quality) from the render pipeline. `out_dir` is a res:// or absolute path.
59    #[func]
60    fn bake_chunk_png(&self, out_dir: GString, tile_res: i64) -> GString {
61        match bake_png(&out_dir.to_string(), tile_res.clamp(8, 2048) as u32) {
62            Ok(m) => GString::from(format!("BAKE_PNG: {m}").as_str()),
63            Err(e) => GString::from(format!("BAKE_PNG: FAIL — {e}").as_str()),
64        }
65    }
66}
67
68/// Bake one chunk (face root, terrain ON) at `tile_res` and write the colour +
69/// normal atlas tiles as PNGs. The atlas is a linear `tile_res*tile_res`-texel
70/// buffer wrapped at `attr_w`, so its raw bytes reinterpret directly as a
71/// `tile_res × tile_res` RGBA8 image (the unused `u+v>1` half stays as-cleared).
72fn bake_png(out_dir: &str, tile_res: u32) -> Result<String, String> {
73    use godot::classes::image::Format;
74    use godot::classes::{DirAccess, Image, ProjectSettings};
75
76    let frames = base_face_frames(RADIUS);
77    let frame = &frames[FACE as usize];
78    let root_bary =
79        [Bary { wb: 0.0, wc: 0.0 }, Bary { wb: 1.0, wc: 0.0 }, Bary { wb: 0.0, wc: 1.0 }];
80    let chunk = Chunk {
81        id: ChunkId { face: FACE, depth: 0, path: 0 },
82        bary: root_bary,
83        corners: [
84            frame.project_bary(root_bary[0].wb, root_bary[0].wc),
85            frame.project_bary(root_bary[1].wb, root_bary[1].wc),
86            frame.project_bary(root_bary[2].wb, root_bary[2].wc),
87        ],
88        level: 0,
89    };
90    let realize = vec![(0u32, chunk)];
91
92    let mut rd = RenderingServer::singleton()
93        .create_local_rendering_device()
94        .ok_or("no local RenderingDevice")?;
95    let mut gpu = ChunkGpuResources::new_compute_only(1, RES, tile_res, RADIUS);
96    if !gpu.ensure_ready(&mut rd) {
97        return Err("ensure_ready failed".into());
98    }
99
100    let terrain = assemble(&HeightGpu::default(), &TextureGpu::default()); // terrain ON
101    let desc = pack_chunks(&frames, &realize, RES);
102    gpu.upload_descs(&mut rd, &desc, 1);
103    gpu.upload_params(&mut rd, 1, &terrain);
104    let list = rd.compute_list_begin();
105    gpu.record_bake(&mut rd, list, tile_res * tile_res);
106    rd.compute_list_end();
107    rd.submit();
108    rd.sync();
109
110    let color = rd.texture_get_data(gpu.color_atlas(), 0);
111    let normal = rd.texture_get_data(gpu.normal_atlas(), 0);
112
113    let want = (tile_res * tile_res * 4) as usize;
114    if color.len() < want {
115        return Err(format!("atlas data {} < {want} (tile_res {tile_res})", color.len()));
116    }
117
118    let abs_dir = ProjectSettings::singleton().globalize_path(out_dir).to_string();
119    DirAccess::make_dir_recursive_absolute(&GString::from(abs_dir.as_str()));
120    let tr = tile_res as i32;
121    let save = |name: &str, data: &PackedByteArray| -> Result<(), String> {
122        // Reinterpret the first tile_res*tile_res texels as a square image.
123        let slice = PackedByteArray::from(&data.to_vec()[..want]);
124        let img = Image::create_from_data(tr, tr, false, Format::RGBA8, &slice)
125            .ok_or("Image::create_from_data failed")?;
126        let path = format!("{abs_dir}/{name}_{tile_res}.png");
127        let err = img.save_png(&GString::from(path.as_str()));
128        if err != godot::global::Error::OK {
129            return Err(format!("save_png {path} -> {err:?}"));
130        }
131        Ok(())
132    };
133    save("bake_color", &color)?;
134    save("bake_normal", &normal)?;
135
136    Ok(format!("wrote bake_color_{tile_res}.png + bake_normal_{tile_res}.png to {abs_dir}"))
137}
138
139const RES: u32 = 8;
140const TILE_RES: u32 = 32;
141const RADIUS: f32 = 1000.0;
142const FACE: u8 = 7;
143
144/// The 1–2 chunk fixture: chunk A is face 7's root triangle; chunk B is its
145/// inverted centre child (non-trivial barycentric mix, exercising the
146/// `wa*bary0 + wb*bary1 + wc*bary2` interpolation). Slots 0 and 1.
147fn fixture() -> (Vec<FaceFrame>, Vec<(u32, Chunk)>) {
148    let frames = base_face_frames(RADIUS);
149    let frame = &frames[FACE as usize];
150
151    let corner = |b: Bary| frame.project_bary(b.wb, b.wc);
152
153    let root_bary = [Bary { wb: 0.0, wc: 0.0 }, Bary { wb: 1.0, wc: 0.0 }, Bary { wb: 0.0, wc: 1.0 }];
154    let chunk_a = Chunk {
155        id: ChunkId { face: FACE, depth: 0, path: 0 },
156        bary: root_bary,
157        corners: [corner(root_bary[0]), corner(root_bary[1]), corner(root_bary[2])],
158        level: 0,
159    };
160
161    // Inverted centre child: corners are the edge midpoints of the root.
162    let mid = |a: Bary, b: Bary| Bary { wb: (a.wb + b.wb) * 0.5, wc: (a.wc + b.wc) * 0.5 };
163    let centre_bary = [
164        mid(root_bary[0], root_bary[1]),
165        mid(root_bary[1], root_bary[2]),
166        mid(root_bary[2], root_bary[0]),
167    ];
168    let chunk_b = Chunk {
169        id: ChunkId { face: FACE, depth: 1, path: 3 },
170        bary: centre_bary,
171        corners: [corner(centre_bary[0]), corner(centre_bary[1]), corner(centre_bary[2])],
172        level: 1,
173    };
174
175    (frames, vec![(0u32, chunk_a), (1u32, chunk_b)])
176}
177
178fn read_f32(data: &PackedByteArray, byte: usize) -> f32 {
179    f32::from_le_bytes([
180        data.get(byte).unwrap(),
181        data.get(byte + 1).unwrap(),
182        data.get(byte + 2).unwrap(),
183        data.get(byte + 3).unwrap(),
184    ])
185}
186
187/// Realize the fixture with `terrain` and read back the vertex-pool positions
188/// for every chunk: returns `(slot, i, j, gpu_position)` for each pool vertex.
189fn realize_positions(
190    gpu: &mut ChunkGpuResources,
191    rd: &mut Gd<RenderingDevice>,
192    frames: &[FaceFrame],
193    realize: &[(u32, Chunk)],
194    terrain: &TerrainGpu,
195) -> Vec<(u32, u32, u32, Vector3)> {
196    let vpc = verts_per_chunk(RES);
197    let desc_bytes = pack_chunks(frames, realize, RES);
198    gpu.upload_descs(rd, &desc_bytes, realize.len() as u32);
199    gpu.upload_params(rd, realize.len() as u32, terrain);
200
201    let list = rd.compute_list_begin();
202    gpu.record_realize(rd, list, realize.len() as u32 * vpc);
203    rd.compute_list_end();
204    rd.submit();
205    rd.sync();
206
207    let raw = rd.buffer_get_data(gpu.pos_buf());
208    let mut out = Vec::new();
209    for (slot, _chunk) in realize {
210        // Enumerate (i, j) in the canonical chunk_mesh order; L increments 0,1,2…
211        let mut l = 0u32;
212        for i in 0..=RES {
213            for j in 0..=(RES - i) {
214                let gv = slot * vpc + l;
215                let b = gv as usize * 16;
216                let pos = Vector3::new(read_f32(&raw, b), read_f32(&raw, b + 4), read_f32(&raw, b + 8));
217                out.push((*slot, i, j, pos));
218                l += 1;
219            }
220        }
221    }
222    out
223}
224
225// ── CEL-73 scatter-place verification ────────────────────────────────────────
226
227const SCATTER_L: u8 = 3; // lattice level
228const SCATTER_K: u32 = 2; // candidates per cell
229const SCATTER_SEED: u32 = 7;
230
231/// The scatter fixture: the realize pair (root + centre child — Subcells with
232/// s=3 and s=2) plus a depth-5 chunk (Ancestor path with an in-chunk filter).
233fn scatter_fixture() -> (Vec<FaceFrame>, Vec<(u32, Chunk)>) {
234    use celestial_algo::scatter::descend_bary;
235    let (frames, mut realize) = fixture();
236    let frame = &frames[FACE as usize];
237    let root =
238        [Bary { wb: 0.0, wc: 0.0 }, Bary { wb: 1.0, wc: 0.0 }, Bary { wb: 0.0, wc: 1.0 }];
239    let path: u64 = 0b11_01_00_10_01;
240    let bary = descend_bary(root, path, 5);
241    let corner = |b: Bary| frame.project_bary(b.wb, b.wc);
242    realize.push((
243        2u32,
244        Chunk {
245            id: ChunkId { face: FACE, depth: 5, path },
246            bary,
247            corners: [corner(bary[0]), corner(bary[1]), corner(bary[2])],
248            level: 5,
249        },
250    ));
251    (frames, realize)
252}
253
254/// CPU expectation for pool record `c` of a chunk: `Some(candidate)` when the
255/// shader must write a valid record, `None` when it must write the sentinel.
256/// Mirrors `ScatterPlace.slang`'s per-thread cell resolution exactly.
257fn expected_candidate(
258    id: ChunkId,
259    bary: [Bary; 3],
260    c: u32,
261) -> Option<celestial_algo::scatter::CandidateBary> {
262    use celestial_algo::scatter::{
263        bary_point_in_triangle, candidate, cell_range, descend_bary, CellRange,
264    };
265    let root =
266        [Bary { wb: 0.0, wc: 0.0 }, Bary { wb: 1.0, wc: 0.0 }, Bary { wb: 0.0, wc: 1.0 }];
267    match cell_range(id, SCATTER_L) {
268        CellRange::None => None,
269        CellRange::Subcells { count } => {
270            let cell_sub = c / SCATTER_K;
271            let k = c % SCATTER_K;
272            if cell_sub >= count {
273                return None;
274            }
275            let levels = (SCATTER_L - id.depth) as u32;
276            let cell = descend_bary(bary, cell_sub as u64, levels);
277            let full_path = (id.path << (2 * levels)) | cell_sub as u64;
278            Some(candidate(id.face, full_path, cell, k, SCATTER_SEED))
279        }
280        CellRange::Ancestor { path } => {
281            if c >= SCATTER_K {
282                return None;
283            }
284            let cell = descend_bary(root, path, SCATTER_L as u32);
285            let cand = candidate(id.face, path, cell, c, SCATTER_SEED);
286            bary_point_in_triangle((cand.wb, cand.wc), bary).then_some(cand)
287        }
288    }
289}
290
291/// The CPU reference transform for a candidate: gnomonic projection to the
292/// sphere (terrain OFF ⇒ no displacement), radial-up basis with yaw + scale —
293/// the exact math of `ScatterPlace.slang`'s tail.
294fn expected_rows(
295    frame: &FaceFrame,
296    cand: &celestial_algo::scatter::CandidateBary,
297) -> [f32; 12] {
298    let wa = 1.0 - cand.wb - cand.wc;
299    let p = frame.a * wa + frame.b * cand.wb + frame.c * cand.wc;
300    let pos = p.normalized() * RADIUS;
301    let up = pos.normalized();
302    let upref =
303        if up.y.abs() < 0.99 { Vector3::new(0.0, 1.0, 0.0) } else { Vector3::new(1.0, 0.0, 0.0) };
304    let t0 = upref.cross(up).normalized();
305    let b0 = up.cross(t0);
306    let xn = t0 * cand.yaw.cos() + b0 * cand.yaw.sin();
307    let zn = xn.cross(up);
308    let (x, y, z) = (xn * cand.scale, up * cand.scale, zn * cand.scale);
309    [x.x, y.x, z.x, pos.x, x.y, y.y, z.y, pos.y, x.z, y.z, z.z, pos.z]
310}
311
312fn scatter_check() -> Result<String, String> {
313    use crate::gpu::chunk_gpu::ScatterConfig;
314    use crate::scatter_descriptors::{pack_scatter_aux, pack_scatter_params};
315
316    let (frames, realize) = scatter_fixture();
317    let capacity = celestial_algo::scatter::capacity(SCATTER_K);
318
319    let mut rd = RenderingServer::singleton()
320        .create_local_rendering_device()
321        .ok_or("no local RenderingDevice")?;
322    let mut gpu = ChunkGpuResources::new_compute_only(realize.len() as u32, RES, TILE_RES, RADIUS);
323    gpu.set_scatter_configs(vec![ScatterConfig {
324        mm_rid: Rid::Invalid,
325        capacity,
326        max_instances: 1024,
327    }]);
328    if !gpu.ensure_ready(&mut rd) {
329        return Err("ensure_ready failed (scatter compute-only build)".into());
330    }
331
332    let desc = pack_chunks(&frames, &realize, RES);
333    gpu.upload_descs(&mut rd, &desc, realize.len() as u32);
334    let terrain_off = TerrainGpu::zeroed(); // enabled == 0 ⇒ pure gnomonic sphere
335    let params = pack_scatter_params(
336        realize.len() as u32,
337        capacity,
338        SCATTER_K,
339        SCATTER_L as u32,
340        RADIUS,
341        1.0,  // density (unused by place)
342        1024, // max_instances (unused by place)
343        SCATTER_SEED,
344        0,   // vis_count (unused by place)
345        0.0, // min_height (compact-side; place records the transform regardless)
346        1.0, // max_height
347        1.0, // base_scale (test's expected_rows assumes native scale)
348        0.0, // surface_enabled: procedural route (no CPU heightmap)
349        0.0, // surface_height_scale
350        TILE_RES,
351        &terrain_off,
352    );
353    let aux = pack_scatter_aux(&realize);
354    gpu.upload_scatter(&mut rd, &aux, &[], &[params]);
355
356    let list = rd.compute_list_begin();
357    gpu.record_scatter_place(&mut rd, list, 0, realize.len() as u32);
358    rd.compute_list_end();
359    rd.submit();
360    rd.sync();
361
362    let raw = rd.buffer_get_data(gpu.scatter_pool_buf(0));
363
364    let frame = &frames[FACE as usize];
365    let pos_tol = 1.0e-4 * RADIUS;
366    let basis_tol = 1.0e-3f32;
367    let mut checked = 0usize;
368    let mut valid = 0usize;
369    let mut max_pos_err = 0.0f32;
370    for (slot, chunk) in &realize {
371        for c in 0..capacity {
372            let base = ((slot * capacity + c) as usize) * 64;
373            let rec: Vec<f32> = (0..16).map(|f| read_f32(&raw, base + f * 4)).collect();
374            let expect = expected_candidate(chunk.id, chunk.bary, c);
375            checked += 1;
376            match expect {
377                None => {
378                    if rec[12] != 2.0 {
379                        return Err(format!(
380                            "slot {slot} c {c}: expected sentinel, got hash01 {}",
381                            rec[12]
382                        ));
383                    }
384                }
385                Some(cand) => {
386                    valid += 1;
387                    if (rec[12] - cand.hash01).abs() > 1.0e-6 {
388                        return Err(format!(
389                            "slot {slot} c {c}: hash01 {} != CPU {}",
390                            rec[12], cand.hash01
391                        ));
392                    }
393                    let want = expected_rows(frame, &cand);
394                    for (f, (&got, &w)) in rec[..12].iter().zip(want.iter()).enumerate() {
395                        let tol = if f % 4 == 3 { pos_tol } else { basis_tol * cand.scale };
396                        let err = (got - w).abs();
397                        if !err.is_finite() || err > tol {
398                            return Err(format!(
399                                "slot {slot} c {c} row-float {f}: gpu {got} vs cpu {w} (err {err:.6} > tol {tol:.6})"
400                            ));
401                        }
402                        if f % 4 == 3 {
403                            max_pos_err = max_pos_err.max(err);
404                        }
405                    }
406                }
407            }
408        }
409    }
410    Ok(format!(
411        "{valid} valid / {checked} records match the CPU reference (gnomonic, \
412         {} chunks incl. depth-5 ancestor path; max origin err {max_pos_err:.5}, tol {pos_tol:.3})",
413        realize.len()
414    ))
415}
416
417fn run_check() -> Result<String, String> {
418    let (frames, realize) = fixture();
419    let vpc = verts_per_chunk(RES);
420
421    let mut rd = RenderingServer::singleton()
422        .create_local_rendering_device()
423        .ok_or("no local RenderingDevice")?;
424
425    let mut gpu = ChunkGpuResources::new_compute_only(realize.len() as u32, RES, TILE_RES, RADIUS);
426    if !gpu.ensure_ready(&mut rd) {
427        return Err("ChunkGpuResources::ensure_ready failed (pipeline/pool build)".into());
428    }
429
430    // Also exercise the instance packer (no multimesh in compute-only mode, so
431    // this is just a shape/size check that it produces the expected bytes).
432    let slots: Vec<u32> = realize.iter().map(|(s, _)| *s).collect();
433    let inst = pack_instances(&slots, &vec![1.0f32; slots.len()]);
434    if inst.len() != realize.len() * 16 * 4 {
435        return Err(format!("pack_instances size {} unexpected", inst.len()));
436    }
437
438    // ── Terrain OFF: positions must equal chunk_subvertex_base exactly. ──
439    let terrain_off = TerrainGpu::zeroed(); // enabled == 0 → pure geometry
440    let off = realize_positions(&mut gpu, &mut rd, &frames, &realize, &terrain_off);
441
442    // Tolerance basis: 1e-4 * scale, scale ≥ radius.
443    let tol = 1.0e-4 * frames[FACE as usize].edge_len().max(RADIUS);
444    let mut max_err = 0.0f32;
445    let mut worst = (0u32, 0u32, 0u32);
446    for &(slot, i, j, pos) in &off {
447        let chunk = realize.iter().find(|(s, _)| *s == slot).map(|(_, c)| c).unwrap();
448        let frame = &frames[chunk.id.face as usize];
449        let want = chunk_subvertex_base(frame, chunk, RES, i, j);
450        let err = (pos - want).length();
451        if !err.is_finite() {
452            return Err(format!("slot {slot} (i={i},j={j}): non-finite position {pos}"));
453        }
454        if err > max_err {
455            max_err = err;
456            worst = (slot, i, j);
457        }
458    }
459    let total = off.len();
460    if max_err > tol {
461        return Err(format!(
462            "terrain-OFF max error {max_err:.6} > tol {tol:.6} at slot {} (i={},j={}); {total} verts",
463            worst.0, worst.1, worst.2
464        ));
465    }
466
467    // ── Terrain ON: every position within the [0.7r, 1.3r] envelope. ──
468    let terrain_on = assemble(&HeightGpu::default(), &TextureGpu::default());
469    let on = realize_positions(&mut gpu, &mut rd, &frames, &realize, &terrain_on);
470    let (lo, hi) = (RADIUS * 0.7, RADIUS * 1.3);
471    let mut env_bad = 0usize;
472    let mut env_worst = 0.0f32;
473    for &(_, _, _, pos) in &on {
474        let l = pos.length();
475        if !l.is_finite() || l < lo || l > hi {
476            env_bad += 1;
477            env_worst = env_worst.max((l - RADIUS).abs());
478        }
479    }
480
481
482    if env_bad > 0 {
483        return Err(format!(
484            "terrain-ON envelope: {env_bad}/{} verts out of [{lo:.0},{hi:.0}] (worst |Δr|={env_worst:.1})",
485            on.len()
486        ));
487    }
488
489    Ok(format!(
490        "terrain-OFF max error {max_err:.6} (tol {tol:.6}) over {total} verts \
491         ({} chunks × {vpc} verts, res {RES}); terrain-ON {} verts within [{lo:.0},{hi:.0}]",
492        realize.len(),
493        on.len(),
494    ))
495}