Skip to main content

celestialsim/
celestial.rs

1//! The `Celestial` node (Phase 2, CEL-62): a rendered, navigable
2//! chunked quadtree planet. Strictly additive to the clipmap.
3//!
4//! Each frame the CPU selects the visible chunk cut (`select_chunks`), maps it to
5//! stable GPU slots (`ChunkCache`), and — only when the cut changes — stages the
6//! dirty descriptors + instances for a render-thread `CesChunkJob` that realizes
7//! chunk vertices into a shared vertex pool (positions in a sampleable `pos_tex`,
8//! attributes in `verts_tex`). The chunk MultiMesh's `terrain_chunk.gdshader`
9//! material samples those textures, so geometry/colour come straight from the GPU
10//! with no CPU readback. On a stationary camera the cut is identical, the cache
11//! reports zero realizes, and no job is scheduled — `executes` stops growing.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15use std::time::Instant;
16
17use celestial_algo::chunk_cache::CacheDiff;
18use celestial_algo::surface_cache::SurfaceCache;
19use celestial_algo::quadtree::{
20    base_face_frames, morph_factor, select_chunks_displaced, Chunk, ChunkId, SurfaceFn,
21};
22use godot::classes::file_access::ModeFlags;
23use godot::classes::rendering_server::MultimeshTransformFormat;
24use godot::register::info::{PropertyInfo, PropertyUsageFlags};
25use godot::classes::{
26    INode3D, MultiMesh, MultiMeshInstance3D, Node3D, RenderingServer, Resource, Shader,
27    ShaderMaterial, Texture2Drd,
28};
29use godot::prelude::*;
30
31use crate::async_bake::{self, SubmitQueue, TAG_MASK};
32use crate::bake_pool::BakePool;
33use crate::chunk_descriptors::{pack_chunks, pack_instances, verts_per_chunk};
34use crate::chunk_mesh::reference_chunk_mesh;
35use crate::chunk_pipeline::{CesChunkJob, ChunkStage};
36use crate::descriptors::{assemble, HeightGpu, TextureGpu};
37use crate::gpu::chunk_gpu::ScatterConfig;
38use crate::gpu::ATTR_TEX_WIDTH;
39use crate::builder::{BuilderRoute, CesBuilder};
40use crate::scatter_descriptors::{pack_scatter_aux, pack_scatter_params, pack_scatter_vis};
41use crate::scatter_layer::CesScatterLayer;
42use crate::surface::{ChunkSurface, CpuSurfaceProvider};
43
44const CHUNK_SHADER: &str = "res://addons/celestialsim/terrain_chunk.gdshader";
45
46/// A procedural planet: the `Node3D` you add to a Godot scene.
47///
48/// It owns the whole terrain pipeline — every frame it selects a screen-space-error
49/// cut of the quadtree around the active camera, realizes the newly-visible chunks
50/// on the GPU, and draws them from an indirect `MultiMesh` child. What the surface
51/// *looks* like comes from the [`CesBuilder`] resource in `builder` (terrain +
52/// ocean); vegetation and props come from the [`CesScatterLayer`] resources in
53/// `scatter_layers`.
54///
55/// The exported properties below are all live: editing one in the inspector (or
56/// from GDScript) reshades or rebuilds as needed, with no scene reload.
57#[derive(GodotClass)]
58#[class(base = Node3D, tool, init)]
59pub struct Celestial {
60    base: Base<Node3D>,
61
62    /// Radius of the undisplaced sphere, in world units — the scale of the whole
63    /// planet. Terrain displaces around it, so the rendered ground can sit above or
64    /// below this radius (see `ground_radius_at`). Changing it re-realizes every
65    /// resident chunk. Default 1000.
66    #[export]
67    #[init(val = 1000.0)]
68    pub radius: f32,
69    /// Target screen-space error, in normalized screen units (chunk edge over
70    /// distance): the LOD cut is chosen so no chunk exceeds it. Lower = sharper
71    /// terrain, but more resident chunks, more bakes and more VRAM pressure; higher
72    /// = coarser and cheaper. Default 0.02 (range 0.005–0.5).
73    #[export(range = (0.005, 0.5, 0.005))]
74    #[init(val = 0.02)]
75    pub screen_error: f32,
76    /// Geometry grid of one chunk: `chunk_res` segments per chunk edge, so each
77    /// chunk carries `chunk_res²` triangles. Raising it makes each chunk denser
78    /// (fewer, larger chunks reach the same `screen_error`) at a higher per-slot
79    /// vertex-pool cost. Default 16 (range 2–32).
80    #[export(range = (2.0, 32.0, 1.0))]
81    #[init(val = 16)]
82    pub chunk_res: i64,
83    /// Phase 4: per-chunk detail-tile resolution. Colour + normal detail is baked
84    /// at `tile_res × tile_res` per chunk (independent of `chunk_res`) and sampled
85    /// per-pixel, so surface shading is crisper than the geometry grid. NOTE: the
86    /// detail atlas costs `tile_res² × 16 B` per resident chunk (detail atlases + surface buffers), so a larger
87    /// `tile_res` means fewer chunks fit in `vram_budget_gib` (see `effective_budget`).
88    #[export(range = (8.0, 1024.0, 1.0))]
89    #[init(val = 32)]
90    pub tile_res: i64,
91    /// Hard cap on quadtree subdivision, i.e. the finest terrain the camera can ever
92    /// reach: below this depth the cut stops refining even if `screen_error` is not
93    /// met, so the ground goes blocky as you get close. 0 = no subdivision (the 20
94    /// base icosphere faces only — "LOD 0"). Default 16 (range 0–20).
95    #[export(range = (0.0, 20.0, 1.0))]
96    #[init(val = 16)]
97    pub max_depth: i64,
98    /// GPU VRAM budget for the resident chunk pools, in **GiB**. You set the
99    /// gigabytes, not a slot count: the number of resident chunk slots is derived
100    /// as `vram / per-chunk bytes`, where per-chunk = `verts_per_chunk×48 B`
101    /// (geometry) + `tile_res²×16 B` (detail atlases + surface buffers). So a bigger `tile_res`
102    /// simply means fewer chunks fit in the same budget — no manual rebalancing.
103    /// (Also clamped so the atlas texture height stays within the GPU's limit.)
104    #[export(range = (0.05, 8.0, 0.05))]
105    #[init(val = 1.0)]
106    pub vram_budget_gib: f32,
107    /// Geomorphing (Phase 5): smoothly blend each chunk between its full-detail
108    /// grid and its coarser (parent-resolution) sublattice as the camera distance
109    /// crosses the LOD band, so detail fades in/out instead of popping when the
110    /// quadtree subdivides/merges. The per-chunk morph factor rides in the
111    /// per-frame instance buffer (`INSTANCE_CUSTOM.g`) and is applied in the
112    /// surface vertex shader, so the realize/bake cache is untouched. When off,
113    /// every chunk gets `morph = 1` (full detail, no blend) and the instance
114    /// buffer is NOT re-uploaded on camera movement.
115    #[export]
116    #[init(val = true)]
117    pub geomorph: bool,
118    /// Debug view: tint each chunk by its pool slot so the chunk tiling (and the LOD
119    /// cut) is visible on the surface. Off by default; purely visual.
120    #[export]
121    #[init(val = false)]
122    pub lod_colors: bool,
123    /// Print a one-line status (chunks in the cut, resident, realized, graph
124    /// executes, selection time) to the Godot output every ~5 seconds. Default on.
125    #[export]
126    #[init(val = true)]
127    pub debug_log: bool,
128    /// Horizon (back-of-planet) culling: skip selecting/realizing/baking/drawing
129    /// chunks that are fully beyond the planet's horizon. Removes the entire far
130    /// hemisphere; and because the horizon is close when the camera is near the
131    /// surface, it also drops distant near-ground chunks — shrinking the resident
132    /// set (and the VRAM/`tile_res` it can afford).
133    #[export]
134    #[init(val = true)]
135    pub horizon_cull: bool,
136    /// Terrain-height slack for horizon culling, as a fraction of `radius`: a
137    /// patch is kept if terrain up to `radius × cull_height_margin` above the
138    /// surface could peek over the horizon. Raise if tall terrain pops in at the
139    /// horizon; lower to cull more aggressively. (HQ max displacement ≈ 0.3·r.)
140    #[export(range = (0.0, 1.0, 0.01))]
141    #[init(val = 0.3)]
142    pub cull_height_margin: f32,
143    /// Max NEW chunks to realize+bake per frame. A large influx (teleport, fast
144    /// turn, first fill) otherwise bakes every newly-visible chunk in one frame and
145    /// spikes frame time; capping it spreads the work over frames (the rest pop in
146    /// over the next few frames). Lower = smoother under influx, slower fill-in.
147    /// Dirty re-bakes (streamed-tile arrivals) share this cap; already-resident
148    /// chunks are always drawn (stale until their re-bake turn). Default 48.
149    #[export(range = (1.0, 4096.0, 1.0))]
150    #[init(val = 48)]
151    pub max_bakes_per_frame: i64,
152    /// TEST (temporary): bypass the per-chunk cache and re-realize + re-bake every
153    /// VISIBLE chunk EVERY frame (no persistence, no eviction). Lets you probe the
154    /// raw per-frame realize+bake cost at high `tile_res`/`chunk_res` without the
155    /// cache size limiting things. Off = normal cached path.
156    #[export]
157    #[init(val = false)]
158    pub recompute_every_frame: bool,
159
160    /// Scatter layers (CEL-73): each layer scatters one mesh over the planet on
161    /// a stable world lattice, with LIVE `density` + `min_height`/`max_height`
162    /// sliders — edits re-run only the scatter-compact dispatch. A layer with no
163    /// mesh is INACTIVE (for grass assign `addons/celestialsim/grass_blade.tres`).
164    /// `lod_level` sets density/reach; adding/removing layers (or changing
165    /// `instances_per_cell`/`max_instances`) rebuilds the GPU job.
166    #[export]
167    pub scatter_layers: Array<Gd<CesScatterLayer>>,
168
169    /// The terrain **builder** (one active at a time). Its TYPE (a `CesBuilder`
170    /// subclass) decides how the surface is produced. A new planet starts with a
171    /// GPU-example builder (added when the node is first readied); **clear it to render a plain
172    /// white sphere**. Swapping or editing the builder reshades/rebuilds live.
173    #[var(get = get_builder, set = set_builder)]
174    #[export]
175    pub builder: Option<Gd<CesBuilder>>,
176
177    /// Hidden, storage-only guard: add the default builder the first time a
178    /// builder-less planet is readied, then never again (so clearing the builder
179    /// stays white). Not shown in the inspector — see `on_validate_property`.
180    #[export]
181    #[init(val = true)]
182    pub auto_add_builder: bool,
183
184    /// Analytic planetary water proxy (created lazily). Its toggle, water level,
185    /// and look params all come from the active [`CesBuilder`], so water config
186    /// travels with the terrain builder — see `update_water`.
187    water: Option<crate::water_runtime::WaterRuntime>,
188
189    /// The CPU-surface provider driving colour/height/normal (only for a
190    /// `CpuNoise` builder → [`NoiseProvider`]); `None` for every other path.
191    /// Shared (`Arc`) with the bake pool's worker threads.
192    provider: Option<Arc<dyn CpuSurfaceProvider>>,
193    /// Worker pool resampling chunk surfaces off the main thread (the fast-
194    /// flight stutter fix): chunks are admitted only once their surface is ready.
195    bake_pool: Option<BakePool>,
196    /// Baked surfaces waiting for admission, keyed by chunk. Each entry is
197    /// stamped with the `param_epoch` current when it arrived; a surface whose
198    /// stamp is older than the CURRENT epoch was requested before the latest
199    /// param edit and is rejected when it tries to be rendered.
200    ///
201    /// BOUNDED (CEL-91): each surface is `12 × tile_res²` bytes (0.75 MiB at
202    /// tile_res 256), and this used to be a plain `HashMap` pruned only against
203    /// the quadtree cut — so it grew to the cut size (1000-3000 chunks = GBs of
204    /// RSS) no matter what `vram_budget_gib` said. It is now a FIFO
205    /// [`SurfaceCache`] capped at `effective_budget()` — the SAME slot count the
206    /// budget gives the GPU pool — so `vram_budget_gib` bounds the CPU side too.
207    /// An evicted (never-admitted) surface simply re-bakes when its chunk is
208    /// still in the cut.
209    #[init(val = SurfaceCache::new(1))]
210    ready_surfaces: SurfaceCache<(u64, ChunkSurface)>,
211    /// The "last parameter update time": bumped the INSTANT a live builder edit
212    /// is detected (not when the coalesced reshade is applied — that can lag
213    /// behind by frames while a realize stage is in flight, a window in which
214    /// old-param bakes used to slip through). Every ready surface carries the
215    /// epoch it arrived under; render-time consumption rejects older stamps.
216    param_epoch: u64,
217    /// Resident chunks whose baked CPU surface was made with STALE params and must
218    /// be re-baked. Owned here, NOT inferred from the cache's dirty flag: `update`
219    /// consumes `dirty` as soon as it re-realizes a chunk (even though that realize
220    /// used the old surface, because the new bake had not landed yet), while the
221    /// per-frame bake budget only re-requests a handful of chunks. Chunks beyond
222    /// that budget would lose `dirty` before ever being re-requested and keep
223    /// old-param terrain forever. An id stays here until its FRESH surface actually
224    /// arrives, so every resident chunk is guaranteed to refresh eventually.
225    stale_surfaces: HashSet<ChunkId>,
226    /// Tracks the provider's base-ready edge so the one-time "switch the initial
227    /// procedural view to the baked surface" re-bake fires exactly once.
228    was_base_ready: bool,
229    /// The active `CpuCustom` builder (if any): its batched GDScript
230    /// `height`/`color`/`normal` are called once per chunk, synchronously on the
231    /// main thread during staging (realize count is already throttled per
232    /// frame). Mutually exclusive with the other paths. See
233    /// `docs/custom_terrain_cpu_gdscript.md`.
234    #[init(val = None)]
235    gd_baker: Option<Gd<CesBuilder>>,
236
237    /// The active `CpuCustomAsync` builder (CEL-86), if any: the planet hands it
238    /// batches of chunks via `_bake_requested` and never waits. Mutually
239    /// exclusive with `gd_baker`, the provider, and the custom GPU surface.
240    #[init(val = None)]
241    gd_async_baker: Option<Gd<CesBuilder>>,
242    /// That builder's hand-back queue, drained on the main thread each frame.
243    /// Held separately so the drain doesn't need to `bind()` the builder while
244    /// a worker thread may be pushing into it.
245    #[init(val = None)]
246    gd_submits: Option<Arc<SubmitQueue>>,
247    /// Chunks handed to the async builder that haven't been submitted back.
248    /// Pruned against the cut, so a chunk the camera flew past is simply
249    /// re-requested if the player returns. This is the whole cancellation story.
250    gd_outstanding: HashSet<ChunkId>,
251
252    cache: Option<celestial_algo::chunk_cache::ChunkCache>,
253    job: Option<Gd<CesChunkJob>>,
254    run_cb: Option<Callable>,
255    /// CEL-91: owned through `IndirectMultiMesh` so Godot's leaked indirect command
256    /// buffer is released when the multimesh goes away.
257    multimesh: Option<crate::gpu::owned::IndirectMultiMesh>,
258    mmi: Option<Gd<MultiMeshInstance3D>>,
259    material: Option<Gd<ShaderMaterial>>,
260    _template: Option<Gd<godot::classes::ArrayMesh>>,
261
262    /// CEL-73 scatter: one indirect MultiMesh child per layer (parallel vecs),
263    /// the mesh keep-alives (RS `set_mesh` doesn't refcount), and the per-layer
264    /// structural snapshot used to classify `changed` edits.
265    scatter_mmis: Vec<Gd<MultiMeshInstance3D>>,
266    scatter_mms: Vec<crate::gpu::owned::IndirectMultiMesh>,
267    scatter_meshes: Vec<Gd<godot::classes::Mesh>>,
268    scatter_snapshot: Vec<ScatterSnapshot>,
269    /// Set by any layer's `changed` signal; consumed at staging (live edits) or
270    /// by `check_scatter_structure` (structural edits).
271    scatter_dirty: bool,
272
273    /// The pos/verts texture RIDs currently wired into the material.
274    #[init(val = Rid::Invalid)]
275    wired_pos: Rid,
276    #[init(val = Rid::Invalid)]
277    wired_verts: Rid,
278    /// The colour/normal detail atlas RIDs currently wired into the material.
279    #[init(val = Rid::Invalid)]
280    wired_color: Rid,
281    #[init(val = Rid::Invalid)]
282    wired_normal: Rid,
283    /// Visible slots staged last time (skip re-staging an identical cut).
284    last_slots: Vec<u32>,
285    /// Camera (local-space) position last frame. Geomorph factors change whenever
286    /// the camera moves even if the cut is identical, so when geomorph is on a
287    /// camera move re-uploads ONLY the instance buffer (no realize/bake). `None`
288    /// until the first frame.
289    last_cam: Option<Vector3>,
290
291    last_select_ms: f64,
292    last_update_ms: f64,
293    last_patch_ms: f64,
294    last_stage_ms: f64,
295    last_realize_count: i64,
296    last_visible_count: i64,
297    /// Depth of every chunk in the last cut (for `cut_report`).
298    last_cut_depths: Vec<u8>,
299    /// Centroid of every chunk in the last cut (debug: expected slot content).
300    last_cut_centroids: Vec<Vector3>,
301    status_accum: f64,
302
303    /// Params baked into the built job/cache. A later change re-applies them:
304    /// `chunk_res`/`tile_res`/`vram_budget_gib` shape the GPU buffers → full
305    /// rebuild; `radius` is baked into every realized chunk's geometry (and its
306    /// scattered instances) → invalidate the cache so all resident chunks
307    /// re-realize at the new radius. Set at build in `ensure_job`; only read
308    /// once the job exists. (`screen_error`/`max_depth`/culling need nothing —
309    /// they change the per-frame cut directly.)
310    built_radius: f32,
311    built_res: i64,
312    built_tile_res: i64,
313    built_budget_gib: f32,
314    /// Assembled custom-GPU-surface GLSL installed in the current job (`None` =
315    /// no custom surface). A `changed` that yields a DIFFERENT source (shader
316    /// file edited / added / removed) forces a rebuild to recompile; an
317    /// identical source with new water/height knobs is a live edit.
318    built_custom_source: Option<String>,
319    /// The routing path the current job was built for (`None` = white / no
320    /// builder). A `changed` that flips the route forces a rebuild.
321    #[init(val = None)]
322    built_builder_kind: Option<BuilderRoute>,
323    /// Last-polled fingerprint of the active builder's `@export` FLOAT values
324    /// (noise knobs / custom params). `None` until primed and after every
325    /// rebuild. Godot does NOT emit `changed` for a plain `@export` edit, so
326    /// `poll_builder_params` detects value edits by comparing this each frame —
327    /// a builder needs no `emit_changed()` setter for its sliders to reshade.
328    #[init(val = None)]
329    last_param_values: Option<Vec<f32>>,
330    /// A live builder edit is waiting to be applied (set by `on_builder_changed`,
331    /// consumed in `process`). Deferring here COALESCES a burst of `changed`
332    /// signals (e.g. dragging an inspector slider) into a single reshade, and
333    /// gates it on no realize being in flight — so we never stack re-realizes.
334    #[init(val = false)]
335    terrain_reshade_pending: bool,
336    /// The next `update_throttled_gated` should ignore `max_bakes_per_frame` and
337    /// re-realize every dirty chunk in ONE frame. Set when a param edit is
338    /// applied so the whole planet updates at once (no visible per-chunk stagger).
339    #[init(val = false)]
340    force_full_reshade: bool,
341    /// Last-seen modified time of a `GpuCustom` builder's `.glsl` (editor only),
342    /// so saving the shader file auto-recompiles. `0` = not yet baselined.
343    #[init(val = 0)]
344    shader_mtime: u64,
345    /// Frame counter that throttles the shader-file mtime poll (see
346    /// `poll_shader_reload`).
347    #[init(val = 0)]
348    shader_poll_ticks: u32,
349}
350
351#[godot_api]
352impl INode3D for Celestial {
353    /// Give a fresh planet its default `CesGPUNoiseExample` builder so it renders
354    /// terrain immediately (safe here — the node is in the tree, unlike an
355    /// instantiated property default). Fires once via the storage-only
356    /// `auto_add_builder` guard, so clearing the builder and saving keeps the
357    /// white planet.
358    fn ready(&mut self) {
359        if self.auto_add_builder && self.builder.is_none() {
360            let b = CesBuilder::new_gd();
361            // Attach the CesGPUNoiseExample script (built-in GPU noise). Loaded by PATH
362            // so it works even before the GDScript class globals are registered;
363            // falls back to a bare custom builder if the addon script is missing.
364            if let Ok(script) = godot::tools::try_load::<godot::classes::Script>(
365                "res://addons/celestialsim/builders/ces_gpu_noise.gd",
366            ) {
367                b.clone().upcast::<Object>().set_script(&script);
368            }
369            self.builder = Some(b);
370        }
371        self.auto_add_builder = false;
372    }
373
374    /// Hide the internal `auto_add_builder` guard from the inspector (kept for
375    /// storage only), so it isn't a user-facing setting.
376    fn on_validate_property(&self, property: &mut PropertyInfo) {
377        if property.property_name.to_string() == "auto_add_builder" {
378            property.usage = PropertyUsageFlags::STORAGE;
379        }
380    }
381
382    // NOTE: deliberately NO `on_notification` teardown hook. Freeing the planet
383    // already releases its GPU pool: `job` is a `Gd<CesChunkJob>` field, so
384    // dropping `Celestial` drops the job, which drops `ChunkGpuResources`, whose
385    // `Owned<K>` handles queue their RIDs for the render-thread free (CEL-91).
386    // Adding an `on_notification` here is actively harmful: gdext `bind_mut()`s
387    // the instance for EVERY notification before the handler filters it, so a
388    // notification delivered while `process()` holds the borrow (the water
389    // runtime adds a child mid-process) panics with a double-borrow abort.
390    fn process(&mut self, delta: f64) {
391        // CEL-73: layer-list / structural scatter edits rebuild the job before
392        // this frame's ensure_job; live edits only mark the stage dirty.
393        self.check_scatter_structure();
394        // Re-apply planet param edits (radius/chunk_res/tile_res/vram) so ALL
395        // resident chunks pick them up, not just newly-visible ones.
396        self.reapply_param_changes();
397        // Editor: auto-recompile when a custom `.glsl` is edited on disk. MUST be
398        // before `ensure_job` — it may `teardown_job`, and ensure_job rebuilds it
399        // this same frame (otherwise the rest of `process` unwraps a None cache).
400        self.poll_shader_reload();
401        self.ensure_job();
402        // Idempotently connect each mesh layer's `changed` signal so a live
403        // slider edit (or a layer assigned after the node was ready) reshades.
404        self.connect_builders();
405        // Detect @export knob/param edits (which emit no `changed` signal) and
406        // flag a coalesced reshade — no `emit_changed()` setter needed.
407        self.poll_builder_params();
408
409        // Apply a coalesced live builder edit — but only once the previous
410        // re-realize has been consumed by the render thread (stage empty). A
411        // slider drag that fires many `changed` signals therefore collapses to
412        // one reshade per completed frame, always at the latest value.
413        if self.terrain_reshade_pending {
414            let in_flight = self.job.as_ref().map(|j| j.bind().stage.is_some()).unwrap_or(false);
415            if !in_flight {
416                self.terrain_reshade_pending = false;
417                self.apply_terrain_reshade();
418            }
419        }
420
421        let Some(cam) = self.camera_local() else { return };
422
423        let res = self.chunk_res.clamp(2, 32) as u32;
424        let tile_res = self.tile_res.clamp(8, 1024) as u32;
425        let budget = self.effective_budget();
426        let frames = base_face_frames(self.radius);
427
428        let t0 = Instant::now();
429        // Horizon culling: drop patches fully behind the planet's horizon. The
430        // margin keeps tall terrain that can peek over (max displacement ≈
431        // radius × height_scale × 1.2; the export defaults cover the HQ terrain).
432        let cull = if self.horizon_cull {
433            Some(self.radius * self.cull_height_margin.max(0.0))
434        } else {
435            None
436        };
437        let se = self.screen_error;
438        let geomorph = self.geomorph;
439        // LOD distances are measured to the DISPLACED surface when CPU-surface
440        // height data is present: with the bare-sphere distance a camera standing on
441        // elevated terrain (e.g. a ~120 m peak × exaggeration) could never
442        // bring `dist` under the local terrain height, capping the reachable
443        // depth — the "detail stops sharpening near the ground" bug. The block
444        // scopes the tile-cache borrow so the fetcher poll below can borrow mut.
445        let (cut, morphs_cut) = {
446            let surf_impl;
447            // LOD is measured to the DISPLACED surface once the provider is
448            // ready, so a camera standing on elevated terrain can still descend
449            // to full depth (the "detail stops sharpening near the ground" bug).
450            let radius = self.radius;
451            let surface: Option<&SurfaceFn<'_>> = match self.provider.as_ref() {
452                Some(p) if p.base_ready() => {
453                    let provider = Arc::clone(p);
454                    let scale = provider.height_scale();
455                    surf_impl =
456                        move |pt: Vector3| provider.sample_height(pt).unwrap_or(0.0) * scale * radius;
457                    Some(&surf_impl)
458                }
459                _ => None,
460            };
461
462            let cut = select_chunks_displaced(
463                &frames,
464                cam,
465                self.screen_error,
466                res,
467                self.max_depth.clamp(0, 20) as u8,
468                cull,
469                surface,
470            );
471
472            // Per-chunk geomorph factor (Phase 5), parallel to `cut`. Computed
473            // from the SAME geometry `descend` uses (displaced centroid distance
474            // + analytic tri-edge) so morph reaches 0 exactly as the parent takes
475            // over — a seamless handover. Geomorph off => full detail (morph 1).
476            let morphs: Vec<f32> = if geomorph {
477                cut.iter()
478                    .map(|c| {
479                        let frame = &frames[c.id.face as usize];
480                        // Gnomonic centroid — the corners are gnomonic, and the
481                        // realize projection now matches them.
482                        let pc = ((c.corners[0] + c.corners[1] + c.corners[2]) / 3.0)
483                            .normalized()
484                            * frame.radius;
485                        let pc = match surface {
486                            Some(f) => pc * (1.0 + f(pc) / pc.length().max(1.0e-6)),
487                            None => pc,
488                        };
489                        let dist = (pc - cam).length();
490                        morph_factor(frame.edge_len(), c.id.depth, res, dist, se)
491                    })
492                    .collect()
493            } else {
494                vec![1.0; cut.len()]
495            };
496            (cut, morphs)
497        };
498        self.last_select_ms = t0.elapsed().as_secs_f64() * 1000.0;
499
500        // Ask the provider for any chunks whose awaited data has arrived
501        // (streaming) and re-bake them; when the fresh surface lands (below) the
502        // resident chunk is marked dirty and re-realized with it. The provider
503        // owns all source-specific streaming — the planet just drives the trait.
504        let refresh = self.provider.as_ref().map(|p| p.poll_refresh()).unwrap_or_default();
505        let base_ready = self.provider.as_ref().map(|p| p.base_ready()).unwrap_or(false);
506        if let Some(pool) = self.bake_pool.as_mut() {
507            for chunk in &refresh {
508                pool.request(&frames[chunk.id.face as usize], chunk);
509            }
510            // One-time handover: when the provider first becomes base-ready,
511            // re-bake the whole current cut so the initial procedural view turns
512            // into the baked surface.
513            if base_ready && !self.was_base_ready {
514                for chunk in &cut {
515                    pool.request(&frames[chunk.id.face as usize], chunk);
516                }
517            }
518        }
519        self.was_base_ready = base_ready;
520
521        // Drain finished bakes: stash the surface for admission/staging and mark
522        // already-resident chunks dirty so they re-realize with the new data.
523        let baked = self.bake_pool.as_mut().map(|p| p.poll()).unwrap_or_default();
524        for r in baked {
525            // `None` = cancelled (chunk flew out of view while queued); the
526            // poll already released its in-flight entry — nothing to stage.
527            let Some(surface) = r.surface else { continue };
528            let id = r.chunk.id;
529            self.ready_surfaces.insert(id, (self.param_epoch, surface));
530            // A FRESH surface has landed (the pool drops results baked from stale
531            // params), so this chunk is no longer stale.
532            self.stale_surfaces.remove(&id);
533            if let Some(c) = self.cache.as_mut() {
534                if c.slot_of(id).is_some() {
535                    c.mark_dirty(id);
536                }
537            }
538        }
539
540        // Enqueue bakes for cut chunks that NEED a surface: either not resident
541        // yet (awaiting admission), or resident but DIRTY — a live param edit
542        // calls `invalidate_all`, which keeps chunks RESIDENT and only marks them
543        // dirty. Gating on `!resident` alone therefore never re-baked those
544        // chunks: they re-realized against their stale surface and kept
545        // old-param terrain, so only the chunks the camera happened to evict and
546        // re-admit ever picked up the new values (the "some old chunks survive an
547        // edit" bug). Skipped if a surface is already ready or in flight.
548        // Bounded per frame + backpressure so a fast flight can't flood the queue.
549        //
550        // The production bounds are DERIVED from the consumption throttle
551        // (`max_bakes_per_frame`, the rate at which admission actually drains
552        // ready surfaces). Hard-coded 24-per-frame / 64-in-flight let the pool
553        // produce ~3× what the planet consumed, so `ready_surfaces` saturated at
554        // the cut bound (GBs). Producing at most one frame's worth of admissions,
555        // with ~2 frames queued, keeps the ready set small by construction.
556        let max_requests: usize = self.max_bakes_per_frame.clamp(1, 64) as usize;
557        let max_in_flight: usize = max_requests * 2;
558        if let Some(pool) = self.bake_pool.as_mut() {
559            let cache = self.cache.as_ref();
560            let mut requested = 0;
561            for chunk in &cut {
562                if pool.in_flight_len() >= max_in_flight || requested >= max_requests {
563                    break;
564                }
565                let id = chunk.id;
566                // Needs a surface if it is not resident yet, OR its surface was
567                // baked from params that have since been edited. `stale_surfaces`
568                // (unlike the cache's `dirty`) is only cleared when the FRESH bake
569                // actually lands, so a chunk pushed past this frame's budget stays
570                // queued for a re-bake instead of being silently starved.
571                let resident = cache.map(|c| c.slot_of(id).is_some()).unwrap_or(false);
572                let needs_surface = !resident || self.stale_surfaces.contains(&id);
573                if needs_surface && !self.ready_surfaces.contains(&id) && !pool.in_flight(id) {
574                    pool.request(&frames[chunk.id.face as usize], chunk);
575                    requested += 1;
576                }
577            }
578        }
579
580        // CEL-86 async GDScript bake: take back whatever the builder finished
581        // (from its threads / downloads), then hand it the chunks still missing.
582        // Both are no-ops unless a `CpuCustomAsync` builder is active.
583        self.drain_gd_submissions(&cut, tile_res);
584        self.request_gd_bakes(&cut, tile_res);
585
586        let tu = Instant::now();
587        let diff = if self.recompute_every_frame {
588            // TEST: no cache. Pack the visible cut into slots 0..N and mark ALL of
589            // them for realize+bake every frame — only the visible chunks, never
590            // persisted. Skip `cache.update` entirely so its state can't interfere.
591            let n = (cut.len() as u32).min(budget) as usize;
592            let realize: Vec<(u32, Chunk)> =
593                cut.iter().take(n).cloned().enumerate().map(|(i, c)| (i as u32, c)).collect();
594            let visible_slots: Vec<u32> = (0..n as u32).collect();
595            let visible_cut_idx: Vec<Option<usize>> = (0..n).map(Some).collect();
596            CacheDiff { realize, evicted: Vec::new(), visible_slots, visible_cut_idx }
597        } else {
598            // Throttle new-chunk admission so a big influx spreads its realize
599            // over frames, and GATE admission on the worker-baked patch being
600            // ready (CPU surface on) — unadmitted chunks stay covered by ancestor
601            // stand-ins, so waiting for the bake is invisible.
602            // A param reshade re-realizes EVERY dirty chunk in one frame
603            // (ignoring the per-frame bake throttle) so the planet updates at
604            // once; normal camera streaming keeps the throttle.
605            let max_new = if self.force_full_reshade {
606                self.force_full_reshade = false;
607                budget
608            } else {
609                self.max_bakes_per_frame.clamp(1, i32::MAX as i64) as u32
610            };
611            // Admission waits on a ready surface for BOTH off-main-thread paths
612            // (the Rust bake pool and the async GDScript builder); until then a
613            // coarse ancestor stand-in covers the chunk.
614            let surface_on = self.bake_pool.is_some() || self.gd_async_baker.is_some();
615            let ready = &self.ready_surfaces;
616            // Admission requires a surface requested AFTER the last param edit;
617            // an older stamp is as good as no surface (the chunk keeps its
618            // ancestor stand-in until the fresh bake lands).
619            let epoch = self.param_epoch;
620            self.cache.as_mut().unwrap().update_throttled_gated(&cut, max_new, &|id| {
621                !surface_on || ready.get(&id).is_some_and(|(e, _)| *e == epoch)
622            })
623        };
624        self.last_update_ms = tu.elapsed().as_secs_f64() * 1000.0;
625        self.last_realize_count = diff.realize.len() as i64;
626        self.last_visible_count = diff.visible_slots.len() as i64;
627        self.last_cut_depths.clear();
628        self.last_cut_depths.extend(cut.iter().map(|c| c.id.depth));
629        self.last_cut_centroids.clear();
630        self.last_cut_centroids
631            .extend(cut.iter().map(|c| (c.corners[0] + c.corners[1] + c.corners[2]) / 3.0));
632
633        // Morph factors aligned 1:1 with `diff.visible_slots` (the DRAWN set).
634        // `visible_cut_idx[k] == Some(i)` maps drawn instance k to `cut[i]` (its
635        // morph); `None` is a coarse ancestor stand-in covering not-yet-admitted
636        // chunks — drawn at full detail (morph 1.0).
637        let drawn_morphs: Vec<f32> = if !geomorph {
638            vec![1.0; diff.visible_slots.len()]
639        } else {
640            diff.visible_cut_idx
641                .iter()
642                .map(|idx| idx.map(|i| morphs_cut[i]).unwrap_or(1.0))
643                .collect()
644        };
645        debug_assert_eq!(drawn_morphs.len(), diff.visible_slots.len(), "morphs must align with drawn slots");
646
647        let has_scatter = !self.scatter_mms.is_empty();
648
649        // Stage when something changed: new/re-realized chunks, the visible set
650        // (instances) moved, OR — with geomorph on — the camera moved with an
651        // unchanged cut (the morph factors changed, so the small instance buffer
652        // must be re-uploaded; realize/bake are skipped because `realize_count`
653        // stays = new chunks only, preserving the cache). A stationary camera =>
654        // identical cut + no move => no stage, no schedule.
655        let cam_moved = self.last_cam.map_or(true, |p| (p - cam).length() > 1.0e-3);
656        let slots_changed = diff.visible_slots != self.last_slots;
657        let geomorph_restage = geomorph && cam_moved && !diff.visible_slots.is_empty();
658        // CEL-73: a live density/height edit stages with realize_count = 0 —
659        // only the scatter params change, so only scatter-compact does real work.
660        let scatter_restage = has_scatter && self.scatter_dirty;
661        if !diff.realize.is_empty() || slots_changed || geomorph_restage || scatter_restage {
662            // Defensive budget clamp (the cache already bounds slots < budget).
663            let realize: Vec<_> =
664                diff.realize.iter().take(budget as usize).cloned().collect();
665            let mut slots = diff.visible_slots.clone();
666            slots.truncate(budget as usize);
667            // `drawn_morphs` is already aligned 1:1 with `diff.visible_slots`.
668            let mut morphs = drawn_morphs.clone();
669            morphs.truncate(slots.len());
670
671            let desc_bytes = pack_chunks(&frames, &realize, res);
672            let instance_bytes = pack_instances(&slots, &morphs);
673
674            // Pull each realized chunk's CPU-baked surface (colour/height/normal)
675            // from the ready set into the stage. Admission was gated on the
676            // surface being ready, so the hit is guaranteed; a defensive miss just
677            // skips the upload (the slot keeps its previous surface). Missing-data
678            // streaming is now handled inside `provider.bake`.
679            let (surface_enabled, surface_height_scale) = if let Some(baker) = self.gd_baker.as_ref() {
680                // GDScript baker: surface always on; optional `height_scale`
681                // property (else 1.0 = the returned fraction displaces directly).
682                let hs = baker.get("height_scale").try_to::<f32>().unwrap_or(1.0);
683                (1.0f32, hs)
684            } else if let Some(baker) = self.gd_async_baker.as_ref() {
685                // Async GDScript baker: same `height_scale`, but the surface is
686                // hidden (procedural fallback shows) until the builder's optional
687                // `_base_ready` says its base data has landed.
688                let hs = baker.get("height_scale").try_to::<f32>().unwrap_or(1.0);
689                (if Self::gd_base_ready(baker) { 1.0 } else { 0.0 }, hs)
690            } else {
691                match self.provider.as_ref() {
692                    Some(p) => (if p.base_ready() { 1.0 } else { 0.0 }, p.height_scale()),
693                    None => (0.0f32, 0.0f32),
694                }
695            };
696            let mut surface_patches = Vec::new();
697            let tp = Instant::now();
698            if let Some(mut baker) = self.gd_baker.clone() {
699                // Bake each newly-realized chunk synchronously (realize is already
700                // throttled per frame by admission), main thread.
701                for (slot, chunk) in &realize {
702                    let surface = Self::gd_bake_chunk(&mut baker, chunk, tile_res);
703                    surface_patches.push((*slot, surface.color, surface.height, surface.normal));
704                }
705            } else if self.provider.is_some() || self.gd_async_baker.is_some() {
706                for (slot, chunk) in &realize {
707                    let Some((epoch, surface)) = self.ready_surfaces.remove(&chunk.id) else {
708                        continue;
709                    };
710                    // Requested before the last param edit → reject at render
711                    // time. The slot keeps its previous surface (or the ancestor
712                    // stand-in) and the chunk is re-requested via the
713                    // not-ready/stale paths with the current params.
714                    if epoch != self.param_epoch {
715                        continue;
716                    }
717                    surface_patches.push((*slot, surface.color, surface.height, surface.normal));
718                }
719            }
720            self.last_patch_ms = tp.elapsed().as_secs_f64() * 1000.0;
721
722            let ts = Instant::now();
723            // CEL-73 scatter staging: aux paths for the realize batch, the
724            // visible-slot gather list, and a fresh per-layer params snapshot
725            // (density/height re-read every stage → live sliders).
726            let (scatter_aux_bytes, scatter_vis_bytes, scatter_vis_count, scatter_layer_params) =
727                if has_scatter {
728                    // Scatter placement displaces instances with the SAME noise +
729                    // params as ChunkRealize, so it must read the builder's terrain
730                    // params (not HeightGpu::default) — otherwise instances sit on a
731                    // different surface than the realized ground and float. Re-read
732                    // every stage so a builder edit (re-realize → re-place) tracks.
733                    let terrain = self.terrain_params();
734                    // ACTIVE layers only (mesh assigned) — parallel to the
735                    // GPU layer list built in `build_scatter_children`.
736                    let params: Vec<Vec<u8>> = self
737                        .scatter_layers
738                        .iter_shared()
739                        .filter(|l| l.bind().mesh.is_some())
740                        .map(|layer| {
741                            let l = layer.bind();
742                            let k = l.instances_per_cell.clamp(1, 64) as u32;
743                            // Disabled layer: density 0 fails every hash01
744                            // gate — zero instances, cached placement kept.
745                            let density =
746                                if l.enabled { l.density.clamp(0.0, 1.0) } else { 0.0 };
747                            pack_scatter_params(
748                                realize.len() as u32,
749                                celestial_algo::scatter::capacity(k),
750                                k,
751                                l.lod_level.clamp(0, 20) as u32,
752                                self.radius,
753                                density,
754                                l.max_instances.clamp(64, 4_000_000) as u32,
755                                l.seed as u32,
756                                slots.len() as u32,
757                                l.min_height.clamp(0.0, 1.0),
758                                l.max_height.clamp(0.0, 1.0),
759                                l.scale.max(0.0),
760                                // CPU-surface route: place must sample the SAME
761                                // baked heightmap realize displaced with, or the
762                                // instances sit on the procedural noise instead
763                                // (floating/buried, wrong height gates).
764                                surface_enabled,
765                                surface_height_scale,
766                                tile_res,
767                                &terrain,
768                            )
769                        })
770                        .collect();
771                    (pack_scatter_aux(&realize), pack_scatter_vis(&slots), slots.len() as u32, params)
772                } else {
773                    (Vec::new(), Vec::new(), 0, Vec::new())
774                };
775
776            if let Some(job) = &mut self.job {
777                let new_stage = ChunkStage {
778                    desc_bytes,
779                    realize_count: realize.len() as u32,
780                    instance_bytes,
781                    instance_count: slots.len() as u32,
782                    surface_enabled,
783                    surface_height_scale,
784                    surface_patches,
785                    scatter_aux_bytes,
786                    scatter_vis_bytes,
787                    scatter_vis_count,
788                    scatter_layer_params,
789                };
790                // MERGE into any still-pending batch — plain overwrite dropped
791                // the unconsumed realizes, leaving their (cache-clean, drawn)
792                // slots holding uninitialized pool memory forever.
793                let mut job = job.bind_mut();
794                match &mut job.stage {
795                    Some(pending) => {
796                        crate::chunk_pipeline::merge_stage(pending, new_stage, budget as usize)
797                    }
798                    slot @ None => *slot = Some(new_stage),
799                }
800            }
801            self.last_slots = diff.visible_slots.clone();
802            self.scatter_dirty = false;
803            self.last_stage_ms = ts.elapsed().as_secs_f64() * 1000.0;
804        }
805        self.last_cam = Some(cam);
806
807        // Camera-driven cancellation: tell both worker pools what is still in
808        // view so queued work for flown-past terrain is skipped, and drop the
809        // matching main-thread bookkeeping. Everything released here is simply
810        // re-requested if the player comes back.
811        if self.bake_pool.is_some()
812            || !self.ready_surfaces.is_empty()
813            || !self.gd_outstanding.is_empty()
814        {
815            let cut_ids: HashSet<ChunkId> = cut.iter().map(|c| c.id).collect();
816            // Baked surfaces whose chunk left the cut before admission.
817            self.ready_surfaces.retain(|id, _| cut_ids.contains(id));
818            // NOTE: deliberately NOT retained against `cut_ids`. A chunk can leave
819            // the cut while staying RESIDENT in the LRU cache; dropping its stale
820            // mark here would mean that when it re-enters view it is resident and
821            // "not stale", so it is never re-requested and keeps its old-param
822            // surface forever — which is exactly what made panning around during a
823            // slider drag leave old chunks behind. Staleness ends only when the
824            // fresh surface lands, or when the chunk is EVICTED (below) — an
825            // evicted chunk re-bakes anyway via the not-resident path.
826            if let Some(c) = self.cache.as_ref() {
827                self.stale_surfaces.retain(|id| c.slot_of(*id).is_some());
828            }
829            // Ditto for chunks the async GDScript builder still owes us: forget
830            // them, so a return trip re-requests them. A submission that lands
831            // anyway is dropped for the same reason (not in the cut).
832            self.gd_outstanding.retain(|id| cut_ids.contains(id));
833            if let Some(pool) = &self.bake_pool {
834                pool.set_wanted(cut_ids.clone());
835            }
836            // The provider prunes its own streaming bookkeeping to the cut.
837            if let Some(p) = self.provider.as_ref() {
838                p.set_wanted(&cut_ids);
839            }
840        }
841
842        // Perf forensics (CELESTIAL_PERF=1): name the slow section of any frame
843        // whose planet-side work exceeded ~15 ms.
844        if std::env::var_os("CELESTIAL_PERF").is_some() {
845            let total = t0.elapsed().as_secs_f64() * 1000.0;
846            if total > 15.0 {
847                eprintln!(
848                    "[perf] frame {total:.1} ms | select {:.1} | cache {:.1} | patches {:.1} ms ({} baked) | stage {:.1}",
849                    self.last_select_ms,
850                    self.last_update_ms,
851                    self.last_patch_ms,
852                    self.last_realize_count,
853                    self.last_stage_ms,
854                );
855            }
856        }
857
858        self.pump();
859        self.wire_textures();
860        self.update_water();
861
862        self.status_accum += delta;
863        if self.debug_log && self.status_accum >= 5.0 {
864            self.status_accum = 0.0;
865            let (resident, executes) = self
866                .job
867                .as_ref()
868                .map(|j| (self.cache.as_ref().unwrap().resident_count(), j.bind().executes))
869                .unwrap_or((0, 0));
870            godot_print!(
871                "[Celestial] chunks {} | resident {resident} | realize {} | executes {executes} | select {:.0} us",
872                cut.len(),
873                self.last_realize_count,
874                self.last_select_ms * 1000.0,
875            );
876        }
877    }
878}
879
880#[godot_api]
881impl Celestial {
882    /// A builder's `changed` signal lands here. A STRUCTURAL change — which
883    /// builder is first-enabled, its [`BuilderRoute`], or a `GpuCustom` shader
884    /// source — rebuilds the job (so the pipeline / compiled shader / provider is
885    /// re-wired). A pure param edit is applied live, by kind, then every resident
886    /// chunk is invalidated so it re-realizes with the new values.
887    #[func]
888    pub fn get_builder(&self) -> Option<Gd<CesBuilder>> {
889        self.builder.clone()
890    }
891
892    /// Swap the terrain builder (assign a different one, or clear it → white).
893    /// Rebuilds the job so the new type/params take effect immediately; the next
894    /// frame re-wires the new builder's `changed` signal (via `connect_builders`).
895    #[func]
896    pub fn set_builder(&mut self, v: Option<Gd<CesBuilder>>) {
897        self.builder = v;
898        // Rebuild from scratch (kind/provider/compiled shader may all differ).
899        self.teardown_job();
900        self.built_builder_kind = None;
901        self.built_custom_source = None;
902    }
903
904    #[func]
905    pub fn on_builder_changed(&mut self) {
906        let new_kind = self.active_builder().map(|b| crate::builder::route_of(&b));
907        let new_custom = self.build_custom_surface();
908        let new_src = new_custom.as_ref().map(|(s, ..)| s.clone());
909
910        // Structural change → rebuild (ensure_job re-wires next frame).
911        if new_kind != self.built_builder_kind || new_src != self.built_custom_source {
912            self.teardown_job();
913            return;
914        }
915
916        // Live param edit — don't do the heavy work here. Flag it and let
917        // `process` apply it once, when no realize is in flight (so a slider
918        // drag firing many `changed` signals collapses to a single reshade at
919        // the latest value instead of stacking a re-realize per signal).
920        self.note_param_update();
921    }
922
923    /// Record "the parameters just changed" — the LAST-UPDATE timestamp every
924    /// in-flight surface request is judged against. Called the INSTANT an edit
925    /// is detected, unlike `apply_terrain_reshade` (which is coalesced and can
926    /// lag by frames while a realize stage is in flight — a window in which
927    /// old-param bakes used to arrive, get accepted and rendered).
928    ///
929    /// From this moment on:
930    /// - results for bakes REQUESTED before now are rejected on arrival
931    ///   ([`BakePool::poll`] compares each result's request generation);
932    /// - surfaces ALREADY delivered are dropped here, and — belt and braces —
933    ///   anything that slips through is rejected again at render time by its
934    ///   `param_epoch` stamp.
935    ///
936    /// For the CPU-noise route the fresh provider is also installed
937    /// immediately, so every re-request from this frame on bakes with the NEW
938    /// params (deferring the swap to the coalesced reshade would stamp
939    /// old-param bakes as fresh).
940    fn note_param_update(&mut self) {
941        self.param_epoch += 1;
942        self.ready_surfaces.clear();
943        if self.bake_pool.is_some() {
944            if let Some(provider) = self.build_provider() {
945                if let Some(pool) = self.bake_pool.as_mut() {
946                    pool.set_provider(Arc::clone(&provider));
947                }
948                self.provider = Some(provider);
949            }
950        }
951        self.terrain_reshade_pending = true;
952    }
953
954    /// Apply a pending live builder edit: push the new params into the running
955    /// job by route, invalidate every resident chunk, and request a throttle-free
956    /// re-realize so the whole planet updates in ONE frame. Called from `process`
957    /// only when nothing is in flight (see `terrain_reshade_pending`).
958    fn apply_terrain_reshade(&mut self) {
959        match self.active_builder().map(|b| crate::builder::route_of(&b)) {
960            Some(BuilderRoute::GpuCustom) => {
961                if let Some((_, w, hs, vals)) = self.build_custom_surface() {
962                    if let Some(job) = self.job.as_mut() {
963                        job.bind_mut().gpu.set_custom_knobs(w, hs, vals);
964                    }
965                }
966            }
967            Some(BuilderRoute::CpuNoise) => {
968                // Rebuild the provider with the edited params and swap it into the
969                // running bake pool (no thread respawn); drop stale baked surfaces.
970                if let Some(provider) = self.build_provider() {
971                    // `set_provider` opens a new generation and releases in-flight
972                    // bakes, so bakes started under the OLD params can neither be
973                    // applied nor block their chunk from being re-baked.
974                    if let Some(pool) = self.bake_pool.as_mut() {
975                        pool.set_provider(Arc::clone(&provider));
976                    }
977                    self.provider = Some(provider);
978                }
979                self.ready_surfaces.clear();
980                self.was_base_ready = false;
981            }
982            Some(BuilderRoute::GpuNoise) => {
983                let terrain = self.terrain_params();
984                if let Some(job) = self.job.as_mut() {
985                    job.bind_mut().terrain = terrain;
986                }
987            }
988            Some(BuilderRoute::CpuCustomAsync) => {
989                // Every surface in flight was baked with the OLD params: drop the
990                // queued submissions and the ready set, and forget what we asked
991                // for, so the invalidate below re-requests the whole cut.
992                if let Some(q) = self.gd_submits.as_ref() {
993                    q.clear();
994                }
995                self.gd_outstanding.clear();
996                self.ready_surfaces.clear();
997            }
998            // CpuCustom (GDScript funcs re-run per re-bake) and None (white) need
999            // nothing beyond the invalidate below.
1000            _ => {}
1001        }
1002
1003        if let Some(cache) = self.cache.as_mut() {
1004            // Old-param terrain must never reappear: every OFF-SCREEN resident
1005            // holds a surface baked with the old params, and a revisit would
1006            // draw it for the frames its re-bake takes. Evict them all — a
1007            // revisited zone then streams in exactly like a first visit (fresh
1008            // ancestor stand-in refining), never showing stale data. On-screen
1009            // chunks stay resident and re-bake in place (no LOD flash).
1010            cache.evict_offscreen();
1011            let resident = cache.invalidate_all();
1012            // Every resident chunk's baked surface was made with the OLD params.
1013            // Remember them ALL: the cache's `dirty` flag is cleared by the very
1014            // next `update` (which re-realizes them against those stale surfaces),
1015            // so it cannot survive long enough to drive a budgeted re-bake.
1016            if self.bake_pool.is_some() {
1017                self.stale_surfaces = resident.into_iter().map(|(_, id)| id).collect();
1018            }
1019        }
1020        self.force_full_reshade = true;
1021        self.last_slots.clear();
1022    }
1023
1024    /// Detect live edits to the active builder's `@export` FLOAT knobs/params by
1025    /// fingerprinting their values each frame. Godot does NOT emit a resource's
1026    /// `changed` signal for a plain `@export` edit, so — instead of requiring a
1027    /// `set(v): x = v; emit_changed()` on every param — we poll and flag a
1028    /// reshade when the fingerprint changes. Coalesced like `on_builder_changed`
1029    /// (via `terrain_reshade_pending`), so a slider drag is one reshade/frame.
1030    /// Base fields (`device`/`shader_file`/`builtin_shader`) are
1031    /// native, not script vars, so the `SCRIPT_VARIABLE` filter skips them — and
1032    /// they carry their own `changed` (structural rebuild) via native setters.
1033    fn poll_builder_params(&mut self) {
1034        let Some(b) = self.active_builder() else {
1035            self.last_param_values = None;
1036            return;
1037        };
1038        let obj = b.upcast::<Object>();
1039        let script_var = PropertyUsageFlags::SCRIPT_VARIABLE.ord() as i64;
1040        let float_ty = VariantType::FLOAT.ord as i64;
1041        // NOTE: bind the property list to a variable — iterating the temporary
1042        // `obj.get_property_list().iter_shared()` yields NOTHING (the Array is
1043        // dropped before iteration), which silently returned an empty fingerprint.
1044        let plist = obj.get_property_list();
1045        let mut vals: Vec<f32> = Vec::new();
1046        for entry in plist.iter_shared() {
1047            let usage = entry.get("usage").and_then(|v| v.try_to::<i64>().ok()).unwrap_or(0);
1048            if usage & script_var == 0 {
1049                continue;
1050            }
1051            let vtype = entry.get("type").and_then(|v| v.try_to::<i64>().ok()).unwrap_or(0);
1052            if vtype != float_ty {
1053                continue;
1054            }
1055            // The property-list "name" is a String variant, NOT StringName —
1056            // read it as GString (try_to::<StringName> fails / to() panics on it).
1057            if let Some(name) = entry.get("name").and_then(|v| v.try_to::<GString>().ok()) {
1058                let name = name.to_string();
1059                vals.push(obj.get(&name).try_to::<f32>().unwrap_or(0.0));
1060            }
1061        }
1062        match &self.last_param_values {
1063            // First observation (or just after a rebuild): baseline, don't reshade.
1064            None => self.last_param_values = Some(vals),
1065            Some(prev) if *prev != vals => {
1066                self.last_param_values = Some(vals);
1067                self.note_param_update();
1068            }
1069            _ => {}
1070        }
1071    }
1072
1073    /// EDITOR ONLY: watch a `GpuCustom` builder's `.glsl` for external edits and
1074    /// rebuild (recompile) when its modified time changes — so saving the shader
1075    /// updates the viewport without re-assigning the builder. Polled every ~15
1076    /// frames; `teardown_job` resets the baseline so a rebuild re-baselines.
1077    fn poll_shader_reload(&mut self) {
1078        if !godot::classes::Engine::singleton().is_editor_hint() {
1079            return;
1080        }
1081        self.shader_poll_ticks = self.shader_poll_ticks.wrapping_add(1);
1082        if self.shader_poll_ticks % 15 != 0 {
1083            return;
1084        }
1085        let Some(b) = self.active_builder() else { return };
1086        if crate::builder::route_of(&b) != BuilderRoute::GpuCustom {
1087            self.shader_mtime = 0;
1088            return;
1089        }
1090        let path = b.bind().shader_file.clone();
1091        if path.is_empty() {
1092            self.shader_mtime = 0;
1093            return;
1094        }
1095        let mtime = godot::classes::FileAccess::get_modified_time(&path);
1096        if self.shader_mtime == 0 {
1097            self.shader_mtime = mtime; // first sighting → baseline, no rebuild
1098        } else if mtime != self.shader_mtime {
1099            // Source changed on disk → full rebuild recompiles the shader; force
1100            // an un-throttled re-realize so the edit shows at once. `teardown_job`
1101            // clears `shader_mtime`, so the next poll re-baselines to `mtime`.
1102            self.teardown_job();
1103            self.force_full_reshade = true;
1104        }
1105    }
1106
1107    /// Chunks currently streaming toward full detail: queued/running background
1108    /// bakes plus baked patches waiting for cache admission. `0` ⇒ every chunk
1109    /// in view is resident at its target LOD (nothing left to arrive).
1110    #[func]
1111    pub fn chunks_in_queue(&self) -> i64 {
1112        let baking = self.bake_pool.as_ref().map(|p| p.in_flight_len()).unwrap_or(0);
1113        (baking + self.ready_surfaces.len()) as i64
1114    }
1115
1116    /// Absolute path of the provider's on-disk cache (empty when none).
1117    #[func]
1118    pub fn tile_cache_path(&self) -> GString {
1119        match self.provider.as_ref().and_then(|p| p.cache_dir()) {
1120            Some(d) => GString::from(d.as_str()),
1121            None => GString::new(),
1122        }
1123    }
1124
1125    /// Provider resource fetches currently in flight (network + decode).
1126    #[func]
1127    pub fn tiles_in_flight(&self) -> i64 {
1128        self.provider.as_ref().map(|p| p.resources_in_flight() as i64).unwrap_or(0)
1129    }
1130
1131    /// Division-algorithm debug: per-depth histogram of the last selected cut,
1132    /// plus how many of those chunks are actually DRAWN (resident) — any gap is
1133    /// a hole on screen (unadmitted/throttled chunks). Example output:
1134    /// `cut 271 drawn 268 | d7:12 d8:24 ... d16:40`.
1135    #[func]
1136    pub fn cut_report(&self) -> GString {
1137        let mut hist = [0u32; 24];
1138        for &d in &self.last_cut_depths {
1139            hist[(d as usize).min(23)] += 1;
1140        }
1141        let parts: Vec<String> = hist
1142            .iter()
1143            .enumerate()
1144            .filter(|(_, &c)| c > 0)
1145            .map(|(d, &c)| format!("d{d}:{c}"))
1146            .collect();
1147        GString::from(
1148            format!(
1149                "cut {} drawn {} | {}",
1150                self.last_cut_depths.len(),
1151                self.last_visible_count,
1152                parts.join(" ")
1153            )
1154            .as_str(),
1155        )
1156    }
1157
1158    /// **The one query gameplay code needs**: how to put something *on* the surface.
1159    ///
1160    /// Radius (world units, from the planet centre) of the RENDERED ground in
1161    /// direction `dir` (a world-space direction from the centre), so
1162    /// `dir.normalized() * ground_radius_at(dir)` is the surface point: the bare
1163    /// sphere plus the CPU-surface displacement,
1164    /// sampled by the provider's `sample_height`. Use this to place
1165    /// cameras/objects on the surface instead of guessing an altitude — the
1166    /// ground can legitimately sit kilometres above the sphere. Returns the bare
1167    /// radius when no CPU-surface provider is active.
1168    #[func]
1169    pub fn ground_radius_at(&self, dir: Vector3) -> f32 {
1170        let h = match self.provider.as_ref() {
1171            // No clamp: `ChunkRealize` displaces by the raw sampled height (the
1172            // seabed dips BELOW the sphere), so clamping here would report a
1173            // ground radius the renderer never draws.
1174            Some(p) => p.sample_height(dir).unwrap_or(0.0) * p.height_scale(),
1175            None => 0.0,
1176        };
1177        self.radius * (1.0 + h)
1178    }
1179
1180    /// TEMP DEBUG: schedule a render-thread dump of every drawn slot's vertex
1181    /// radius range (see `CesChunkJob::debug_dump_radii`).
1182    #[func]
1183    pub fn debug_dump_radii(&self) {
1184        let Some(job) = &self.job else { return };
1185        // EXPECTED: the DEEPEST chunks (the near field — where the breakage
1186        // is). Print their cut index / slot / location, and have the GPU dump
1187        // report the same slots' actual geometry.
1188        let mut deep_slots: Vec<u32> = Vec::new();
1189        for (i, (&d, c)) in self
1190            .last_cut_depths
1191            .iter()
1192            .zip(self.last_cut_centroids.iter())
1193            .enumerate()
1194        {
1195            if d < 13 {
1196                continue;
1197            }
1198            let cn = c.normalized();
1199            let lat = (cn.y.clamp(-1.0, 1.0)).asin().to_degrees();
1200            let lon = cn.z.atan2(cn.x).to_degrees();
1201            let slot = self.last_slots.get(i).copied().unwrap_or(u32::MAX);
1202            godot_print!(
1203                "[radii] CPU cut[{i}] d{d} slot {slot}: lat {lat:.2} lon {lon:.2}"
1204            );
1205            if slot != u32::MAX {
1206                deep_slots.push(slot);
1207            }
1208        }
1209        let slots = PackedInt32Array::from_iter(deep_slots.iter().map(|&s| s as i32));
1210        let cb = Callable::from_object_method(job, "debug_dump_radii").bind(&[slots.to_variant()]);
1211        RenderingServer::singleton().call_on_render_thread(&cb);
1212    }
1213
1214    /// Any scatter layer's `changed` signal lands here (CEL-73); the edit is
1215    /// classified next frame in `check_scatter_structure` / the stage packer.
1216    #[func]
1217    pub fn on_scatter_layer_changed(&mut self) {
1218        self.scatter_dirty = true;
1219    }
1220
1221    /// Chunks currently holding a GPU pool slot (cached geometry + detail atlas).
1222    /// Compare with `effective_budget` to see how full the VRAM budget is.
1223    #[func]
1224    pub fn resident_count(&self) -> i64 {
1225        self.cache.as_ref().map(|c| c.resident_count() as i64).unwrap_or(0)
1226    }
1227
1228    /// Chunks realized in the last staged batch (0 once the camera settles).
1229    #[func]
1230    pub fn realize_count(&self) -> i64 {
1231        self.last_realize_count
1232    }
1233
1234    /// Graph executes that actually consumed a stage (flat when stationary).
1235    #[func]
1236    pub fn executes(&self) -> i64 {
1237        self.job.as_ref().map(|j| j.bind().executes as i64).unwrap_or(0)
1238    }
1239
1240    /// Last CPU selection time in milliseconds.
1241    #[func]
1242    pub fn select_ms(&self) -> f64 {
1243        self.last_select_ms
1244    }
1245
1246    /// Last `ChunkCache::update` time in ms (eviction/bookkeeping cost).
1247    #[func]
1248    pub fn update_ms(&self) -> f64 {
1249        self.last_update_ms
1250    }
1251
1252    /// Per-stage GPU time of the last `CesChunkJob` execute, e.g.
1253    /// `"upload 0.01 + realize 0.42 ms"`. Each graph node (compute stage) is a
1254    /// term — a future pass (e.g. a separate normals/noise shader) appears here
1255    /// automatically. `"idle"` when no realize ran since the camera last moved;
1256    /// this is the realize COMPUTE cost, distinct from the every-frame render
1257    /// (rasterization) cost shown as `render gpu`.
1258    #[func]
1259    pub fn gpu_report(&self) -> GString {
1260        let Some(job) = &self.job else { return GString::from("n/a") };
1261        let gpu_ms = &job.bind().gpu_ms;
1262        if gpu_ms.is_empty() {
1263            return GString::from("idle");
1264        }
1265        let parts: Vec<String> = gpu_ms
1266            .iter()
1267            .map(|(name, ms)| {
1268                let short = name.strip_prefix("celestial/chunk-").unwrap_or(name);
1269                format!("{short} {ms:.2}")
1270            })
1271            .collect();
1272        GString::from(format!("{} ms", parts.join(" + ")).as_str())
1273    }
1274
1275    /// Triangles currently drawn = visible chunks × `chunk_res²`.
1276    #[func]
1277    pub fn triangle_count(&self) -> i64 {
1278        let res = self.chunk_res.clamp(2, 32);
1279        self.last_visible_count * res * res
1280    }
1281
1282    /// VRAM (bytes) the GPU pools reserve for `budget` slots. Per-vertex the
1283    /// pool costs pos_tex(rgba32f=16) + verts_tex(2×rgba16f=16) + verts_buf
1284    /// (float4=16) = 48 B × `verts_per_chunk(res)`. Phase 4 adds the detail
1285    /// atlases: colour(rgba8=4) + normal(rgba8=4) = 8 B × `tile_res²` per slot.
1286    #[func]
1287    pub fn pool_vram_bytes(&self) -> i64 {
1288        self.effective_budget() as i64 * self.per_slot_bytes()
1289    }
1290
1291    /// Resident chunk SLOT count derived from the VRAM budget. Each slot costs
1292    /// `verts_per_chunk(chunk_res)×48 B` (geometry) + `tile_res²×16 B` (detail atlases +
1293    /// normal atlas); `slots = vram_budget / per_slot`. Also clamped so the atlas
1294    /// texture height (`slots×tile_res²/ATTR_TEX_WIDTH`) stays within the GPU's max
1295    /// texture dimension (the atlas is one strip of width `ATTR_TEX_WIDTH`).
1296    #[func]
1297    pub fn effective_budget(&self) -> u32 {
1298        let tile_res = self.tile_res.clamp(8, 1024) as i64;
1299        let vram = (self.vram_budget_gib.max(0.01) as f64 * 1024.0 * 1024.0 * 1024.0) as i64;
1300        let from_vram = (vram / self.per_slot_bytes()).max(1);
1301        // Atlas height = slots × tile_res² / width must fit the GPU texture limit.
1302        let height_cap =
1303            (MAX_ATLAS_TEX_HEIGHT * ATTR_TEX_WIDTH as i64 / (tile_res * tile_res)).max(1);
1304        from_vram.min(height_cap) as u32
1305    }
1306
1307    /// VRAM one resident chunk slot reserves: geometry pool + detail atlases +
1308    /// the CPU-surface colour/height buffers (see
1309    /// [`crate::chunk_descriptors::per_slot_bytes`]).
1310    fn per_slot_bytes(&self) -> i64 {
1311        let res = self.chunk_res.clamp(2, 32) as u32;
1312        let tile_res = self.tile_res.clamp(8, 1024) as u32;
1313        crate::chunk_descriptors::per_slot_bytes(res, tile_res)
1314    }
1315}
1316
1317/// Structural snapshot of one scatter layer (CEL-73), used to classify edits:
1318/// capacity-affecting fields rebuild the job, lod_level/seed/scale re-place
1319/// resident chunks (place-side transform), a mesh swap rebinds in place.
1320/// density/min_height/max_height are compact-side (no snapshot needed).
1321struct ScatterSnapshot {
1322    layer_id: i64,
1323    lod_level: i64,
1324    instances_per_cell: i64,
1325    max_instances: i64,
1326    seed: i64,
1327    scale: f32,
1328    mesh_rid: Rid,
1329}
1330
1331/// Max atlas-strip texture height (texels). The detail atlas is a single texture
1332/// of width `ATTR_TEX_WIDTH`; its height `slots×tile_res²/width` must stay within
1333/// this so allocation can't exceed the GPU's max texture dimension.
1334const MAX_ATLAS_TEX_HEIGHT: i64 = 16384;
1335
1336impl Celestial {
1337    /// The active builder (drives the terrain), or `None` (→ white planet) when
1338    /// unset.
1339    fn active_builder(&self) -> Option<Gd<CesBuilder>> {
1340        self.builder.clone()
1341    }
1342
1343    /// The terrain params for the inline example shader. ONLY the built-in
1344    /// Noise modes drive the base terrain with the noise knobs; the Custom modes
1345    /// (and no builder) return a DISABLED terrain (`enabled = 0`) so the base is
1346    /// a plain sphere. That way a Custom builder whose surface isn't active yet
1347    /// (no `shader_file`, a shader error, or a chunk not baked) shows a white
1348    /// sphere — never the example noise leaking through.
1349    fn terrain_params(&self) -> crate::descriptors::TerrainGpu {
1350        let noise_builder = self.active_builder().filter(|b| {
1351            matches!(
1352                crate::builder::route_of(b),
1353                BuilderRoute::GpuNoise | BuilderRoute::CpuNoise
1354            )
1355        });
1356        if let Some(b) = noise_builder {
1357            let l = b.bind();
1358            assemble(&l.to_height_gpu(), &l.to_texture_gpu())
1359        } else {
1360            let h = HeightGpu { enabled: 0.0, ..HeightGpu::default() };
1361            assemble(&h, &TextureGpu::default())
1362        }
1363    }
1364
1365    /// Idempotently connect the builder's `changed` signal to the reshade /
1366    /// rebuild handler (skips if already wired), so editor edits are live.
1367    fn connect_builders(&mut self) {
1368        let Some(builder) = self.builder.clone() else { return };
1369        let self_gd = self.to_gd();
1370        let callable = Callable::from_object_method(&self_gd, "on_builder_changed");
1371        let mut res = builder.upcast::<Resource>();
1372        if !res.is_connected("changed", &callable) {
1373            res.connect("changed", &callable);
1374        }
1375    }
1376
1377    /// Lazily build the MultiMesh + instance + chunk material + render-thread job.
1378    fn ensure_job(&mut self) {
1379        if self.job.is_some() {
1380            return;
1381        }
1382        let res = self.chunk_res.clamp(2, 32) as u32;
1383        let tile_res = self.tile_res.clamp(8, 1024) as u32;
1384        let budget = self.effective_budget();
1385        godot_print!(
1386            "[Celestial] vram budget {:.2} GiB | chunk_res {res} tile_res {tile_res} | {budget} resident chunk slots (~{} MB pools, {} KB/slot)",
1387            self.vram_budget_gib,
1388            self.pool_vram_bytes() / 1_048_576,
1389            self.per_slot_bytes() / 1024,
1390        );
1391
1392        // Material + reference chunk mesh + indirect MultiMesh (CEL-58 order:
1393        // allocate indirect BEFORE set_mesh so the command buffer is created).
1394        let mut material = make_material(res, tile_res, self.lod_colors);
1395        let template = reference_chunk_mesh(res, &material.clone().upcast());
1396
1397        let mut rs = RenderingServer::singleton();
1398        let multimesh = MultiMesh::new_gd();
1399        let mm_rid = multimesh.get_rid();
1400        let mut mmi = MultiMeshInstance3D::new_alloc();
1401        mmi.set_multimesh(&multimesh);
1402        self.base_mut().add_child(&mmi);
1403
1404        rs.multimesh_allocate_data_ex(mm_rid, budget as i32, MultimeshTransformFormat::TRANSFORM_3D)
1405            .custom_data_format(true)
1406            .use_indirect(true)
1407            .done();
1408        rs.multimesh_set_mesh(mm_rid, template.get_rid());
1409        // Transforms are GPU-only (identity) and VERTEX comes from pos_tex, so
1410        // give an explicit AABB covering the displaced sphere envelope.
1411        let m = self.radius * 1.3;
1412        rs.multimesh_set_custom_aabb(
1413            mm_rid,
1414            Aabb { position: Vector3::splat(-m), size: Vector3::splat(2.0 * m) },
1415        );
1416
1417        // CEL-73: one indirect MultiMesh child per scatter layer (same CEL-58
1418        // allocate-before-set_mesh order as the terrain multimesh above).
1419        let scatter_cfgs = self.build_scatter_children(&mut rs, m);
1420
1421        // Remember which builder kind this job is wired for (rebuild on a flip).
1422        self.built_builder_kind = self.active_builder().map(|b| crate::builder::route_of(&b));
1423
1424        // Terrain from the first enabled builder (disabled → white when none).
1425        let terrain = self.terrain_params();
1426        // Detail-normal bump removed (it scattered lit speckles across the
1427        // surface / lakes); pass 0.0 so the baked normal is the smooth FD normal.
1428        let mut job = CesChunkJob::create(
1429            mm_rid,
1430            budget,
1431            res,
1432            tile_res,
1433            self.radius,
1434            0.0,
1435            terrain,
1436            scatter_cfgs,
1437        );
1438
1439        // Custom GPU surface (user GLSL) takes precedence over the CPU provider:
1440        // install it into the job (compiled lazily on the render thread) BEFORE
1441        // the first run, and remember its source so a later shader-file edit can
1442        // be told apart from a live knob edit.
1443        let custom_surface = self.build_custom_surface();
1444        if let Some((src, w, hs, vals)) = &custom_surface {
1445            job.bind_mut().gpu.set_custom_surface(src.clone(), *w, *hs, vals.clone());
1446        }
1447        self.built_custom_source = custom_surface.as_ref().map(|(s, ..)| s.clone());
1448
1449        // Secondary GDScript CPU bakers — only when no GPU custom surface owns
1450        // the surface buffers. `gd_baker` bakes synchronously at staging;
1451        // `gd_async_baker` is handed chunks and submits them back whenever it
1452        // likes. `route_of` picks between them, so they are never both set.
1453        if custom_surface.is_none() {
1454            self.gd_baker = self.find_gd_baker();
1455            self.gd_async_baker = self.find_gd_async_baker();
1456        } else {
1457            self.gd_baker = None;
1458            self.gd_async_baker = None;
1459        }
1460        self.gd_submits = self.gd_async_baker.as_ref().map(|b| b.bind().submits());
1461        self.gd_outstanding.clear();
1462
1463        // A script defining BOTH contracts gets the async one; say so rather than
1464        // silently ignoring the `height` the author clearly wrote.
1465        if let Some(b) = self.gd_async_baker.as_ref() {
1466            if b.clone().upcast::<Object>().has_method("height") {
1467                godot_warn!(
1468                    "[Celestial] builder defines both `_bake_requested` and \
1469                     `height`; using the async `_bake_requested` path and ignoring `height`."
1470                );
1471            }
1472        }
1473
1474        self.run_cb = Some(Callable::from_object_method(&job, "run"));
1475
1476        // Avoid an unused-variable warning while keeping the material alive.
1477        material.set_shader_parameter("attr_w", &(ATTR_TEX_WIDTH as i32).to_variant());
1478
1479        self.cache = Some(celestial_algo::chunk_cache::ChunkCache::new(budget));
1480        // CEL-91: the CPU surface cache gets the SAME budget as the GPU slot pool,
1481        // so `vram_budget_gib` bounds BOTH sides of the memory.
1482        self.ready_surfaces.set_capacity(budget as usize);
1483        self.job = Some(job);
1484        self.multimesh =
1485            Some(crate::gpu::owned::IndirectMultiMesh::new(multimesh, crate::gpu::owned::MainDeviceSink::new()));
1486        self.mmi = Some(mmi);
1487        self.material = Some(material);
1488        self._template = Some(template);
1489        self.last_slots.clear();
1490        self.last_cam = None;
1491
1492        // Select the CPU-surface provider from the layers (the first CpuNoise
1493        // mesh layer, else none). When present, spin up the shared provider +
1494        // off-thread bake pool; absent ⇒ GPU procedural path. A custom GPU
1495        // surface owns the surface buffers itself, so skip the CPU provider then.
1496        // Likewise skip when either GDScript CPU baker is active (they own the
1497        // surface buffers themselves).
1498        if custom_surface.is_none() && self.gd_baker.is_none() && self.gd_async_baker.is_none() {
1499        if let Some(provider) = self.build_provider() {
1500            // A surface bake costs several ms and CPU generation is the
1501            // bottleneck, so scale workers with the machine: half the logical
1502            // cores (leaving the other half for the render/main threads and the
1503            // tile fetchers), floor of 2. Off-thread baking keeps fast flight
1504            // smooth (admission waits on readiness).
1505            let workers = std::thread::available_parallelism()
1506                .map(|n| (n.get() / 2).max(2))
1507                .unwrap_or(3);
1508            // The result channel is bounded by the same in-flight ceiling the
1509            // request loop uses (2 frames' worth of admissions), so finished-but-
1510            // undrained surfaces can never pile up on the heap (CEL-91).
1511            let result_cap = (self.max_bakes_per_frame.clamp(1, 64) as usize) * 2;
1512            self.bake_pool =
1513                Some(BakePool::new(Arc::clone(&provider), tile_res, workers, result_cap));
1514            self.provider = Some(provider);
1515        }
1516        }
1517
1518        // Baseline for detecting later param edits (see `reapply_param_changes`).
1519        self.built_radius = self.radius;
1520        self.built_res = self.chunk_res;
1521        self.built_tile_res = self.tile_res;
1522        self.built_budget_gib = self.vram_budget_gib;
1523    }
1524
1525    /// A CPU-surface provider for the first enabled builder, or `None`. Only a
1526    /// [`BuilderRoute::CpuNoise`] builder yields one (the built-in
1527    /// [`NoiseProvider`], baked on the worker pool); every other kind returns
1528    /// `None` (their surface comes from a different path).
1529    fn build_provider(&self) -> Option<Arc<dyn CpuSurfaceProvider>> {
1530        let b = self.active_builder()?;
1531        if crate::builder::route_of(&b) == BuilderRoute::CpuNoise {
1532            let l = b.bind();
1533            Some(Arc::new(crate::noise_provider::NoiseProvider::new(l.to_noise_params(self.radius))))
1534        } else {
1535            None
1536        }
1537    }
1538
1539    /// The custom **GPU** surface to install when the first enabled builder is
1540    /// [`BuilderRoute::GpuCustom`]:
1541    /// `(assembled_glsl, water_height, height_scale, user_param_values)`, or
1542    /// `None`. Reads the builder's `res://` `.glsl`, enumerates its own
1543    /// `@export var name: float` script vars (surfaced to the shader as
1544    /// `#define NAME`), and splices both into the library template
1545    /// ([`crate::custom_surface::assemble_source_with_params`]); a missing file
1546    /// or a source that omits a required function is logged and treated as `None`
1547    /// (→ white) so a typo never crashes the planet.
1548    fn build_custom_surface(&self) -> Option<(String, f32, f32, Vec<f32>)> {
1549        let b = self.active_builder()?;
1550        if crate::builder::route_of(&b) != BuilderRoute::GpuCustom {
1551            return None;
1552        }
1553        let l = b.bind();
1554        if l.shader_file.is_empty() {
1555            return None;
1556        }
1557        let path = l.shader_file.clone();
1558        let water = l.water_height();
1559        let hs = l.height_scale();
1560        drop(l);
1561        let Some(file) = godot::classes::FileAccess::open(&path, ModeFlags::READ) else {
1562            godot_error!("[Celestial] custom surface: cannot open shader file {path}");
1563            return None;
1564        };
1565        let user_glsl = file.get_as_text().to_string();
1566
1567        // Enumerate the builder's OWN @export float script vars (its params),
1568        // in declaration order, excluding the base builder fields. Each becomes
1569        // a `#define UPPERCASE_NAME` in the assembled shader (value packed into
1570        // the params UBO's cels_user tail in the same order).
1571        let excluded = ["shader_file", "device", "builtin_shader"];
1572        let mut names: Vec<String> = Vec::new();
1573        let mut values: Vec<f32> = Vec::new();
1574        let obj = b.clone().upcast::<Object>();
1575        let script_var = PropertyUsageFlags::SCRIPT_VARIABLE.ord() as i64;
1576        // Bind the list to a variable — iterating the temporary yields nothing.
1577        let plist = obj.get_property_list();
1578        for entry in plist.iter_shared() {
1579            // Parse property-list dict values with `try_to` (NOT `to`, which
1580            // panics on an unexpected variant type — hit in practice).
1581            let usage = entry.get("usage").and_then(|v| v.try_to::<i64>().ok()).unwrap_or(0);
1582            if usage & script_var == 0 {
1583                continue;
1584            }
1585            let vtype = entry.get("type").and_then(|v| v.try_to::<i64>().ok()).unwrap_or(0);
1586            if vtype != VariantType::FLOAT.ord as i64 {
1587                continue;
1588            }
1589            let name = entry
1590                .get("name")
1591                .and_then(|v| v.try_to::<GString>().ok())
1592                .map(|g| g.to_string())
1593                .unwrap_or_default();
1594            if name.is_empty() || excluded.contains(&name.as_str()) {
1595                continue;
1596            }
1597            let val = obj.get(&name).try_to::<f32>().unwrap_or(0.0);
1598            names.push(name);
1599            values.push(val);
1600        }
1601
1602        match crate::custom_surface::assemble_source_with_params(&user_glsl, &names) {
1603            Ok(src) => Some((src, water, hs, values)),
1604            Err(e) => {
1605                godot_error!("[Celestial] custom surface ({path}): {e}");
1606                None
1607            }
1608        }
1609    }
1610
1611    // ---- CEL-86: the async GDScript bake ------------------------------------
1612
1613    /// This planet's handle tag: distinct planets sharing one builder `.tres`
1614    /// stamp different tags, so a submission can never be applied to the wrong
1615    /// planet. Derived from the (unique, stable) Godot instance id.
1616    fn planet_tag(&self) -> u16 {
1617        (self.to_gd().instance_id().to_i64() as u64 & TAG_MASK) as u16
1618    }
1619
1620    /// The active `CpuCustomAsync` builder, if the first enabled builder is one.
1621    fn find_gd_async_baker(&self) -> Option<Gd<CesBuilder>> {
1622        let b = self.active_builder()?;
1623        (crate::builder::route_of(&b) == BuilderRoute::CpuCustomAsync).then_some(b)
1624    }
1625
1626    /// Ask an async builder whether its base data has landed. A builder that
1627    /// doesn't define `_base_ready` is always ready (the common, non-streaming
1628    /// case); a streaming one returns `false` until its coarse base map arrives,
1629    /// and the planet shows the procedural fallback meanwhile.
1630    fn gd_base_ready(baker: &Gd<CesBuilder>) -> bool {
1631        let mut obj = baker.clone().upcast::<Object>();
1632        if !obj.has_method(crate::builder::BASE_READY) {
1633            return true;
1634        }
1635        obj.call(crate::builder::BASE_READY, &[]).try_to::<bool>().unwrap_or(true)
1636    }
1637
1638    /// Take back everything the async builder finished since last frame.
1639    ///
1640    /// A submission is applied only if its chunk is still in `cut` — which both
1641    /// implements cancellation (fly past a chunk and its late result is dropped)
1642    /// and guarantees we have the chunk's corners for the finite-difference
1643    /// normal. Applying one to an ALREADY-RESIDENT chunk marks it dirty so it
1644    /// re-realizes with the new surface: that is the streaming refinement path
1645    /// (coarse tile now, finer tile when the download lands).
1646    fn drain_gd_submissions(&mut self, cut: &[Chunk], tile_res: u32) {
1647        let Some(queue) = self.gd_submits.clone() else { return };
1648        let raws = queue.drain();
1649        if raws.is_empty() {
1650            return;
1651        }
1652        let tag = self.planet_tag();
1653        let by_id: HashMap<ChunkId, &Chunk> = cut.iter().map(|c| (c.id, c)).collect();
1654
1655        for raw in &raws {
1656            // Unknown / stale / wrong-planet handle: silently dropped, by design.
1657            let Some(id) = async_bake::handle_decode(tag, raw.handle) else { continue };
1658            let Some(chunk) = by_id.get(&id) else {
1659                self.gd_outstanding.remove(&id);
1660                continue;
1661            };
1662
1663            let sub = match async_bake::validate_submission(raw, tile_res) {
1664                Ok(s) => s,
1665                Err(e) => {
1666                    // A bad payload inside a bake loop would print thousands of
1667                    // identical lines a second; say it once, then stay quiet.
1668                    if queue.should_report_error() {
1669                        godot_error!(
1670                            "[Celestial] submit_chunk rejected: {e}. \
1671                             Further submit_chunk errors from this builder are suppressed."
1672                        );
1673                    }
1674                    self.gd_outstanding.remove(&id);
1675                    continue;
1676                }
1677            };
1678
1679            let normal = sub.normal.unwrap_or_else(|| {
1680                let dirs = async_bake::chunk_dirs(chunk.corners, tile_res);
1681                async_bake::fd_normals(chunk.corners, &dirs, &sub.height, tile_res)
1682            });
1683            let surface = ChunkSurface { color: sub.color, height: sub.height, normal };
1684            self.ready_surfaces.insert(id, (self.param_epoch, surface));
1685            self.gd_outstanding.remove(&id);
1686            // Already drawn? Re-realize it with the fresher surface.
1687            if let Some(c) = self.cache.as_mut() {
1688                if c.slot_of(id).is_some() {
1689                    c.mark_dirty(id);
1690                }
1691            }
1692        }
1693    }
1694
1695    /// Hand the async builder the cut chunks it hasn't produced yet — those not
1696    /// resident, with no ready surface and not already outstanding. Bounded by
1697    /// the same constants the Rust bake pool uses, so a fast flight can't flood
1698    /// the builder with chunks it will never draw. One call per frame; the
1699    /// builder must not block in it.
1700    fn request_gd_bakes(&mut self, cut: &[Chunk], tile_res: u32) {
1701        let Some(mut baker) = self.gd_async_baker.clone() else { return };
1702        let tag = self.planet_tag();
1703        let cache = self.cache.as_ref();
1704
1705        let mut requests = VarArray::new();
1706        let mut fresh: Vec<ChunkId> = Vec::new();
1707        for chunk in cut {
1708            if self.gd_outstanding.len() + fresh.len() >= 64 || fresh.len() >= 24 {
1709                break;
1710            }
1711            let id = chunk.id;
1712            let resident = cache.map(|c| c.slot_of(id).is_some()).unwrap_or(false);
1713            if resident || self.ready_surfaces.contains(&id) || self.gd_outstanding.contains(&id)
1714            {
1715                continue;
1716            }
1717            // Too deep to encode a handle for: never handed over (it keeps its
1718            // ancestor stand-in). `MAX_HANDLE_DEPTH` is 20, well past any cut.
1719            let Some(handle) = async_bake::handle_encode(tag, id) else { continue };
1720
1721            let mut d = VarDictionary::new();
1722            d.set(&"handle".to_variant(), &handle.to_variant());
1723            d.set(
1724                &"corners".to_variant(),
1725                &PackedVector3Array::from(&chunk.corners[..]).to_variant(),
1726            );
1727            d.set(&"tile_res".to_variant(), &(tile_res as i64).to_variant());
1728            d.set(&"depth".to_variant(), &(id.depth as i64).to_variant());
1729            requests.push(&d.to_variant());
1730            fresh.push(id);
1731        }
1732
1733        if fresh.is_empty() {
1734            return;
1735        }
1736        self.gd_outstanding.extend(fresh);
1737        baker.call(crate::builder::BAKE_REQUESTED, &[requests.to_variant()]);
1738    }
1739
1740    /// The active `CpuCustom` builder (its GDScript `height`/`color`/`normal`
1741    /// overrides are called per texel), when the first enabled builder is
1742    /// [`BuilderRoute::CpuCustom`] — else `None`.
1743    fn find_gd_baker(&self) -> Option<Gd<CesBuilder>> {
1744        let b = self.active_builder()?;
1745        if crate::builder::route_of(&b) == BuilderRoute::CpuCustom {
1746            Some(b)
1747        } else {
1748            None
1749        }
1750    }
1751
1752    /// Bake one chunk's surface by calling the `CpuCustom` builder's **batched**
1753    /// `height` / `color` / `normal` — ONE call each per chunk (main thread),
1754    /// passing a `PackedVector3Array` of texel directions and getting a packed
1755    /// array back. This collapses the per-texel Rust→GDScript FFI (`tile_res²`
1756    /// calls) into a handful, the dominant cost of the GDScript path. Uses the
1757    /// SAME direction mapping the shaders use; the normal is finite-differenced
1758    /// from the height grid unless the builder returned its own. `height_scale`
1759    /// is applied by the GPU (as `surface_height_scale`), so we store the raw
1760    /// fraction here — matching the GPU custom path.
1761    fn gd_bake_chunk(
1762        builder: &mut Gd<CesBuilder>,
1763        chunk: &Chunk,
1764        tile_res: u32,
1765    ) -> ChunkSurface {
1766        let n = tile_res as usize;
1767        // Texel-centre world directions (folded onto the chunk triangle),
1768        // computed once and reused for every batched call and the FD normal.
1769        // Same helper the async route exposes to GDScript as `chunk_dirs`.
1770        let dirs = async_bake::chunk_dirs(chunk.corners, tile_res);
1771        let dirs_packed = PackedVector3Array::from(&dirs[..]);
1772
1773        // Which optional functions the GDScript builder defines (missing height =
1774        // flat, missing color = white, missing normal = finite-differenced).
1775        let res_obj = builder.clone().upcast::<Object>();
1776        let has_height = res_obj.has_method("height");
1777        let has_color = res_obj.has_method("color");
1778        let has_normal = res_obj.has_method("normal");
1779
1780        // height(dirs) -> PackedFloat32Array — one call. A short/absent return
1781        // just leaves the missing texels flat (0).
1782        let mut height = vec![0f32; n * n];
1783        if has_height {
1784            let hp = builder
1785                .call("height", &[dirs_packed.to_variant()])
1786                .try_to::<PackedFloat32Array>()
1787                .unwrap_or_default();
1788            let hs = hp.as_slice();
1789            for (i, h) in height.iter_mut().enumerate() {
1790                *h = hs.get(i).copied().unwrap_or(0.0);
1791            }
1792        }
1793
1794        // color(dirs, heights) -> PackedColorArray — one call (missing = white).
1795        let white = Color::from_rgba(1.0, 1.0, 1.0, 1.0);
1796        let mut color = vec![255u8; n * n * 4];
1797        if has_color {
1798            let heights_packed = PackedFloat32Array::from(&height[..]);
1799            let cp = builder
1800                .call("color", &[dirs_packed.to_variant(), heights_packed.to_variant()])
1801                .try_to::<PackedColorArray>()
1802                .unwrap_or_default();
1803            let cs = cp.as_slice();
1804            for i in 0..(n * n) {
1805                let col = cs.get(i).copied().unwrap_or(white);
1806                color[i * 4] = (col.r.clamp(0.0, 1.0) * 255.0) as u8;
1807                color[i * 4 + 1] = (col.g.clamp(0.0, 1.0) * 255.0) as u8;
1808                color[i * 4 + 2] = (col.b.clamp(0.0, 1.0) * 255.0) as u8;
1809                color[i * 4 + 3] = 255;
1810            }
1811        }
1812
1813        // Optional normal(dirs, heights) -> PackedVector3Array — one call; each
1814        // texel with a returned normal uses it, the rest fall back to the FD.
1815        let overridden = if has_normal {
1816            let heights_packed = PackedFloat32Array::from(&height[..]);
1817            builder
1818                .call("normal", &[dirs_packed.to_variant(), heights_packed.to_variant()])
1819                .try_to::<PackedVector3Array>()
1820                .ok()
1821        } else {
1822            None
1823        };
1824        let over = overridden.as_ref().map(|a| a.as_slice());
1825
1826        // Normals: each texel the builder gave one for uses it; the rest are
1827        // finite-differenced from the height grid (shared with the async route).
1828        let normal = async_bake::pack_normals(chunk.corners, &dirs, &height, tile_res, over);
1829        ChunkSurface { color, height, normal }
1830    }
1831
1832    /// Re-apply planet params changed since the job was built (editor live-edit
1833    /// or a runtime setter). Buffer-shaping params (`chunk_res`, `tile_res`,
1834    /// `vram_budget_gib`) force a full rebuild. `radius` is baked into every
1835    /// realized chunk's geometry — and into the world positions of its
1836    /// scattered instances — so a change invalidates the whole chunk cache:
1837    /// every resident chunk re-realizes (and re-places its scatter) at the new
1838    /// radius over the next frames, instead of only newly-visible chunks
1839    /// picking it up. Also refreshes the draw AABB envelope.
1840    fn reapply_param_changes(&mut self) {
1841        if self.job.is_none() {
1842            return;
1843        }
1844        if self.chunk_res != self.built_res
1845            || self.tile_res != self.built_tile_res
1846            || self.vram_budget_gib != self.built_budget_gib
1847        {
1848            // Re-cap the CPU surface cache with the edited sizing right away
1849            // (CEL-91): shrinking the budget must free the held surfaces NOW, not
1850            // only once `ensure_job` rebuilds. `teardown_job` clears the entries;
1851            // this is what keeps the *capacity* in step with the new budget.
1852            let budget = self.effective_budget() as usize;
1853            self.ready_surfaces.set_capacity(budget);
1854            self.teardown_job(); // ensure_job rebuilds with the new sizing
1855            return;
1856        }
1857        if self.radius != self.built_radius {
1858            self.built_radius = self.radius;
1859            if let Some(cache) = self.cache.as_mut() {
1860                cache.invalidate_all();
1861            }
1862            let m = self.radius * 1.3;
1863            let aabb = Aabb { position: Vector3::splat(-m), size: Vector3::splat(2.0 * m) };
1864            let mut rs = RenderingServer::singleton();
1865            if let Some(mm) = &self.multimesh {
1866                rs.multimesh_set_custom_aabb(mm.get_rid(), aabb);
1867            }
1868            for smm in &self.scatter_mms {
1869                rs.multimesh_set_custom_aabb(smm.get_rid(), aabb);
1870            }
1871        }
1872    }
1873
1874    /// Build one indirect MultiMesh child per scatter layer (CEL-73), connect
1875    /// each layer's `changed` signal, and snapshot the structural params for
1876    /// later edit classification. Returns the GPU configs for the job.
1877    fn build_scatter_children(
1878        &mut self,
1879        rs: &mut Gd<RenderingServer>,
1880        aabb_margin: f32,
1881    ) -> Vec<ScatterConfig> {
1882        self.scatter_mmis.clear();
1883        self.scatter_mms.clear();
1884        self.scatter_meshes.clear();
1885        self.scatter_snapshot.clear();
1886        let self_gd = self.to_gd();
1887        let mut cfgs = Vec::new();
1888        let layers: Vec<Gd<CesScatterLayer>> = self.scatter_layers.iter_shared().collect();
1889        for (i, layer) in layers.into_iter().enumerate() {
1890            // Every layer gets the `changed` connection (so assigning a mesh
1891            // to an inactive layer later triggers the structure check)...
1892            let callable = Callable::from_object_method(&self_gd, "on_scatter_layer_changed");
1893            let mut layer_mut = layer.clone();
1894            if !layer_mut.is_connected("changed", &callable) {
1895                layer_mut.connect("changed", &callable);
1896            }
1897
1898            let (k, max_inst, mesh, snap) = {
1899                let l = layer.bind();
1900                // ...but only layers WITH a mesh become GPU layers. No mesh =
1901                // inactive: renders nothing (no silent fallback).
1902                let Some(mesh) = l.mesh.clone() else { continue };
1903                let k = l.instances_per_cell.clamp(1, 64) as u32;
1904                let max_inst = l.max_instances.clamp(64, 4_000_000) as u32;
1905                let snap = ScatterSnapshot {
1906                    layer_id: layer.instance_id().to_i64(),
1907                    lod_level: l.lod_level,
1908                    instances_per_cell: l.instances_per_cell,
1909                    max_instances: l.max_instances,
1910                    seed: l.seed,
1911                    scale: l.scale,
1912                    mesh_rid: mesh.get_rid(),
1913                };
1914                (k, max_inst, mesh, snap)
1915            };
1916
1917            let multimesh = MultiMesh::new_gd();
1918            let mm_rid = multimesh.get_rid();
1919            let mut mmi = MultiMeshInstance3D::new_alloc();
1920            let display = layer.bind().layer_name.to_string();
1921            if display.is_empty() {
1922                mmi.set_name(&format!("CesScatterLayer{i}"));
1923            } else {
1924                mmi.set_name(&format!("Scatter_{display}"));
1925            }
1926            mmi.set_multimesh(&multimesh);
1927            self.base_mut().add_child(&mmi);
1928
1929            // CEL-58 order: allocate indirect BEFORE set_mesh so the renderer
1930            // creates the indirect command buffer the compact pass writes.
1931            rs.multimesh_allocate_data_ex(
1932                mm_rid,
1933                max_inst as i32,
1934                MultimeshTransformFormat::TRANSFORM_3D,
1935            )
1936            .use_indirect(true)
1937            .done();
1938            rs.multimesh_set_mesh(mm_rid, mesh.get_rid());
1939            rs.multimesh_set_custom_aabb(
1940                mm_rid,
1941                Aabb {
1942                    position: Vector3::splat(-aabb_margin),
1943                    size: Vector3::splat(2.0 * aabb_margin),
1944                },
1945            );
1946
1947            cfgs.push(ScatterConfig {
1948                mm_rid,
1949                capacity: celestial_algo::scatter::capacity(k),
1950                max_instances: max_inst,
1951            });
1952            self.scatter_mmis.push(mmi);
1953            self.scatter_mms.push(crate::gpu::owned::IndirectMultiMesh::new(
1954                multimesh,
1955                crate::gpu::owned::MainDeviceSink::new(),
1956            ));
1957            self.scatter_meshes.push(mesh);
1958            self.scatter_snapshot.push(snap);
1959        }
1960        // First stage after a (re)build must carry scatter params.
1961        self.scatter_dirty = !self.scatter_mms.is_empty();
1962        cfgs
1963    }
1964
1965    /// Classify scatter edits (CEL-73). Layer add/remove or a capacity-affecting
1966    /// change (`instances_per_cell`, `max_instances`) tears the job down for a
1967    /// rebuild next frame; `lod_level`/`seed`/`scale` re-place resident chunks
1968    /// via `invalidate_all`; a mesh swap rebinds in place. density/height need
1969    /// nothing here — they flow through the next stage's params snapshot.
1970    fn check_scatter_structure(&mut self) {
1971        if self.job.is_none() {
1972            return;
1973        }
1974        // The ACTIVE set (layers with a mesh) must match the snapshot: a layer
1975        // added/removed/reordered — or gaining/losing its mesh — rebuilds.
1976        let active: Vec<Gd<CesScatterLayer>> = self
1977            .scatter_layers
1978            .iter_shared()
1979            .filter(|l| l.bind().mesh.is_some())
1980            .collect();
1981        let ids: Vec<i64> = active.iter().map(|l| l.instance_id().to_i64()).collect();
1982        let snap_ids: Vec<i64> = self.scatter_snapshot.iter().map(|s| s.layer_id).collect();
1983        if ids != snap_ids {
1984            self.teardown_job();
1985            return;
1986        }
1987        if !self.scatter_dirty {
1988            return;
1989        }
1990        for (i, layer) in active.into_iter().enumerate() {
1991            let (k, max_inst, lod, seed, scale, mesh) = {
1992                let l = layer.bind();
1993                (
1994                    l.instances_per_cell,
1995                    l.max_instances,
1996                    l.lod_level,
1997                    l.seed,
1998                    l.scale,
1999                    l.mesh.clone().expect("active layer has a mesh"),
2000                )
2001            };
2002            let snap_k = self.scatter_snapshot[i].instances_per_cell;
2003            let snap_max = self.scatter_snapshot[i].max_instances;
2004            let snap_lod = self.scatter_snapshot[i].lod_level;
2005            let snap_seed = self.scatter_snapshot[i].seed;
2006            let snap_scale = self.scatter_snapshot[i].scale;
2007            let snap_mesh_rid = self.scatter_snapshot[i].mesh_rid;
2008            if k != snap_k || max_inst != snap_max {
2009                self.teardown_job();
2010                return;
2011            }
2012            // lod_level/seed/scale are baked into the cached placement, so an
2013            // edit must re-place resident chunks (GPU-only; slots preserved, the
2014            // next updates push them back through realize → scatter-place).
2015            // Height gates + density are compact-side (params snapshot).
2016            if lod != snap_lod || seed != snap_seed || scale != snap_scale {
2017                if let Some(cache) = self.cache.as_mut() {
2018                    cache.invalidate_all();
2019                }
2020                self.scatter_snapshot[i].lod_level = lod;
2021                self.scatter_snapshot[i].seed = seed;
2022                self.scatter_snapshot[i].scale = scale;
2023            }
2024            if mesh.get_rid() != snap_mesh_rid {
2025                // Mesh swap (Some -> Some): rebind in place, no rebuild.
2026                RenderingServer::singleton()
2027                    .multimesh_set_mesh(self.scatter_mms[i].get_rid(), mesh.get_rid());
2028                self.scatter_snapshot[i].mesh_rid = mesh.get_rid();
2029                self.scatter_meshes[i] = mesh;
2030            }
2031        }
2032    }
2033
2034    /// Tear the job + all MultiMesh children down; `ensure_job` rebuilds next
2035    /// frame (used for structural scatter edits). The chunk cache restarts
2036    /// empty, so terrain re-realizes over the following frames (amortized by
2037    /// `MAX_BAKES_PER_FRAME`).
2038    fn teardown_job(&mut self) {
2039        // Dropping the job drops its `ChunkGpuResources`, whose `Owned<K>` handles
2040        // queue their RIDs on the render-thread free-drain (CEL-91). No deferred
2041        // `dispose` Callable: a Callable holds no strong ref, so the job used to die
2042        // before it ever ran — leaking the whole GPU pool.
2043        self.job = None;
2044        self.run_cb = None;
2045        self.cache = None;
2046        // Clear the CPU-surface state too: `ensure_job` repopulates it from the
2047        // CURRENT builder next frame, so leaving stale values here means a swap
2048        // to a builder that needs none (e.g. CpuNoise → GpuNoise) keeps the
2049        // old bake pool alive and it keeps painting the previous surface. Every
2050        // rebuild path funnels through here, so this is the one place to reset.
2051        self.provider = None;
2052        self.bake_pool = None;
2053        self.gd_baker = None;
2054        self.gd_async_baker = None;
2055        // Drop anything the old builder's workers already queued: it was baked
2056        // against the old params/geometry and must not land on the new job.
2057        if let Some(q) = self.gd_submits.take() {
2058            q.clear();
2059        }
2060        self.gd_outstanding.clear();
2061        self.ready_surfaces.clear();
2062        self.stale_surfaces.clear();
2063        self.was_base_ready = false;
2064        if let Some(mut mmi) = self.mmi.take() {
2065            mmi.queue_free();
2066        }
2067        self.multimesh = None;
2068        self.material = None;
2069        self._template = None;
2070        for mmi in &mut self.scatter_mmis {
2071            mmi.queue_free();
2072        }
2073        self.scatter_mmis.clear();
2074        self.scatter_mms.clear();
2075        self.scatter_meshes.clear();
2076        self.scatter_snapshot.clear();
2077        self.wired_pos = Rid::Invalid;
2078        self.wired_verts = Rid::Invalid;
2079        self.wired_color = Rid::Invalid;
2080        self.wired_normal = Rid::Invalid;
2081        self.last_slots.clear();
2082        self.last_cam = None;
2083        self.scatter_dirty = false;
2084        // Re-baseline the shader-file watcher after any rebuild.
2085        self.shader_mtime = 0;
2086        // Re-prime the param poll: the rebuild already applied current values, so
2087        // the next poll should baseline (not fire a spurious reshade).
2088        self.last_param_values = None;
2089    }
2090
2091    /// Schedule the render-thread run whenever a stage is pending (retries until
2092    /// the lazily-created multimesh buffers exist and the stage is consumed).
2093    fn pump(&mut self) {
2094        let pending = self.job.as_ref().map(|j| j.bind().stage.is_some()).unwrap_or(false);
2095        if pending {
2096            if let Some(cb) = self.run_cb.clone() {
2097                RenderingServer::singleton().call_on_render_thread(&cb);
2098            }
2099        }
2100    }
2101
2102    /// Wire the GPU-created pos/verts + detail-atlas textures into the material
2103    /// once they exist.
2104    fn wire_textures(&mut self) {
2105        let (pos, verts, color, normal) = match &self.job {
2106            Some(job) => {
2107                let g = &job.bind().gpu;
2108                (g.pos_tex(), g.attr_tex(), g.color_atlas(), g.normal_atlas())
2109            }
2110            None => return,
2111        };
2112        if self.material.is_none() {
2113            return;
2114        }
2115        for (rid, wired, name) in [
2116            (pos, &mut self.wired_pos, "pos_tex"),
2117            (verts, &mut self.wired_verts, "verts_tex"),
2118            (color, &mut self.wired_color, "color_atlas"),
2119            (normal, &mut self.wired_normal, "normal_atlas"),
2120        ] {
2121            if rid.is_valid() && rid != *wired {
2122                let mut tex = Texture2Drd::new_gd();
2123                tex.set_texture_rd_rid(rid);
2124                self.material.as_mut().unwrap().set_shader_parameter(name, &tex.to_variant());
2125                *wired = rid;
2126            }
2127        }
2128    }
2129
2130    /// Create (once) and update the analytic water proxy from the ACTIVE builder.
2131    /// The builder owns the water config, so water travels with the terrain: the
2132    /// sea sits at the builder's `water_height` (same formula for noise & custom,
2133    /// so the level means the same everywhere), it draws only when the builder's
2134    /// `water_enabled` is on, and the look comes from the builder's water exports.
2135    fn update_water(&mut self) {
2136        if self.water.is_none() {
2137            let mut parent = self.to_gd().upcast::<Node3D>();
2138            self.water = Some(crate::water_runtime::WaterRuntime::create(&mut parent));
2139        }
2140
2141        let builder = self.active_builder();
2142        let (enabled, water_radius, params) = match builder {
2143            Some(b) => {
2144                let l = b.bind();
2145                let wr = crate::water::water_radius(self.radius, l.water_height(), l.height_scale());
2146                let params = crate::water_runtime::WaterParams {
2147                    deep_color: l.water_deep_color,
2148                    shallow_color: l.water_shallow_color,
2149                    wave_strength: l.water_wave_strength,
2150                    wave_scale: l.water_wave_scale,
2151                    wave_speed: l.water_wave_speed,
2152                    underwater_color: l.water_underwater_color,
2153                    underwater_density: l.water_underwater_density,
2154                    sun_dir: self.sun_direction(),
2155                };
2156                (l.water_enabled, wr, params)
2157            }
2158            // No builder → white planet, no water. `params` is unused when
2159            // disabled (the runtime early-outs on `visible == false`).
2160            None => (
2161                false,
2162                self.radius,
2163                crate::water_runtime::WaterParams {
2164                    deep_color: Color::from_rgb(0.05, 0.22, 0.42),
2165                    shallow_color: Color::from_rgb(0.20, 0.55, 0.70),
2166                    wave_strength: 0.55,
2167                    wave_scale: 0.15,
2168                    wave_speed: 0.04,
2169                    underwater_color: Color::from_rgb(0.04, 0.16, 0.28),
2170                    underwater_density: 0.02,
2171                    sun_dir: Vector3::UP,
2172                },
2173            ),
2174        };
2175        let center = self.base().get_global_position();
2176        if let Some(water) = self.water.as_mut() {
2177            water.update(center, water_radius, params, enabled);
2178        }
2179    }
2180
2181    /// World-space direction pointing TOWARD the sun: the +Z (BACK) axis of the
2182    /// first `DirectionalLight3D` in the scene (a directional light emits along
2183    /// its −Z/FORWARD). Falls back to a fixed key direction if none is found.
2184    fn sun_direction(&self) -> Vector3 {
2185        use godot::classes::DirectionalLight3D;
2186        let fallback = || Vector3::new(0.4, 0.7, 0.55).normalized();
2187        let Some(root) = self.base().get_tree().get_root() else {
2188            return fallback();
2189        };
2190        let root: Gd<Node> = root.upcast();
2191        let light = root
2192            .find_children_ex("*")
2193            .type_("DirectionalLight3D")
2194            .recursive(true)
2195            .owned(false)
2196            .done()
2197            .iter_shared()
2198            .find_map(|n: Gd<Node>| n.try_cast::<DirectionalLight3D>().ok());
2199        match light {
2200            Some(l) => (l.get_global_transform().basis * Vector3::BACK).normalized(),
2201            None => fallback(),
2202        }
2203    }
2204
2205    /// Active camera position in this node's local space.
2206    fn camera_local(&self) -> Option<Vector3> {
2207        use godot::classes::{EditorInterface, Engine};
2208        let global = if Engine::singleton().is_editor_hint() {
2209            EditorInterface::singleton()
2210                .get_editor_viewport_3d()
2211                .and_then(|vp| vp.get_camera_3d())
2212                .map(|c| c.get_global_position())
2213        } else {
2214            self.base()
2215                .get_viewport()
2216                .and_then(|vp| vp.get_camera_3d())
2217                .map(|c| c.get_global_position())
2218        }?;
2219        Some(self.base().get_global_transform().affine_inverse() * global)
2220    }
2221}
2222
2223/// Build the chunk surface material (VERTEX from pos_tex by slot+VERTEX_ID;
2224/// colour + normal per-pixel from the detail atlas indexed by slot + UV).
2225fn make_material(res: u32, tile_res: u32, lod_colors: bool) -> Gd<ShaderMaterial> {
2226    let shader = godot::tools::load::<Shader>(CHUNK_SHADER);
2227    let mut mat = ShaderMaterial::new_gd();
2228    mat.set_shader(&shader);
2229    mat.set_shader_parameter("attr_w", &(ATTR_TEX_WIDTH as i32).to_variant());
2230    mat.set_shader_parameter("verts_per_chunk", &(verts_per_chunk(res) as i32).to_variant());
2231    // chunk_res drives the surface shader's geomorph even-sublattice decode.
2232    mat.set_shader_parameter("chunk_res", &(res as i32).to_variant());
2233    mat.set_shader_parameter("tile_res", &(tile_res as i32).to_variant());
2234    mat.set_shader_parameter("lod_colors", &lod_colors.to_variant());
2235    mat
2236}