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}