Skip to main content

celestialsim/
chunk_descriptors.rs

1//! CPU → GPU packing of chunk descriptors for the Phase 2 chunked quadtree (CEL-62).
2//!
3//! `ChunkGpu` must match `struct ChunkGpu` in `shaders/ChunkRealize.slang`
4//! byte for byte (std430: 96 bytes, 16-byte aligned due to `[f32;4]` members).
5//! The size invariant is locked by the `chunk_gpu_layout_is_96_bytes` test.
6//!
7//! # Instance buffer layout (Godot 3D MultiMesh + custom data)
8//!
9//! Each instance is 16 `f32` values (64 bytes) in Godot's TRANSFORM_3D + custom_data format:
10//! - Floats 0–11: 3×4 transform matrix (3 rows of 4 floats; identity → vertex shader
11//!   positions geometry from the vertex-pool texture, not from the instance transform)
12//! - **Float 12** (`INSTANCE_CUSTOM.r`): slot index as `f32`; the shader reads
13//!   this to index into the per-face vertex pool
14//! - **Float 13** (`INSTANCE_CUSTOM.g`): per-chunk geomorph factor (Phase 5):
15//!   `1` = full detail, `0` = coarse/parent resolution. The surface vertex shader
16//!   blends the realized grid toward its even (parent) sublattice by this factor.
17//!   It rides in the per-frame instance buffer (NOT the cached realize descriptor
18//!   / atlas), so a moving camera only re-uploads this small buffer — the
19//!   realize/bake passes stay fully cached.
20//! - Floats 14–15: zero (reserved custom channels b/a)
21
22use celestial_algo::clipmap::FaceFrame;
23use celestial_algo::quadtree::Chunk;
24
25use crate::descriptors::TerrainGpu;
26
27/// Realize-batch parameters + embedded terrain, byte-identical to
28/// `struct ChunkParams` in `shaders/ChunkRealize.slang` / `ChunkTileBake.slang`
29/// (binding 1).
30///
31/// std430 layout: a 4-`u32` header `{ res, verts_per_chunk, attr_w, chunk_count }`
32/// (16 bytes) immediately followed by the 16-float [`TerrainGpu`] (64 bytes) at
33/// offset 16. `tile_res` (Phase 4) sits at offset 80, `bump_enable` at 84, and
34/// the CPU-surface `surface_enabled`/`surface_height_scale` at 88/92,
35/// filling the struct to 96 bytes — a multiple of 16, as the std430 struct-array
36/// rule requires. `#[repr(C)]` matches because `u32`/`f32` are 4-aligned and the
37/// fields tile the tail with no interior holes (bytemuck `Pod`).
38///
39/// `ChunkRealize.slang` still declares the original 80-byte `ChunkParams`; it
40/// only reads fields at offsets 0..80, so the appended `tile_res`+pad are
41/// invisible to it and its committed SPIR-V stays valid (only `params[0]` is
42/// read, so the array stride never matters there).
43///
44/// The `chunk_params_layout` test locks these offsets.
45#[repr(C)]
46#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
47pub struct ChunkParams {
48    /// Edge resolution (== `chunks[i].res`).
49    pub res: u32,
50    /// `(res+1)(res+2)/2` — vertices per chunk.
51    pub verts_per_chunk: u32,
52    /// `verts_tex` width in texels, for the `idx -> (x, y)` wrap.
53    pub attr_w: u32,
54    /// Number of chunks in this realize batch.
55    pub chunk_count: u32,
56    /// Terrain noise params (shared with the clipmap path); `enabled == 0` ⇒
57    /// pure geometry (no displacement), the readback reference case.
58    pub terrain: TerrainGpu,
59    /// Phase 4: per-chunk detail-tile resolution (`tile_res × tile_res` texels
60    /// baked into the colour/normal atlas). Read by `ChunkTileBake.slang`.
61    pub tile_res: u32,
62    /// Detail-normal bump enable (1.0 on, 0.0 off): a debug toggle that removes
63    /// the high-frequency normal perturbation from the baked normal atlas. Read
64    /// by `ChunkTileBake.slang`; invisible to `ChunkRealize.slang` (offset > 80).
65    pub bump_enable: f32,
66    /// CPU-surface global toggle at offset 88: `1.0` ⇒ the chunk
67    /// shaders sample the per-chunk surface color/height storage buffers instead of
68    /// the procedural surface; `0.0` ⇒ pixel-identical to the procedural path.
69    pub surface_enabled: f32,
70    /// CPU-surface displaced-radius factor per meter: the realize shader
71    /// multiplies the sampled surface elevation (meters) by this when displacing.
72    pub surface_height_scale: f32,
73    /// Tail padding keeping the struct a 16-byte multiple (std430 stride rule).
74    pub _pad0: f32,
75    pub _pad1: f32,
76}
77
78/// Pack one [`ChunkParams`] (header + terrain + tile_res + bump + surface) into its
79/// buffer. `surface_enabled`/`surface_height_scale` drive the CPU-surface
80/// path (pass `0.0, 0.0` when the CPU surface is irrelevant).
81pub fn pack_params(
82    res: u32,
83    verts_per_chunk: u32,
84    attr_w: u32,
85    chunk_count: u32,
86    tile_res: u32,
87    bump_enable: f32,
88    terrain: &TerrainGpu,
89    surface_enabled: f32,
90    surface_height_scale: f32,
91) -> Vec<u8> {
92    let cp = ChunkParams {
93        res,
94        verts_per_chunk,
95        attr_w,
96        chunk_count,
97        terrain: *terrain,
98        tile_res,
99        bump_enable,
100        surface_enabled,
101        surface_height_scale,
102        _pad0: 0.0,
103        _pad1: 0.0,
104    };
105    bytemuck::bytes_of(&cp).to_vec()
106}
107
108/// One chunk's GPU descriptor (std430, 96 bytes).
109///
110/// Field order is the shader's layout contract — do not reorder without updating
111/// `ChunkRealize.slang`. Every array member is `[f32;4]` or smaller, and the struct
112/// is padded to a 16-byte multiple so that an array of `ChunkGpu` has the correct
113/// std430 stride (the shader indexes `chunks[i]` directly).
114#[repr(C)]
115#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
116pub struct ChunkGpu {
117    /// Face frame corner A (xyz) and sphere radius (w); radius=0 → flat terrain.
118    pub frame_a: [f32; 4],
119    /// Face frame corner B (xyz), w=0 (unused).
120    pub frame_b: [f32; 4],
121    /// Face frame corner C (xyz), w=0 (unused).
122    pub frame_c: [f32; 4],
123    /// Barycentric coords of chunk corner 0: (wb, wc); wa = 1 − wb − wc.
124    pub bary0: [f32; 2],
125    /// Barycentric coords of chunk corner 1.
126    pub bary1: [f32; 2],
127    /// Barycentric coords of chunk corner 2.
128    pub bary2: [f32; 2],
129    /// Vertex-pool slot: this chunk owns `verts_per_chunk(res)` contiguous vertices
130    /// starting at `slot * verts_per_chunk(res)` in the pool texture.
131    pub slot: u32,
132    /// LOD level (== quadtree depth from the icosphere face root).
133    pub level: u32,
134    /// Edge resolution: each chunk edge has `res` segments; vertex count = `verts_per_chunk(res)`.
135    pub res: u32,
136    /// Explicit std430 padding: rounds the struct from 84 → 96 bytes so that
137    /// `size_of::<ChunkGpu>() % 16 == 0` (required by the std430 array-stride rule).
138    pub _pad: [u32; 3],
139}
140
141/// **Interior** vertex count for one chunk at resolution `res`.
142///
143/// The triangular grid with `res` edge segments has `(res+1)*(res+2)/2` vertices
144/// (sum of rows 1..=res+1). These occupy local indices `L < interior_verts_per_chunk`;
145/// the realize `L→(i,j)` decode and the GPU-readback reference both depend on this
146/// interior block being byte-identical, so it is split out from the skirt verts.
147pub fn interior_verts_per_chunk(res: u32) -> u32 {
148    (res + 1) * (res + 2) / 2
149}
150
151/// Number of **skirt** vertices appended after the interior grid (Phase 3): one
152/// ring vertex per edge position on each of the 3 edges (corners duplicated per
153/// edge so each edge owns its own skirt ring), `3*(res+1)`.
154pub fn skirt_verts_per_chunk(res: u32) -> u32 {
155    3 * (res + 1)
156}
157
158/// **Total** vertex count for one chunk at resolution `res` = interior grid +
159/// perimeter skirt (Phase 3 crack fix). This is the per-slot stride of the vertex
160/// pool, the `verts_tex`/`pos_tex` sizing unit, and the realize dispatch count.
161/// The interior block (`L < interior_verts_per_chunk(res)`) is unchanged; skirt
162/// verts are appended at `L >= interior_verts_per_chunk(res)`.
163pub fn verts_per_chunk(res: u32) -> u32 {
164    interior_verts_per_chunk(res) + skirt_verts_per_chunk(res)
165}
166
167/// VRAM one resident chunk slot reserves across ALL the GPU pools:
168/// geometry `verts_per_chunk(res) × 48 B` (pos_tex rgba32f 16 + verts_tex
169/// 2×rgba16f 16 + verts_buf float4 16), the colour + normal detail atlases
170/// (`tile_res² × 8 B`), and the CPU-surface colour + height + normal storage
171/// buffers (`tile_res² × 12 B` — allocated unconditionally by
172/// `ChunkGpu::ensure`). The VRAM budget divides by this; forgetting the surface
173/// buffers (the old accounting) made the real allocation ~2× the configured
174/// budget at large `tile_res`.
175pub fn per_slot_bytes(res: u32, tile_res: u32) -> i64 {
176    let tile = tile_res as i64;
177    verts_per_chunk(res) as i64 * 48 + tile * tile * (8 + 12)
178}
179
180#[cfg(test)]
181mod slot_bytes_tests {
182    use super::*;
183
184    #[test]
185    fn per_slot_bytes_counts_atlases_and_surface_buffers() {
186        // tile_res 256: atlases 256²×8 + surface colour/height/normal 256²×12.
187        let geom = verts_per_chunk(20) as i64 * 48;
188        assert_eq!(per_slot_bytes(20, 256), geom + 65536 * 20);
189        // The old accounting (atlases only) under-counted by tile_res²×12.
190        assert_eq!(per_slot_bytes(20, 256) - (geom + 65536 * 8), 65536 * 12);
191    }
192}
193
194impl ChunkGpu {
195    /// Pack one chunk into its GPU descriptor.
196    ///
197    /// `frame` is the icosphere face that owns this chunk; `slot` is the
198    /// chunk's assigned vertex-pool slot.
199    pub fn from_chunk(frame: &FaceFrame, c: &Chunk, slot: u32, res: u32) -> Self {
200        Self {
201            frame_a: [frame.a.x, frame.a.y, frame.a.z, frame.radius],
202            frame_b: [frame.b.x, frame.b.y, frame.b.z, 0.0],
203            frame_c: [frame.c.x, frame.c.y, frame.c.z, 0.0],
204            bary0: [c.bary[0].wb, c.bary[0].wc],
205            bary1: [c.bary[1].wb, c.bary[1].wc],
206            bary2: [c.bary[2].wb, c.bary[2].wc],
207            slot,
208            level: c.level as u32,
209            res,
210            _pad: [0; 3],
211        }
212    }
213}
214
215/// Pack all visible chunks into a contiguous `ChunkGpu` byte buffer (one entry per chunk).
216///
217/// `frames` is the full 20-element icosphere face-frame array indexed by `chunk.id.face`;
218/// `realize` is a list of `(slot, Chunk)` pairs as returned by the chunk-cache allocator.
219/// Returns `realize.len() * size_of::<ChunkGpu>()` bytes.
220pub fn pack_chunks(frames: &[FaceFrame], realize: &[(u32, Chunk)], res: u32) -> Vec<u8> {
221    let descs: Vec<ChunkGpu> = realize
222        .iter()
223        .map(|(slot, chunk)| {
224            let frame = &frames[chunk.id.face as usize];
225            ChunkGpu::from_chunk(frame, chunk, *slot, res)
226        })
227        .collect();
228    bytemuck::cast_slice(&descs).to_vec()
229}
230
231/// Pack per-instance data for the chunk MultiMesh into a byte buffer.
232///
233/// Each instance is 16 `f32` values (64 bytes) matching Godot's
234/// `TRANSFORM_3D + custom_data` multimesh format:
235///
236/// ```text
237/// floats [0..3]   — transform row 0  (identity: 1 0 0 0)
238/// floats [4..7]   — transform row 1  (identity: 0 1 0 0)
239/// floats [8..11]  — transform row 2  (identity: 0 0 1 0)
240/// float  [12]     — INSTANCE_CUSTOM.r = slot as f32    ← slot index here
241/// float  [13]     — INSTANCE_CUSTOM.g = geomorph factor ← morph here
242/// floats [14..15] — INSTANCE_CUSTOM.b/a = 0
243/// ```
244///
245/// `visible_slots` and `morphs` are parallel arrays (one entry per drawn instance,
246/// same order); `morphs[i]` is the geomorph factor for the chunk in slot
247/// `visible_slots[i]` (`1` = full detail, `0` = parent resolution). Pass `1.0` for
248/// every entry to disable morphing.
249///
250/// The identity transform is intentional: the vertex shader reads vertex positions
251/// from the vertex-pool texture using the slot, so no CPU-side transform is needed.
252///
253/// # Panics
254/// Panics (debug) if `morphs.len() != visible_slots.len()`.
255pub fn pack_instances(visible_slots: &[u32], morphs: &[f32]) -> Vec<u8> {
256    debug_assert_eq!(
257        visible_slots.len(),
258        morphs.len(),
259        "pack_instances: slots/morphs length mismatch"
260    );
261    // Identity Transform3D rows as Godot stores them in the multimesh buffer.
262    const IDENTITY_TRANSFORM: [f32; 12] = [
263        1.0, 0.0, 0.0, 0.0, // row 0: basis-col-0 x, basis-col-1 x, basis-col-2 x, origin.x
264        0.0, 1.0, 0.0, 0.0, // row 1: basis-col-0 y, basis-col-1 y, basis-col-2 y, origin.y
265        0.0, 0.0, 1.0, 0.0, // row 2: basis-col-0 z, basis-col-1 z, basis-col-2 z, origin.z
266    ];
267
268    let mut buf = Vec::with_capacity(visible_slots.len() * 16 * std::mem::size_of::<f32>());
269    for (i, &slot) in visible_slots.iter().enumerate() {
270        let morph = morphs.get(i).copied().unwrap_or(1.0);
271        let floats: [f32; 16] = [
272            IDENTITY_TRANSFORM[0],
273            IDENTITY_TRANSFORM[1],
274            IDENTITY_TRANSFORM[2],
275            IDENTITY_TRANSFORM[3],
276            IDENTITY_TRANSFORM[4],
277            IDENTITY_TRANSFORM[5],
278            IDENTITY_TRANSFORM[6],
279            IDENTITY_TRANSFORM[7],
280            IDENTITY_TRANSFORM[8],
281            IDENTITY_TRANSFORM[9],
282            IDENTITY_TRANSFORM[10],
283            IDENTITY_TRANSFORM[11],
284            slot as f32, // float index 12 = INSTANCE_CUSTOM.r
285            morph,       // float index 13 = INSTANCE_CUSTOM.g (geomorph factor)
286            0.0,         // float index 14 = INSTANCE_CUSTOM.b
287            0.0,         // float index 15 = INSTANCE_CUSTOM.a
288        ];
289        buf.extend_from_slice(bytemuck::bytes_of(&floats));
290    }
291    buf
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use celestial_algo::clipmap::FaceFrame;
298    use celestial_algo::quadtree::{Bary, Chunk, ChunkId};
299    use godot::builtin::Vector3;
300
301    fn test_frame() -> FaceFrame {
302        FaceFrame {
303            a: Vector3::new(-50.0, 0.0, 28.8),
304            b: Vector3::new(50.0, 0.0, 28.8),
305            c: Vector3::new(0.0, 0.0, -57.7),
306            radius: 100.0,
307        }
308    }
309
310    fn test_chunk() -> Chunk {
311        Chunk {
312            id: ChunkId { face: 0, depth: 2, path: 5 },
313            bary: [
314                Bary { wb: 0.0, wc: 0.0 },
315                Bary { wb: 1.0, wc: 0.0 },
316                Bary { wb: 0.0, wc: 1.0 },
317            ],
318            corners: [
319                Vector3::ZERO,
320                Vector3::new(1.0, 0.0, 0.0),
321                Vector3::new(0.0, 1.0, 0.0),
322            ],
323            level: 2,
324        }
325    }
326
327    #[test]
328    fn chunk_gpu_layout_is_96_bytes() {
329        // std430: 16-byte alignment enforced by [f32;4] members; the struct stride
330        // must be a multiple of 16 or the shader reads chunks[i] at wrong offsets.
331        assert_eq!(std::mem::size_of::<ChunkGpu>(), 96);
332        assert_eq!(std::mem::size_of::<ChunkGpu>() % 16, 0);
333    }
334
335    #[test]
336    fn chunk_params_layout() {
337        use std::mem::offset_of;
338        // std430 array-stride rule: a StructuredBuffer<ChunkParams> stride must be
339        // a multiple of 16, or params[0] reads at the wrong offset.
340        assert_eq!(std::mem::size_of::<ChunkParams>(), 96);
341        assert_eq!(std::mem::size_of::<ChunkParams>() % 16, 0);
342        // Header offsets — must match ChunkRealize.slang / ChunkTileBake.slang.
343        assert_eq!(offset_of!(ChunkParams, res), 0);
344        assert_eq!(offset_of!(ChunkParams, verts_per_chunk), 4);
345        assert_eq!(offset_of!(ChunkParams, attr_w), 8);
346        assert_eq!(offset_of!(ChunkParams, chunk_count), 12);
347        // TerrainGpu sits immediately after the 16-byte header, no interior pad.
348        assert_eq!(offset_of!(ChunkParams, terrain), 16);
349        assert_eq!(std::mem::size_of::<TerrainGpu>(), 56);
350        // tile_res (Phase 4) follows the 56-byte terrain block at offset 72.
351        assert_eq!(offset_of!(ChunkParams, tile_res), 72);
352        assert_eq!(offset_of!(ChunkParams, bump_enable), 76);
353        assert_eq!(offset_of!(ChunkParams, surface_enabled), 80);
354        assert_eq!(offset_of!(ChunkParams, surface_height_scale), 84);
355        // Two tail pads keep the struct a 16-byte multiple (88 -> 96).
356        assert_eq!(offset_of!(ChunkParams, _pad0), 88);
357    }
358
359    #[test]
360    fn pack_params_roundtrips_header_and_terrain() {
361        let terrain = TerrainGpu {
362            frequency: 1.0,
363            height_octaves: 2.0,
364            height_amp: 3.0,
365            height_gain: 4.0,
366            height_lacunarity: 5.0,
367            ridge_tiles: 6.0,
368            ridge_octaves: 7.0,
369            ridge_gain: 8.0,
370            ridge_lacunarity: 9.0,
371            ridge_strength: 12.0,
372            water_height: 13.0,
373            height_scale: 14.0,
374            fd_eps: 15.0,
375            enabled: 0.0,
376        };
377        let bytes = pack_params(16, verts_per_chunk(16), 4096, 2, 32, 1.0, &terrain, 1.0, 0.25);
378        assert_eq!(bytes.len(), 96);
379        let cp: &ChunkParams = bytemuck::from_bytes(&bytes);
380        assert_eq!(cp.res, 16);
381        assert_eq!(cp.verts_per_chunk, verts_per_chunk(16));
382        assert_eq!(cp.attr_w, 4096);
383        assert_eq!(cp.chunk_count, 2);
384        assert_eq!(cp.terrain, terrain);
385        assert_eq!(cp.tile_res, 32);
386        assert_eq!(cp.bump_enable, 1.0);
387        assert_eq!(cp.surface_enabled, 1.0);
388        assert_eq!(cp.surface_height_scale, 0.25);
389    }
390
391    #[test]
392    fn verts_per_chunk_formula() {
393        // Interior is the triangular number (res+1)*(res+2)/2.
394        assert_eq!(interior_verts_per_chunk(1), 3); // (2*3)/2 = 3
395        assert_eq!(interior_verts_per_chunk(16), 153); // (17*18)/2 = 153
396        // Skirt adds 3*(res+1) ring verts (Phase 3).
397        assert_eq!(skirt_verts_per_chunk(1), 6); // 3*2
398        assert_eq!(skirt_verts_per_chunk(16), 51); // 3*17
399        // Total = interior + skirt.
400        assert_eq!(verts_per_chunk(1), 9); // 3 + 6
401        assert_eq!(verts_per_chunk(16), 204); // 153 + 51
402    }
403
404    #[test]
405    fn from_chunk_maps_fields_correctly() {
406        let frame = test_frame();
407        let chunk = test_chunk();
408        let g = ChunkGpu::from_chunk(&frame, &chunk, 42, 16);
409
410        // Frame corners — radius goes in frame_a.w, not b/c.
411        assert_eq!(g.frame_a, [frame.a.x, frame.a.y, frame.a.z, frame.radius]);
412        assert_eq!(g.frame_b, [frame.b.x, frame.b.y, frame.b.z, 0.0]);
413        assert_eq!(g.frame_c, [frame.c.x, frame.c.y, frame.c.z, 0.0]);
414
415        // Barycentric corners.
416        assert_eq!(g.bary0, [chunk.bary[0].wb, chunk.bary[0].wc]);
417        assert_eq!(g.bary1, [chunk.bary[1].wb, chunk.bary[1].wc]);
418        assert_eq!(g.bary2, [chunk.bary[2].wb, chunk.bary[2].wc]);
419
420        // Metadata.
421        assert_eq!(g.slot, 42);
422        assert_eq!(g.level, 2);
423        assert_eq!(g.res, 16);
424
425        // Padding must be zeroed (bytemuck Pod requires no uninit bytes).
426        assert_eq!(g._pad, [0u32; 3]);
427    }
428
429    #[test]
430    fn pack_chunks_length_matches_descriptor_size() {
431        let frame = test_frame();
432        let frames = vec![frame];
433        let chunk = test_chunk();
434        let realize = vec![(0u32, chunk), (1u32, chunk)];
435        let bytes = pack_chunks(&frames, &realize, 16);
436        assert_eq!(bytes.len(), realize.len() * std::mem::size_of::<ChunkGpu>());
437    }
438
439    #[test]
440    fn pack_instances_layout_and_slot_position() {
441        // Two slots with distinct morph factors; verify the 16-float / 64-byte blocks.
442        let slots = [5u32, 9u32];
443        let morphs = [0.25f32, 1.0f32];
444        let bytes = pack_instances(&slots, &morphs);
445
446        // 2 instances × 16 f32 × 4 bytes
447        assert_eq!(bytes.len(), 2 * 16 * 4);
448
449        let floats: &[f32] = bytemuck::cast_slice(&bytes);
450
451        // Identity transform: rows 0/1/2 of the first instance.
452        assert_eq!(&floats[0..4], &[1.0f32, 0.0, 0.0, 0.0]);
453        assert_eq!(&floats[4..8], &[0.0f32, 1.0, 0.0, 0.0]);
454        assert_eq!(&floats[8..12], &[0.0f32, 0.0, 1.0, 0.0]);
455
456        // Float index 12 = INSTANCE_CUSTOM.r = slot (documented layout contract).
457        assert_eq!(floats[12], 5.0, "first instance: slot at float index 12");
458        assert_eq!(floats[16 + 12], 9.0, "second instance: slot at float index 12");
459
460        // Float index 13 = INSTANCE_CUSTOM.g = geomorph factor (Phase 5 contract).
461        assert_eq!(floats[13], 0.25, "first instance: morph at float index 13");
462        assert_eq!(floats[16 + 13], 1.0, "second instance: morph at float index 13");
463
464        // Remaining custom channels (b/a) must be zero.
465        assert_eq!(floats[14], 0.0);
466        assert_eq!(floats[15], 0.0);
467        assert_eq!(floats[16 + 14], 0.0);
468        assert_eq!(floats[16 + 15], 0.0);
469    }
470}