Skip to main content

celestialsim/
quadtree_debug.rs

1//! Phase 1 debug visualization of the CPU chunked-quadtree selection: turn a
2//! `Vec<Chunk>` into a per-LOD-coloured `ArrayMesh` (throwaway CPU mesh — Phase 2
3//! replaces this with GPU realize through the `nodes` pipeline). The pure helpers
4//! (`lod_color`, `build_debug_mesh`) return plain Rust data so they unit-test
5//! without a running Godot engine; the Godot node converts them to engine-backed
6//! `Packed*Array`/`Color` and wires them to a `MeshInstance3D` each frame.
7
8use celestial_algo::quadtree::Chunk;
9use godot::builtin::Vector3;
10
11/// Distinct linear-RGB colour per LOD level. Same hue spin (`level * 0.137`) and
12/// HSV→RGB construction as the existing `terrain_surface` `layer_color`, so the
13/// debug palette matches the rest of the project. Returns plain `[f32;3]` (no
14/// engine-backed `Color`) so it is testable headlessly.
15pub fn lod_color(level: u8) -> [f32; 3] {
16    let hue = ((level as f32) * 0.137).fract();
17    let h6 = hue * 6.0;
18    let c = 0.95 * 0.8;
19    let x = c * (1.0 - ((h6 % 2.0) - 1.0).abs());
20    let m = 0.95 - c;
21    let (r, g, b) = if h6 < 1.0 {
22        (c, x, 0.0)
23    } else if h6 < 2.0 {
24        (x, c, 0.0)
25    } else if h6 < 3.0 {
26        (0.0, c, x)
27    } else if h6 < 4.0 {
28        (0.0, x, c)
29    } else if h6 < 5.0 {
30        (x, 0.0, c)
31    } else {
32        (c, 0.0, x)
33    };
34    [r + m, g + m, b + m]
35}
36
37/// CPU vertex arrays for one debug frame. Plain `Vec`s so the builder is
38/// headlessly testable; the node converts to `Packed*`.
39pub struct DebugMeshData {
40    pub positions: Vec<Vector3>,
41    pub colors: Vec<[f32; 3]>,
42    pub indices: Vec<i32>,
43}
44
45/// Tessellate one chunk's triangle into `res`×`res` sub-triangles, renormalised to
46/// the sphere. This is the (CPU, throwaway) *realize* of a chunk — Phase 2 does it
47/// on the GPU. `res` 1 => the chunk itself (one triangle).
48fn tessellate(corners: [Vector3; 3], radius: f32, res: u32) -> Vec<[Vector3; 3]> {
49    let n = res.max(1);
50    let fnn = n as f32;
51    let pt = |i: u32, j: u32| {
52        let (wa, wb, wc) = ((n - i - j) as f32 / fnn, i as f32 / fnn, j as f32 / fnn);
53        let p = corners[0] * wa + corners[1] * wb + corners[2] * wc;
54        if radius > 0.0 {
55            p.normalized() * radius
56        } else {
57            p
58        }
59    };
60    let mut tris = Vec::with_capacity((n * n) as usize);
61    for i in 0..n {
62        for j in 0..(n - i) {
63            tris.push([pt(i, j), pt(i + 1, j), pt(i, j + 1)]);
64            if i + j + 1 < n {
65                tris.push([pt(i + 1, j), pt(i + 1, j + 1), pt(i, j + 1)]);
66            }
67        }
68    }
69    tris
70}
71
72/// Realize the selected chunks into a per-LOD-coloured triangle soup. Each chunk
73/// becomes `chunk_res`×`chunk_res` triangles all carrying the chunk's LOD colour.
74pub fn build_debug_mesh(chunks: &[Chunk], radius: f32, chunk_res: u32) -> DebugMeshData {
75    let mut positions = Vec::new();
76    let mut colors = Vec::new();
77    let mut indices = Vec::new();
78    for c in chunks {
79        let col = lod_color(c.level);
80        for tri in tessellate(c.corners, radius, chunk_res) {
81            let base = positions.len() as i32;
82            for v in tri {
83                positions.push(v);
84                colors.push(col);
85            }
86            indices.push(base);
87            indices.push(base + 1);
88            indices.push(base + 2);
89        }
90    }
91    DebugMeshData { positions, colors, indices }
92}
93
94// ---------------------------------------------------------------------------
95// Godot node: selects the cut each frame and draws it. Verified visually
96// (screenshot), not unit-tested — it touches the live engine.
97// ---------------------------------------------------------------------------
98
99use std::time::Instant;
100
101use celestial_algo::quadtree::{base_face_frames, select_chunks};
102use godot::classes::base_material_3d::{CullMode, Flags, ShadingMode};
103use godot::classes::mesh::{ArrayType, PrimitiveType};
104use godot::classes::viewport::DebugDraw;
105use godot::classes::{ArrayMesh, INode3D, MeshInstance3D, Node3D, StandardMaterial3D};
106use godot::prelude::*;
107
108/// Phase 1 debug node: each frame, select the chunked-quadtree cut on the CPU and
109/// draw it as a per-LOD-coloured mesh. Additive — does not touch the clipmap.
110#[derive(GodotClass)]
111#[class(base = Node3D, tool, init, internal)]
112pub struct CelestialQuadtreeDebug {
113    base: Base<Node3D>,
114    #[export]
115    #[init(val = 1000.0)]
116    radius: f32,
117    #[export]
118    #[init(val = 0.02)]
119    screen_error: f32,
120    #[export]
121    #[init(val = 16)]
122    chunk_res: i64,
123    #[export]
124    #[init(val = 16)]
125    max_depth: i64,
126    #[export]
127    wireframe: bool,
128    last_select_ms: f64,
129    last_realize_ms: f64,
130    chunk_count: i64,
131    mesh_instance: Option<Gd<MeshInstance3D>>,
132}
133
134#[godot_api]
135impl INode3D for CelestialQuadtreeDebug {
136    fn ready(&mut self) {
137        let mut mi = MeshInstance3D::new_alloc();
138        let mut mat = StandardMaterial3D::new_gd();
139        mat.set_shading_mode(ShadingMode::UNSHADED);
140        mat.set_flag(Flags::ALBEDO_FROM_VERTEX_COLOR, true);
141        mat.set_cull_mode(CullMode::DISABLED);
142        mi.set_material_override(&mat);
143        self.mesh_instance = Some(mi.clone());
144        self.base_mut().add_child(&mi);
145    }
146
147    fn process(&mut self, _delta: f64) {
148        let Some(camera) = self
149            .base()
150            .get_viewport()
151            .and_then(|vp| vp.get_camera_3d())
152            .map(|c| c.get_global_position())
153        else {
154            return;
155        };
156
157        let frames = base_face_frames(self.radius);
158        let t0 = Instant::now();
159        let chunks =
160            select_chunks(&frames, camera, self.screen_error, self.chunk_res as u32, self.max_depth as u8, None);
161        self.last_select_ms = t0.elapsed().as_secs_f64() * 1000.0;
162        self.chunk_count = chunks.len() as i64;
163
164        let t1 = Instant::now();
165        let data = build_debug_mesh(&chunks, self.radius, self.chunk_res as u32);
166        self.last_realize_ms = t1.elapsed().as_secs_f64() * 1000.0;
167        let positions: PackedVector3Array = data.positions.iter().copied().collect();
168        let colors: PackedColorArray =
169            data.colors.iter().map(|c| Color::from_rgba(c[0], c[1], c[2], 1.0)).collect();
170        let indices: PackedInt32Array = data.indices.iter().copied().collect();
171
172        let mut arrays = VarArray::new();
173        arrays.resize(ArrayType::MAX.ord() as usize, &Variant::nil());
174        arrays.set(ArrayType::VERTEX.ord() as usize, &positions.to_variant());
175        arrays.set(ArrayType::COLOR.ord() as usize, &colors.to_variant());
176        arrays.set(ArrayType::INDEX.ord() as usize, &indices.to_variant());
177
178        let mut mesh = ArrayMesh::new_gd();
179        if !indices.is_empty() {
180            mesh.add_surface_from_arrays(PrimitiveType::TRIANGLES, &arrays);
181        }
182        if let Some(mi) = self.mesh_instance.as_mut() {
183            mi.set_mesh(&mesh);
184        }
185
186        if let Some(mut vp) = self.base().get_viewport() {
187            vp.set_debug_draw(if self.wireframe { DebugDraw::WIREFRAME } else { DebugDraw::DISABLED });
188        }
189    }
190}
191
192#[godot_api]
193impl CelestialQuadtreeDebug {
194    #[func]
195    fn last_select_ms(&self) -> f64 {
196        self.last_select_ms
197    }
198
199    #[func]
200    fn last_realize_ms(&self) -> f64 {
201        self.last_realize_ms
202    }
203
204    #[func]
205    fn chunk_count(&self) -> i64 {
206        self.chunk_count
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use celestial_algo::quadtree::{base_face_frames, select_chunks};
214
215    #[test]
216    fn lod_color_differs_per_level_and_is_deterministic() {
217        assert_ne!(lod_color(0), lod_color(1));
218        assert_ne!(lod_color(1), lod_color(2));
219        assert_eq!(lod_color(3), lod_color(3));
220    }
221
222    #[test]
223    fn build_debug_mesh_tessellates_each_chunk() {
224        let frames = base_face_frames(1000.0);
225        let chunks = select_chunks(&frames, Vector3::new(0.0, 0.0, 1100.0), 0.1, 16, 4, None);
226        // res 1 => one triangle per chunk.
227        let mesh1 = build_debug_mesh(&chunks, 1000.0, 1);
228        assert_eq!(mesh1.positions.len(), chunks.len() * 3);
229        let c0 = lod_color(chunks[0].level);
230        assert_eq!(mesh1.colors[0], c0);
231        assert_eq!(mesh1.colors[2], c0);
232        // res 2 => 4 triangles (12 verts) per chunk.
233        let mesh2 = build_debug_mesh(&chunks, 1000.0, 2);
234        assert_eq!(mesh2.positions.len(), chunks.len() * 4 * 3);
235        assert_eq!(mesh2.indices.len(), chunks.len() * 4 * 3);
236    }
237}