Skip to main content

celestialsim/
scatter_layer.rs

1//! `CesScatterLayer` — the editor-facing scatter layer resource (CEL-73).
2//!
3//! One layer = one mesh scattered over the planet. The day-to-day sliders —
4//! `density`, `min_height`/`max_height` — are LIVE: editing them re-runs only
5//! the scatter-compact dispatch (no re-realize, no bake, no cache invalidation).
6//!
7//! `lod_level` sets how fine the **stable world lattice** the candidates live on
8//! is (higher = denser + shorter reach; fine ≈ 12 for grass, coarse ≈ 6 for
9//! trees), with `instances_per_cell` (K) the count per cell. Editing `lod_level`
10//! / `scale` / `seed` re-places resident chunks (GPU-only, via
11//! `ChunkCache::invalidate_all`); changing `max_instances` or the layer list
12//! rebuilds the job. Every setter `emit_changed()`s so the planet node can diff
13//! what actually changed.
14
15use godot::classes::{Mesh, Resource};
16use godot::prelude::*;
17
18/// One scattered mesh — grass, trees, rocks — spread over the planet.
19///
20/// A `Resource`: create one per kind of object and put it in the planet's
21/// `scatter_layers` array ([`Celestial`](crate::celestial::Celestial)). Placement is
22/// GPU-only: candidates sit on a stable world lattice keyed to the terrain, so
23/// instances never move when chunks split or merge, and nothing is read back to the
24/// CPU. Layers are cheap to iterate on — `enabled`, `density`, `min_height` and
25/// `max_height` re-run only the gather pass, so they respond live while you drag them.
26#[derive(GodotClass)]
27#[class(base = Resource, tool, init)]
28pub struct CesScatterLayer {
29    base: Base<Resource>,
30
31    /// Display name (e.g. "Grass", "Oak trees"). Mirrored into
32    /// `resource_name`, so the inspector's layer array shows it per element —
33    /// name your layers instead of hunting through "CesScatterLayer" entries.
34    #[var(get = get_layer_name, set = set_layer_name)]
35    #[export]
36    pub layer_name: GString,
37    /// LIVE on/off toggle: a disabled layer draws nothing but keeps its cached
38    /// GPU placement, so re-enabling is instant (compact-only update).
39    #[var(get = get_enabled, set = set_enabled)]
40    #[export]
41    #[init(val = true)]
42    pub enabled: bool,
43    /// Fraction of the lattice candidates actually drawn, 0..1 — the thinning knob
44    /// (LIVE: only the gather pass re-runs, so it is free to drag). It never adds
45    /// instances beyond what `lod_level`/`instances_per_cell` place; 1.0 draws them
46    /// all. Default 0.5.
47    #[var(get = get_density, set = set_density)]
48    #[export(range = (0.0, 1.0, 0.005))]
49    #[init(val = 0.5)]
50    pub density: f32,
51    /// The mesh every instance of this layer draws. **No mesh ⇒ the layer is
52    /// INACTIVE**: it is skipped entirely (no GPU placement, nothing drawn), which is
53    /// also how a layer you are not using costs nothing. For grass, assign
54    /// `res://addons/celestialsim/grass_blade.tres` (regenerable via
55    /// `CesScatterLayer.make_grass_blade_mesh()`).
56    #[var(get = get_mesh, set = set_mesh)]
57    #[export]
58    pub mesh: Option<Gd<Mesh>>,
59    /// Base scale applied to every instance (on top of a ±20% per-instance
60    /// jitter), so you can size a mesh without re-authoring it. 1.0 = the
61    /// mesh's native size.
62    #[var(get = get_scale, set = set_scale)]
63    #[export(range = (0.01, 100.0, 0.01))]
64    #[init(val = 1.0)]
65    pub scale: f32,
66    /// LIVE lower bound on normalized terrain height (0..1) where this layer may
67    /// appear. Default 0.45 = the default sea level, so nothing scatters
68    /// underwater; drop to 0 to allow it. (Terrain height is normalized: ~0.45
69    /// is the shoreline, 1.0 the highest peaks.)
70    #[var(get = get_min_height, set = set_min_height)]
71    #[export(range = (0.0, 1.0, 0.01))]
72    #[init(val = 0.45)]
73    pub min_height: f32,
74    /// LIVE upper bound on normalized terrain height (0..1). Default 1.0 = no
75    /// upper limit; lower it to keep this layer off the mountain tops.
76    #[var(get = get_max_height, set = set_max_height)]
77    #[export(range = (0.0, 1.0, 0.01))]
78    #[init(val = 1.0)]
79    pub max_height: f32,
80    /// LOD level the candidates are keyed to — the fineness of the stable world
81    /// lattice. Higher = denser and shorter-range; the layer only appears on
82    /// terrain chunks at depth `>= lod_level - 3` (per-slot capacity bound), so
83    /// this is the single knob for both density and reach. Instances never move
84    /// when chunks split/merge. Rough starting points: ~12 for grass, ~6 for trees.
85    /// Editing it re-places every resident chunk. Default 9 (range 0–20).
86    #[var(get = get_lod_level, set = set_lod_level)]
87    #[export(range = (0.0, 20.0, 1.0))]
88    #[init(val = 9)]
89    pub lod_level: i64,
90    /// Candidates placed per lattice cell (K) — jitters several instances into one
91    /// cell instead of one per cell, which breaks up the grid look. Costs GPU memory:
92    /// the per-slot candidate pool is `K * 64` entries. Changing it rebuilds the GPU
93    /// job. Default 4 (range 1–64).
94    #[var(get = get_instances_per_cell, set = set_instances_per_cell)]
95    #[export(range = (1.0, 64.0, 1.0))]
96    #[init(val = 4)]
97    pub instances_per_cell: i64,
98    /// Hard cap on the instances this layer draws — the size of its `MultiMesh` pool.
99    /// The gather pass stops appending once it is hit (so instances go missing rather
100    /// than the frame blowing up), and the buffer is sized for it, so raising it costs
101    /// VRAM. Changing it rebuilds the GPU job. Default 100000.
102    #[var(get = get_max_instances, set = set_max_instances)]
103    #[export(range = (64.0, 4000000.0, 64.0))]
104    #[init(val = 100000)]
105    pub max_instances: i64,
106    /// Seed of the placement hash: change it to shuffle *which* candidates exist and
107    /// where they land, keeping the same density. Re-places every resident chunk.
108    #[var(get = get_seed, set = set_seed)]
109    #[export]
110    #[init(val = 0)]
111    pub seed: i64,
112}
113
114#[godot_api]
115impl CesScatterLayer {
116    /// Build the procedural grass blade mesh (the source of the committed
117    /// `addons/celestialsim/grass_blade.tres`). Callable from GDScript:
118    /// `CesScatterLayer.make_grass_blade_mesh()`.
119    #[func]
120    pub fn make_grass_blade_mesh() -> Gd<godot::classes::ArrayMesh> {
121        crate::scatter_mesh::grass_blade_mesh()
122    }
123
124    #[func]
125    pub fn get_layer_name(&self) -> GString {
126        self.layer_name.clone()
127    }
128    #[func]
129    pub fn set_layer_name(&mut self, v: GString) {
130        if self.layer_name != v {
131            self.layer_name = v.clone();
132            // resource_name is what the inspector shows on array elements.
133            self.base_mut().set_name(&v);
134            self.base_mut().emit_changed();
135        }
136    }
137
138    #[func]
139    pub fn get_enabled(&self) -> bool {
140        self.enabled
141    }
142    #[func]
143    pub fn set_enabled(&mut self, v: bool) {
144        if self.enabled != v {
145            self.enabled = v;
146            self.base_mut().emit_changed();
147        }
148    }
149
150    #[func]
151    pub fn get_density(&self) -> f32 {
152        self.density
153    }
154    #[func]
155    pub fn set_density(&mut self, v: f32) {
156        if self.density != v {
157            self.density = v;
158            self.base_mut().emit_changed();
159        }
160    }
161
162    #[func]
163    pub fn get_scale(&self) -> f32 {
164        self.scale
165    }
166    #[func]
167    pub fn set_scale(&mut self, v: f32) {
168        if self.scale != v {
169            self.scale = v;
170            self.base_mut().emit_changed();
171        }
172    }
173
174    #[func]
175    pub fn get_min_height(&self) -> f32 {
176        self.min_height
177    }
178    #[func]
179    pub fn set_min_height(&mut self, v: f32) {
180        if self.min_height != v {
181            self.min_height = v;
182            self.base_mut().emit_changed();
183        }
184    }
185
186    #[func]
187    pub fn get_max_height(&self) -> f32 {
188        self.max_height
189    }
190    #[func]
191    pub fn set_max_height(&mut self, v: f32) {
192        if self.max_height != v {
193            self.max_height = v;
194            self.base_mut().emit_changed();
195        }
196    }
197
198    #[func]
199    pub fn get_mesh(&self) -> Option<Gd<Mesh>> {
200        self.mesh.clone()
201    }
202    #[func]
203    pub fn set_mesh(&mut self, mesh: Option<Gd<Mesh>>) {
204        if self.mesh != mesh {
205            self.mesh = mesh;
206            self.base_mut().emit_changed();
207        }
208    }
209
210    #[func]
211    pub fn get_lod_level(&self) -> i64 {
212        self.lod_level
213    }
214    #[func]
215    pub fn set_lod_level(&mut self, v: i64) {
216        if self.lod_level != v {
217            self.lod_level = v;
218            self.base_mut().emit_changed();
219        }
220    }
221
222    #[func]
223    pub fn get_instances_per_cell(&self) -> i64 {
224        self.instances_per_cell
225    }
226    #[func]
227    pub fn set_instances_per_cell(&mut self, v: i64) {
228        if self.instances_per_cell != v {
229            self.instances_per_cell = v;
230            self.base_mut().emit_changed();
231        }
232    }
233
234    #[func]
235    pub fn get_max_instances(&self) -> i64 {
236        self.max_instances
237    }
238    #[func]
239    pub fn set_max_instances(&mut self, v: i64) {
240        if self.max_instances != v {
241            self.max_instances = v;
242            self.base_mut().emit_changed();
243        }
244    }
245
246    #[func]
247    pub fn get_seed(&self) -> i64 {
248        self.seed
249    }
250    #[func]
251    pub fn set_seed(&mut self, v: i64) {
252        if self.seed != v {
253            self.seed = v;
254            self.base_mut().emit_changed();
255        }
256    }
257}