Skip to main content

celestialsim/
tile_viewer.rs

1//! Debug-only standalone single-tile texture viewer (`CelestialTileViewer`).
2//!
3//! A flat 2-D view of ONE icosphere face's baked surface colour, isolated from
4//! the clipmap/icosphere. The `TileViewer` compute shader bakes the SAME albedo
5//! + grass/rock detail as `ChunkTileBake.slang` over a SQUARE barycentric region
6//! of one face into a `W×W` rgba8 texture. Each scroll/pan re-dispatches the bake
7//! over a smaller/shifted barycentric window (no caching), so the user can see
8//! how the detail noise holds up as they zoom toward rock/grass scale.
9//!
10//! Additive and read-only with respect to the production chunk path: it only
11//! reuses the shared shading code + `TerrainGpu` defaults. GPU work runs on the
12//! main `RenderingDevice` via `RenderingServer::call_on_render_thread`. The GPU
13//! resources live in a separate `RefCounted` [`TileViewerJob`] (the CEL-58
14//! pattern): the render-thread callback borrows the job, not the node, so a
15//! synchronous `call_on_render_thread` can't re-enter the node's borrow.
16
17use std::sync::Arc;
18
19use bytemuck::Zeroable;
20use godot::classes::notify::NodeNotification;
21use godot::classes::rendering_device::UniformType;
22use godot::classes::rendering_server::MultimeshTransformFormat;
23use godot::classes::{
24    ArrayMesh, INode, INode3D, MultiMesh, MultiMeshInstance3D, Node, Node3D, RdUniform, RefCounted,
25    RenderingDevice, RenderingServer, Shader, ShaderMaterial, Texture2Drd,
26};
27use godot::prelude::*;
28
29use celestial_algo::quadtree::{base_face_frames, Bary, Chunk, ChunkId};
30
31use crate::chunk_descriptors::{pack_chunks, pack_instances, verts_per_chunk};
32use crate::chunk_mesh::reference_chunk_mesh;
33use crate::chunk_pipeline::{CesChunkJob, ChunkStage};
34use crate::descriptors::{assemble, HeightGpu, TerrainGpu, TextureGpu};
35use crate::gpu::ATTR_TEX_WIDTH;
36use crate::gpu::device;
37use crate::gpu::owned::{
38    MainDeviceSink, Owned, RdBuffer, RdPipeline, RdShader, RdTexture, RdUniformSet, RidSink,
39};
40
41const TILE_VIEWER_SPV: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/TileViewer.spv"));
42
43/// Sphere radius the viewer bakes against (matches the HQ scene / chunk tests).
44const RADIUS: f32 = 1000.0;
45
46/// std430 params for `TileViewer.slang` — byte-identical to `struct
47/// TileViewerParams` there (176 bytes). The `tile_viewer_params_layout` test
48/// locks the offsets. Field offsets: a 0, b 16, c 32, sub0 48, sub1 64,
49/// sub2 80, width 96, tex_res 100, terrain 112.
50#[repr(C)]
51#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
52pub struct TileViewerParams {
53    /// Face corner A (xyz world) + sphere radius (w).
54    pub a: [f32; 4],
55    /// Face corner B (xyz), w unused.
56    pub b: [f32; 4],
57    /// Face corner C (xyz), w unused.
58    pub c: [f32; 4],
59    /// Viewed sub-triangle corner 0 in face barycentric (wb, wc in xy) → UV (0,0).
60    pub sub0: [f32; 4],
61    /// Sub-triangle corner 1 (wb, wc) → UV (1,0).
62    pub sub1: [f32; 4],
63    /// Sub-triangle corner 2 (wb, wc) → UV (0,1).
64    pub sub2: [f32; 4],
65    /// Output texture edge (texels).
66    pub width: u32,
67    /// Texture-resolution quantisation grid.
68    pub tex_res: u32,
69    /// Padding to 16-align the trailing terrain block.
70    pub _pad: [u32; 2],
71    /// Embedded 56-byte terrain block (shared with the chunk path).
72    pub terrain: TerrainGpu,
73    /// Tail padding keeping the struct a 16-byte multiple (std430 stride rule).
74    pub _pad2: [f32; 2],
75}
76
77/// The viewer's GPU resources on the MAIN `RenderingDevice` (render-thread only).
78///
79/// FIELD ORDER IS THE DROP ORDER (see the DROP-ORDER CONTRACT in `gpu::owned`):
80/// the uniform set (a dependent) first, then the pipeline, then the shader and
81/// the buffers/textures it derives from.
82struct TileViewerGpu {
83    sink: Arc<dyn RidSink>,
84    set: RdUniformSet,
85    pipeline: RdPipeline,
86    shader: RdShader,
87    /// `W×W` rgba8 colour output (STORAGE|SAMPLING|CAN_COPY_FROM) — wrapped in a
88    /// `Texture2DRD` for display.
89    tex: RdTexture,
90    /// `W×W` rgba8 world-normal output (encoded *0.5+0.5) for the flat+normal mode.
91    nrm: RdTexture,
92    params_buf: RdBuffer,
93    width: u32,
94    built: bool,
95    init_failed: bool,
96}
97
98impl Default for TileViewerGpu {
99    fn default() -> Self {
100        // Main device: frees are queued and drained on the render thread.
101        let sink: Arc<dyn RidSink> = MainDeviceSink::new();
102        Self {
103            set: Owned::invalid(sink.clone()),
104            pipeline: Owned::invalid(sink.clone()),
105            shader: Owned::invalid(sink.clone()),
106            tex: Owned::invalid(sink.clone()),
107            nrm: Owned::invalid(sink.clone()),
108            params_buf: Owned::invalid(sink.clone()),
109            width: 0,
110            built: false,
111            init_failed: false,
112            sink,
113        }
114    }
115}
116
117impl TileViewerGpu {
118    /// Build the pipeline + output texture + params buffer + uniform set. Idempotent.
119    fn ensure_ready(&mut self, rd: &mut Gd<RenderingDevice>, width: u32) -> bool {
120        if self.init_failed {
121            return false;
122        }
123        if self.built {
124            return true;
125        }
126        let sink = self.sink.clone();
127        let Some((shader, pipeline)) =
128            device::compute_pipeline(rd, &sink, TILE_VIEWER_SPV, "TileViewer")
129        else {
130            self.init_failed = true;
131            return false;
132        };
133        self.shader = shader;
134        self.pipeline = pipeline;
135        // rgba8 STORAGE|SAMPLING|CAN_COPY_FROM square textures (atlas_texture fits).
136        self.tex = device::atlas_texture(rd, &sink, width, width);
137        self.nrm = device::atlas_texture(rd, &sink, width, width);
138        self.params_buf =
139            device::storage_buffer(rd, &sink, &vec![0u8; std::mem::size_of::<TileViewerParams>()]);
140        let uniforms: Array<Gd<RdUniform>> = [
141            device::uniform(UniformType::STORAGE_BUFFER, 0, self.params_buf.rid()),
142            device::uniform(UniformType::IMAGE, 1, self.tex.rid()),
143            device::uniform(UniformType::IMAGE, 2, self.nrm.rid()),
144        ]
145        .into_iter()
146        .collect();
147        self.set = Owned::new(rd.uniform_set_create(&uniforms, self.shader.rid(), 0), sink);
148        self.width = width;
149        self.built = true;
150        true
151    }
152
153    /// Upload params and record a `W*W`-thread bake into the output texture.
154    fn dispatch(&mut self, rd: &mut Gd<RenderingDevice>, params: &TileViewerParams) {
155        let bytes = bytemuck::bytes_of(params);
156        rd.buffer_update(
157            self.params_buf.rid(),
158            0,
159            bytes.len() as u32,
160            &PackedByteArray::from(bytes),
161        );
162        let total = self.width.saturating_mul(self.width);
163        let groups = total.div_ceil(64);
164        if groups == 0 {
165            return;
166        }
167        let list = rd.compute_list_begin();
168        rd.compute_list_bind_compute_pipeline(list, self.pipeline.rid());
169        rd.compute_list_bind_uniform_set(list, self.set.rid(), 0);
170        rd.compute_list_dispatch(list, groups, 1, 1);
171        rd.compute_list_end();
172    }
173}
174
175/// Render-thread job owning the viewer's GPU resources + the pending view. A
176/// separate `RefCounted` so the render-thread callback never re-enters the
177/// node's borrow (mirrors `CesChunkJob`).
178#[derive(GodotClass)]
179#[class(base = RefCounted, no_init)]
180pub struct TileViewerJob {
181    base: Base<RefCounted>,
182    gpu: TileViewerGpu,
183    /// The view to bake on the next render-thread run.
184    params: TileViewerParams,
185    /// Set by `set_view`, drained by `run`.
186    pending: bool,
187}
188
189impl TileViewerJob {
190    fn create() -> Gd<Self> {
191        Gd::from_init_fn(|base| Self {
192            base,
193            gpu: TileViewerGpu::default(),
194            params: TileViewerParams::zeroed(),
195            pending: false,
196        })
197    }
198
199    /// The colour output texture RID (`Invalid` until the first run builds it).
200    fn tex_rid(&self) -> Rid {
201        self.gpu.tex.rid()
202    }
203
204    /// The normal output texture RID (`Invalid` until the first run builds it).
205    fn nrm_rid(&self) -> Rid {
206        self.gpu.nrm.rid()
207    }
208}
209
210#[godot_api]
211impl TileViewerJob {
212    /// Render-thread entry point: ensure resources, then bake the pending view.
213    #[func]
214    fn run(&mut self) {
215        if !self.pending {
216            return;
217        }
218        let rs = RenderingServer::singleton();
219        let Some(mut rd) = rs.get_rendering_device() else { return };
220        let w = self.params.width;
221        if !self.gpu.ensure_ready(&mut rd, w) {
222            return; // retry next request
223        }
224        let params = self.params;
225        self.gpu.dispatch(&mut rd, &params);
226        self.pending = false;
227    }
228}
229
230/// Debug node driving the single-tile texture viewer. Exposed to the editor as a
231/// `tool` so the bundled `debug/tile_viewer.tscn` scene can run it standalone.
232#[derive(GodotClass)]
233#[class(base = Node, tool, init, internal)]
234pub struct CelestialTileViewer {
235    base: Base<Node>,
236
237    /// Output texture edge in texels (square `W×W` bake).
238    #[export]
239    #[init(val = 1024)]
240    width: i64,
241    /// Which of the 20 icosphere faces to view.
242    #[export]
243    #[init(val = 7)]
244    face: i64,
245    /// Texture (bake) resolution: the surface is sampled on a `tex_res × tex_res`
246    /// grid (carried in the params `c.w` slot), so the view shows the detail AT
247    /// that texture resolution regardless of the output texel count. Cycled by R
248    /// via `set_tex_res` (internal — not an `#[export]`, which would auto-generate
249    /// a colliding `set_tex_res`).
250    #[init(val = 64)]
251    tex_res: i64,
252
253    /// Viewed sub-triangle corners in face barycentric (wb, wc) — set by
254    /// `set_region`. Defaults to the full face triangle so the standalone node
255    /// shows the whole face if no region is supplied.
256    #[init(val = Vector2::new(0.0, 0.0))]
257    sub0: Vector2,
258    #[init(val = Vector2::new(1.0, 0.0))]
259    sub1: Vector2,
260    #[init(val = Vector2::new(0.0, 1.0))]
261    sub2: Vector2,
262
263    /// Detail-normal bump enable (1.0 on, 0.0 off) for the baked normal map —
264    /// carried to the shader in the spare `b.w` slot. Default off (bump removed
265    /// from production); the scene's B key re-enables it for comparison.
266    #[init(val = 0.0)]
267    bump_enable: f32,
268
269    job: Option<Gd<TileViewerJob>>,
270    run_cb: Option<Callable>,
271}
272
273#[godot_api]
274impl INode for CelestialTileViewer {
275    fn on_notification(&mut self, what: NodeNotification) {
276        if matches!(what, NodeNotification::EXIT_TREE | NodeNotification::PREDELETE) {
277            // Dropping the job drops its `Owned<K>` handles, which queue the RID
278            // frees on the render thread (CEL-91).
279            self.job = None;
280            self.run_cb = None;
281        }
282    }
283}
284
285#[godot_api]
286impl CelestialTileViewer {
287    /// Set the face and the viewed sub-triangle (barycentric corners, matching
288    /// the chunk's region) and schedule a render-thread re-bake. Corner 0 → UV
289    /// (0,0), corner 1 → UV (1,0), corner 2 → UV (0,1), to match the display
290    /// mesh's vertex UVs.
291    #[func]
292    fn set_region(&mut self, face: i64, c0: Vector2, c1: Vector2, c2: Vector2) {
293        self.face = face;
294        self.sub0 = c0;
295        self.sub1 = c1;
296        self.sub2 = c2;
297        self.rebake();
298    }
299
300    /// Set the texture (bake) resolution and re-bake. Cycled by the `R` keybind.
301    #[func]
302    fn set_tex_res(&mut self, n: i64) {
303        self.tex_res = n.clamp(1, 4096);
304        self.rebake();
305    }
306
307    /// Build params from the stored region + resolution and schedule the bake.
308    fn rebake(&mut self) {
309        self.ensure_job();
310
311        let frames = base_face_frames(RADIUS);
312        let fi = self.face.clamp(0, frames.len() as i64 - 1) as usize;
313        let frame = &frames[fi];
314        let terrain = assemble(&HeightGpu::default(), &TextureGpu::default());
315        let params = TileViewerParams {
316            a: [frame.a.x, frame.a.y, frame.a.z, frame.radius],
317            // b.w carries the detail-normal bump enable (1.0 on, 0.0 off).
318            b: [frame.b.x, frame.b.y, frame.b.z, self.bump_enable],
319            c: [frame.c.x, frame.c.y, frame.c.z, 0.0],
320            sub0: [self.sub0.x, self.sub0.y, 0.0, 0.0],
321            sub1: [self.sub1.x, self.sub1.y, 0.0, 0.0],
322            sub2: [self.sub2.x, self.sub2.y, 0.0, 0.0],
323            width: self.width.clamp(16, 4096) as u32,
324            tex_res: self.tex_res.clamp(1, 4096) as u32,
325            _pad: [0, 0],
326            terrain,
327            _pad2: [0.0, 0.0],
328        };
329
330        if let Some(job) = &mut self.job {
331            let mut j = job.bind_mut();
332            j.params = params;
333            j.pending = true;
334        }
335        if let Some(cb) = self.run_cb.clone() {
336            RenderingServer::singleton().call_on_render_thread(&cb);
337        }
338    }
339
340    /// Current texture (bake) resolution (for the HUD label).
341    #[func]
342    fn tex_resolution(&self) -> i64 {
343        self.tex_res
344    }
345
346    /// Enable/disable the baked detail-normal bump (1.0 on, 0.0 off) and re-bake.
347    #[func]
348    fn set_bump(&mut self, enable: f32) {
349        self.bump_enable = if enable > 0.5 { 1.0 } else { 0.0 };
350        self.rebake();
351    }
352
353    /// The output texture RID (wrap in a `Texture2DRD` for display). `Invalid`
354    /// until the first render-thread run has built resources.
355    #[func]
356    fn texture_rid(&self) -> Rid {
357        self.job.as_ref().map(|j| j.bind().tex_rid()).unwrap_or(Rid::Invalid)
358    }
359
360    /// The baked world-normal texture RID (wrap in a `Texture2DRD`). `Invalid`
361    /// until the first render-thread run has built resources.
362    #[func]
363    fn normal_texture_rid(&self) -> Rid {
364        self.job.as_ref().map(|j| j.bind().nrm_rid()).unwrap_or(Rid::Invalid)
365    }
366
367    /// Lazily create the render-thread job + its run callable.
368    fn ensure_job(&mut self) {
369        if self.job.is_some() {
370            return;
371        }
372        let job = TileViewerJob::create();
373        self.run_cb = Some(Callable::from_object_method(&job, "run"));
374        self.job = Some(job);
375    }
376}
377
378// ───────────────────────── Mode B: real chunk pipeline (FLAT) ─────────────────
379//
380// `CelestialTileChunk` renders ONE chunk through the *production* chunk path
381// (`ChunkRealize` + `ChunkTileBake` + `terrain_chunk.gdshader`) so the viewer can
382// A/B-compare the per-pixel baked tile (mode A) against the actual surface the
383// planet draws (mode B). It is the same machinery `celestial.rs` drives,
384// reduced to a single chunk laid out flat-on to the camera.
385//
386// NOTE on "flat": the production realize/bake shaders gate ALL terrain detail
387// (displacement, grass/rock albedo, detail normals) on `radius > 0` — a literal
388// `radius == 0` descriptor renders as a featureless flat-green triangle that is
389// invariant to `chunk_res`. To make the A/B comparison meaningful (and to show
390// resolution-dependent faceting) this node defaults to the real sphere frame
391// (`flat_face == false`) but covers only a small sub-triangle of the face
392// (`face_fraction`), so the patch is nearly planar yet runs the full detail path.
393// Set `flat_face = true` to reproduce the literal `radius == 0` flat path.
394
395const CHUNK_SHADER: &str = "res://addons/celestialsim/terrain_chunk.gdshader";
396
397/// Debug node (mode B): one production chunk realized + baked + drawn flat-on.
398#[derive(GodotClass)]
399#[class(base = Node3D, tool, init, internal)]
400pub struct CelestialTileChunk {
401    base: Base<Node3D>,
402
403    /// Which of the 20 icosphere faces this chunk lives on.
404    #[export]
405    #[init(val = 7)]
406    face: i64,
407    /// Sphere radius (matches the HQ scene / mode A).
408    #[export]
409    #[init(val = 1000.0)]
410    radius: f32,
411    /// Triangle resolution: the chunk is `chunk_res × chunk_res` triangles.
412    #[export]
413    #[init(val = 64)]
414    chunk_res: i64,
415    /// Per-chunk detail-tile resolution (colour/normal atlas), as in production.
416    /// Cycled by R via `set_tile_res` (internal — not `#[export]`, which would
417    /// auto-generate a colliding `set_tile_res`).
418    #[init(val = 64)]
419    tile_res: i64,
420    /// Detail-normal bump enable (1.0 on, 0.0 off) — debug toggle that re-bakes
421    /// the chunk's normal atlas with/without the high-frequency bump. Default off
422    /// (the bump was removed from production); the scene's B key re-enables it.
423    #[init(val = 0.0)]
424    bump_enable: f32,
425    /// Sub-triangle size in barycentric units (1.0 = the whole face). 1.0 keeps
426    /// the chunk's full sphere curvature + displacement so low `chunk_res`
427    /// tessellation faceting is visible; smaller values isolate a flatter patch.
428    #[export(range = (0.02, 1.0, 0.01))]
429    #[init(val = 1.0)]
430    face_fraction: f32,
431    /// Literal flat path: pass `radius = 0` to the descriptor. The production
432    /// shader then emits featureless flat-green (detail is gated on `radius > 0`).
433    #[export]
434    #[init(val = false)]
435    flat_face: bool,
436
437    job: Option<Gd<CesChunkJob>>,
438    run_cb: Option<Callable>,
439    multimesh: Option<Gd<MultiMesh>>,
440    mmi: Option<Gd<MultiMeshInstance3D>>,
441    material: Option<Gd<ShaderMaterial>>,
442    _template: Option<Gd<ArrayMesh>>,
443
444    #[init(val = Rid::Invalid)]
445    wired_pos: Rid,
446    #[init(val = Rid::Invalid)]
447    wired_verts: Rid,
448    #[init(val = Rid::Invalid)]
449    wired_color: Rid,
450    #[init(val = Rid::Invalid)]
451    wired_normal: Rid,
452
453    built: bool,
454}
455
456#[godot_api]
457impl INode3D for CelestialTileChunk {
458    fn process(&mut self, _delta: f64) {
459        self.ensure_built();
460        self.pump();
461        self.wire_textures();
462    }
463
464    fn on_notification(&mut self, what: godot::classes::notify::Node3DNotification) {
465        use godot::classes::notify::Node3DNotification as N;
466        if matches!(what, N::EXIT_TREE | N::PREDELETE) {
467            // The job's `Owned<K>` handles free themselves on drop (CEL-91).
468            self.job = None;
469            self.run_cb = None;
470        }
471    }
472}
473
474#[godot_api]
475impl CelestialTileChunk {
476    /// Rebuild the chunk at a new triangle resolution (re-mesh + re-realize +
477    /// re-bake). Called by the viewer's `R` keybind.
478    #[func]
479    fn set_resolution(&mut self, res: i64) {
480        let r = res.clamp(2, 1024);
481        if r == self.chunk_res && self.built {
482            return;
483        }
484        self.chunk_res = r;
485        self.teardown();
486    }
487
488    /// Set the per-chunk detail-tile (texture) resolution and rebuild the chunk
489    /// so the new tile_res flows through ChunkTileBake. Triangles are unchanged.
490    #[func]
491    fn set_tile_res(&mut self, n: i64) {
492        let r = n.clamp(8, 1024);
493        if r == self.tile_res && self.built {
494            return;
495        }
496        self.tile_res = r;
497        self.teardown();
498    }
499
500    /// Enable/disable the detail-normal bump (1.0 on, 0.0 off) and rebuild so the
501    /// normal atlas is re-baked. Lets the debug scene toggle bumps entirely.
502    #[func]
503    fn set_bump(&mut self, enable: f32) {
504        let e = if enable > 0.5 { 1.0 } else { 0.0 };
505        if e == self.bump_enable && self.built {
506            return;
507        }
508        self.bump_enable = e;
509        self.teardown();
510    }
511
512    /// Current triangle resolution (for the HUD label).
513    #[func]
514    fn chunk_resolution(&self) -> i64 {
515        self.chunk_res
516    }
517
518    /// Current detail-tile resolution (for the HUD label).
519    #[func]
520    fn tile_resolution(&self) -> i64 {
521        self.tile_res
522    }
523
524    /// World-space centroid of the chunk's base triangle (camera look-at target).
525    #[func]
526    fn chunk_centroid(&self) -> Vector3 {
527        let c = self.world_corners();
528        (c[0] + c[1] + c[2]) / 3.0
529    }
530
531    /// Outward (away-from-origin) face normal of the chunk's base triangle.
532    #[func]
533    fn chunk_normal(&self) -> Vector3 {
534        let c = self.world_corners();
535        let cen = (c[0] + c[1] + c[2]) / 3.0;
536        let n = (c[1] - c[0]).cross(c[2] - c[0]).normalized();
537        if n.dot(cen) < 0.0 {
538            -n
539        } else {
540            n
541        }
542    }
543
544    /// An in-plane "up" axis (toward corner A) for orienting the camera.
545    #[func]
546    fn chunk_up(&self) -> Vector3 {
547        let c = self.world_corners();
548        let cen = (c[0] + c[1] + c[2]) / 3.0;
549        (c[0] - cen).normalized()
550    }
551
552    /// Circumradius of the base triangle (used to frame the camera distance).
553    #[func]
554    fn chunk_extent(&self) -> f32 {
555        let c = self.world_corners();
556        let cen = (c[0] + c[1] + c[2]) / 3.0;
557        c.iter().map(|p| (*p - cen).length()).fold(0.0_f32, f32::max)
558    }
559
560    /// This chunk's face index (so Mode A bakes the same face).
561    #[func]
562    fn face_id(&self) -> i64 {
563        self.face_index() as i64
564    }
565
566    /// The chunk's three sub-triangle corners in face barycentric (wb, wc), in
567    /// the same order as `world_corners_packed` — fed to `TileViewer::set_region`.
568    #[func]
569    fn bary_corners(&self) -> PackedVector2Array {
570        let b = self.sub_bary();
571        PackedVector2Array::from(&[
572            Vector2::new(b[0].wb, b[0].wc),
573            Vector2::new(b[1].wb, b[1].wc),
574            Vector2::new(b[2].wb, b[2].wc),
575        ])
576    }
577
578    /// The chunk's three world-space corners (flat triangle for Mode A's mesh).
579    #[func]
580    fn world_corners_packed(&self) -> PackedVector3Array {
581        let c = self.world_corners();
582        PackedVector3Array::from(&[c[0], c[1], c[2]])
583    }
584}
585
586impl CelestialTileChunk {
587    /// Descriptor radius: 0 in literal flat mode, else the sphere radius.
588    fn effective_radius(&self) -> f32 {
589        if self.flat_face {
590            0.0
591        } else {
592            self.radius
593        }
594    }
595
596    /// The chunk's three barycentric corners: a `face_fraction`-scaled triangle
597    /// centred on the face centroid (so the patch is small and nearly planar).
598    fn sub_bary(&self) -> [Bary; 3] {
599        let s = self.face_fraction.clamp(0.02, 1.0);
600        let cb = 1.0 / 3.0;
601        let lerp = |wb: f32, wc: f32| Bary { wb: cb + s * (wb - cb), wc: cb + s * (wc - cb) };
602        [lerp(0.0, 0.0), lerp(1.0, 0.0), lerp(0.0, 1.0)]
603    }
604
605    /// Face index clamped to the valid 0..20 range.
606    fn face_index(&self) -> usize {
607        self.face.clamp(0, 19) as usize
608    }
609
610    /// The icosphere face frames with the descriptor radius applied (corners stay
611    /// at sphere scale; `radius == 0` just makes `project_bary` linear == flat).
612    fn frames(&self) -> Vec<celestial_algo::clipmap::FaceFrame> {
613        let mut frames = base_face_frames(self.radius);
614        let er = self.effective_radius();
615        for f in &mut frames {
616            f.radius = er;
617        }
618        frames
619    }
620
621    /// World positions of the chunk's three corners.
622    fn world_corners(&self) -> [Vector3; 3] {
623        let frames = self.frames();
624        let f = &frames[self.face_index()];
625        let b = self.sub_bary();
626        [
627            f.project_bary(b[0].wb, b[0].wc),
628            f.project_bary(b[1].wb, b[1].wc),
629            f.project_bary(b[2].wb, b[2].wc),
630        ]
631    }
632
633    /// Lazily build the material + reference mesh + indirect MultiMesh + render
634    /// job, then stage the single chunk. Mirrors `celestial::ensure_job`.
635    fn ensure_built(&mut self) {
636        if self.built {
637            return;
638        }
639        let res = self.chunk_res.clamp(2, 1024) as u32;
640        let tile_res = self.tile_res.clamp(8, 1024) as u32;
641
642        let mut material = make_chunk_material(res, tile_res);
643        let template = reference_chunk_mesh(res, &material.clone().upcast());
644
645        let mut rs = RenderingServer::singleton();
646        let multimesh = MultiMesh::new_gd();
647        let mm_rid = multimesh.get_rid();
648        let mut mmi = MultiMeshInstance3D::new_alloc();
649        mmi.set_multimesh(&multimesh);
650        // Hidden until `wire_textures` binds the GPU atlases — they only become
651        // valid after the render-thread job runs (next frame), and drawing with
652        // an unwired atlas trips a "binding 1 invalid" error every rebuild.
653        mmi.set_visible(false);
654        self.base_mut().add_child(&mmi);
655
656        rs.multimesh_allocate_data_ex(mm_rid, 1, MultimeshTransformFormat::TRANSFORM_3D)
657            .custom_data_format(true)
658            .use_indirect(true)
659            .done();
660        rs.multimesh_set_mesh(mm_rid, template.get_rid());
661        let m = self.radius.max(1.0) * 1.4;
662        rs.multimesh_set_custom_aabb(
663            mm_rid,
664            Aabb { position: Vector3::splat(-m), size: Vector3::splat(2.0 * m) },
665        );
666
667        // Terrain ON (HQ defaults) so the production detail path runs.
668        let terrain = assemble(&HeightGpu::default(), &TextureGpu::default());
669        let job = CesChunkJob::create(mm_rid, 1, res, tile_res, self.radius, self.bump_enable, terrain, Vec::new());
670        self.run_cb = Some(Callable::from_object_method(&job, "run"));
671
672        material.set_shader_parameter("attr_w", &(ATTR_TEX_WIDTH as i32).to_variant());
673
674        self.job = Some(job);
675        self.multimesh = Some(multimesh);
676        self.mmi = Some(mmi);
677        self.material = Some(material);
678        self._template = Some(template);
679        self.built = true;
680
681        self.stage_chunk(res);
682    }
683
684    /// Stage the single flat chunk for the render-thread job.
685    fn stage_chunk(&mut self, res: u32) {
686        let frames = self.frames();
687        let fi = self.face_index();
688        let f = &frames[fi];
689        let bary = self.sub_bary();
690        let corners = [
691            f.project_bary(bary[0].wb, bary[0].wc),
692            f.project_bary(bary[1].wb, bary[1].wc),
693            f.project_bary(bary[2].wb, bary[2].wc),
694        ];
695        let chunk = Chunk {
696            id: ChunkId { face: fi as u8, depth: 0, path: 0 },
697            bary,
698            corners,
699            level: 0,
700        };
701        let desc_bytes = pack_chunks(&frames, &[(0u32, chunk)], res);
702        // Single static depth-0 chunk: morph = 1 (no geomorph in the viewer).
703        let instance_bytes = pack_instances(&[0u32], &[1.0]);
704        if let Some(job) = &mut self.job {
705            job.bind_mut().stage = Some(ChunkStage {
706                desc_bytes,
707                realize_count: 1,
708                instance_bytes,
709                instance_count: 1,
710                // The CPU-surface path is the quadtree planet's; the tile viewer
711                // stays procedural.
712                surface_enabled: 0.0,
713                surface_height_scale: 0.0,
714                surface_patches: Vec::new(),
715                scatter_aux_bytes: Vec::new(),
716                scatter_vis_bytes: Vec::new(),
717                scatter_vis_count: 0,
718                scatter_layer_params: Vec::new(),
719            });
720        }
721    }
722
723    /// Schedule the render-thread run while a stage is pending.
724    fn pump(&mut self) {
725        let pending = self.job.as_ref().map(|j| j.bind().stage.is_some()).unwrap_or(false);
726        if pending {
727            if let Some(cb) = self.run_cb.clone() {
728                RenderingServer::singleton().call_on_render_thread(&cb);
729            }
730        }
731    }
732
733    /// Wire the GPU-realized textures into the chunk material once they exist.
734    fn wire_textures(&mut self) {
735        let (pos, verts, color, normal) = match &self.job {
736            Some(job) => {
737                let g = &job.bind().gpu;
738                (g.pos_tex(), g.attr_tex(), g.color_atlas(), g.normal_atlas())
739            }
740            None => return,
741        };
742        if self.material.is_none() {
743            return;
744        }
745        for (rid, wired, name) in [
746            (pos, &mut self.wired_pos, "pos_tex"),
747            (verts, &mut self.wired_verts, "verts_tex"),
748            (color, &mut self.wired_color, "color_atlas"),
749            (normal, &mut self.wired_normal, "normal_atlas"),
750        ] {
751            if rid.is_valid() && rid != *wired {
752                let mut tex = Texture2Drd::new_gd();
753                tex.set_texture_rd_rid(rid);
754                self.material.as_mut().unwrap().set_shader_parameter(name, &tex.to_variant());
755                *wired = rid;
756            }
757        }
758        // Reveal the chunk only once every atlas is bound (see `ensure_built`).
759        if self.wired_pos.is_valid()
760            && self.wired_verts.is_valid()
761            && self.wired_color.is_valid()
762            && self.wired_normal.is_valid()
763        {
764            if let Some(mmi) = self.mmi.as_mut() {
765                if !mmi.is_visible() {
766                    mmi.set_visible(true);
767                }
768            }
769        }
770    }
771
772    /// Drop the job (its `Owned<K>` handles queue the RID frees on the render
773    /// thread) and all keep-alives, so the next `ensure_built` rebuilds at the new
774    /// resolution.
775    fn teardown(&mut self) {
776        if let Some(mut mmi) = self.mmi.take() {
777            // Hide immediately: `queue_free` is deferred to end-of-frame, but the
778            // atlas frees are queued on the render thread now, so a still-visible
779            // MMI would draw a freed texture (binding-1 invalid) for a frame on
780            // every resolution change.
781            mmi.set_visible(false);
782            mmi.queue_free();
783        }
784        self.job = None;
785        self.run_cb = None;
786        self.multimesh = None;
787        self.material = None;
788        self._template = None;
789        self.wired_pos = Rid::Invalid;
790        self.wired_verts = Rid::Invalid;
791        self.wired_color = Rid::Invalid;
792        self.wired_normal = Rid::Invalid;
793        self.built = false;
794    }
795}
796
797/// Build the production chunk surface material (same shader the planet uses).
798fn make_chunk_material(res: u32, tile_res: u32) -> Gd<ShaderMaterial> {
799    let shader = godot::tools::load::<Shader>(CHUNK_SHADER);
800    let mut mat = ShaderMaterial::new_gd();
801    mat.set_shader(&shader);
802    mat.set_shader_parameter("attr_w", &(ATTR_TEX_WIDTH as i32).to_variant());
803    mat.set_shader_parameter("verts_per_chunk", &(verts_per_chunk(res) as i32).to_variant());
804    mat.set_shader_parameter("chunk_res", &(res as i32).to_variant());
805    mat.set_shader_parameter("tile_res", &(tile_res as i32).to_variant());
806    mat.set_shader_parameter("lod_colors", &false.to_variant());
807    mat
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    use std::mem::offset_of;
814
815    #[test]
816    fn tile_viewer_params_layout() {
817        // std430: float4 members force 16-byte alignment; the struct array stride
818        // must be a multiple of 16 or `params[0]` reads at the wrong offset. These
819        // offsets must match `struct TileViewerParams` in TileViewer.slang.
820        assert_eq!(std::mem::size_of::<TileViewerParams>(), 176);
821        assert_eq!(std::mem::size_of::<TileViewerParams>() % 16, 0);
822        assert_eq!(offset_of!(TileViewerParams, a), 0);
823        assert_eq!(offset_of!(TileViewerParams, b), 16);
824        assert_eq!(offset_of!(TileViewerParams, c), 32);
825        assert_eq!(offset_of!(TileViewerParams, sub0), 48);
826        assert_eq!(offset_of!(TileViewerParams, sub1), 64);
827        assert_eq!(offset_of!(TileViewerParams, sub2), 80);
828        assert_eq!(offset_of!(TileViewerParams, width), 96);
829        assert_eq!(offset_of!(TileViewerParams, tex_res), 100);
830        assert_eq!(offset_of!(TileViewerParams, terrain), 112);
831        assert_eq!(std::mem::size_of::<TerrainGpu>(), 56);
832    }
833
834    #[test]
835    fn params_default_is_zeroed_pod() {
836        // Pod/Zeroable must hold (no uninit padding) so the zero-init buffer and
837        // bytemuck round-trip are safe.
838        let p = TileViewerParams::zeroed();
839        assert_eq!(bytemuck::bytes_of(&p).len(), 176);
840    }
841}