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}