Skip to main content

celestialsim/
scatter_descriptors.rs

1//! CPU → GPU std430 packing for the scatter passes (CEL-73).
2//!
3//! Three buffers per the plan's locked contracts:
4//! - [`ScatterParamsGpu`] — one 112-byte params block per layer, shared by
5//!   `ScatterPlace.slang` and `ScatterCompact.slang`.
6//! - aux ([`pack_scatter_aux`]) — one 16-byte `{path_lo, path_hi, face, pad}`
7//!   entry per realize-batch chunk, parallel to the `ChunkGpu` batch (the
8//!   descriptor lacks the quadtree path the stable lattice key needs).
9//! - vis ([`pack_scatter_vis`]) — one 8-byte `{slot, depth}` `uint2` per
10//!   visible instance, the compact pass's gather list.
11//!
12//! Layouts are locked by tests like `chunk_descriptors::chunk_params_layout`.
13
14use celestial_algo::quadtree::Chunk;
15
16use crate::descriptors::TerrainGpu;
17
18/// Per-layer scatter parameters, byte-identical to `struct ScatterParams` in
19/// `ScatterPlace.slang` / `ScatterCompact.slang` (binding 2 / 0).
20///
21/// std430: a 64-byte scalar header followed by the 56-byte [`TerrainGpu`] at
22/// offset 64, plus two tail pads; 128 bytes total (multiple of 16 per the
23/// struct-array rule).
24#[repr(C)]
25#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
26pub struct ScatterParamsGpu {
27    /// Chunks in this realize batch (place dispatch domain).
28    pub chunk_count: u32,
29    /// Per-slot pool capacity `C = k_per_cell * 4^S_MAX`.
30    pub capacity: u32,
31    /// Candidates per lattice cell (K).
32    pub k_per_cell: u32,
33    /// The layer's LOD level (== the stable world lattice level L in
34    /// `celestial_algo::scatter`).
35    pub lod_level: u32,
36    /// Sphere radius.
37    pub radius: f32,
38    /// LIVE: fraction of candidates drawn (`hash01 < density`).
39    pub density: f32,
40    /// MultiMesh pool cap (compact clamps its atomic counter to this).
41    pub max_instances: u32,
42    /// Layer placement seed.
43    pub seed: u32,
44    /// Visible instances (compact dispatch domain).
45    pub vis_count: u32,
46    /// LIVE: min normalized terrain height (0..1) an instance may sit at; below
47    /// it, nothing scatters (0.45 = the default sea level → no underwater).
48    pub min_height: f32,
49    /// LIVE: max normalized terrain height (0..1); above it, nothing scatters
50    /// (1.0 = no upper limit → keep grass off peaks by lowering this).
51    pub max_height: f32,
52    /// Per-layer base scale multiplier (place-side: baked into the transform).
53    pub base_scale: f32,
54    /// CPU-surface route gate (mirrors `ChunkParams.surface_enabled`): non-zero
55    /// ⇒ place samples the baked heightmap instead of the procedural noise.
56    pub surface_enabled: f32,
57    /// CPU-surface displacement scale (`CpuSurfaceProvider::height_scale`) —
58    /// must equal `ChunkParams.surface_height_scale` or instances float/sink.
59    pub surface_height_scale: f32,
60    /// Detail-tile resolution: the per-slot heightmap is `tile_res²` floats.
61    pub tile_res: u32,
62    pub _pad: u32,
63    /// Terrain noise params — displacement must match `ChunkRealize.slang`.
64    pub terrain: TerrainGpu,
65    /// Tail padding keeping the struct a 16-byte multiple (std430 stride rule).
66    pub _pad2: f32,
67    pub _pad3: f32,
68}
69
70/// Pack one [`ScatterParamsGpu`] into bytes for its per-layer buffer.
71#[allow(clippy::too_many_arguments)]
72pub fn pack_scatter_params(
73    chunk_count: u32,
74    capacity: u32,
75    k_per_cell: u32,
76    lod_level: u32,
77    radius: f32,
78    density: f32,
79    max_instances: u32,
80    seed: u32,
81    vis_count: u32,
82    min_height: f32,
83    max_height: f32,
84    base_scale: f32,
85    surface_enabled: f32,
86    surface_height_scale: f32,
87    tile_res: u32,
88    terrain: &TerrainGpu,
89) -> Vec<u8> {
90    let p = ScatterParamsGpu {
91        chunk_count,
92        capacity,
93        k_per_cell,
94        lod_level,
95        radius,
96        density,
97        max_instances,
98        seed,
99        vis_count,
100        min_height,
101        max_height,
102        base_scale,
103        surface_enabled,
104        surface_height_scale,
105        tile_res,
106        _pad: 0,
107        terrain: *terrain,
108        _pad2: 0.0,
109        _pad3: 0.0,
110    };
111    bytemuck::bytes_of(&p).to_vec()
112}
113
114/// One realize-batch chunk's scatter aux entry (std430, 16 bytes): the chunk's
115/// quadtree path split into two words + its face. Parallel to the `ChunkGpu`
116/// batch (same index), which already carries slot/level/frame/bary.
117#[repr(C)]
118#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
119pub struct ScatterAuxGpu {
120    pub path_lo: u32,
121    pub path_hi: u32,
122    pub face: u32,
123    pub _pad: u32,
124}
125
126/// Pack the aux buffer for a realize batch (parallel to `pack_chunks` order).
127pub fn pack_scatter_aux(realize: &[(u32, Chunk)]) -> Vec<u8> {
128    let entries: Vec<ScatterAuxGpu> = realize
129        .iter()
130        .map(|(_, c)| ScatterAuxGpu {
131            path_lo: c.id.path as u32,
132            path_hi: (c.id.path >> 32) as u32,
133            face: c.id.face as u32,
134            _pad: 0,
135        })
136        .collect();
137    bytemuck::cast_slice(&entries).to_vec()
138}
139
140/// Pack the compact pass's visible list: one `uint` slot per drawn instance
141/// (same order as the instance buffer / `visible_slots`).
142pub fn pack_scatter_vis(slots: &[u32]) -> Vec<u8> {
143    let mut buf = Vec::with_capacity(slots.len() * 4);
144    for s in slots {
145        buf.extend_from_slice(&s.to_le_bytes());
146    }
147    buf
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use celestial_algo::quadtree::{Bary, Chunk, ChunkId};
154    use godot::builtin::Vector3;
155
156    #[test]
157    fn scatter_params_layout() {
158        use std::mem::offset_of;
159        assert_eq!(std::mem::size_of::<ScatterParamsGpu>(), 128);
160        assert_eq!(std::mem::size_of::<ScatterParamsGpu>() % 16, 0);
161        assert_eq!(offset_of!(ScatterParamsGpu, chunk_count), 0);
162        assert_eq!(offset_of!(ScatterParamsGpu, capacity), 4);
163        assert_eq!(offset_of!(ScatterParamsGpu, k_per_cell), 8);
164        assert_eq!(offset_of!(ScatterParamsGpu, lod_level), 12);
165        assert_eq!(offset_of!(ScatterParamsGpu, radius), 16);
166        assert_eq!(offset_of!(ScatterParamsGpu, density), 20);
167        assert_eq!(offset_of!(ScatterParamsGpu, max_instances), 24);
168        assert_eq!(offset_of!(ScatterParamsGpu, seed), 28);
169        assert_eq!(offset_of!(ScatterParamsGpu, vis_count), 32);
170        assert_eq!(offset_of!(ScatterParamsGpu, min_height), 36);
171        assert_eq!(offset_of!(ScatterParamsGpu, max_height), 40);
172        assert_eq!(offset_of!(ScatterParamsGpu, base_scale), 44);
173        assert_eq!(offset_of!(ScatterParamsGpu, surface_enabled), 48);
174        assert_eq!(offset_of!(ScatterParamsGpu, surface_height_scale), 52);
175        assert_eq!(offset_of!(ScatterParamsGpu, tile_res), 56);
176        assert_eq!(offset_of!(ScatterParamsGpu, terrain), 64);
177    }
178
179    #[test]
180    fn pack_params_roundtrips() {
181        let t = crate::descriptors::assemble(
182            &crate::descriptors::HeightGpu::default(),
183            &crate::descriptors::TextureGpu::default(),
184        );
185        let bytes = pack_scatter_params(
186            3, 256, 4, 9, 1000.0, 0.5, 100_000, 7, 42, 0.45, 0.9, 2.0, 1.0, 0.18, 64, &t,
187        );
188        assert_eq!(bytes.len(), 128);
189        let p: &ScatterParamsGpu = bytemuck::from_bytes(&bytes);
190        assert_eq!(p.chunk_count, 3);
191        assert_eq!(p.capacity, 256);
192        assert_eq!(p.k_per_cell, 4);
193        assert_eq!(p.lod_level, 9);
194        assert_eq!(p.radius, 1000.0);
195        assert_eq!(p.density, 0.5);
196        assert_eq!(p.max_instances, 100_000);
197        assert_eq!(p.seed, 7);
198        assert_eq!(p.vis_count, 42);
199        assert_eq!(p.min_height, 0.45);
200        assert_eq!(p.max_height, 0.9);
201        assert_eq!(p.base_scale, 2.0);
202        assert_eq!(p.surface_enabled, 1.0);
203        assert_eq!(p.surface_height_scale, 0.18);
204        assert_eq!(p.tile_res, 64);
205        assert_eq!(p.terrain, t);
206    }
207
208    #[test]
209    fn pack_aux_layout() {
210        let path: u64 = 0xdead_beef_0000_0003;
211        let chunk = Chunk {
212            id: ChunkId { face: 5, depth: 2, path },
213            bary: [
214                Bary { wb: 0.0, wc: 0.0 },
215                Bary { wb: 1.0, wc: 0.0 },
216                Bary { wb: 0.0, wc: 1.0 },
217            ],
218            corners: [Vector3::ZERO; 3],
219            level: 2,
220        };
221        let bytes = pack_scatter_aux(&[(9, chunk), (1, chunk)]);
222        assert_eq!(bytes.len(), 32); // 16 B per entry
223        let entries: &[ScatterAuxGpu] = bytemuck::cast_slice(&bytes);
224        assert_eq!(entries[0].path_lo, 0x0000_0003);
225        assert_eq!(entries[0].path_hi, 0xdead_beef);
226        assert_eq!(entries[0].face, 5);
227        assert_eq!(entries[1], entries[0]);
228    }
229
230    #[test]
231    fn pack_vis_slots() {
232        let bytes = pack_scatter_vis(&[7, 3]);
233        assert_eq!(bytes.len(), 8);
234        let words: &[u32] = bytemuck::cast_slice(&bytes);
235        assert_eq!(words, &[7, 3]);
236    }
237}