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}