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}