Skip to main content

unlit_wgpu/
mesh.rs

1//! Vertex compression and the per-mesh decode metadata.
2//!
3//! The built-in pipeline consumes a compact vertex layout: positions and UVs
4//! are 16-bit normalized integers relative to a per-mesh bounding box, so the
5//! shader needs the parameters that reverse that encoding. [`MeshMetadata`]
6//! carries those parameters; [`compress_positions`] and [`compress_uvs`]
7//! produce them together with the packed attribute streams.
8//!
9//! The Rust side and the WGSL side (`mesh_metadata.wesl`) are kept in sync by
10//! the crate. [`SecondaryStreamWriter`] and [`PositionStreamWriter`] pack the
11//! per-vertex streams a draw declares, whichever pipeline then draws them.
12//!
13//! # One description per stream
14//!
15//! A stream is described by [`StreamChannels`]: one attribute location and
16//! [`ChannelEncoding`] per channel, in the order the channels are interleaved.
17//! The pipeline's vertex layout, the compression that packs a mesh and the
18//! streams a pool allocates all derive their bytes per vertex from that same
19//! description, so the three cannot drift apart.
20
21use wgpu::WriteOnly;
22
23/// How one compressed channel is encoded as a vertex attribute.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub enum ChannelEncoding {
26    /// An AABB-quantized position: `Snorm16x4`, decoded through
27    /// [`MeshMetadata`]'s bounding box.
28    CompressedPosition,
29    /// A full-precision position: `Float32x3`.
30    UncompressedPosition,
31    /// A full-precision planar position: `Float32x2`. The missing third
32    /// component reads as zero, which is what geometry that already lies in the
33    /// target's plane — a user interface's, say — would store anyway.
34    PlanarPosition,
35    /// A range-remapped UV: `Snorm16x2`, decoded through
36    /// [`MeshMetadata::uv_min_and_extents`].
37    CompressedUv,
38    /// A full-precision UV: `Float32x2`.
39    UncompressedUv,
40    /// A vertex color, `Unorm8x4`, stored as it arrives.
41    Color,
42    /// A unit normal, octahedral-encoded into two `snorm16` lanes.
43    ///
44    /// A normal needs no decode parameters — the encoding is absolute, unlike
45    /// the per-mesh ranges positions and UVs are quantized against — so a
46    /// normal never makes its variant read the metadata array. The encoding is
47    /// also the only one a stream offers: octahedral `Snorm16x2` resolves a
48    /// direction to well under a degree, which three full-precision lanes
49    /// would not improve on for shading.
50    CompressedNormal,
51    /// A joint index, `Uint16x4`. Indices address data structurally rather
52    /// than interpolating, so they are never normalized.
53    Joints,
54    /// A joint weight, `Unorm16x4` in `[0, 1]`.
55    Weights,
56}
57
58impl ChannelEncoding {
59    /// The vertex format the channel is stored in.
60    pub const fn format(self) -> wgpu::VertexFormat {
61        match self {
62            Self::CompressedPosition => wgpu::VertexFormat::Snorm16x4,
63            Self::UncompressedPosition => wgpu::VertexFormat::Float32x3,
64            Self::PlanarPosition => wgpu::VertexFormat::Float32x2,
65            Self::CompressedUv => wgpu::VertexFormat::Snorm16x2,
66            Self::UncompressedUv => wgpu::VertexFormat::Float32x2,
67            Self::Color => wgpu::VertexFormat::Unorm8x4,
68            Self::CompressedNormal => wgpu::VertexFormat::Snorm16x2,
69            Self::Joints => wgpu::VertexFormat::Uint16x4,
70            Self::Weights => wgpu::VertexFormat::Unorm16x4,
71        }
72    }
73
74    /// Bytes the channel occupies, which is its attribute's size.
75    pub const fn size(self) -> u32 {
76        self.format().size() as u32
77    }
78}
79
80/// The channels one per-vertex stream carries, in the order they are
81/// interleaved.
82///
83/// Each channel is an attribute location paired with its encoding, and the
84/// attributes of a wgpu vertex-buffer layout are laid out in exactly this
85/// order: sequential offsets from the channel sizes, stride the sum of them.
86#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
87pub struct StreamChannels {
88    /// The channels, in attribute order.
89    channels: arrayvec::ArrayVec<(u32, ChannelEncoding), { StreamChannels::CAPACITY }>,
90}
91
92impl StreamChannels {
93    /// How many channels one stream can carry.
94    pub const CAPACITY: usize = 8;
95
96    /// A stream carrying `channels`, in attribute order.
97    ///
98    /// # Panics
99    ///
100    /// If there are more than [`Self::CAPACITY`] of them.
101    pub fn new(channels: impl IntoIterator<Item = (u32, ChannelEncoding)>) -> Self {
102        Self {
103            channels: channels.into_iter().collect(),
104        }
105    }
106
107    /// Append one channel after the channels already declared.
108    ///
109    /// # Panics
110    ///
111    /// If the stream already holds [`Self::CAPACITY`] channels.
112    pub fn push(&mut self, channel: (u32, ChannelEncoding)) {
113        self.channels
114            .try_push(channel)
115            .expect("a vertex stream holds at most `StreamChannels::CAPACITY` channels");
116    }
117
118    /// The channels in attribute order.
119    pub fn iter(&self) -> impl Iterator<Item = &(u32, ChannelEncoding)> {
120        self.channels.iter()
121    }
122
123    /// Whether no channel is declared, in which case the slot must be left out
124    /// of a pipeline's vertex-buffer list.
125    pub fn is_empty(&self) -> bool {
126        self.channels.is_empty()
127    }
128
129    /// Bytes per vertex, the sum of the channel sizes.
130    pub fn stride(&self) -> u32 {
131        self.channels
132            .iter()
133            .map(|(_, encoding)| encoding.size())
134            .sum()
135    }
136
137    /// Bytes a `vertex_count`-long stream occupies.
138    pub fn byte_len(&self, vertex_count: usize) -> usize {
139        vertex_count * self.stride() as usize
140    }
141
142    /// The offset each channel's attribute sits at, in attribute order.
143    ///
144    /// These are the offsets a wgpu vertex-buffer layout declares; a packed
145    /// stream writes its channels at the same offsets.
146    pub fn attribute_offsets(&self) -> impl Iterator<Item = (u32, ChannelEncoding, u64)> + '_ {
147        let mut offset = 0u64;
148        self.channels.iter().map(move |&(location, encoding)| {
149            let at = offset;
150            offset += u64::from(encoding.size());
151            (location, encoding, at)
152        })
153    }
154
155    /// The wgpu vertex-buffer layout these channels describe.
156    ///
157    /// The attributes are declared in channel order with the offsets
158    /// [`Self::attribute_offsets`] reports and the stride [`Self::stride`]
159    /// reports, so the layout a pipeline declares and the bytes
160    /// [`PositionStreamWriter`]/[`SecondaryStreamWriter`] pack always agree.
161    pub fn layout(&self) -> crate::specialize::VertexBufferLayoutDesc {
162        let array_stride = u64::from(self.stride());
163        assert!(
164            array_stride.is_multiple_of(wgpu::VERTEX_ALIGNMENT),
165            "vertex stride {array_stride} must be a multiple of `VERTEX_ALIGNMENT`"
166        );
167        crate::specialize::VertexBufferLayoutDesc {
168            array_stride,
169            step_mode: wgpu::VertexStepMode::Vertex,
170            attributes: self
171                .attribute_offsets()
172                .map(
173                    |(shader_location, encoding, offset)| wgpu::VertexAttribute {
174                        format: encoding.format(),
175                        offset,
176                        shader_location,
177                    },
178                )
179                .collect(),
180        }
181    }
182}
183
184/// Failure reasons reported by the compression helpers.
185#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
186pub enum CompressError {
187    /// The input slice was empty.
188    #[error("the input slice is empty")]
189    EmptyInput,
190    /// A vertex index is at or above the primitive-restart value `0xFFFF`,
191    /// so the mesh cannot use 16-bit indices. Carries the offending index.
192    #[error("vertex index {0} does not fit in u16")]
193    TooManyVertices(u32),
194}
195
196/// Per-mesh vertex-decode parameters and draw addressing.
197///
198/// Mirrors `mesh_metadata.wesl::MeshMetadata`; the layout is checked at
199/// compile time against the WGSL storage-buffer alignment rules.
200///
201/// The struct carries the mesh's addressing as well as its decode parameters,
202/// because both are per-mesh and immutable once the mesh is uploaded: a draw
203/// reaches them through one index, the metadata index its instance record
204/// carries. The addressing scalars fill what the `vec3` members would
205/// otherwise leave as padding, so the two together cost no more than the decode
206/// parameters alone.
207#[repr(C)]
208#[derive(
209    Clone,
210    Copy,
211    Debug,
212    Default,
213    PartialEq,
214    zerocopy_derive::FromBytes,
215    zerocopy_derive::Immutable,
216    zerocopy_derive::IntoBytes,
217    zerocopy_derive::KnownLayout,
218    const_shader_layout::ShaderLayout,
219)]
220pub struct MeshMetadata {
221    /// Center of the position bounding box.
222    pub aabb_center: glam::Vec3,
223    /// First element this mesh's vertices occupy in the pool its streams live
224    /// in, which is the draw's `firstVertex` or `baseVertex`.
225    ///
226    /// A shader that has to map a vertex back to its mesh-local ordinal — the
227    /// morph deltas, which are stored per mesh — subtracts it from
228    /// `@builtin(vertex_index)`.
229    pub vertex_offset: u32,
230    /// Half-extents of the position bounding box.
231    pub aabb_half_extents: glam::Vec3,
232    /// How many morph targets follow each vertex in the morph displacement
233    /// array.
234    ///
235    /// Zero for a mesh that declares no morph targets.
236    pub morph_count: u32,
237    /// `xy` is the UV minimum and `zw` its extents.
238    pub uv_min_and_extents: glam::Vec4,
239    /// First element this mesh's morph displacements occupy in the frame's
240    /// shared `array<f32>`.
241    ///
242    /// The displacements are the mesh's own geometry, but they live in one
243    /// frame-wide array rather than a per-mesh buffer, so a draw names its
244    /// slice by an offset like this one instead of binding a resource.
245    pub morph_deltas_offset: u32,
246    /// Explicit tail padding: the struct's 16-byte alignment rounds its 52 used
247    /// bytes up to 64, and an array element is that whole stride.
248    pub pad0: [u32; 3],
249}
250
251/// One per-instance record: the affine model matrix, base color, pose bases,
252/// metadata index, alpha cutoff and inverted-hull width the built-in pipeline
253/// reads from its per-instance vertex buffer.
254///
255/// This is the vertex stream of `lit.wesl`'s instance slot, so upload a
256/// `&[MeshInstance]` as that slot's buffer. The matrix is packed as three
257/// columns with each column's `.w` holding the matching translation
258/// component, which is why it is stored as columns rather than a
259/// [`glam::Mat4`]: the shader transforms a point with three dot products and
260/// never treats it as a matrix.
261///
262/// [`Self::joints_base`] and [`Self::morph_base`] address the frame's shared
263/// pose arrays, which is what lets two instances of one mesh deform
264/// differently: the joints and morph weights a draw deforms by are per-instance
265/// state, so they cannot live in the mesh's own bind group. Skinning and
266/// morphing are independent, so the two bases are separate fields rather than
267/// the lanes of one vector — a variant that only skins reads no weight base.
268///
269/// Vertex attributes are addressed by the explicit offsets of
270/// [`crate::pipeline::LitVariant::vertex_buffer_layouts`], not by WGSL
271/// shader-layout rules, so this type makes no `const_shader_layout` claim;
272/// that the offsets line up is asserted by a test instead.
273///
274/// The record is tightly packed — the sum of its vertex formats is its size —
275/// which is why the columns are `[f32; 4]` rather than [`glam::Vec4`]: a `Vec4`
276/// is sixteen-byte aligned, which would round the record up and leave trailing
277/// padding the layout would have to account for.
278#[repr(C)]
279#[derive(
280    Clone,
281    Copy,
282    Debug,
283    PartialEq,
284    zerocopy_derive::FromBytes,
285    zerocopy_derive::Immutable,
286    zerocopy_derive::IntoBytes,
287    zerocopy_derive::KnownLayout,
288)]
289pub struct MeshInstance {
290    /// Affine model matrix columns, translation in each `.w` lane.
291    pub model: [[f32; 4]; 3],
292    /// Linear RGBA base color, `Unorm8x4`.
293    pub base_color: CompressedColor,
294    /// This instance's first joint matrix in the frame's shared joint array.
295    pub joints_base: u32,
296    /// Index of the mesh's entry in the frame's metadata array, which carries
297    /// its decode parameters and its draw addressing.
298    ///
299    /// The index is a property of the mesh rather than of the instance, but it
300    /// rides the instance stream because that is what a draw can reach without
301    /// a bind group of its own. Every instance of one mesh carries the same
302    /// index.
303    pub metadata_index: u32,
304    /// Alpha below which a cut-off variant discards the fragment.
305    ///
306    /// Zero for an instance that cuts nothing off, which no alpha falls below.
307    pub cutoff: f32,
308    /// This instance's first morph weight in the frame's shared weight array.
309    pub morph_base: u32,
310    /// How far this instance's vertices are pushed out along their normals, in
311    /// world units.
312    ///
313    /// Zero for an instance that draws the mesh as itself, which is every
314    /// instance that is not an
315    /// [inverted hull](crate::pipeline::LitOptions::inverted_hull). The width
316    /// is per-instance rather than a uniform because one mesh drawn twice —
317    /// once as itself, once as the shell behind it — needs two widths from one
318    /// pipeline, and because two shells of one mesh may be drawn at different
319    /// thicknesses.
320    pub hull_width: f32,
321}
322
323impl MeshInstance {
324    /// Build an instance from a world transform and a linear RGBA base color.
325    ///
326    /// The color is quantized to the `Unorm8x4` the instance stream carries, so
327    /// a component outside `0..=1` saturates rather than wrapping. The instance
328    /// reads no pose data, no metadata, no alpha cutoff and expands by no
329    /// hull; point it at them with the `with_*` setters.
330    pub fn new(world_from_local: glam::Affine3A, base_color: glam::Vec4) -> Self {
331        let linear = world_from_local.matrix3;
332        let translation = world_from_local.translation;
333        Self {
334            model: [
335                linear.x_axis.extend(translation.x).to_array(),
336                linear.y_axis.extend(translation.y).to_array(),
337                linear.z_axis.extend(translation.z).to_array(),
338            ],
339            base_color: base_color.to_array().map(f32_to_unorm8),
340            joints_base: 0,
341            metadata_index: 0,
342            cutoff: 0.0,
343            morph_base: 0,
344            hull_width: 0.0,
345        }
346    }
347
348    /// The world-space translation this instance places its mesh at.
349    ///
350    /// The model matrix stores it in the `.w` lane of each column, so this is
351    /// how a caller reads it back out.
352    pub fn translation(&self) -> glam::Vec3 {
353        glam::Vec3::new(self.model[0][3], self.model[1][3], self.model[2][3])
354    }
355
356    /// Point this instance at the joint matrices starting at `joints_base` in
357    /// the frame's shared joint array.
358    #[must_use]
359    pub fn with_joints_base(mut self, joints_base: u32) -> Self {
360        self.joints_base = joints_base;
361        self
362    }
363
364    /// Point this instance at the morph weights starting at `morph_base` in the
365    /// frame's shared weight array.
366    #[must_use]
367    pub fn with_morph_base(mut self, morph_base: u32) -> Self {
368        self.morph_base = morph_base;
369        self
370    }
371
372    /// Point this instance at the mesh metadata entry at `metadata_index`.
373    #[must_use]
374    pub fn with_metadata_index(mut self, metadata_index: u32) -> Self {
375        self.metadata_index = metadata_index;
376        self
377    }
378
379    /// Discard this instance's fragments below the alpha `cutoff`.
380    #[must_use]
381    pub fn with_cutoff(mut self, cutoff: f32) -> Self {
382        self.cutoff = cutoff;
383        self
384    }
385
386    /// Push this instance's vertices out along their normals by `hull_width`.
387    #[must_use]
388    pub fn with_hull_width(mut self, hull_width: f32) -> Self {
389        self.hull_width = hull_width;
390        self
391    }
392}
393
394impl Default for MeshInstance {
395    /// Identity transform and opaque white.
396    fn default() -> Self {
397        Self::new(glam::Affine3A::IDENTITY, glam::Vec4::ONE)
398    }
399}
400
401/// One compressed vertex position: `Snorm16x4` relative to the mesh AABB.
402///
403/// The `w` lane is unused by the built-in lit shader and is written as `0`.
404pub type CompressedPosition = [i16; 4];
405
406/// One compressed vertex UV: `Snorm16x2` remapped by
407/// [`MeshMetadata::uv_min_and_extents`].
408pub type CompressedUv = [i16; 2];
409
410/// One vertex color: `Unorm8x4`, straight (non-premultiplied) linear RGBA.
411pub type CompressedColor = [u8; 4];
412
413/// One compressed vertex normal: `Snorm16x2`, octahedral-encoded.
414///
415/// The encoding is absolute rather than relative to a per-mesh range, so it
416/// needs no decode parameters: a normal survives the round trip through
417/// [`compress_normals`] and the `decode_normal` counterpart in the shader
418/// without reading any metadata.
419pub type CompressedNormal = [i16; 2];
420
421/// One vertex's joint indices: `Uint16x4`.
422pub type CompressedJoints = [u16; 4];
423
424/// One vertex's joint weights: `Unorm16x4`, summing to 1.
425pub type CompressedWeights = [u16; 4];
426
427/// The reciprocal of `extents`, with degenerate axes collapsed to zero.
428///
429/// A zero-extent axis would otherwise scale by infinity and turn `0 * inf`
430/// into NaN, so a collapsed axis has to encode onto the box center instead.
431#[inline]
432fn encode_scale(extents: glam::Vec3) -> glam::Vec3 {
433    glam::Vec3::select(
434        (1.0 / extents).is_nan_mask(),
435        glam::Vec3::ZERO,
436        1.0 / extents,
437    )
438}
439
440/// The 2D counterpart of [`encode_scale`], for the UV range.
441#[inline]
442fn encode_scale2(extents: glam::Vec2) -> glam::Vec2 {
443    glam::Vec2::select(
444        (1.0 / extents).is_nan_mask(),
445        glam::Vec2::ZERO,
446        1.0 / extents,
447    )
448}
449
450/// Derive the position decode parameters from `positions` and write them into
451/// `out_meta`.
452///
453/// The parameters are the bounding box the compression needs: positions
454/// encode to `[-1, 1]` relative to it, which is what
455/// [`mesh_compression.wesl`](../shaders/mesh_compression.wesl) reverses.
456///
457/// # Panics
458/// If `positions` is empty.
459fn derive_position_bounds(positions: &[[f32; 3]], out_meta: &mut MeshMetadata) {
460    assert!(
461        !positions.is_empty(),
462        "the position stream must not be empty"
463    );
464
465    let mut min = glam::Vec3::splat(f32::INFINITY);
466    let mut max = glam::Vec3::splat(f32::NEG_INFINITY);
467    for position in positions {
468        let position = glam::Vec3::from(*position);
469        min = min.min(position);
470        max = max.max(position);
471    }
472
473    out_meta.aabb_center = (min + max) * 0.5;
474    out_meta.aabb_half_extents = (max - min) * 0.5;
475}
476
477/// Compress positions into `Snorm16x4` relative to their bounding box,
478/// writing the decode parameters into `out_meta`.
479///
480/// The returned iterator is lazy: nothing is allocated, and a caller can
481/// stream the values straight into a GPU buffer.
482///
483/// # Panics
484/// If `positions` is empty.
485pub fn compress_positions<'a>(
486    positions: &'a [[f32; 3]],
487    out_meta: &mut MeshMetadata,
488) -> impl Iterator<Item = CompressedPosition> + 'a {
489    derive_position_bounds(positions, out_meta);
490    let center = out_meta.aabb_center;
491    let half = out_meta.aabb_half_extents;
492    let scale = encode_scale(half);
493    positions.iter().map(move |position| {
494        let encoded = (glam::Vec3::from(*position) - center) * scale;
495        f32_to_snorm16(encoded.extend(0.0).to_array())
496    })
497}
498
499/// Derive the UV decode parameters from `uvs` and write them into
500/// `out_meta.uv_min_and_extents`.
501///
502/// # Panics
503/// If `uvs` is empty.
504fn derive_uv_bounds(uvs: &[[f32; 2]], out_meta: &mut MeshMetadata) {
505    assert!(!uvs.is_empty(), "the UV stream must not be empty");
506
507    let mut min = glam::Vec2::splat(f32::INFINITY);
508    let mut max = glam::Vec2::splat(f32::NEG_INFINITY);
509    for uv in uvs {
510        let uv = glam::Vec2::from(*uv);
511        min = min.min(uv);
512        max = max.max(uv);
513    }
514
515    let extents = max - min;
516    out_meta.uv_min_and_extents = glam::Vec4::new(min.x, min.y, extents.x, extents.y);
517}
518
519/// Compress UVs into `Snorm16x2` remapped by their bounding box, writing the
520/// decode parameters into `out_meta.uv_min_and_extents`.
521///
522/// The returned iterator is lazy: nothing is allocated, and a caller can
523/// stream the values straight into a GPU buffer.
524///
525/// # Panics
526/// If `uvs` is empty.
527pub fn compress_uvs<'a>(
528    uvs: &'a [[f32; 2]],
529    out_meta: &mut MeshMetadata,
530) -> impl Iterator<Item = CompressedUv> + 'a {
531    derive_uv_bounds(uvs, out_meta);
532    let range = out_meta.uv_min_and_extents;
533    let min = glam::Vec2::new(range.x, range.y);
534    let extents = glam::Vec2::new(range.z, range.w);
535    let scale = encode_scale2(extents);
536    uvs.iter().map(move |uv| {
537        // Remap [0, 1] to [-1, 1] so the value can use the signed format the
538        // shader decodes with `mesh_compression::decode_uv`.
539        let encoded = (glam::Vec2::from(*uv) - min) * scale * 2.0 - glam::Vec2::ONE;
540        f32_to_snorm16(encoded.to_array())
541    })
542}
543
544/// The compressed UVs of `uvs`, encoded against `out_meta`'s existing
545/// parameters without re-deriving them.
546///
547/// Useful to reuse one UV range across several meshes; pair it with
548/// [`compress_uvs`] on the first mesh to fill in `out_meta`.
549pub fn encode_uvs<'a>(
550    uvs: &'a [[f32; 2]],
551    out_meta: &MeshMetadata,
552) -> impl Iterator<Item = CompressedUv> + 'a {
553    let range = out_meta.uv_min_and_extents;
554    let min = glam::Vec2::new(range.x, range.y);
555    let extents = glam::Vec2::new(range.z, range.w);
556    let scale = encode_scale2(extents);
557    uvs.iter().map(move |uv| {
558        let encoded = (glam::Vec2::from(*uv) - min) * scale * 2.0 - glam::Vec2::ONE;
559        f32_to_snorm16(encoded.to_array())
560    })
561}
562
563/// Quantize `[f32; 4]` colors to `Unorm8x4`, clamping out-of-range
564/// components.
565///
566/// Colors are normally already `[u8; 4]` and need no conversion; this is
567/// for callers whose colors come from a float source such as a [glam::Vec4].
568/// Computed on demand, so nothing is allocated.
569pub fn quantize_colors(colors: &[[f32; 4]]) -> impl Iterator<Item = CompressedColor> + '_ {
570    colors.iter().map(|color| color.map(f32_to_unorm8))
571}
572
573/// Octahedral-encode a unit normal into two `[-1, 1]` lanes.
574///
575/// The octahedral map folds the sphere onto a square: a direction is
576/// projected along `z` onto the octahedron's upper half, and the lower half
577/// is folded over by mirroring about the diagonal. Every direction lands on a
578/// distinct point of the square with no pole singularity, which is why two
579/// lanes suffice for a normal where a plain `xy` projection would lose the
580/// hemisphere it folds.
581#[inline]
582fn octahedral_encode(normal: glam::Vec3) -> glam::Vec2 {
583    let normal = normal.normalize_or_zero();
584    let l1 = normal.x.abs() + normal.y.abs() + normal.z.abs();
585    // A zero normal has no direction to encode; it collapses to the center of
586    // the square, which decodes back to `+z`, rather than dividing by zero.
587    let projected = normal.truncate() / if l1 == 0.0 { 1.0 } else { l1 };
588    if normal.z < 0.0 {
589        (glam::Vec2::ONE - glam::Vec2::new(projected.y.abs(), projected.x.abs()))
590            * projected.signum()
591    } else {
592        projected
593    }
594}
595
596/// Octahedral-encode `normals` into `Snorm16x2` lanes.
597///
598/// The encoding is absolute, so it needs no per-mesh range and leaves
599/// [`MeshMetadata`] alone. The returned iterator is lazy: nothing is
600/// allocated, and a caller can stream the values straight into a GPU buffer.
601pub fn compress_normals(normals: &[[f32; 3]]) -> impl Iterator<Item = CompressedNormal> + '_ {
602    normals
603        .iter()
604        .map(|normal| f32_to_snorm16(octahedral_encode(glam::Vec3::from(*normal)).to_array()))
605}
606
607/// Quantize one normalized `f32` to `Unorm8`.
608#[inline]
609fn f32_to_unorm8(component: f32) -> u8 {
610    (component.clamp(0.0, 1.0) * u8::MAX as f32).round() as u8
611}
612
613/// Quantize one normalized `f32` to `Unorm16`.
614#[inline]
615fn f32_to_unorm16(component: f32) -> u16 {
616    (component.clamp(0.0, 1.0) * u16::MAX as f32).round() as u16
617}
618
619/// Quantize joint weights to `Unorm16x4`, clamping out-of-range components.
620///
621/// The caller is responsible for weights that already sum to 1; this helper
622/// does not renormalize. Computed on demand, so nothing is allocated.
623pub fn compress_weights(
624    weights: &[[f32; 4]],
625) -> impl ExactSizeIterator<Item = CompressedWeights> + '_ {
626    weights.iter().map(|weight| weight.map(f32_to_unorm16))
627}
628
629/// Whether a vertex index can be named by a `Uint16` index buffer.
630///
631/// `0xFFFF` is the primitive-restart value rather than a vertex index, so a
632/// `Uint16` buffer can name at most `0xFFFE` and an index at or above
633/// `u16::MAX` needs a `Uint32` one.
634pub const fn index_fits_u16(index: u32) -> bool {
635    index < u16::MAX as u32
636}
637
638/// Narrow `u32` indices to `u16`.
639///
640/// Which indices can be narrowed is [`index_fits_u16`]'s rule. Unlike the
641/// vertex-attribute compressors this returns a [`Result`], because the input
642/// can genuinely be too large for the output format; the check runs over the
643/// whole input before any value is produced.
644pub fn compress_indices(indices: &[u32]) -> Result<impl Iterator<Item = u16> + '_, CompressError> {
645    if indices.is_empty() {
646        return Err(CompressError::EmptyInput);
647    }
648    if let Some(&index) = indices.iter().find(|&&index| !index_fits_u16(index)) {
649        return Err(CompressError::TooManyVertices(index));
650    }
651    Ok(indices.iter().map(|&index| index as u16))
652}
653
654/// Quantize `[-1, 1]` floats to signed 16-bit normalized integers.
655#[inline]
656fn f32_to_snorm16<const N: usize>(value: [f32; N]) -> [i16; N] {
657    value.map(|component| (component.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16)
658}
659
660bitflags::bitflags! {
661    /// The optional per-vertex channels of a secondary vertex stream: the UV,
662    /// color and normal a mesh carries beside its position.
663    ///
664    /// A stream is described by what it carries rather than by which pipeline
665    /// consumes it, so packing a stream needs nothing the built-in pipeline
666    /// defines: the built-in pipeline derives one of these from its own
667    /// variant, and a custom pipeline declares one directly.
668    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
669    pub struct SecondaryFlags: u16 {
670        /// The stream carries a per-vertex UV.
671        const UV = 1 << 0;
672        /// The UV is written full precision -- `Float32x2` -- rather than
673        /// compressed to `Snorm16x2`.
674        ///
675        /// An uncompressed UV needs no decode parameters, so writing it
676        /// leaves [`crate::mesh::MeshMetadata`] alone.
677        const UNCOMPRESSED_UV = 1 << 1;
678        /// The stream carries a per-vertex color.
679        const COLOR = 1 << 2;
680        /// The stream carries a per-vertex normal, octahedral-encoded into
681        /// `Snorm16x2`.
682        const NORMAL = 1 << 3;
683    }
684}
685
686/// The optional per-vertex channels of a secondary vertex stream: the UV,
687/// color and normal that a mesh carries beside its position.
688///
689/// Each channel adds one attribute to [`crate::pipeline::SECONDARY_SLOT`], in
690/// the order UV, color then normal, so the packed stream matches the shader's
691/// declared locations for any combination. [`Self::write`] compresses the raw
692/// attributes and interleaves them straight into the target buffer.
693#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
694pub struct SecondaryStreamWriter {
695    /// The channels this stream writes.
696    pub flags: SecondaryFlags,
697}
698
699impl SecondaryStreamWriter {
700    /// Whether the UV attribute is written.
701    pub fn uv(&self) -> bool {
702        self.flags.contains(SecondaryFlags::UV)
703    }
704
705    /// Whether the color attribute is written.
706    pub fn color(&self) -> bool {
707        self.flags.contains(SecondaryFlags::COLOR)
708    }
709
710    /// Whether the normal attribute is written.
711    pub fn normal(&self) -> bool {
712        self.flags.contains(SecondaryFlags::NORMAL)
713    }
714
715    /// Whether the UV is written full precision rather than compressed to
716    /// `Snorm16x2`.
717    ///
718    /// An uncompressed UV needs no decode parameters, so [`Self::write`]
719    /// leaves the metadata alone.
720    pub fn uncompressed_uv(&self) -> bool {
721        self.flags.contains(SecondaryFlags::UNCOMPRESSED_UV)
722    }
723
724    /// Whether the variant declares no attributes at all, in which case the
725    /// slot must be omitted from the vertex-buffer list.
726    pub fn is_empty(&self) -> bool {
727        !self.uv() && !self.color() && !self.normal()
728    }
729
730    /// The channels this stream carries, in attribute order.
731    ///
732    /// The single description the stride, the attribute offsets and the packed
733    /// bytes are all derived from.
734    pub fn channels(&self) -> StreamChannels {
735        let mut channels = StreamChannels::default();
736        if self.uv() {
737            channels.push((
738                crate::pipeline::location::UV,
739                if self.uncompressed_uv() {
740                    ChannelEncoding::UncompressedUv
741                } else {
742                    ChannelEncoding::CompressedUv
743                },
744            ));
745        }
746        if self.color() {
747            channels.push((crate::pipeline::location::COLOR, ChannelEncoding::Color));
748        }
749        if self.normal() {
750            channels.push((
751                crate::pipeline::location::NORMAL,
752                ChannelEncoding::CompressedNormal,
753            ));
754        }
755        channels
756    }
757
758    /// Bytes per vertex of the packed stream.
759    pub fn stride(&self) -> u32 {
760        self.channels().stride()
761    }
762
763    /// Bytes one `vertex_count`-long stream occupies.
764    pub fn byte_len(&self, vertex_count: usize) -> usize {
765        self.channels().byte_len(vertex_count)
766    }
767
768    /// Compress the raw `uvs` and `normals` and write the interleaved
769    /// result, together with the already compressed `colors`, into `out` —
770    /// which must be exactly [`Self::byte_len`]`(vertex_count)` bytes,
771    /// normally a mapped-at-creation vertex buffer, so the vertices land in
772    /// GPU memory without an intermediate byte buffer.
773    ///
774    /// Colors are taken as [`CompressedColor`] because that is what the
775    /// stream stores; float colors from a [glam::Vec4] can be quantized with
776    /// [`quantize_colors`] first. Normals need no such preparation: the
777    /// octahedral encoding is absolute, so they are compressed here.
778    ///
779    /// `metadata` receives the UV decode parameters the compression derives,
780    /// exactly as [`compress_uvs`] would.
781    ///
782    /// Nothing is allocated: the compressed UVs are produced lazily and
783    /// written as they are computed. Use [`Self::write_compressed`] to write
784    /// channels that are already compressed, for example to share one stream
785    /// across several meshes.
786    ///
787    /// # Panics
788    /// If an enabled channel has no matching slice, if the channel lengths
789    /// disagree, or if `out` is not exactly one stream long.
790    pub fn write(
791        &self,
792        uvs: &[[f32; 2]],
793        colors: &[CompressedColor],
794        normals: &[[f32; 3]],
795        metadata: &mut MeshMetadata,
796        out: WriteOnly<'_, [u8]>,
797    ) {
798        use zerocopy::IntoBytes;
799
800        let vertex_count = self.raw_vertex_count(uvs, colors, normals);
801        assert_eq!(
802            out.len(),
803            self.byte_len(vertex_count),
804            "the target must hold exactly one packed vertex stream"
805        );
806        if vertex_count == 0 {
807            return;
808        }
809
810        // Deriving the UV range is the only pass over the input; the encoding
811        // itself streams straight into `out`. An uncompressed channel is
812        // copied as it is, so it never derives metadata.
813        let (write_uv, write_color, write_normal) = (self.uv(), self.color(), self.normal());
814        let compress_uv = write_uv && !self.uncompressed_uv();
815        let stride = self.stride() as usize;
816        let mut packed_uvs = compress_uv
817            .then(|| compress_uvs(uvs, metadata))
818            .into_iter()
819            .flatten();
820        let mut compressed_normals = write_normal.then(|| compress_normals(normals));
821
822        // Each vertex is assembled as a fixed-size array on the stack and
823        // streamed out.
824        out.write_iter((0..vertex_count).flat_map(move |index| {
825            let mut vertex = [0u8; MAX_SECONDARY_STRIDE];
826            let mut len = 0;
827            if write_uv {
828                // A compressed UV is produced lazily as a temporary, so it
829                // is copied out while it is still alive; an uncompressed one
830                // is the caller's `f32` pair verbatim.
831                if compress_uv {
832                    let uv = packed_uvs.next().expect("one UV per vertex");
833                    let bytes = uv.as_bytes();
834                    vertex[len..len + bytes.len()].copy_from_slice(bytes);
835                    len += bytes.len();
836                } else {
837                    let bytes = uvs[index].as_bytes();
838                    vertex[len..len + bytes.len()].copy_from_slice(bytes);
839                    len += bytes.len();
840                }
841            }
842            if write_color {
843                let bytes = colors[index].as_bytes();
844                vertex[len..len + bytes.len()].copy_from_slice(bytes);
845                len += bytes.len();
846            }
847            if write_normal {
848                // The octahedral encoding needs no per-mesh range, so unlike
849                // the UVs it streams straight out with the same laziness.
850                let normal = compressed_normals
851                    .as_mut()
852                    .expect("compressed normals")
853                    .next()
854                    .expect("one normal per vertex");
855                let bytes = normal.as_bytes();
856                vertex[len..len + bytes.len()].copy_from_slice(bytes);
857                len += bytes.len();
858            }
859            debug_assert_eq!(len, stride);
860            vertex.into_iter().take(len)
861        }));
862    }
863
864    /// Write the already compressed `uvs`, `colors` and `normals` into
865    /// `out` as the interleaved vertex stream, which must be exactly
866    /// [`Self::byte_len`]`(vertex_count)` bytes.
867    ///
868    /// Takes the packed form produced by [`compress_uvs`],
869    /// [`quantize_colors`] and [`compress_normals`], so one compressed
870    /// stream can be uploaded for several meshes without recompressing it.
871    ///
872    /// # Panics
873    /// If an enabled channel has no matching slice, if the channel lengths
874    /// disagree, or if `out` is not exactly one stream long.
875    pub fn write_compressed(
876        &self,
877        uvs: &[CompressedUv],
878        colors: &[CompressedColor],
879        normals: &[CompressedNormal],
880        out: WriteOnly<'_, [u8]>,
881    ) {
882        use zerocopy::IntoBytes;
883
884        let vertex_count = self.compressed_vertex_count(uvs, colors, normals);
885        assert_eq!(
886            out.len(),
887            self.byte_len(vertex_count),
888            "the target must hold exactly one packed vertex stream"
889        );
890
891        // Each vertex is assembled as a fixed-size array on the stack and
892        // streamed out, so writing never allocates at all.
893        out.write_iter((0..vertex_count).flat_map(move |index| {
894            let mut vertex = [0u8; MAX_SECONDARY_STRIDE];
895            let mut len = 0;
896            if self.uv() {
897                let bytes = uvs[index].as_bytes();
898                vertex[..bytes.len()].copy_from_slice(bytes);
899                len += bytes.len();
900            }
901            if self.color() {
902                let bytes = colors[index].as_bytes();
903                vertex[len..len + bytes.len()].copy_from_slice(bytes);
904                len += bytes.len();
905            }
906            if self.normal() {
907                let bytes = normals[index].as_bytes();
908                vertex[len..len + bytes.len()].copy_from_slice(bytes);
909                len += bytes.len();
910            }
911            debug_assert_eq!(len, self.stride() as usize);
912            vertex.into_iter().take(len)
913        }));
914    }
915
916    /// Number of vertices the raw channel slices describe.
917    ///
918    /// Every enabled channel describes the same vertices, so the first one
919    /// sets the count and the rest must agree; a stream whose channels
920    /// disagree has no length that would pack them together.
921    fn raw_vertex_count(
922        &self,
923        uvs: &[[f32; 2]],
924        colors: &[CompressedColor],
925        normals: &[[f32; 3]],
926    ) -> usize {
927        let lengths: [(&str, Option<usize>); 3] = [
928            ("UV", self.uv().then_some(uvs.len())),
929            ("color", self.color().then_some(colors.len())),
930            ("normal", self.normal().then_some(normals.len())),
931        ];
932        vertex_count_of(&lengths)
933    }
934
935    /// Number of vertices the compressed channel slices describe.
936    fn compressed_vertex_count(
937        &self,
938        uvs: &[CompressedUv],
939        colors: &[CompressedColor],
940        normals: &[CompressedNormal],
941    ) -> usize {
942        let lengths: [(&str, Option<usize>); 3] = [
943            ("UV", self.uv().then_some(uvs.len())),
944            ("color", self.color().then_some(colors.len())),
945            ("normal", self.normal().then_some(normals.len())),
946        ];
947        vertex_count_of(&lengths)
948    }
949}
950
951/// One joint's skinning matrix: the joint's transform times the inverse of
952/// the bind-pose transform, applied to a vertex in the mesh's local space.
953///
954/// This is the element type of the `array<mat4x4<f32>>` a skinned variant
955/// binds at [`JOINTS_BINDING`](crate::pipeline::JOINTS_BINDING), so a mesh
956/// uploads a `&[JointMatrix]` there and a vertex's joint index selects one.
957pub type JointMatrix = glam::Mat4;
958
959/// Bytes per vertex of the widest secondary stream, used to size the
960/// per-vertex scratch the writer assembles on the stack.
961///
962/// The widest encoding the stream can write is an uncompressed UV followed by
963/// the color and the normal, so the scratch covers those three attribute
964/// formats.
965const MAX_SECONDARY_STRIDE: usize = wgpu::VertexFormat::Float32x2.size() as usize
966    + wgpu::VertexFormat::Unorm8x4.size() as usize
967    + wgpu::VertexFormat::Snorm16x2.size() as usize;
968
969/// The vertex count a stream's enabled channels agree on, or 0 for a stream
970/// with no channels.
971///
972/// # Panics
973/// If two enabled channels describe different numbers of vertices.
974fn vertex_count_of(lengths: &[(&str, Option<usize>)]) -> usize {
975    let mut count = None;
976    for (channel, length) in lengths.iter() {
977        let Some(length) = *length else { continue };
978        match count {
979            None => count = Some(length),
980            Some(expected) => assert_eq!(
981                length, expected,
982                "the {channel} stream must describe the same vertices as the first channel"
983            ),
984        }
985    }
986    count.unwrap_or(0)
987}
988
989/// The channels of the position vertex stream: a position and, for a skinned
990/// mesh, the joint indices and weights that deform it.
991///
992/// Joints and weights share this stream rather than getting one of their own,
993/// so a skinned mesh's per-vertex data stays one buffer and one stride. The
994/// joint pair is addressed by vertex index rather than by attribute, so
995/// nothing else about a draw changes when the stream carries it.
996///
997/// The order is the attribute order: position, joint indices, joint weights.
998#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
999pub struct PositionStreamChannels {
1000    /// How the position itself is encoded, or `None` for a variant that reads
1001    /// no position at all — its geometry is a single point at the instance
1002    /// origin, which is what a point or impostor draw wants.
1003    pub position: Option<ChannelEncoding>,
1004    /// Whether the stream carries joint indices and weights.
1005    pub joints: bool,
1006}
1007
1008impl PositionStreamChannels {
1009    /// The channels of the stream, in attribute order.
1010    pub fn channels(&self) -> StreamChannels {
1011        let mut channels = StreamChannels::default();
1012        if let Some(position) = self.position {
1013            channels.push((crate::pipeline::location::POSITION, position));
1014        }
1015        if self.joints {
1016            channels.push((crate::pipeline::location::JOINTS, ChannelEncoding::Joints));
1017            channels.push((
1018                crate::pipeline::location::JOINTS_WEIGHTS,
1019                ChannelEncoding::Weights,
1020            ));
1021        }
1022        channels
1023    }
1024}
1025
1026/// Packs the position vertex stream: the compressed position and, when the
1027/// stream declares them, the joint indices and weights that deform it.
1028///
1029/// Joints and weights share the position's stream, so a skinned mesh's draw
1030/// names one buffer for all three channels. Positions are compressed against
1031/// the mesh's bounding box exactly as [`compress_positions`] does; the joint
1032/// pair arrives already in its stored widths and is copied through.
1033#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1034pub struct PositionStreamWriter {
1035    /// The channels this stream writes.
1036    pub channels: PositionStreamChannels,
1037}
1038
1039impl PositionStreamWriter {
1040    /// Whether the stream carries joint indices and weights.
1041    pub fn joints(&self) -> bool {
1042        self.channels.joints
1043    }
1044
1045    /// Whether the position is written full precision rather than compressed
1046    /// to `Snorm16x4`. A planar position is full precision too, only two
1047    /// components wide.
1048    pub fn uncompressed_position(&self) -> bool {
1049        matches!(
1050            self.channels.position,
1051            Some(ChannelEncoding::UncompressedPosition | ChannelEncoding::PlanarPosition)
1052        )
1053    }
1054
1055    /// Whether the position is written as two components rather than three,
1056    /// with the third read as zero.
1057    pub fn planar_position(&self) -> bool {
1058        matches!(
1059            self.channels.position,
1060            Some(ChannelEncoding::PlanarPosition)
1061        )
1062    }
1063
1064    /// Bytes per vertex of the packed stream.
1065    pub fn stride(&self) -> u32 {
1066        self.channels.channels().stride()
1067    }
1068
1069    /// Bytes one `vertex_count`-long stream occupies.
1070    pub fn byte_len(&self, vertex_count: usize) -> usize {
1071        self.channels.channels().byte_len(vertex_count)
1072    }
1073
1074    /// Compress the raw `positions` and write the interleaved result, together
1075    /// with the already compressed joint pair, into `out` — which must be
1076    /// exactly [`Self::byte_len`]`(vertex_count)` bytes.
1077    ///
1078    /// `metadata` receives the bounding box the position compression derives,
1079    /// exactly as [`compress_positions`] would.
1080    ///
1081    /// `joints` and `weights` are taken as their stored widths, because that
1082    /// is what the stream stores; float weights can be quantized with
1083    /// [`compress_weights`] first. They arrive as iterators so a caller can
1084    /// compress its weights lazily as the stream is packed, without collecting
1085    /// them first. A stream that declares no joints ignores both.
1086    ///
1087    /// # Panics
1088    ///
1089    /// If a declared channel's iterator is shorter than the positions, if the
1090    /// two lengths disagree, or if `out` is not exactly one stream long.
1091    pub fn write(
1092        &self,
1093        positions: &[[f32; 3]],
1094        joints: impl ExactSizeIterator<Item = CompressedJoints>,
1095        weights: impl ExactSizeIterator<Item = CompressedWeights>,
1096        metadata: &mut MeshMetadata,
1097        out: WriteOnly<'_, [u8]>,
1098    ) {
1099        use zerocopy::IntoBytes;
1100
1101        let vertex_count = positions.len();
1102        let mut joints = joints;
1103        let mut weights = weights;
1104        if self.joints() {
1105            assert_eq!(
1106                joints.len(),
1107                vertex_count,
1108                "the joint stream must describe the same vertices as the positions"
1109            );
1110            assert_eq!(
1111                weights.len(),
1112                vertex_count,
1113                "the weight stream must describe the same vertices as the positions"
1114            );
1115        }
1116        assert_eq!(
1117            out.len(),
1118            self.byte_len(vertex_count),
1119            "the target must hold exactly one packed vertex stream"
1120        );
1121        if vertex_count == 0 {
1122            return;
1123        }
1124
1125        // Deriving the bounding box is the only pass over the input; the
1126        // encoding itself streams straight into `out`. An uncompressed
1127        // position is copied as it is, so it never derives metadata.
1128        let write_position = self.channels.position;
1129        let uncompressed = self.uncompressed_position();
1130        let write_joints = self.joints();
1131        let stride = self.stride() as usize;
1132        let mut packed_positions = (!uncompressed && write_position.is_some())
1133            .then(|| compress_positions(positions, metadata))
1134            .into_iter()
1135            .flatten();
1136
1137        // Each vertex is assembled as a fixed-size array on the stack and
1138        // streamed out.
1139        out.write_iter((0..vertex_count).flat_map(move |index| {
1140            let mut vertex = [0u8; MAX_POSITION_STRIDE];
1141            let mut len = 0;
1142            match write_position {
1143                // A planar position stores the two components the source has;
1144                // the zero third is the array padding `take` drops below.
1145                Some(ChannelEncoding::PlanarPosition) => {
1146                    let flat = [positions[index][0], positions[index][1]];
1147                    let bytes = flat.as_bytes();
1148                    vertex[len..len + bytes.len()].copy_from_slice(bytes);
1149                    len += bytes.len();
1150                }
1151                Some(_) if uncompressed => {
1152                    let bytes = positions[index].as_bytes();
1153                    vertex[len..len + bytes.len()].copy_from_slice(bytes);
1154                    len += bytes.len();
1155                }
1156                Some(_) => {
1157                    let position = packed_positions.next().expect("one position per vertex");
1158                    let bytes = position.as_bytes();
1159                    vertex[len..len + bytes.len()].copy_from_slice(bytes);
1160                    len += bytes.len();
1161                }
1162                // No position channel: the stream carries whatever follows,
1163                // which is nothing unless the variant also reads joints.
1164                None => {}
1165            }
1166            if write_joints {
1167                let joint = joints.next().expect("one joint set per vertex");
1168                let bytes = joint.as_bytes();
1169                vertex[len..len + bytes.len()].copy_from_slice(bytes);
1170                len += bytes.len();
1171                let weight = weights.next().expect("one weight set per vertex");
1172                let bytes = weight.as_bytes();
1173                vertex[len..len + bytes.len()].copy_from_slice(bytes);
1174                len += bytes.len();
1175            }
1176            debug_assert_eq!(len, stride);
1177            vertex.into_iter().take(len)
1178        }));
1179    }
1180}
1181
1182/// The joint indices and weights of one vertex, as the position stream stores
1183/// them.
1184///
1185/// The pair shares the position's stream, so this is the layout the shader's
1186/// `VertexInput` declares for them: `Uint16x4` indices followed by `Unorm16x4`
1187/// weights. The two are written together and never separately, which is why
1188/// the stream carries them as one unit.
1189#[repr(C)]
1190#[derive(
1191    Clone,
1192    Copy,
1193    Debug,
1194    Default,
1195    PartialEq,
1196    Eq,
1197    zerocopy_derive::FromBytes,
1198    zerocopy_derive::Immutable,
1199    zerocopy_derive::IntoBytes,
1200    zerocopy_derive::KnownLayout,
1201)]
1202pub struct JointPair {
1203    /// Joint indices into the mesh's joint matrices, `Uint16x4`.
1204    pub joints: CompressedJoints,
1205    /// Joint weights, `Unorm16x4`, which should sum to 1.
1206    pub weights: CompressedWeights,
1207}
1208
1209impl JointPair {
1210    /// Bytes one pair occupies, the sum of the two channels.
1211    pub const SIZE: u32 = ChannelEncoding::Joints.size() + ChannelEncoding::Weights.size();
1212}
1213
1214/// Bytes per vertex of the widest position stream, used to size the per-vertex
1215/// scratch the writer assembles on the stack.
1216///
1217/// The widest encoding is an uncompressed position followed by the joint pair.
1218const MAX_POSITION_STRIDE: usize =
1219    wgpu::VertexFormat::Float32x3.size() as usize + JointPair::SIZE as usize;
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224    use zerocopy::IntoBytes;
1225
1226    /// Write raw UVs, compressed colors and raw normals through the
1227    /// mapped-buffer path and return the bytes.
1228    fn write_stream(
1229        stream: SecondaryStreamWriter,
1230        uvs: &[[f32; 2]],
1231        colors: &[CompressedColor],
1232        normals: &[[f32; 3]],
1233        vertex_count: usize,
1234        metadata: &mut MeshMetadata,
1235    ) -> Vec<u8> {
1236        let mut out = vec![0u8; stream.byte_len(vertex_count)];
1237        stream.write(
1238            uvs,
1239            colors,
1240            normals,
1241            metadata,
1242            WriteOnly::from_mut(out.as_mut_slice()),
1243        );
1244        out
1245    }
1246
1247    /// Both writers must produce byte-identical streams, since the raw one
1248    /// compresses its UVs into exactly what the compressed one expects and
1249    /// both copy the colors verbatim.
1250    #[test]
1251    fn raw_and_compressed_writers_agree() {
1252        let uvs = [[0.0f32, 0.25], [0.5, 1.0], [1.0, 0.0]];
1253        let colors: Vec<CompressedColor> = quantize_colors(&[
1254            [0.0f32, 0.25, 0.5, 1.0],
1255            [1.0, 0.0, 0.0, 1.0],
1256            [0.2, 0.4, 0.6, 0.8],
1257        ])
1258        .collect();
1259
1260        let normals = [[0.0f32, 0.0, 1.0], [0.6, 0.8, 0.0], [-0.6, 0.0, -0.8]];
1261
1262        for stream in [
1263            SecondaryStreamWriter {
1264                flags: SecondaryFlags::UV | SecondaryFlags::COLOR | SecondaryFlags::NORMAL,
1265            },
1266            SecondaryStreamWriter {
1267                flags: SecondaryFlags::UV,
1268            },
1269            SecondaryStreamWriter {
1270                flags: SecondaryFlags::COLOR,
1271            },
1272            SecondaryStreamWriter {
1273                flags: SecondaryFlags::NORMAL,
1274            },
1275        ] {
1276            let mut metadata = MeshMetadata::default();
1277            let from_raw = write_stream(stream, &uvs, &colors, &normals, 3, &mut metadata);
1278
1279            // Compress separately, exactly as a caller sharing one stream
1280            // across meshes would.
1281            let packed_uvs: Vec<_> = if stream.uv() {
1282                compress_uvs(&uvs, &mut metadata).collect()
1283            } else {
1284                Vec::new()
1285            };
1286            let packed_colors: Vec<_> = if stream.color() {
1287                colors.clone()
1288            } else {
1289                Vec::new()
1290            };
1291            let packed_normals: Vec<_> = if stream.normal() {
1292                compress_normals(&normals).collect()
1293            } else {
1294                Vec::new()
1295            };
1296            let mut from_compressed = vec![0u8; stream.byte_len(3)];
1297            stream.write_compressed(
1298                &packed_uvs,
1299                &packed_colors,
1300                &packed_normals,
1301                WriteOnly::from_mut(from_compressed.as_mut_slice()),
1302            );
1303
1304            assert_eq!(from_raw, from_compressed, "stream {stream:?}");
1305            assert_eq!(from_raw.len(), stream.byte_len(3), "stream {stream:?}");
1306        }
1307    }
1308
1309    #[test]
1310    fn secondary_stream_interleaves_in_attribute_order() {
1311        let uvs = [[0.0f32, 0.0], [1.0, 1.0]];
1312        // Colors are linear RGBA in [0, 1]; they quantize to Unorm8.
1313        let colors = [[0u8, 64, 128, 255], [255, 0, 0, 255]];
1314        let mut metadata = MeshMetadata::default();
1315
1316        let out = write_stream(
1317            SecondaryStreamWriter {
1318                flags: SecondaryFlags::UV | SecondaryFlags::COLOR,
1319            },
1320            &uvs,
1321            &colors,
1322            &[],
1323            2,
1324            &mut metadata,
1325        );
1326        assert_eq!(out.len(), 2 * 8);
1327        // Vertex 0 starts with the UV then the color, matching the shader's
1328        // location order 1 then 2. The UV remaps [0, 1] to Snorm16 [-1, 1],
1329        // so 0.0 quantizes to -32767 (little-endian) and 1.0 to 32767.
1330        let expected_uv = [(-32767i16).to_le_bytes(), (-32767i16).to_le_bytes()].concat();
1331        assert_eq!(&out[0..4], &expected_uv[..]);
1332        assert_eq!(&out[4..8], &[0, 64, 128, 255]);
1333        // The second vertex sits at the far corner of the UV range.
1334        assert_eq!(
1335            &out[8..12],
1336            &[32767i16.to_le_bytes(), 32767i16.to_le_bytes()].concat()
1337        );
1338
1339        // The UV compression still produced the decode parameters.
1340        assert_eq!(
1341            metadata.uv_min_and_extents,
1342            glam::Vec4::new(0.0, 0.0, 1.0, 1.0)
1343        );
1344
1345        // UV only: four bytes per vertex.
1346        let out = write_stream(
1347            SecondaryStreamWriter {
1348                flags: SecondaryFlags::UV,
1349            },
1350            &uvs,
1351            &[],
1352            &[],
1353            2,
1354            &mut MeshMetadata::default(),
1355        );
1356        assert_eq!(out.len(), 2 * 4);
1357
1358        // Color only: four bytes per vertex, no UV bytes.
1359        let out = write_stream(
1360            SecondaryStreamWriter {
1361                flags: SecondaryFlags::COLOR,
1362            },
1363            &[],
1364            &colors,
1365            &[],
1366            2,
1367            &mut MeshMetadata::default(),
1368        );
1369        assert_eq!(out.len(), 2 * 4);
1370        assert_eq!(&out[0..4], &[0, 64, 128, 255]);
1371    }
1372
1373    /// An uncompressed UV is copied at full precision: it neither derives
1374    /// metadata nor quantizes, so the bytes are the input `f32`s.
1375    #[test]
1376    fn uncompressed_uv_is_written_at_full_precision() {
1377        let uvs = [[0.0f32, 0.0], [0.5, 0.75]];
1378        let stream = SecondaryStreamWriter {
1379            flags: SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV,
1380        };
1381        let mut metadata = MeshMetadata::default();
1382        let out = write_stream(stream, &uvs, &[], &[], 2, &mut metadata);
1383
1384        let stride = wgpu::VertexFormat::Float32x2.size() as usize;
1385        assert_eq!(out.len(), 2 * stride);
1386        for (vertex, uv) in out.chunks(stride).zip(&uvs) {
1387            assert_eq!(vertex, uv.as_bytes());
1388        }
1389        // No compression happened, so the metadata is untouched.
1390        assert_eq!(metadata, MeshMetadata::default());
1391    }
1392
1393    /// Decode exactly like `mesh_compression.wesl::decode_position`.
1394    fn decode_position(encoded: CompressedPosition, metadata: &MeshMetadata) -> glam::Vec3 {
1395        let encode = glam::Vec3::new(
1396            encoded[0] as f32 / i16::MAX as f32,
1397            encoded[1] as f32 / i16::MAX as f32,
1398            encoded[2] as f32 / i16::MAX as f32,
1399        );
1400        metadata.aabb_center + encode * metadata.aabb_half_extents
1401    }
1402
1403    /// Decode exactly like `mesh_compression.wesl::decode_uv`.
1404    fn decode_uv(encoded: CompressedUv, metadata: &MeshMetadata) -> glam::Vec2 {
1405        let encode = glam::Vec2::new(
1406            encoded[0] as f32 / i16::MAX as f32,
1407            encoded[1] as f32 / i16::MAX as f32,
1408        );
1409        let t = encode * 0.5 + 0.5;
1410        glam::Vec2::new(metadata.uv_min_and_extents.x, metadata.uv_min_and_extents.y)
1411            + t * glam::Vec2::new(metadata.uv_min_and_extents.z, metadata.uv_min_and_extents.w)
1412    }
1413
1414    #[test]
1415    fn position_roundtrip_stays_within_quantization_error() {
1416        let positions = [[0.0, 0.0, 0.0], [1.0, 2.0, 3.0], [-1.0, 0.5, 2.0]];
1417        let mut metadata = MeshMetadata::default();
1418        let encoded: Vec<_> = compress_positions(&positions, &mut metadata).collect();
1419
1420        assert_eq!(encoded.len(), positions.len());
1421        for (position, packed) in positions.iter().zip(&encoded) {
1422            let decoded = decode_position(*packed, &metadata);
1423            let expected = glam::Vec3::from(*position);
1424            // One quantization step of the box's half-extent.
1425            let tolerance = metadata.aabb_half_extents.max_element() * 2.0 / i16::MAX as f32;
1426            assert!(
1427                (decoded - expected).abs().max_element() <= tolerance,
1428                "{decoded} vs {expected}"
1429            );
1430        }
1431        assert_eq!(metadata.aabb_center, glam::Vec3::new(0.0, 1.0, 1.5));
1432        assert_eq!(metadata.aabb_half_extents, glam::Vec3::new(1.0, 1.0, 1.5));
1433    }
1434
1435    #[test]
1436    fn degenerate_position_axis_collapses_to_center() {
1437        // All vertices share z = 4.0, so that axis has zero extent.
1438        let positions = [[0.0, 0.0, 4.0], [1.0, 1.0, 4.0]];
1439        let mut metadata = MeshMetadata::default();
1440        let encoded: Vec<_> = compress_positions(&positions, &mut metadata).collect();
1441
1442        assert_eq!(metadata.aabb_half_extents.z, 0.0);
1443        assert!(encoded.iter().all(|packed| packed[2] == 0));
1444        assert_eq!(decode_position(encoded[0], &metadata).z, 4.0);
1445    }
1446
1447    #[test]
1448    fn uv_roundtrip_stays_within_quantization_error() {
1449        let uvs = [[0.25, 0.5], [1.0, 0.75], [-0.5, 2.0]];
1450        let mut metadata = MeshMetadata::default();
1451        let encoded: Vec<_> = compress_uvs(&uvs, &mut metadata).collect();
1452
1453        assert_eq!(encoded.len(), uvs.len());
1454        for (uv, packed) in uvs.iter().zip(&encoded) {
1455            let decoded = decode_uv(*packed, &metadata);
1456            let expected = glam::Vec2::from(*uv);
1457            let extents =
1458                glam::Vec2::new(metadata.uv_min_and_extents.z, metadata.uv_min_and_extents.w);
1459            let tolerance = extents.max_element() * 2.0 / i16::MAX as f32;
1460            assert!(
1461                (decoded - expected).abs().max_element() <= tolerance,
1462                "{decoded} vs {expected}"
1463            );
1464        }
1465    }
1466
1467    /// Decode exactly like `mesh_compression.wesl::decode_normal`.
1468    fn decode_normal(encoded: CompressedNormal) -> glam::Vec3 {
1469        let encode = glam::Vec2::new(
1470            encoded[0] as f32 / i16::MAX as f32,
1471            encoded[1] as f32 / i16::MAX as f32,
1472        );
1473        let mut normal = glam::Vec3::new(encode.x, encode.y, 1.0 - encode.abs().element_sum());
1474        if normal.z < 0.0 {
1475            normal = glam::Vec3::new(
1476                (1.0 - normal.y.abs()) * normal.x.signum(),
1477                (1.0 - normal.x.abs()) * normal.y.signum(),
1478                normal.z,
1479            );
1480        }
1481        normal.normalize()
1482    }
1483
1484    /// A normal survives the octahedral round trip to well under the angular
1485    /// error shading would notice, including the folded lower hemisphere.
1486    #[test]
1487    fn normal_roundtrip_stays_within_quantization_error() {
1488        let normals = [
1489            [0.0f32, 0.0, 1.0],
1490            [0.0, 0.0, -1.0],
1491            [1.0, 0.0, 0.0],
1492            [0.0, 1.0, 0.0],
1493            [0.6, 0.8, 0.0],
1494            [-0.6, 0.0, -0.8],
1495            [0.36, -0.48, 0.8],
1496        ];
1497        let encoded: Vec<_> = compress_normals(&normals).collect();
1498        assert_eq!(encoded.len(), normals.len());
1499
1500        // Snorm16 carries a direction to about `1 / i16::MAX` radians; allow
1501        // that plus normalization slack.
1502        let tolerance = 1e-3;
1503        for (normal, packed) in normals.iter().zip(&encoded) {
1504            let decoded = decode_normal(*packed);
1505            let expected = glam::Vec3::from(*normal).normalize();
1506            assert!(
1507                (decoded - expected).abs().max_element() <= tolerance,
1508                "{decoded} vs {expected}"
1509            );
1510        }
1511    }
1512
1513    /// A normal has no per-mesh range, so compressing one is self-contained:
1514    /// every direction survives without a metadata array to read back.
1515    #[test]
1516    fn normals_need_no_decode_parameters() {
1517        let encoded: Vec<_> = compress_normals(&[[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]]).collect();
1518        assert_eq!(decode_normal(encoded[0]), glam::Vec3::Z);
1519        assert_eq!(decode_normal(encoded[1]), glam::Vec3::Y);
1520
1521        // A zero normal has no direction to encode; it collapses to the center
1522        // of the octahedron, which decodes to `+z`, rather than to a NaN.
1523        let zero: Vec<_> = compress_normals(&[[0.0, 0.0, 0.0]]).collect();
1524        assert_eq!(zero[0], [0, 0]);
1525        assert_eq!(decode_normal(zero[0]), glam::Vec3::Z);
1526    }
1527
1528    #[test]
1529    fn quantize_colors_clamp_out_of_range_components() {
1530        let colors: Vec<_> =
1531            quantize_colors(&[[0.0, 0.5, 1.0, 2.0], [-1.0, 0.0, 0.0, 0.0]]).collect();
1532        assert_eq!(colors[0], [0, 128, 255, 255]);
1533        assert_eq!(colors[1], [0, 0, 0, 0]);
1534    }
1535
1536    #[test]
1537    fn weights_clamp_out_of_range_components() {
1538        let weights: Vec<_> = compress_weights(&[[0.0, 0.5, 1.0, 2.0]]).collect();
1539        assert_eq!(weights[0], [0, 32_768, 65_535, 65_535]);
1540    }
1541
1542    /// The joint pair follows the position in the same stream, in the shader's
1543    /// attribute order, and a stream that declares no joints stays as narrow as
1544    /// the position alone.
1545    #[test]
1546    fn position_stream_appends_the_joint_pair_after_the_position() {
1547        let positions = [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]];
1548        let joints = [[1u16, 2, 3, 4], [5, 6, 7, 8]];
1549        let weights: Vec<_> =
1550            compress_weights(&[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]]).collect();
1551
1552        let mut metadata = MeshMetadata::default();
1553        let plain = PositionStreamWriter {
1554            channels: PositionStreamChannels {
1555                position: Some(ChannelEncoding::CompressedPosition),
1556                joints: false,
1557            },
1558        };
1559        assert_eq!(plain.stride(), ChannelEncoding::CompressedPosition.size());
1560
1561        let skinned = PositionStreamWriter {
1562            channels: PositionStreamChannels {
1563                position: Some(ChannelEncoding::CompressedPosition),
1564                joints: true,
1565            },
1566        };
1567        assert_eq!(
1568            skinned.stride(),
1569            ChannelEncoding::CompressedPosition.size() + JointPair::SIZE
1570        );
1571
1572        let mut out = vec![0u8; skinned.byte_len(2)];
1573        skinned.write(
1574            &positions,
1575            joints.iter().copied(),
1576            weights.iter().copied(),
1577            &mut metadata,
1578            WriteOnly::from_mut(out.as_mut_slice()),
1579        );
1580
1581        // The position comes first, exactly as the plain stream writes it, so
1582        // the joint pair cannot disturb the decode the metadata describes.
1583        let mut plain_out = vec![0u8; plain.byte_len(2)];
1584        plain.write(
1585            &positions,
1586            core::iter::empty(),
1587            core::iter::empty(),
1588            &mut MeshMetadata::default(),
1589            WriteOnly::from_mut(plain_out.as_mut_slice()),
1590        );
1591        let position_size = ChannelEncoding::CompressedPosition.size() as usize;
1592        let pair_size = JointPair::SIZE as usize;
1593        let stride = skinned.stride() as usize;
1594        for vertex in 0..2 {
1595            assert_eq!(
1596                &out[vertex * stride..vertex * stride + position_size],
1597                &plain_out[vertex * position_size..(vertex + 1) * position_size],
1598                "vertex {vertex} position"
1599            );
1600            let pair =
1601                &out[vertex * stride + position_size..vertex * stride + position_size + pair_size];
1602            assert_eq!(&pair[..8], joints[vertex].as_bytes());
1603            assert_eq!(&pair[8..], weights[vertex].as_bytes());
1604        }
1605
1606        // A stream with neither a position nor joints writes nothing at all.
1607        let empty = PositionStreamWriter {
1608            channels: PositionStreamChannels {
1609                position: None,
1610                joints: false,
1611            },
1612        };
1613        assert_eq!(empty.stride(), 0);
1614        assert_eq!(empty.byte_len(2), 0);
1615    }
1616
1617    #[test]
1618    fn indices_reject_primitive_restart_and_overflow() {
1619        assert_eq!(
1620            compress_indices(&[0, 65_536]).err(),
1621            Some(CompressError::TooManyVertices(65_536))
1622        );
1623        // 0xFFFF is the primitive-restart value and cannot name a vertex.
1624        assert_eq!(
1625            compress_indices(&[0, u16::MAX as u32]).err(),
1626            Some(CompressError::TooManyVertices(u16::MAX as u32))
1627        );
1628        assert_eq!(compress_indices(&[]).err(), Some(CompressError::EmptyInput));
1629
1630        let indices: Vec<_> = compress_indices(&[0, u16::MAX as u32 - 1])
1631            .expect("valid indices")
1632            .collect();
1633        assert_eq!(indices, vec![0, u16::MAX - 1]);
1634    }
1635
1636    #[test]
1637    fn empty_input_is_rejected() {
1638        let mut metadata = MeshMetadata::default();
1639        assert!(
1640            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1641                compress_positions(&[], &mut metadata).count()
1642            }))
1643            .is_err(),
1644            "an empty position stream must be rejected"
1645        );
1646        assert!(
1647            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1648                compress_uvs(&[], &mut metadata).count()
1649            }))
1650            .is_err(),
1651            "an empty UV stream must be rejected"
1652        );
1653    }
1654}