Skip to main content

concinnity_core/gfx/
mesh_payload.rs

1//! Canonical vertex type and the binary serialisation format shared between
2//! the build step (build_mesh.rs writes) and GraphicsSystem (reads).
3//!
4//! The layout asserts below stay hand-written. A vertex payload reaches a shader
5//! through a vertex descriptor or a raw pointer, never as a declared buffer
6//! block, so slangc's reflection reports it as an attribute index with no byte
7//! offset -- the reflection-driven check in concinnity-device's `shader_layout`
8//! has nothing to compare against here.
9//!
10//! Format (little-endian):
11//!   u32  vertex_count
12//!   vertex_count * 56 bytes   float3 pos + float3 normal + float3 tangent + float3 color + float2 uv (14 x f32)
13//!   u32  index_count                              // LOD0 indices
14//!   index_count  * 2 bytes    u16 indices
15//!   optional LOD trailer
16//!   4 bytes                   ascii "LODS" magic (absent for legacy / single-LOD payloads)
17//!   u32  alt_count            // number of additional LODs beyond LOD0
18//!   alt_count × {
19//!     f32  switch_distance    // camera-distance threshold (LOD i+1 applies at d >= switch_distance)
20//!     u32  index_count
21//!     index_count * 2 bytes   u16 indices
22//!   }
23//!
24//! `deserialise` reads only the LOD0 indices and ignores any trailer, so old
25//! readers keep working unchanged. `deserialise_with_lods` reads the trailer
26//! when present and returns the additional LODs alongside LOD0.
27
28use crate::decode::{ByteReader, checked_product};
29use alloc::format;
30use alloc::string::String;
31use alloc::string::ToString;
32use alloc::vec::Vec;
33
34// The little-endian f32 at byte offset `at` of a fixed-size chunk.
35fn chunk_f32(chunk: &[u8], at: usize) -> f32 {
36    f32::from_le_bytes([chunk[at], chunk[at + 1], chunk[at + 2], chunk[at + 3]])
37}
38
39// The little-endian u32 at byte offset `at` of a fixed-size chunk.
40fn chunk_u32(chunk: &[u8], at: usize) -> u32 {
41    u32::from_le_bytes([chunk[at], chunk[at + 1], chunk[at + 2], chunk[at + 3]])
42}
43
44// The little-endian u16 at byte offset `at` of a fixed-size chunk.
45fn chunk_u16(chunk: &[u8], at: usize) -> u16 {
46    u16::from_le_bytes([chunk[at], chunk[at + 1]])
47}
48
49// Read a length-prefixed UTF-8 name, bounds-checking the whole block once.
50fn read_name(cur: &mut ByteReader<'_>, len: usize, what: &str) -> Result<String, String> {
51    core::str::from_utf8(cur.take(len)?)
52        .map_err(|e| format!("{what} is not valid utf-8: {e}"))
53        .map(str::to_string)
54}
55
56// Read `n` little-endian u16 indices, bounds-checking the whole block once.
57fn read_indices(cur: &mut ByteReader<'_>, n: usize, what: &str) -> Result<Vec<u16>, String> {
58    let block = cur.take(checked_product(what, &[n, 2])?)?;
59    Ok(block.chunks_exact(2).map(|c| chunk_u16(c, 0)).collect())
60}
61
62/// Vertex layout shared by all mesh producers and both GPU backends.
63/// Repr(C) so it can be cast directly to GPU buffer memory.
64#[derive(Copy, Clone, Debug, bytemuck::NoUninit)]
65#[repr(C)]
66pub struct Vertex {
67    /// Object-space position.
68    pub pos: [f32; 3],
69    /// Object-space surface normal, normalised. Transformed to world space in
70    /// the vertex shader. Used for diffuse lighting in the fragment shader.
71    pub normal: [f32; 3],
72    /// Object-space tangent vector (U direction of the normal map). Transformed
73    /// to world space in the vertex shader. Used to build the TBN matrix for
74    /// tangent-space normal mapping.
75    pub tangent: [f32; 3],
76    /// Linear RGB colour.
77    pub color: [f32; 3],
78    /// Texture coordinates in [0, 1] space.  (0,0) is top-left.
79    pub uv: [f32; 2],
80}
81
82// Interleaved vertex tuple the payload format stores: position, normal,
83// tangent, color, uv.
84type VertTuple = ([f32; 3], [f32; 3], [f32; 3], [f32; 3], [f32; 2]);
85
86// LOD alternates: (switch_distance, index buffer) pairs (LOD1..N).
87type LodAlternates = Vec<(f32, Vec<u16>)>;
88
89// Deserialised static mesh: vertices, LOD0 indices, and LOD alternates.
90type DeserialisedStatic = (Vec<Vertex>, Vec<u16>, LodAlternates);
91
92// Deserialised skinned mesh: vertices, indices, and the bind-pose skeleton.
93type DeserialisedSkinned = (Vec<SkinnedVertex>, Vec<u16>, Vec<PayloadJoint>);
94
95/// A fully deserialised skinned payload, including the optional morph and
96/// LOD blocks (empty when the payload carries none).
97#[derive(Clone, Debug, Default)]
98pub struct SkinnedPayload {
99    /// Skinned vertices.
100    pub vertices: Vec<SkinnedVertex>,
101    /// Triangle indices into `vertices`.
102    pub indices: Vec<u16>,
103    /// The bind-pose skeleton, parents before children.
104    pub joints: Vec<PayloadJoint>,
105    /// Morph-target block; empty when the mesh has no morphs.
106    pub morphs: PayloadMorphs,
107    /// LOD slices past LOD0; empty when the mesh declares one level.
108    pub lods: LodAlternates,
109}
110
111/// Serialise vertex and index slices into the packed binary payload format.
112/// Each vertex tuple is (pos, normal, tangent, color, uv).
113pub fn serialise(vertices: &[VertTuple], indices: &[u16]) -> Vec<u8> {
114    let mut buf = Vec::with_capacity(4 + vertices.len() * 56 + 4 + indices.len() * 2);
115    buf.extend_from_slice(&(vertices.len() as u32).to_le_bytes());
116    for (pos, normal, tangent, color, uv) in vertices {
117        for x in pos
118            .iter()
119            .chain(normal.iter())
120            .chain(tangent.iter())
121            .chain(color.iter())
122            .chain(uv.iter())
123        {
124            buf.extend_from_slice(&x.to_le_bytes());
125        }
126    }
127    buf.extend_from_slice(&(indices.len() as u32).to_le_bytes());
128    for i in indices {
129        buf.extend_from_slice(&i.to_le_bytes());
130    }
131    buf
132}
133
134// Magic header for the optional LOD trailer. Absent in legacy payloads so
135// `deserialise` keeps working without changes.
136const LODS_MAGIC: &[u8; 4] = b"LODS";
137
138/// Serialise a multi-LOD mesh payload. `indices` is LOD0; `lod_alternates`
139/// is the list of additional LODs (LOD1..N), each paired with the
140/// camera-distance threshold that triggers a switch to it. When
141/// `lod_alternates` is empty this is byte-identical to the single-LOD
142/// `serialise` output, so the build can call this unconditionally.
143pub fn serialise_with_lods(
144    vertices: &[VertTuple],
145    indices: &[u16],
146    lod_alternates: &[(f32, Vec<u16>)],
147) -> Vec<u8> {
148    let mut buf = serialise(vertices, indices);
149    if lod_alternates.is_empty() {
150        return buf;
151    }
152    buf.extend_from_slice(LODS_MAGIC);
153    buf.extend_from_slice(&(lod_alternates.len() as u32).to_le_bytes());
154    for (distance, idx) in lod_alternates {
155        buf.extend_from_slice(&distance.to_le_bytes());
156        buf.extend_from_slice(&(idx.len() as u32).to_le_bytes());
157        for i in idx {
158            buf.extend_from_slice(&i.to_le_bytes());
159        }
160    }
161    buf
162}
163
164// Magic header for the optional baked-heightfield collider trailer. Rides
165// after the (optional) LOD trailer on a `heightfield`-generator ProceduralMesh
166// payload so the physics terrain collider can read a ready-made height grid
167// instead of decoding the source image at runtime. `deserialise` and
168// `deserialise_with_lods` stop after the LOD block and ignore these bytes, so
169// the render path is unaffected and legacy payloads keep loading unchanged.
170const HFLD_MAGIC: &[u8; 4] = b"HFLD";
171
172/// A baked heightfield collider grid: `rows` x `cols` world-space heights in
173/// row-major order (row index increases along +Z, column index along +X),
174/// matching the vertex order the heightfield mesh generator emits.
175pub struct HeightfieldGrid {
176    /// Grid rows, increasing along +Z.
177    pub rows: usize,
178    /// Grid columns, increasing along +X.
179    pub cols: usize,
180    /// World-space heights, row-major.
181    pub heights: Vec<f32>,
182}
183
184/// Serialise a baked-heightfield collider trailer: `"HFLD"` magic, `u32 rows`,
185/// `u32 cols`, then `rows * cols` little-endian f32 heights in row-major order.
186/// Appended to a heightfield ProceduralMesh payload after the optional LOD
187/// trailer.
188pub fn serialise_heightfield_trailer(rows: usize, cols: usize, heights: &[f32]) -> Vec<u8> {
189    let mut buf = Vec::with_capacity(4 + 4 + 4 + heights.len() * 4);
190    buf.extend_from_slice(HFLD_MAGIC);
191    buf.extend_from_slice(&(rows as u32).to_le_bytes());
192    buf.extend_from_slice(&(cols as u32).to_le_bytes());
193    for h in heights {
194        buf.extend_from_slice(&h.to_le_bytes());
195    }
196    buf
197}
198
199/// Decode the baked-heightfield trailer from a static mesh payload, if present.
200/// The trailer rides at the very end, so this walks past the vertex, LOD0
201/// index, and optional LOD blocks positionally before reading the `"HFLD"`
202/// block. Returns `Ok(None)` for any payload without the trailer (i.e. every
203/// non-heightfield mesh) so callers can treat absence as "no baked collider".
204pub fn deserialise_heightfield(bytes: &[u8]) -> Result<Option<HeightfieldGrid>, String> {
205    let mut cur = ByteReader::new(bytes, "mesh payload");
206
207    // Vertex block (56 bytes each), then LOD0 indices (2 bytes each).
208    let vertex_count = cur.u32()? as usize;
209    cur.skip(checked_product("vertices", &[vertex_count, 56])?)?;
210    let index_count = cur.u32()? as usize;
211    cur.skip(checked_product("indices", &[index_count, 2])?)?;
212
213    // Optional LOD trailer: skip the whole block when present so the cursor
214    // lands on the HFLD trailer (if any) that follows it.
215    if cur.peek(LODS_MAGIC) {
216        cur.skip(4)?;
217        let alt_count = cur.u32()? as usize;
218        for _ in 0..alt_count {
219            cur.skip(4)?; // switch distance (f32)
220            let n = cur.u32()? as usize;
221            cur.skip(checked_product("lod indices", &[n, 2])?)?;
222        }
223    }
224
225    // Optional HFLD trailer.
226    if !cur.peek(HFLD_MAGIC) {
227        return Ok(None);
228    }
229    cur.skip(4)?;
230    let rows = cur.u32()? as usize;
231    let cols = cur.u32()? as usize;
232    let count = checked_product("heightfield grid", &[rows, cols])?;
233    let block = cur
234        .take(checked_product("heightfield grid", &[count, 4])?)
235        .map_err(|_| format!("heightfield trailer too short for {rows} x {cols} grid"))?;
236    let heights = block.chunks_exact(4).map(|h| chunk_f32(h, 0)).collect();
237    Ok(Some(HeightfieldGrid {
238        rows,
239        cols,
240        heights,
241    }))
242}
243
244/// Vertex layout for skeletally animated meshes. A superset of `Vertex`: the
245/// same 56-byte static attributes plus four joint indices and four blend
246/// weights. `repr(C)`, 80 bytes, so it casts directly to a GPU buffer.
247///
248/// The vertex shader skins `pos` / `normal` / `tangent` by blending up to four
249/// joint matrices: `sum(weights[k] * joint[joints[k]] * v)`. Weights that sum
250/// to less than 1 leave the remainder un-skinned; the build step normalises
251/// them so this never happens for authored meshes.
252#[derive(Copy, Clone, Debug, PartialEq, bytemuck::NoUninit)]
253#[repr(C)]
254pub struct SkinnedVertex {
255    /// Object-space position.
256    pub pos: [f32; 3],
257    /// Unit-length normal.
258    pub normal: [f32; 3],
259    /// Object-space tangent, the normal map's U direction.
260    pub tangent: [f32; 3],
261    /// Linear RGB colour.
262    pub color: [f32; 3],
263    /// Texture coordinates.
264    pub uv: [f32; 2],
265    /// Indices into the skeleton's joint array, one per blend weight.
266    pub joints: [u16; 4],
267    /// Blend weights, parallel to `joints`. Normalised at build time.
268    pub weights: [f32; 4],
269}
270
271// Magic header for the skinned-mesh binary payload. Distinguishes a skinned
272// blob from the headerless static `Vertex` format so a mismatched payload
273// fails loudly instead of being misread.
274const SKINNED_MAGIC: &[u8; 4] = b"SKMV";
275
276// Magic for the optional sparse morph-target block after the joint block.
277const MORPH_MAGIC: &[u8; 4] = b"MRPS";
278
279pub use super::morph_targets::{MORPH_DELTA_EPSILON, MorphDelta, MorphEntry, PayloadMorphs};
280
281/// One joint of a skinned mesh's bind-pose skeleton, as stored in the
282/// compiled payload. Mirrors `assets::skinned_mesh::SkeletonJoint` but lives in
283/// `gfx` so the payload format stays self-contained: the build/runtime
284/// boundaries convert between the two. Parents must appear before their
285/// children, so the runtime can walk the array once when building the
286/// `Skeleton`.
287#[derive(Clone, Debug, PartialEq)]
288pub struct PayloadJoint {
289    /// The joint's authored name.
290    pub name: String,
291    /// Index of the parent joint, or -1 for a root.
292    pub parent: i32,
293    /// Bind-pose local translation.
294    pub translation: [f32; 3],
295    /// YXZ Euler rotation in degrees.
296    pub rotation_deg: [f32; 3],
297    /// Per-axis scale.
298    pub scale: [f32; 3],
299}
300
301// Serialise skinned vertices, indices, and bind-pose skeleton into a packed
302// binary payload.
303//
304// Format (little-endian): `"SKMV"` magic, `u32 vertex_count`,
305// `vertex_count * 80` bytes of interleaved `SkinnedVertex` data,
306// `u32 index_count`, `index_count * 2` bytes of u16 indices,
307// `u32 joint_count`, then `joint_count` joint records, each:
308// `u32 name_byte_len`, name UTF-8 bytes, `i32 parent`,
309// `f32×3 translation`, `f32×3 rotation_deg`, `f32×3 scale`.
310//
311// The skeleton block is always present (possibly with `joint_count == 0`),
312// so a payload deserialises into a self-contained runtime view, no need
313// for the args JSON to carry the skeleton alongside.
314//
315// Calls [`serialise_skinned_with_lods`] with an empty alternates list, so
316// the on-wire format is identical to the legacy single-LOD payload when
317// no alternates are present.
318#[cfg(test)]
319pub(crate) fn serialise_skinned(
320    vertices: &[SkinnedVertex],
321    indices: &[u16],
322    joints: &[PayloadJoint],
323) -> Vec<u8> {
324    serialise_skinned_with_lods(vertices, indices, joints, &PayloadMorphs::default(), &[])
325}
326
327/// Serialise a multi-LOD skinned mesh. Two optional blocks ride after the
328/// joint block, each announced by a magic: `"MRPS"` (`u32 target_count`, per
329/// target `u32 name_byte_len` + name UTF-8 bytes, then `u32 entry_count`,
330/// `(vertex_count + 1) * 4` bytes of u32 entry offsets and `entry_count * 28`
331/// bytes of sparse [`MorphEntry`]s, see [`PayloadMorphs`]) and `"LODS"`
332/// (`u32 alt_count`, then per alternate `f32 switch_distance`,
333/// `u32 index_count`, `index_count * 2` bytes of u16 indices). Empty morphs
334/// and alternates match the legacy single-LOD payload byte-for-byte.
335pub fn serialise_skinned_with_lods(
336    vertices: &[SkinnedVertex],
337    indices: &[u16],
338    joints: &[PayloadJoint],
339    morphs: &PayloadMorphs,
340    lod_alternates: &[(f32, Vec<u16>)],
341) -> Vec<u8> {
342    let mut buf = Vec::with_capacity(4 + 4 + vertices.len() * 80 + 4 + indices.len() * 2 + 4);
343    buf.extend_from_slice(SKINNED_MAGIC);
344    buf.extend_from_slice(&(vertices.len() as u32).to_le_bytes());
345    for v in vertices {
346        for f in v
347            .pos
348            .iter()
349            .chain(v.normal.iter())
350            .chain(v.tangent.iter())
351            .chain(v.color.iter())
352            .chain(v.uv.iter())
353        {
354            buf.extend_from_slice(&f.to_le_bytes());
355        }
356        for j in v.joints {
357            buf.extend_from_slice(&j.to_le_bytes());
358        }
359        for w in v.weights {
360            buf.extend_from_slice(&w.to_le_bytes());
361        }
362    }
363    buf.extend_from_slice(&(indices.len() as u32).to_le_bytes());
364    for i in indices {
365        buf.extend_from_slice(&i.to_le_bytes());
366    }
367    buf.extend_from_slice(&(joints.len() as u32).to_le_bytes());
368    for j in joints {
369        let name_bytes = j.name.as_bytes();
370        buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
371        buf.extend_from_slice(name_bytes);
372        buf.extend_from_slice(&j.parent.to_le_bytes());
373        for x in j
374            .translation
375            .iter()
376            .chain(j.rotation_deg.iter())
377            .chain(j.scale.iter())
378        {
379            buf.extend_from_slice(&x.to_le_bytes());
380        }
381    }
382    if !morphs.is_empty() {
383        buf.extend_from_slice(MORPH_MAGIC);
384        buf.extend_from_slice(&(morphs.names.len() as u32).to_le_bytes());
385        for name in &morphs.names {
386            let name_bytes = name.as_bytes();
387            buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
388            buf.extend_from_slice(name_bytes);
389        }
390        buf.extend_from_slice(&(morphs.entries.len() as u32).to_le_bytes());
391        for o in &morphs.offsets {
392            buf.extend_from_slice(&o.to_le_bytes());
393        }
394        for e in &morphs.entries {
395            buf.extend_from_slice(&e.target.to_le_bytes());
396            for x in e.position.iter().chain(e.normal.iter()) {
397                buf.extend_from_slice(&x.to_le_bytes());
398            }
399        }
400    }
401    if !lod_alternates.is_empty() {
402        buf.extend_from_slice(LODS_MAGIC);
403        buf.extend_from_slice(&(lod_alternates.len() as u32).to_le_bytes());
404        for (distance, idx) in lod_alternates {
405            buf.extend_from_slice(&distance.to_le_bytes());
406            buf.extend_from_slice(&(idx.len() as u32).to_le_bytes());
407            for i in idx {
408                buf.extend_from_slice(&i.to_le_bytes());
409            }
410        }
411    }
412    buf
413}
414
415/// Deserialise a packed skinned-mesh payload produced by `serialise_skinned`.
416/// The returned skeleton lives in the payload; the args JSON no longer needs
417/// to carry it. The optional LOD trailer is parsed and discarded; callers
418/// who need LOD alternates should use [`deserialise_skinned_with_lods`].
419pub fn deserialise_skinned(bytes: &[u8]) -> Result<DeserialisedSkinned, String> {
420    let p = deserialise_skinned_with_lods(bytes)?;
421    Ok((p.vertices, p.indices, p.joints))
422}
423
424/// Deserialise a packed skinned-mesh payload, also returning any optional
425/// LOD trailer. Mirrors [`deserialise_with_lods`] for static meshes:
426/// legacy single-LOD payloads have no trailer and produce an empty
427/// alternates vec.
428pub fn deserialise_skinned_with_lods(bytes: &[u8]) -> Result<SkinnedPayload, String> {
429    if bytes.len() < 8 || &bytes[0..4] != SKINNED_MAGIC {
430        return Err("skinned mesh payload missing SKMV magic header".to_string());
431    }
432    let mut cur = ByteReader::new(bytes, "skinned mesh payload");
433    cur.skip(4)?;
434
435    let vertex_count = cur.u32()? as usize;
436    let vertices = read_skinned_vertices(&mut cur, vertex_count)?;
437
438    let index_count = cur.u32()? as usize;
439    let indices = read_indices(&mut cur, index_count, "indices")?;
440
441    let joint_count = cur.u32()? as usize;
442    let mut joints_out = Vec::with_capacity(joint_count);
443    for _ in 0..joint_count {
444        let name_len = cur.u32()? as usize;
445        let name = read_name(&mut cur, name_len, "joint name")?;
446        let parent = cur.i32()?;
447        let mut t = [0f32; 3];
448        for x in &mut t {
449            *x = cur.f32()?;
450        }
451        let mut r = [0f32; 3];
452        for x in &mut r {
453            *x = cur.f32()?;
454        }
455        let mut s = [0f32; 3];
456        for x in &mut s {
457            *x = cur.f32()?;
458        }
459        joints_out.push(PayloadJoint {
460            name,
461            parent,
462            translation: t,
463            rotation_deg: r,
464            scale: s,
465        });
466    }
467
468    // Optional morph-target block: names, then the sparse offsets + entries.
469    let mut morphs = PayloadMorphs::default();
470    if cur.peek(MORPH_MAGIC) {
471        cur.skip(4)?;
472        let target_count = cur.u32()? as usize;
473        for _ in 0..target_count {
474            let name_len = cur.u32()? as usize;
475            morphs
476                .names
477                .push(read_name(&mut cur, name_len, "morph target name")?);
478        }
479        let entry_count = cur.u32()? as usize;
480        let block = cur.take(checked_product("morph offsets", &[vertex_count + 1, 4])?)?;
481        morphs
482            .offsets
483            .extend(block.chunks_exact(4).map(|c| chunk_u32(c, 0)));
484        let block = cur.take(checked_product("morph entries", &[entry_count, 28])?)?;
485        morphs.entries.extend(block.chunks_exact(28).map(|e| {
486            let f = |i: usize| chunk_f32(e, i * 4);
487            MorphEntry {
488                target: chunk_u32(e, 0),
489                position: [f(1), f(2), f(3)],
490                normal: [f(4), f(5), f(6)],
491            }
492        }));
493        morphs
494            .validate()
495            .map_err(|e| format!("skinned mesh payload morph block: {e}"))?;
496    }
497
498    // Optional LOD trailer (mirrors the static-mesh format): legacy
499    // single-LOD payloads end at the joint block; if the next four bytes
500    // are the `LODS` magic, the alternates follow.
501    let mut alternates: Vec<(f32, Vec<u16>)> = Vec::new();
502    if cur.peek(LODS_MAGIC) {
503        cur.skip(4)?;
504        let alt_count = cur.u32()? as usize;
505        alternates.reserve(alt_count);
506        for _ in 0..alt_count {
507            let distance = cur.f32()?;
508            let n = cur.u32()? as usize;
509            let alt = read_indices(&mut cur, n, "LOD indices")?;
510            alternates.push((distance, alt));
511        }
512    }
513
514    Ok(SkinnedPayload {
515        vertices,
516        indices,
517        joints: joints_out,
518        morphs,
519        lods: alternates,
520    })
521}
522
523/// Deserialise a packed payload, also returning any optional LOD trailer.
524/// Legacy single-LOD payloads have no trailer and produce an empty
525/// alternates vec; multi-LOD payloads parse the `"LODS"` block after the
526/// LOD0 indices and return one entry per additional level. The order is
527/// preserved: `alternates[i]` is LOD `i + 1` and applies at camera
528/// distance ≥ `alternates[i].0`.
529pub fn deserialise_with_lods(bytes: &[u8]) -> Result<DeserialisedStatic, String> {
530    let mut cur = ByteReader::new(bytes, "mesh payload");
531
532    let vertex_count = cur.u32()? as usize;
533    let vertices = read_vertices(&mut cur, vertex_count)?;
534
535    let index_count = cur.u32()? as usize;
536    let indices = read_indices(&mut cur, index_count, "indices")?;
537
538    // Optional LOD trailer. The legacy single-LOD payload ends here; check
539    // for the `LODS` magic before reading anything more.
540    let mut alternates = Vec::new();
541    if cur.peek(LODS_MAGIC) {
542        cur.skip(4)?;
543        let alt_count = cur.u32()? as usize;
544        alternates.reserve(alt_count);
545        for _ in 0..alt_count {
546            let distance = cur.f32()?;
547            let n = cur.u32()? as usize;
548            let alt = read_indices(&mut cur, n, "LOD indices")?;
549            alternates.push((distance, alt));
550        }
551    }
552
553    Ok((vertices, indices, alternates))
554}
555
556// Read `count` interleaved skinned vertices (14 floats + 4 u16 joints + 4
557// weights = 80 bytes), bounds-checking the whole block once.
558fn read_skinned_vertices(
559    cur: &mut ByteReader<'_>,
560    count: usize,
561) -> Result<Vec<SkinnedVertex>, String> {
562    let block = cur.take(checked_product("skinned vertices", &[count, 80])?)?;
563    Ok(block
564        .chunks_exact(80)
565        .map(|v| {
566            let f = |i: usize| chunk_f32(v, i * 4);
567            let j = |i: usize| chunk_u16(v, 56 + i * 2);
568            let w = |i: usize| chunk_f32(v, 64 + i * 4);
569            SkinnedVertex {
570                pos: [f(0), f(1), f(2)],
571                normal: [f(3), f(4), f(5)],
572                tangent: [f(6), f(7), f(8)],
573                color: [f(9), f(10), f(11)],
574                uv: [f(12), f(13)],
575                joints: [j(0), j(1), j(2), j(3)],
576                weights: [w(0), w(1), w(2), w(3)],
577            }
578        })
579        .collect())
580}
581
582// Read `count` interleaved 14-float vertices, bounds-checking the whole block
583// once rather than per field.
584fn read_vertices(cur: &mut ByteReader<'_>, count: usize) -> Result<Vec<Vertex>, String> {
585    let block = cur.take(checked_product("vertices", &[count, 56])?)?;
586    Ok(block
587        .chunks_exact(56)
588        .map(|v| {
589            let f = |i: usize| chunk_f32(v, i * 4);
590            Vertex {
591                pos: [f(0), f(1), f(2)],
592                normal: [f(3), f(4), f(5)],
593                tangent: [f(6), f(7), f(8)],
594                color: [f(9), f(10), f(11)],
595                uv: [f(12), f(13)],
596            }
597        })
598        .collect())
599}
600/// Deserialise a packed payload back into typed vertex and index vecs (static),
601/// ignoring any LOD trailer.
602#[cfg(test)]
603pub fn deserialise(bytes: &[u8]) -> Result<(Vec<Vertex>, Vec<u16>), String> {
604    let (vertices, indices, _) = deserialise_with_lods(bytes)?;
605    Ok((vertices, indices))
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use alloc::vec;
612
613    fn sample_skinned() -> Vec<SkinnedVertex> {
614        vec![
615            SkinnedVertex {
616                pos: [1.0, 2.0, 3.0],
617                normal: [0.0, 1.0, 0.0],
618                tangent: [1.0, 0.0, 0.0],
619                color: [0.5, 0.6, 0.7],
620                uv: [0.25, 0.75],
621                joints: [0, 1, 2, 3],
622                weights: [0.5, 0.3, 0.2, 0.0],
623            },
624            SkinnedVertex {
625                pos: [-4.0, 5.0, -6.0],
626                normal: [0.0, 0.0, 1.0],
627                tangent: [0.0, 1.0, 0.0],
628                color: [1.0, 1.0, 1.0],
629                uv: [0.0, 1.0],
630                joints: [7, 0, 0, 0],
631                weights: [1.0, 0.0, 0.0, 0.0],
632            },
633        ]
634    }
635
636    fn sample_skeleton() -> Vec<PayloadJoint> {
637        vec![
638            PayloadJoint {
639                name: "root".to_string(),
640                parent: -1,
641                translation: [0.0, 0.0, 0.0],
642                rotation_deg: [0.0, 0.0, 0.0],
643                scale: [1.0, 1.0, 1.0],
644            },
645            PayloadJoint {
646                name: "tip".to_string(),
647                parent: 0,
648                translation: [0.0, 1.0, 0.0],
649                rotation_deg: [0.0, 0.0, 0.0],
650                scale: [1.0, 1.0, 1.0],
651            },
652        ]
653    }
654
655    #[test]
656    fn skinned_roundtrip_preserves_data() {
657        let verts = sample_skinned();
658        let idxs = vec![0u16, 1, 0];
659        let skel = sample_skeleton();
660        let bytes = serialise_skinned(&verts, &idxs, &skel);
661        let (out_v, out_i, out_s) = deserialise_skinned(&bytes).expect("deserialise");
662        assert_eq!(out_v, verts);
663        assert_eq!(out_i, idxs);
664        assert_eq!(out_s, skel);
665    }
666
667    #[test]
668    fn skinned_roundtrip_with_empty_skeleton_keeps_trailer_present() {
669        // joint_count == 0 still emits the u32 length prefix, so the format
670        // is uniform regardless of whether the asset declared a skeleton.
671        let verts = sample_skinned();
672        let idxs = vec![0u16, 1, 0];
673        let bytes = serialise_skinned(&verts, &idxs, &[]);
674        let (out_v, out_i, out_s) = deserialise_skinned(&bytes).expect("deserialise");
675        assert_eq!(out_v, verts);
676        assert_eq!(out_i, idxs);
677        assert!(out_s.is_empty());
678    }
679
680    #[test]
681    fn skinned_payload_size_is_predictable() {
682        // magic + vert_count + 2*vertex + idx_count + 3*idx + joint_count
683        // + per-joint: name_len + name + parent + 3*vec3.
684        let skel = sample_skeleton();
685        let bytes = serialise_skinned(&sample_skinned(), &[0u16, 1, 0], &skel);
686        let per_joint = skel
687            .iter()
688            .map(|j| 4 + j.name.len() + 4 + 12 + 12 + 12)
689            .sum::<usize>();
690        assert_eq!(bytes.len(), 4 + 4 + 2 * 80 + 4 + 3 * 2 + 4 + per_joint);
691    }
692
693    #[test]
694    fn vertex_layout_matches_msl() {
695        // `Vertex` is read through a pointer by the RT skinning kernel
696        // (the deformed-vertex layout rt_skin.slang writes, 56-byte
697        // stride) and as the static RT vertex format, so the field offsets
698        // must match exactly. The main/shadow passes consume it through a
699        // vertex descriptor declaring the same 0/12/24/36/48 attribute offsets.
700        use core::mem::{offset_of, size_of};
701        assert_eq!(size_of::<Vertex>(), 56);
702        assert_eq!(offset_of!(Vertex, pos), 0);
703        assert_eq!(offset_of!(Vertex, normal), 12);
704        assert_eq!(offset_of!(Vertex, tangent), 24);
705        assert_eq!(offset_of!(Vertex, color), 36);
706        assert_eq!(offset_of!(Vertex, uv), 48);
707    }
708
709    #[test]
710    fn skinned_vertex_layout_matches_msl() {
711        // `SkinnedVertex` is read through a pointer by the RT skinning kernel
712        // (the bind-pose layout rt_skin.slang reads), whose float3s + u16[4] +
713        // packed_float4 fields must line up byte-for-byte with this 80-byte
714        // struct. The main/shadow skinned passes consume it through a vertex
715        // descriptor declaring the same attribute offsets.
716        use core::mem::{offset_of, size_of};
717        assert_eq!(size_of::<SkinnedVertex>(), 80);
718        assert_eq!(offset_of!(SkinnedVertex, pos), 0);
719        assert_eq!(offset_of!(SkinnedVertex, normal), 12);
720        assert_eq!(offset_of!(SkinnedVertex, tangent), 24);
721        assert_eq!(offset_of!(SkinnedVertex, color), 36);
722        assert_eq!(offset_of!(SkinnedVertex, uv), 48);
723        assert_eq!(offset_of!(SkinnedVertex, joints), 56);
724        assert_eq!(offset_of!(SkinnedVertex, weights), 64);
725    }
726
727    #[test]
728    fn deserialise_skinned_rejects_missing_magic() {
729        // The static payload format has no magic header, so feeding one in
730        // must be rejected rather than silently misread.
731        let static_bytes = serialise(&[([0.0; 3], [0.0; 3], [0.0; 3], [1.0; 3], [0.0; 2])], &[]);
732        assert!(deserialise_skinned(&static_bytes).is_err());
733    }
734
735    fn sample_skinned_vertex(pos: [f32; 3]) -> SkinnedVertex {
736        SkinnedVertex {
737            pos,
738            normal: [0.0, 1.0, 0.0],
739            tangent: [1.0, 0.0, 0.0],
740            color: [1.0; 3],
741            uv: [0.0, 0.0],
742            joints: [0; 4],
743            weights: [1.0, 0.0, 0.0, 0.0],
744        }
745    }
746
747    #[test]
748    fn skinned_payload_round_trips_the_morph_block() {
749        let vertices = vec![
750            sample_skinned_vertex([0.0, 0.0, 0.0]),
751            sample_skinned_vertex([1.0, 0.0, 0.0]),
752        ];
753        let joints = vec![PayloadJoint {
754            name: "root".to_string(),
755            parent: -1,
756            translation: [0.0; 3],
757            rotation_deg: [0.0; 3],
758            scale: [1.0; 3],
759        }];
760        let dense = vec![
761            MorphDelta {
762                position: [0.1, 0.2, 0.3],
763                normal: [0.0, 0.0, 1.0],
764            },
765            MorphDelta::default(),
766            MorphDelta::default(),
767            MorphDelta {
768                position: [-0.5, 0.0, 0.0],
769                normal: [0.0, 1.0, 0.0],
770            },
771        ];
772        let morphs =
773            PayloadMorphs::from_dense(vec!["smile".to_string(), "blink".to_string()], 2, &dense)
774                .expect("sparse");
775        assert_eq!(
776            morphs.entries.len(),
777            2,
778            "only the two non-zero deltas are stored"
779        );
780        let lods = vec![(9.0_f32, vec![0u16, 1, 0])];
781        let bytes = serialise_skinned_with_lods(&vertices, &[0, 1, 0], &joints, &morphs, &lods);
782        let p = deserialise_skinned_with_lods(&bytes).expect("deserialise");
783        assert_eq!(p.vertices.len(), 2);
784        assert_eq!(p.joints.len(), 1);
785        assert_eq!(p.morphs, morphs, "morph block must round-trip exactly");
786        assert_eq!(
787            p.morphs.to_dense(),
788            dense,
789            "sparse block expands to the source"
790        );
791        assert_eq!(p.lods.len(), 1, "LOD trailer must survive after MRPS");
792        assert_eq!(p.lods[0].1, vec![0u16, 1, 0]);
793    }
794
795    #[test]
796    fn a_morph_block_whose_tables_disagree_is_rejected() {
797        let vertices = vec![sample_skinned_vertex([0.0, 0.0, 0.0])];
798        let morphs = PayloadMorphs {
799            names: vec!["t".to_string()],
800            offsets: vec![0, 1],
801            entries: vec![MorphEntry {
802                target: 3,
803                position: [1.0, 0.0, 0.0],
804                normal: [0.0; 3],
805            }],
806        };
807        let bytes = serialise_skinned_with_lods(&vertices, &[0, 0, 0], &[], &morphs, &[]);
808        let err = deserialise_skinned_with_lods(&bytes).unwrap_err();
809        assert!(err.contains("morph block"), "{err}");
810        assert!(err.contains("target 3 of 1"), "{err}");
811    }
812
813    #[test]
814    fn skinned_payload_without_morphs_is_byte_identical_to_legacy() {
815        let vertices = vec![sample_skinned_vertex([0.0, 0.0, 0.0])];
816        let legacy = serialise_skinned(&vertices, &[0, 0, 0], &[]);
817        let with_empty =
818            serialise_skinned_with_lods(&vertices, &[0, 0, 0], &[], &PayloadMorphs::default(), &[]);
819        assert_eq!(legacy, with_empty, "empty morphs must add no bytes");
820        let p = deserialise_skinned_with_lods(&legacy).expect("deserialise");
821        assert!(p.morphs.is_empty());
822    }
823
824    fn sample_static_verts() -> Vec<VertTuple> {
825        vec![
826            (
827                [0.0, 0.0, 0.0],
828                [0.0, 1.0, 0.0],
829                [1.0, 0.0, 0.0],
830                [1.0; 3],
831                [0.0, 0.0],
832            ),
833            (
834                [1.0, 0.0, 0.0],
835                [0.0, 1.0, 0.0],
836                [1.0, 0.0, 0.0],
837                [1.0; 3],
838                [1.0, 0.0],
839            ),
840            (
841                [0.0, 0.0, 1.0],
842                [0.0, 1.0, 0.0],
843                [1.0, 0.0, 0.0],
844                [1.0; 3],
845                [0.0, 1.0],
846            ),
847        ]
848    }
849
850    #[test]
851    fn serialise_with_no_lods_matches_legacy_format() {
852        let verts = sample_static_verts();
853        let idx = vec![0u16, 1, 2];
854        let legacy = serialise(&verts, &idx);
855        let with_lods = serialise_with_lods(&verts, &idx, &[]);
856        assert_eq!(legacy, with_lods, "no alternates → no trailer bytes");
857    }
858
859    #[test]
860    fn lod_trailer_roundtrip_preserves_distances_and_indices() {
861        let verts = sample_static_verts();
862        let lod0 = vec![0u16, 1, 2];
863        let alternates = vec![(8.0_f32, vec![0u16, 2, 1]), (25.0_f32, vec![0u16, 1, 2])];
864        let bytes = serialise_with_lods(&verts, &lod0, &alternates);
865        let (out_v, out_idx, out_alts) = deserialise_with_lods(&bytes).expect("deserialise");
866        assert_eq!(out_v.len(), verts.len());
867        assert_eq!(out_idx, lod0);
868        assert_eq!(out_alts.len(), 2);
869        assert_eq!(out_alts[0].0, 8.0);
870        assert_eq!(out_alts[0].1, vec![0u16, 2, 1]);
871        assert_eq!(out_alts[1].0, 25.0);
872        assert_eq!(out_alts[1].1, vec![0u16, 1, 2]);
873    }
874
875    #[test]
876    fn legacy_payload_has_no_alternates() {
877        // A payload written by the single-LOD `serialise` must deserialise via
878        // `deserialise_with_lods` with an empty alternates vec: backward
879        // compatibility for every existing on-disk blob.
880        let verts = sample_static_verts();
881        let idx = vec![0u16, 1, 2];
882        let bytes = serialise(&verts, &idx);
883        let (_, _, alts) = deserialise_with_lods(&bytes).expect("deserialise");
884        assert!(alts.is_empty());
885    }
886
887    #[test]
888    fn heightfield_trailer_roundtrips_without_lods() {
889        let verts = sample_static_verts();
890        let idx = vec![0u16, 1, 2];
891        let heights = vec![0.0f32, 1.0, 2.0, 3.0];
892        let mut bytes = serialise_with_lods(&verts, &idx, &[]);
893        bytes.extend_from_slice(&serialise_heightfield_trailer(2, 2, &heights));
894
895        let grid = deserialise_heightfield(&bytes)
896            .expect("parse")
897            .expect("trailer present");
898        assert_eq!(grid.rows, 2);
899        assert_eq!(grid.cols, 2);
900        assert_eq!(grid.heights, heights);
901
902        // The render path ignores the trailer entirely.
903        let (out_v, out_i, out_alts) = deserialise_with_lods(&bytes).expect("render path");
904        assert_eq!(out_v.len(), verts.len());
905        assert_eq!(out_i, idx);
906        assert!(out_alts.is_empty());
907    }
908
909    #[test]
910    fn heightfield_trailer_roundtrips_after_lod_trailer() {
911        let verts = sample_static_verts();
912        let lod0 = vec![0u16, 1, 2];
913        let alternates = vec![(8.0_f32, vec![0u16, 2, 1]), (25.0_f32, vec![0u16, 1, 2])];
914        let heights = vec![-1.0f32, 0.5, 0.5, 1.0, 2.0, 2.5, 3.0, 3.5, 4.0];
915        let mut bytes = serialise_with_lods(&verts, &lod0, &alternates);
916        bytes.extend_from_slice(&serialise_heightfield_trailer(3, 3, &heights));
917
918        // Both trailers parse independently from the same payload.
919        let (_, out_i, out_alts) = deserialise_with_lods(&bytes).expect("render path");
920        assert_eq!(out_i, lod0);
921        assert_eq!(out_alts.len(), 2);
922
923        let grid = deserialise_heightfield(&bytes)
924            .expect("parse")
925            .expect("trailer present");
926        assert_eq!((grid.rows, grid.cols), (3, 3));
927        assert_eq!(grid.heights, heights);
928    }
929
930    #[test]
931    fn a_heightfield_trailer_whose_footprint_overflows_is_rejected() {
932        // `rows * cols` fits a usize while the byte footprint `* 4` does not, so
933        // checking only the texel count leaves the multiply to wrap: the read
934        // then succeeds against an empty slice and hands back a grid whose
935        // declared extent has no heights behind it, which every consumer indexes
936        // straight off the end.
937        let verts = sample_static_verts();
938        let mut bytes = serialise_with_lods(&verts, &[0u16, 1, 2], &[]);
939        bytes.extend_from_slice(HFLD_MAGIC);
940        bytes.extend_from_slice(&0x8000_0000u32.to_le_bytes());
941        bytes.extend_from_slice(&0x8000_0000u32.to_le_bytes());
942
943        let err = match deserialise_heightfield(&bytes) {
944            Err(e) => e,
945            Ok(_) => panic!("an overflowing grid must be rejected"),
946        };
947        assert!(err.contains("heightfield grid"), "{err}");
948    }
949
950    #[test]
951    fn no_heightfield_trailer_returns_none() {
952        let verts = sample_static_verts();
953        let bytes = serialise_with_lods(&verts, &[0u16, 1, 2], &[(10.0, vec![0u16, 2, 1])]);
954        assert!(deserialise_heightfield(&bytes).expect("parse").is_none());
955    }
956
957    #[test]
958    fn legacy_deserialise_still_works_on_multi_lod_payload() {
959        // The legacy `deserialise` reader must keep ignoring the LODS
960        // trailer so any code path that didn't migrate yet still loads
961        // LOD0 from a multi-LOD payload.
962        let verts = sample_static_verts();
963        let lod0 = vec![0u16, 1, 2];
964        let bytes = serialise_with_lods(&verts, &lod0, &[(10.0, vec![0u16, 2, 1])]);
965        let (out_v, out_idx) = deserialise(&bytes).expect("legacy reader");
966        assert_eq!(out_v.len(), verts.len());
967        assert_eq!(out_idx, lod0);
968    }
969}