1use 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 #[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 #[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 #[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
68fn 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()); 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 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
144fn 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 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
187fn 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 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
225const SCATTER_L: u8 = 3; const SCATTER_K: u32 = 2; const SCATTER_SEED: u32 = 7;
230
231fn 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
254fn 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
291fn 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(); 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, 1024, SCATTER_SEED,
344 0, 0.0, 1.0, 1.0, 0.0, 0.0, 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 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 let terrain_off = TerrainGpu::zeroed(); let off = realize_positions(&mut gpu, &mut rd, &frames, &realize, &terrain_off);
441
442 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 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}