Skip to main content

unlit_wgpu/
pipeline.rs

1//! The built-in lit pipeline and the WESL composition behind it.
2//!
3//! [`SpecializedLitPipeline`] composes an [`LitVariant`]'s entry shader —
4//! the built-in `lit.wesl`, or the caller's own when
5//! [`LitOptions::shader`] carries one — builds the layouts the shader
6//! expects, and exposes the vertex-buffer layouts the caller declares when
7//! creating meshes. Each variant contains exactly the bindings and attributes
8//! the selected channels need, so nothing unused reaches the GPU.
9
10use crate::mesh::{
11    ChannelEncoding, PositionStreamChannels, PositionStreamWriter, SecondaryFlags,
12    SecondaryStreamWriter,
13};
14use crate::specialize::{PipelineDescriptor, PipelineVariant, SurfaceKey, VertexBufferLayoutDesc};
15use crate::util::Hashed;
16use std::sync::Arc;
17
18/// Binding slot of the camera uniform in the global bind group.
19pub const CAMERA_BINDING: u32 = 0;
20/// Binding slot of the frame-globals uniform in the global bind group.
21pub const FRAME_BINDING: u32 = 1;
22/// Binding slot of the pass's view uniform in the global bind group.
23///
24/// It carries the region of the target the pass draws through, which is what a
25/// shader measuring in pixels divides by. The camera and this differ in who
26/// owns them: a camera is the frame's, a view is the pass's, so one camera drawn
27/// through two viewports is two views.
28pub const VIEW_BINDING: u32 = 2;
29/// Binding slot of the mesh-metadata storage buffer in the global bind group.
30pub const MESH_METADATA_BINDING: u32 = 3;
31/// Binding slot of the frame's joint matrices in the global bind group.
32///
33/// The array holds the pose of every visible instance, so it is per-frame data
34/// the source packs rather than anything a mesh owns; an instance's own slice
35/// starts at [`crate::mesh::MeshInstance::joints_base`].
36pub const JOINTS_BINDING: u32 = 4;
37/// Binding slot of the frame's morph weights in the global bind group.
38///
39/// Like the joint matrices this is one array for the whole frame, and an
40/// instance's own slice starts at [`crate::mesh::MeshInstance::morph_base`].
41pub const MORPH_WEIGHTS_BINDING: u32 = 5;
42/// Binding slot of the frame's morph position displacements in the global bind
43/// group.
44///
45/// A mesh's displacements are its own geometry, but one array holds them for
46/// the whole frame and the mesh names its slice through the `morph_deltas_offset`
47/// of its [`MeshMetadata`](crate::mesh::MeshMetadata) entry. Pooling them here
48/// rather than in a per-mesh buffer is what lets a draw bind no mesh group.
49pub const MORPH_DELTAS_BINDING: u32 = 6;
50/// Binding slot of the frame's lights in the global bind group.
51///
52/// One record per light, in view space, packed by the source every frame. The
53/// fragment stage reads it, so it is not visible to a vertex stage.
54pub const LIGHTS_BINDING: u32 = 7;
55/// Binding slot of the frame's cluster cells in the global bind group.
56///
57/// Each cell's record lists the lights assigned to it; see
58/// [`crate::light::CELL_WORDS`].
59pub const LIGHT_CELLS_BINDING: u32 = 8;
60/// Binding slot of the frame's polygon-light vertices in the global bind group.
61///
62/// A light whose shape has corners names its slice through its record's
63/// `kind.y` and `kind.z`.
64pub const LIGHT_VERTICES_BINDING: u32 = 9;
65/// Binding slot of the cluster uniform in the global bind group.
66///
67/// The cluster grid's shape and depth mapping: what a fragment needs to find the
68/// cell it is in.
69pub const CLUSTER_BINDING: u32 = 10;
70/// Binding slot of the frame-wide lighting uniform in the global bind group.
71///
72/// The ambient term and the frame's directional lights, which reach every
73/// fragment: what a fragment needs to light itself before it asks the grid.
74pub const LIGHTING_BINDING: u32 = 11;
75/// Bind-group index of the global group.
76pub const GLOBAL_GROUP: u32 = 0;
77/// Bind-group index of the material group.
78pub const MATERIAL_GROUP: u32 = 1;
79/// Binding slot of the base-color texture in the material group.
80pub const BASE_COLOR_TEXTURE_BINDING: u32 = 0;
81/// Binding slot of the base-color sampler in the material group.
82pub const BASE_COLOR_SAMPLER_BINDING: u32 = 1;
83/// Bind-group index of the mesh group.
84///
85/// The built-in lit variants bind nothing here: every input they read is in
86/// the global group or on the instance stream. The index stays a general
87/// extension point, so a caller's own pipeline can bind per-mesh data — a tint,
88/// a transform palette, a material parameter — and
89/// [`crate::scene::DrawEntry`] will bind it.
90pub const MESH_GROUP: u32 = 2;
91
92/// Vertex-buffer slot carrying the primary stream: positions and, for a skinned
93/// mesh, the joint indices and weights that deform them.
94pub const PRIMARY_SLOT: u32 = 0;
95/// Vertex-buffer slot carrying the secondary stream: the UV, color and normal a
96/// mesh carries beside its position.
97pub const SECONDARY_SLOT: u32 = 1;
98/// Vertex-buffer slot carrying per-instance data.
99pub const INSTANCE_SLOT: u32 = 2;
100
101/// Vertex attribute locations declared by the built-in shader.
102pub mod location {
103    /// Compressed position (`Snorm16x4`).
104    pub const POSITION: u32 = 0;
105    /// Compressed UV (`Snorm16x2`).
106    pub const UV: u32 = 1;
107    /// Vertex color (`Unorm8x4`).
108    pub const COLOR: u32 = 2;
109    /// First column of the per-instance model matrix.
110    pub const MODEL_0: u32 = 3;
111    /// Second column of the per-instance model matrix.
112    pub const MODEL_1: u32 = 4;
113    /// Third column of the per-instance model matrix.
114    pub const MODEL_2: u32 = 5;
115    /// Per-instance base color.
116    pub const BASE_COLOR: u32 = 6;
117    /// Joint indices (`Uint16x4`), read from the position stream.
118    pub const JOINTS: u32 = 7;
119    /// Joint weights (`Unorm16x4`), read from the position stream right after
120    /// the indices.
121    pub const JOINTS_WEIGHTS: u32 = 8;
122    /// Per-instance joint-matrix base (`Uint32`), read from the instance
123    /// stream.
124    ///
125    /// The instance's first joint matrix in the frame's shared joint array; the
126    /// shader reads a joint at this base plus its own joint index.
127    pub const JOINTS_BASE: u32 = 9;
128    /// Per-instance index of the mesh metadata entry (`Uint32`), read from the
129    /// instance stream.
130    pub const METADATA_INDEX: u32 = 10;
131    /// Per-instance alpha cutoff (`Float32`), read from the instance stream.
132    ///
133    /// A [`LitOptions::alpha_cutoff`](crate::pipeline::LitOptions::alpha_cutoff)
134    /// variant compares a fragment's alpha against it and discards the
135    /// fragment when it is below.
136    pub const CUTOFF: u32 = 11;
137    /// Per-instance morph-weight base (`Uint32`), read from the instance
138    /// stream.
139    ///
140    /// The instance's first morph weight in the frame's shared weight array;
141    /// the shader reads a target's weight at this base plus its own target
142    /// index. It is a location of its own rather than a lane of
143    /// [`JOINTS_BASE`] because skinning and morphing are independent — a
144    /// variant that reads one has no business declaring the other.
145    pub const MORPH_BASE: u32 = 12;
146    /// Vertex normal (`Snorm16x2`), read from the secondary stream.
147    ///
148    /// Octahedral-encoded, so the shader decodes it on its own; see
149    /// [`crate::mesh::compress_normals`].
150    pub const NORMAL: u32 = 13;
151    /// Per-instance inverted-hull width (`Float32`), read from the instance
152    /// stream.
153    ///
154    /// The distance the shader pushes a vertex out along its normal. An
155    /// [`LitOptions::expanded`](crate::pipeline::LitOptions::expanded) variant
156    /// reads it; every other variant leaves the field unread, and an instance
157    /// that is not a hull carries zero.
158    pub const HULL_WIDTH: u32 = 14;
159}
160
161/// Entry point name of the built-in shader's vertex stage.
162pub const VS_MAIN: &str = "vs_main";
163/// Entry point name of the built-in shader's fragment stage.
164pub const FS_MAIN: &str = "fs_main";
165/// Label of the built-in entry shader: its shader module's label, and the name
166/// a composition error is reported under.
167const BUILT_IN_SHADER_LABEL: &str = "unlit_wgpu::lit";
168
169/// Name of the WESL module the built-in entry shader is composed from: the file
170/// `shaders/lit.wesl` of this crate's package.
171const BUILT_IN_MODULE: &str = "lit";
172
173/// The per-vertex channels the built-in shader variant reads.
174///
175/// These are not part of [`LitOptions`]: they are a property of the geometry
176/// rather than of anything a caller picks, so a pipeline reads them off the
177/// vertex buffers its draws are recorded with — every draw of a mesh carries
178/// the channels that mesh's buffers hold, so there is nothing to specialize on
179/// and nothing for a caller to get wrong. A family resolves them into
180/// [`LitVariant::channels`].
181///
182/// The two halves are [`crate::mesh`]'s own channel types, so the attributes a
183/// pipeline declares and the bytes a packed mesh writes come from one
184/// description and cannot drift apart.
185#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
186pub struct LitVertexChannels {
187    /// The position stream: whether it carries a position at all — and in
188    /// which encoding — plus the joint pair that rides along with it.
189    ///
190    /// A stream with no position leaves the geometry a single point at the
191    /// instance origin, which is what point, particle and impostor draws want:
192    /// the per-instance data alone places the vertex.
193    pub position: PositionStreamChannels,
194    /// The secondary stream's channels: a UV in either encoding, a vertex
195    /// color, a normal, or any combination.
196    pub secondary: SecondaryFlags,
197}
198
199impl LitVertexChannels {
200    /// The channels of a layout that carries none: no position, no joints, and
201    /// an empty secondary stream.
202    ///
203    /// A variant built from this reads no per-vertex attribute at all — the
204    /// geometry is a single point at the instance origin — which is what point,
205    /// particle and impostor draws want. The instance record is unconditional,
206    /// so a `VertexInput` struct always has members.
207    pub const fn empty() -> Self {
208        Self {
209            position: PositionStreamChannels {
210                position: None,
211                joints: false,
212            },
213            secondary: SecondaryFlags::empty(),
214        }
215    }
216
217    /// The channels one vertex-buffer slot carries.
218    ///
219    /// A channel is identified by its shader location, which is stable across
220    /// variants. Only the built-in pipeline's own slots are read —
221    /// [`PRIMARY_SLOT`] and [`SECONDARY_SLOT`] — so any other slot, the
222    /// per-instance one included, contributes nothing.
223    pub fn for_vertex_buffer(slot: u32, layout: &VertexBufferLayoutDesc) -> Self {
224        let mut channels = Self::empty();
225        match slot {
226            PRIMARY_SLOT => {
227                for attribute in &layout.attributes {
228                    if attribute.shader_location == location::POSITION {
229                        channels.position.position = Some(match attribute.format {
230                            wgpu::VertexFormat::Float32x3 => ChannelEncoding::UncompressedPosition,
231                            wgpu::VertexFormat::Float32x2 => ChannelEncoding::PlanarPosition,
232                            _ => ChannelEncoding::CompressedPosition,
233                        });
234                    }
235                    // The joint pair is part of this stream rather than a
236                    // stream of its own: the indices are the evidence, and the
237                    // weights follow them.
238                    if attribute.shader_location == location::JOINTS {
239                        channels.position.joints = true;
240                    }
241                }
242            }
243            SECONDARY_SLOT => {
244                for attribute in &layout.attributes {
245                    match attribute.shader_location {
246                        location::UV => {
247                            channels.secondary |= SecondaryFlags::UV;
248                            if attribute.format == wgpu::VertexFormat::Float32x2 {
249                                channels.secondary |= SecondaryFlags::UNCOMPRESSED_UV;
250                            }
251                        }
252                        location::COLOR => channels.secondary |= SecondaryFlags::COLOR,
253                        location::NORMAL => channels.secondary |= SecondaryFlags::NORMAL,
254                        _ => {}
255                    }
256                }
257            }
258            _ => {}
259        }
260        channels
261    }
262
263    /// The channels a whole vertex layout carries: the union of
264    /// [`Self::for_vertex_buffer`] over `layout`'s slots.
265    pub fn for_vertex_layout(layout: &[(u32, VertexBufferLayoutDesc)]) -> Self {
266        let mut channels = Self::empty();
267        for (slot, slot_layout) in layout {
268            let slot_channels = Self::for_vertex_buffer(*slot, slot_layout);
269            if slot_channels.position.position.is_some() {
270                channels.position.position = slot_channels.position.position;
271            }
272            channels.position.joints |= slot_channels.position.joints;
273            channels.secondary |= slot_channels.secondary;
274        }
275        channels
276    }
277}
278
279/// How a sampled base-color texel expands into the fragment's color.
280#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
281pub enum BaseColorChannels {
282    /// The texel is a color in all four channels.
283    #[default]
284    Rgba,
285    /// The texel is luminance: one channel, expanded into every color channel.
286    ///
287    /// The sampled value is decoded from sRGB on the way, because a
288    /// one-channel image cannot be uploaded in an sRGB format: WebGPU offers
289    /// the transfer function only for the four-channel `Rgba8UnormSrgb` and
290    /// `Bgra8UnormSrgb`, so a luminance texture carries the encoded value and
291    /// the shader decodes it.
292    Luminance,
293    /// The texel is luminance and alpha: two channels holding `l` and `a`,
294    /// sampled into the color and the alpha channel.
295    ///
296    /// The luminance half decodes from sRGB exactly as [`Self::Luminance`]
297    /// does; alpha is coverage, so it never converts.
298    LuminanceAlpha,
299}
300
301/// A caller's own shader code, spliced into the built-in `lit.wesl`.
302///
303/// The built-in entry shader keeps three marks, each a comment naming the place
304/// it sits: one at module scope, one at the end of the vertex stage and one at
305/// the end of the fragment stage. A shader is spliced in at those marks while the
306/// module is still WESL, before anything is compiled, so each snippet is ordinary
307/// WESL: it sees every binding, flag and helper the place it lands in sees, it
308/// takes part in the conditional compilation, and its diagnostics point at the
309/// line it was written on rather than at a fragment of generated WGSL.
310///
311/// The marks sit where the shader still has something left to do, which is what
312/// keeps a snippet short:
313///
314/// * The module mark is after every built-in binding, so a snippet can declare
315///   the functions, types and constants the stages call, and read the built-in
316///   bindings from them.
317/// * The vertex mark is after the instance transform, before the clip position
318///   is built. The vertex is a `var world` there, and everything downstream —
319///   the clip position, the world position the lights are addressed from, the
320///   normal and the cluster cell — follows the vertex the snippet leaves.
321/// * The fragment mark is after the base color is lit and cut off, before the
322///   target's encoding. The color is a `var color` there, so a snippet can
323///   rewrite it in place, and what it leaves is what the target receives.
324///
325/// The layouts and the vertex streams stay the variant's, exactly as they are
326/// for the built-in shader: a snippet changes what a stage computes, not what the
327/// variant reads. A snippet that declares a binding of its own therefore fails
328/// pipeline creation rather than drawing something else; one that declares
329/// functions, types or constants over the bindings every variant of that shape
330/// already declares works.
331///
332/// The label names the shader: it is the shader module's label, and a
333/// composition error is reported under it.
334#[derive(Clone, PartialEq, Eq, Hash)]
335pub struct LitShader {
336    label: Arc<str>,
337    module: Arc<str>,
338    vertex: Arc<str>,
339    fragment: Arc<str>,
340}
341
342impl LitShader {
343    /// A shader named `label` whose `module`, `vertex` and `fragment` snippets
344    /// are spliced into the built-in entry shader.
345    ///
346    /// Any snippet may be empty, which leaves its place exactly as the built-in
347    /// shader has it.
348    pub fn new(
349        label: impl Into<Arc<str>>,
350        module: impl Into<Arc<str>>,
351        vertex: impl Into<Arc<str>>,
352        fragment: impl Into<Arc<str>>,
353    ) -> Self {
354        Self {
355            label: label.into(),
356            module: module.into(),
357            vertex: vertex.into(),
358            fragment: fragment.into(),
359        }
360    }
361
362    /// The name this shader is reported under.
363    pub fn label(&self) -> &str {
364        &self.label
365    }
366
367    /// The WESL declarations spliced in at module scope.
368    pub fn module(&self) -> &str {
369        &self.module
370    }
371
372    /// The WESL statements spliced into the vertex stage.
373    pub fn vertex(&self) -> &str {
374        &self.vertex
375    }
376
377    /// The WESL statements spliced into the fragment stage.
378    pub fn fragment(&self) -> &str {
379        &self.fragment
380    }
381}
382
383impl core::fmt::Debug for LitShader {
384    /// Prints the label and each snippet's length rather than the snippets
385    /// themselves, so an [`LitOptions`] dump stays readable.
386    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
387        f.debug_struct("LitShader")
388            .field("label", &self.label)
389            .field("module", &format_args!("{} bytes", self.module.len()))
390            .field("vertex", &format_args!("{} bytes", self.vertex.len()))
391            .field("fragment", &format_args!("{} bytes", self.fragment.len()))
392            .finish()
393    }
394}
395
396/// The policy a caller decides about a built-in lit pipeline.
397///
398/// Everything a draw resolves is absent: the vertex channels, whether the draw
399/// samples a material, whether its mesh morphs, the render target and the
400/// device's array path are facts a family folds into an [`LitVariant`] when
401/// it resolves a draw, not choices a caller can make. The target owns the
402/// color format, the sample count and whether there is a depth attachment at
403/// all; what is left is what a caller actually picks.
404///
405/// [`LitOptions::standard`] is the usual starting point: back-face culling,
406/// a filtering base-color texture binding and reverse-z depth.
407#[derive(Clone, Debug, PartialEq, Eq, Hash)]
408pub struct LitOptions {
409    /// The entry shader this policy draws with, or `None` for the built-in
410    /// `lit.wesl`.
411    ///
412    /// A caller's snippets extend the built-in source and nothing else: the
413    /// layouts, vertex streams and entry-point names stay the variant's. See
414    /// [`LitShader`].
415    pub shader: Option<LitShader>,
416    /// Whether the fragment stage returns its base color unchanged instead of
417    /// multiplying it by the frame's lights.
418    ///
419    /// An unlit variant declares none of the lighting bindings, so the lights a
420    /// source clusters cost it nothing; a lit one reads the light records, the
421    /// cell array, the polygon vertices and the cluster uniform from the global
422    /// group. Lighting is the common case, so this states the exception and
423    /// [`Self::standard`] leaves it off.
424    pub unlit: bool,
425    /// The color target is sRGB-aware: it encodes the values written to it, so
426    /// the fragment converts them from sRGB to linear first.
427    ///
428    /// The shader multiplies the caller's colors in whichever space they
429    /// arrive in — premultiplied sRGB for a user-interface pass, say — and a
430    /// target that encodes sRGB would otherwise encode them a second time.
431    /// Alpha is coverage rather than color, so it never converts.
432    pub srgb_to_linear_output: bool,
433    /// How a sampled base-color texel expands into the fragment's color.
434    ///
435    /// Only meaningful for a variant that samples a base-color texture:
436    /// without one there is no texel to expand.
437    pub base_color: BaseColorChannels,
438    /// Discard a fragment whose alpha is below the instance's cutoff.
439    ///
440    /// The cutoff itself rides the instance stream rather than the material
441    /// group: it is per-instance state, and this switch only selects a code
442    /// path. An instance that carries no cutoff leaves the value at zero, which
443    /// no alpha falls below, so the path costs a compare and nothing else.
444    ///
445    /// An untextured fragment can be cut off too: its alpha is the instance
446    /// color's, and the cutoff is explicit per instance either way.
447    pub alpha_cutoff: bool,
448    /// Push every vertex out along its normal by the instance's hull width.
449    ///
450    /// This is the geometry half of the inverted-hull outline: a mesh drawn
451    /// with this variant expands into a shell, and a mesh drawn behind it with
452    /// the ordinary front-facing variant covers the shell except where it
453    /// pokes out past the silhouette, which is the line. Both halves draw the
454    /// same mesh with the same buffers — the shell reads the very same vertex
455    /// and index streams — so an outline costs one extra draw and no second
456    /// mesh.
457    ///
458    /// The width rides the instance stream
459    /// ([`MeshInstance::hull_width`](crate::mesh::MeshInstance::hull_width)),
460    /// so two instances of one mesh can be shells of different thicknesses,
461    /// and a mesh that is not a hull carries zero. A variant with this set
462    /// reads the mesh's normals and declares the
463    /// [`HULL_WIDTH`](crate::pipeline::location::HULL_WIDTH) attribute, which
464    /// a mesh with no normal channel cannot supply — without a normal there is
465    /// no direction to expand along.
466    ///
467    /// Expansion happens in the vertex stage, so it moves the clip position,
468    /// the world position the lights are addressed from and the shading normal
469    /// together. Which faces are drawn is the primitive state's business:
470    /// [`LitOptions::inverted_hull`] culls the front and writes no depth,
471    /// which is what leaves the ordinary variant's nearer surface in front.
472    pub expanded: bool,
473    /// Whether the base-color texture binding is filterable.
474    ///
475    /// A filtering binding accepts a sampler that interpolates between
476    /// texels and a texture whose format is filterable — the usual case, and
477    /// what [`Self::standard`] leaves. Clearing it declares the binding
478    /// unfilterable instead, which is what a texture sampled with
479    /// `nearest`-only filters has to bind to on a device that cannot filter
480    /// that format: `Rgba32Float`, say, is filterable only where
481    /// `FLOAT32_FILTERABLE` is present, and is still bindable without it
482    /// through an unfilterable binding.
483    ///
484    /// The two declare different bind-group layouts, so this is part of what
485    /// distinguishes one variant from another: a pipeline built for one cannot
486    /// bind a material built for the other.
487    pub texture_filtering: bool,
488    /// How the pipeline assembles and culls primitives, including the strip
489    /// index format a strip topology requires.
490    ///
491    /// [`Self::standard`] leaves `strip_index_format` at its default, and the
492    /// built-in lit family fills it from the mesh's index buffer, so the
493    /// field is a caller's concern only for a pipeline a mesh does not
494    /// describe.
495    pub primitive: wgpu::PrimitiveState,
496    /// How the pipeline tests and writes the depth attachment, for a target
497    /// that has one.
498    ///
499    /// A target without a depth attachment runs the pipeline without a
500    /// depth-stencil state at all, whatever this field says, because `wgpu`
501    /// rejects a pipeline that declares one against a pass that has none. The
502    /// format is the attachment's own, which the options cannot know: it is
503    /// the target's word, and the variant carries the target.
504    ///
505    /// [`Self::standard`] states the renderer's reverse-z convention: depth
506    /// is cleared to the far plane, so nearer geometry carries the greater
507    /// value.
508    pub depth_stencil: DepthStencilPolicy,
509    /// The blend state the fragment output composites with, or `None` to
510    /// write it unblended.
511    pub blend: Option<wgpu::BlendState>,
512    /// Which color channels the pipeline writes to the target.
513    pub write_mask: wgpu::ColorWrites,
514}
515
516/// The depth-test policy of a lit pipeline, without the attachment's format.
517///
518/// The format belongs to the target a pipeline is specialized for
519/// ([`SurfaceKey::depth_stencil_format`]), not to the policy: one policy
520/// serves every target, and the variant's descriptor fills the format in when
521/// it builds the pipeline. The options could not carry it and stay a
522/// policy — a pipeline built for one attachment's format cannot bind against a
523/// pass recording into another.
524#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
525pub struct DepthStencilPolicy {
526    /// Whether the pipeline writes the depth of the fragments it draws, or
527    /// leaves the attachment's records to the draws that wrote them.
528    pub depth_write_enabled: bool,
529    /// The comparison a fragment's depth passes against the attachment's
530    /// record with.
531    pub depth_compare: wgpu::CompareFunction,
532}
533
534impl Default for DepthStencilPolicy {
535    /// The renderer's reverse-z convention: depth is cleared to the far plane,
536    /// so nearer geometry carries the greater value, and writes its own.
537    fn default() -> Self {
538        Self {
539            depth_write_enabled: true,
540            depth_compare: wgpu::CompareFunction::Greater,
541        }
542    }
543}
544
545impl DepthStencilPolicy {
546    /// The wgpu state this policy names, recording into a depth attachment of
547    /// `format`.
548    fn state(self, format: wgpu::TextureFormat) -> wgpu::DepthStencilState {
549        wgpu::DepthStencilState {
550            format,
551            depth_write_enabled: Some(self.depth_write_enabled),
552            depth_compare: Some(self.depth_compare),
553            stencil: wgpu::StencilState::default(),
554            bias: wgpu::DepthBiasState::default(),
555        }
556    }
557}
558
559impl LitOptions {
560    /// The usual starting point: the full compressed mesh variant — positions,
561    /// UVs, vertex colors, a base-color texture and per-instance transforms —
562    /// with reverse-z depth.
563    ///
564    /// The policy names no attachment format: the target the variant is
565    /// specialized for does. Back faces are culled, because the geometry this
566    /// variant is for is closed meshes wound counter-clockwise, whose insides
567    /// are never meant to show.
568    pub fn standard() -> Self {
569        Self {
570            shader: None,
571            unlit: false,
572            srgb_to_linear_output: false,
573            base_color: BaseColorChannels::Rgba,
574            alpha_cutoff: false,
575            expanded: false,
576            texture_filtering: true,
577            primitive: wgpu::PrimitiveState {
578                cull_mode: Some(wgpu::Face::Back),
579                ..Default::default()
580            },
581            depth_stencil: DepthStencilPolicy::default(),
582            blend: None,
583            write_mask: wgpu::ColorWrites::ALL,
584        }
585    }
586
587    /// The outline shell that goes behind a mesh drawn with [`Self::standard`].
588    ///
589    /// The inverted-hull trick draws the same mesh twice: once as itself, and
590    /// once expanded along its normals, with its front faces culled and its
591    /// backwards-facing surface — which is now the outside of a slightly
592    /// larger copy — drawn where the real mesh does not cover it. What shows
593    /// is a band the width of the expansion, all the way around the
594    /// silhouette, including the silhouette's own inner folds, which a
595    /// per-triangle line cannot find.
596    ///
597    /// The returned options differ from the standard ones in what makes the
598    /// shell a shell rather than a copy:
599    ///
600    /// - [`Self::expanded`] is set, so the vertex stage pushes each vertex out
601    ///   along its normal by the instance's hull width.
602    /// - The front faces are culled, so the far side of the shell is what
603    ///   covers the near side of the mesh.
604    /// - Depth is not written, so the shell leaves the depth buffer to the
605    ///   meshes drawn around it — the surface it belongs to still occludes and
606    ///   is occluded exactly as if the shell were not there.
607    /// - It is unlit, so the band is one flat color rather than a shaded copy
608    ///   of the mesh, and it declares none of the lighting bindings.
609    ///
610    /// The expansion width and the band's color are per-instance values, so
611    /// these options cannot hold them: the shell entity carries them as its
612    /// [`MeshInstance::hull_width`](crate::mesh::MeshInstance::hull_width) and
613    /// its base color, which is the same record every other instance is packed
614    /// from.
615    ///
616    /// Both options hash into the same `LitPipelineKey`, so the pair
617    /// compiles to two pipelines and needs no family of its own, no second
618    /// mesh and no second pass: the two draws sort against each other by depth
619    /// like any others, and the shell's unwritten depth is what keeps the
620    /// order between them stable.
621    pub fn inverted_hull() -> Self {
622        Self {
623            unlit: true,
624            expanded: true,
625            primitive: wgpu::PrimitiveState {
626                cull_mode: Some(wgpu::Face::Front),
627                ..Default::default()
628            },
629            depth_stencil: DepthStencilPolicy {
630                depth_write_enabled: false,
631                depth_compare: wgpu::CompareFunction::Greater,
632            },
633            ..Self::standard()
634        }
635    }
636
637    /// The material bind-group layout this policy's variants declare.
638    ///
639    /// The layout holds the base-color texture and its sampler; whether a
640    /// variant actually declares it is the draw's answer
641    /// ([`LitVariant::base_color_texture`]), so this is only the shape of the
642    /// group. Building it here rather than from a variant lets a caller
643    /// allocate a material without having resolved a draw first.
644    pub fn material_bind_group_layout(&self, device: &wgpu::Device) -> wgpu::BindGroupLayout {
645        let mut entries = arrayvec::ArrayVec::<wgpu::BindGroupLayoutEntry, 2>::new();
646        entries.push(wgpu::BindGroupLayoutEntry {
647            binding: BASE_COLOR_TEXTURE_BINDING,
648            visibility: wgpu::ShaderStages::FRAGMENT,
649            ty: wgpu::BindingType::Texture {
650                sample_type: wgpu::TextureSampleType::Float {
651                    filterable: self.texture_filtering,
652                },
653                view_dimension: wgpu::TextureViewDimension::D2,
654                multisampled: false,
655            },
656            count: None,
657        });
658        entries.push(wgpu::BindGroupLayoutEntry {
659            binding: BASE_COLOR_SAMPLER_BINDING,
660            visibility: wgpu::ShaderStages::FRAGMENT,
661            ty: if self.texture_filtering {
662                wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering)
663            } else {
664                wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering)
665            },
666            count: None,
667        });
668        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
669            label: Some("unlit_wgpu::lit::material"),
670            entries: &entries,
671        })
672    }
673}
674
675/// The complete identity of a built-in lit pipeline, and its blueprint.
676///
677/// A family resolves one from an entity's [`LitOptions`] and the facts its
678/// draw carries: the mesh's vertex channels, whether the draw binds a material,
679/// whether the mesh morphs, the mesh's index format and the render target. Two
680/// draws that agree on all of it share one compiled pipeline; because the
681/// variant is the complete description of the blueprint, the cache key is
682/// canonical by construction.
683#[derive(Clone, Debug, PartialEq, Eq, Hash)]
684pub struct LitVariant {
685    /// The caller's policy, with its hash computed once. The variant is the
686    /// cache key, so the stored word is what every per-frame lookup hashes;
687    /// rewrite the policy through [`Hashed::update`] rather than the value
688    /// itself, so the word follows the change.
689    pub options: Hashed<LitOptions>,
690    /// The render target the pipeline is built for.
691    pub surface: SurfaceKey,
692    /// The per-vertex channels the mesh's layout carries.
693    pub channels: LitVertexChannels,
694    /// Whether the draw binds a material group and so samples a base-color
695    /// texture.
696    pub base_color_texture: bool,
697    /// Whether the mesh carries morph displacements.
698    pub morph: bool,
699    /// The strip index format the mesh's index buffer requires, if any.
700    pub strip_index_format: Option<wgpu::IndexFormat>,
701}
702
703impl LitVariant {
704    /// The WESL `@if` features the variant asks for, paired with their state.
705    ///
706    /// Every name appears, so the composed variant never sees a name it does
707    /// not know. The per-vertex names come from [`Self::channels`], the material
708    /// one from [`Self::base_color_texture`] and the policy ones from
709    /// [`Self::options`]. The per-instance record is not conditional at all, so
710    /// it contributes no names here: the built-in vertex stream always
711    /// declares and binds an instance buffer.
712    ///
713    /// `texel_arrays` is the device's answer rather than the variant's, so it
714    /// is passed in from the caller that read the device.
715    fn features(&self, texel_arrays: bool) -> [(&'static str, bool); 17] {
716        [
717            ("VERTEX_POSITION", self.channels.position.position.is_some()),
718            (
719                "UNCOMPRESSED_POSITION",
720                matches!(
721                    self.channels.position.position,
722                    Some(ChannelEncoding::UncompressedPosition | ChannelEncoding::PlanarPosition)
723                ),
724            ),
725            (
726                "PLANAR_POSITION",
727                matches!(
728                    self.channels.position.position,
729                    Some(ChannelEncoding::PlanarPosition)
730                ),
731            ),
732            (
733                "VERTEX_UV",
734                self.channels.secondary.contains(SecondaryFlags::UV),
735            ),
736            (
737                "UNCOMPRESSED_UV",
738                self.channels
739                    .secondary
740                    .contains(SecondaryFlags::UNCOMPRESSED_UV),
741            ),
742            (
743                "VERTEX_COLOR",
744                self.channels.secondary.contains(SecondaryFlags::COLOR),
745            ),
746            (
747                "VERTEX_NORMAL",
748                self.channels.secondary.contains(SecondaryFlags::NORMAL),
749            ),
750            ("BASE_COLOR_TEXTURE", self.base_color_texture),
751            ("SRGB_TO_LINEAR_OUTPUT", self.options.srgb_to_linear_output),
752            ("VERTEX_JOINTS", self.channels.position.joints),
753            ("MORPH_POSITIONS", self.morph),
754            ("TEXEL_ARRAY", texel_arrays),
755            (
756                "BASE_COLOR_LUMINANCE",
757                self.options.base_color == BaseColorChannels::Luminance,
758            ),
759            (
760                "BASE_COLOR_LUMINANCE_ALPHA",
761                self.options.base_color == BaseColorChannels::LuminanceAlpha,
762            ),
763            ("ALPHA_CUTOFF", self.options.alpha_cutoff),
764            ("EXPANDED", self.options.expanded),
765            ("LIT", !self.options.unlit),
766        ]
767    }
768
769    /// Whether the variant deforms its vertices by joint matrices and so reads
770    /// the global group's array of them.
771    pub fn needs_joints(&self) -> bool {
772        self.channels.position.joints
773    }
774
775    /// Whether the variant reads the global group's morph arrays: the
776    /// per-instance weights and the per-mesh displacements.
777    pub fn needs_morphs(&self) -> bool {
778        self.morph
779    }
780
781    /// Whether the variant reads the global group's mesh-metadata array: the
782    /// per-mesh decode parameters and the addressing a morph reads.
783    ///
784    /// Mirrors the shader's metadata condition, so the layout and the composed
785    /// variant agree on whether the binding exists. A morph needs the array
786    /// even when every channel is uncompressed, because the struct also carries
787    /// the vertex offset and morph count its displacements are addressed by.
788    pub fn needs_metadata(&self) -> bool {
789        let compressed_position = matches!(
790            self.channels.position.position,
791            Some(ChannelEncoding::CompressedPosition)
792        );
793        let compressed_uv = self.channels.secondary.contains(SecondaryFlags::UV)
794            && !self
795                .channels
796                .secondary
797                .contains(SecondaryFlags::UNCOMPRESSED_UV);
798        compressed_position || compressed_uv || self.needs_morphs()
799    }
800
801    /// The secondary vertex stream this variant expects.
802    ///
803    /// The stream is described in [`crate::mesh`]'s own terms: this variant's
804    /// channels are translated into the ones that make up a vertex stream, so
805    /// packing meshes for this pipeline needs nothing specific to it.
806    pub fn secondary_stream(&self) -> SecondaryStreamWriter {
807        SecondaryStreamWriter {
808            flags: self.channels.secondary,
809        }
810    }
811
812    /// The position vertex stream this variant expects.
813    ///
814    /// The joint indices and weights the variant deforms with belong to this
815    /// stream rather than one of their own, so a skinned variant's stream is
816    /// wider by the joint pair and nothing else changes.
817    pub fn position_stream(&self) -> PositionStreamWriter {
818        PositionStreamWriter {
819            channels: self.channels.position,
820        }
821    }
822
823    /// Reject a variant whose array path the device cannot serve.
824    ///
825    /// The texel path works on every device — a texture read is universal — so
826    /// forcing it on a device that also has storage buffers is allowed. The
827    /// reverse is not: a bind-group layout naming a storage buffer is rejected
828    /// outright by a device without them, so the failure is caught here with the
829    /// reason rather than left to wgpu's validation.
830    ///
831    /// A variant that declares no array binding at all — the UI's, which reads
832    /// only full-precision vertex attributes — has nothing to serve either way,
833    /// so it is not asked to pick a path.
834    ///
835    /// # Panics
836    ///
837    /// If the variant reads storage buffers on a device that has none.
838    fn assert_device_supports_arrays(&self, device: &wgpu::Device, texel_arrays: bool) {
839        let reads_an_array = self.needs_metadata() || self.needs_joints() || !self.options.unlit;
840        assert!(
841            !reads_an_array || texel_arrays || supports_storage_buffers(device),
842            "this variant reads its arrays from storage buffers, but the device has none \
843             (its `max_storage_buffers_per_shader_stage` is 0, as WebGL2's is): compose \
844             the variant with `texel_arrays` set, the way the built-in family does"
845        );
846    }
847}
848
849impl LitVariant {
850    /// Build the bind-group layouts this variant declares, without compiling
851    /// the pipeline.
852    ///
853    /// The layouts come from the same variant a pipeline is built from, so the
854    /// two agree: the global group always exists, and the material group only
855    /// for a variant that samples a base-color texture. The mesh group is
856    /// always `None` — the built-in variants bind nothing there.
857    ///
858    /// The array path is the device's answer rather than the variant's, so it
859    /// is read here: a cache is built for one device, which is why the path
860    /// never enters the variant key.
861    pub fn bind_group_layouts(&self, device: &wgpu::Device) -> LitBindGroupLayouts {
862        let texel_arrays = !supports_storage_buffers(device);
863        self.assert_device_supports_arrays(device, texel_arrays);
864        // The group holds the frame's shared inputs: the camera, the frame
865        // globals, the mesh-metadata decode parameters, the pose arrays every
866        // instance slices into, and the morph displacements every mesh slices
867        // into. A variant that reads none of the optional ones declares no
868        // binding for them. The builder is the same one a caller's own pipeline
869        // uses, so the built-in variants carry no private layout path.
870        let bindings = GlobalBindings {
871            metadata: self.needs_metadata(),
872            joints: self.needs_joints(),
873            morphs: self.needs_morphs(),
874            // A lit variant reads the frame's lights, its cells and its
875            // polygon vertices; an unlit one declares no such binding.
876            lights: !self.options.unlit,
877        };
878        let global = global_bind_group_layout(device, bindings, texel_arrays);
879
880        let material = self
881            .base_color_texture
882            .then(|| self.options.material_bind_group_layout(device));
883
884        // The built-in variants bind nothing at the mesh group: every input
885        // they read is in the global group or on the instance stream. The
886        // index remains a general extension point a caller's pipeline can use.
887        let mesh = None;
888
889        LitBindGroupLayouts {
890            global,
891            material,
892            mesh,
893        }
894    }
895
896    /// The vertex-buffer declarations the built-in shader expects, in slot
897    /// order.
898    ///
899    /// The first two slots follow the variant's channels; the third is always
900    /// the instance stream, which every built-in variant binds. A slot whose
901    /// variant declares no attributes is reported as `None`, so a variant never
902    /// binds a buffer the shader does not declare.
903    pub fn vertex_buffer_layouts(&self) -> [Option<VertexBufferLayoutDesc>; 3] {
904        // Each stream describes its own layout, so the attributes a pipeline
905        // declares and the bytes a packed mesh writes come from one
906        // description and cannot drift apart.
907        let position = self.position_stream().channels.channels();
908        let secondary = self.secondary_stream().channels();
909        [
910            (!position.is_empty()).then(|| position.layout()),
911            (!secondary.is_empty()).then(|| secondary.layout()),
912            Some(self.instance_layout()),
913        ]
914    }
915
916    /// The instance stream every built-in variant expects.
917    ///
918    /// One record layout serves every variant of a frame — the source uploads a
919    /// single `&[MeshInstance]` buffer and binds it at [`INSTANCE_SLOT`] for
920    /// every draw — so the stride is always the whole [`MeshInstance`]. The
921    /// declared attributes mirror the shader's own input declarations: the
922    /// model matrix, the base color, the pose bases and the metadata index
923    /// unconditionally, the alpha cutoff and the hull width under the features
924    /// that declare them.
925    /// A pipeline never declares an attribute the shader does not, which the
926    /// validation layers would otherwise flag on every draw.
927    ///
928    /// [`MeshInstance`]: crate::mesh::MeshInstance
929    fn instance_layout(&self) -> VertexBufferLayoutDesc {
930        let mut attributes = crate::specialize::VertexAttributes::new();
931        let field = |offset: usize| offset as u64;
932        let model = core::mem::offset_of!(crate::mesh::MeshInstance, model);
933        let column = core::mem::size_of::<[f32; 4]>() as u64;
934        let mut fields = vec![
935            (
936                location::MODEL_0,
937                wgpu::VertexFormat::Float32x4,
938                field(model),
939            ),
940            (
941                location::MODEL_1,
942                wgpu::VertexFormat::Float32x4,
943                field(model) + column,
944            ),
945            (
946                location::MODEL_2,
947                wgpu::VertexFormat::Float32x4,
948                field(model) + 2 * column,
949            ),
950            (
951                location::BASE_COLOR,
952                wgpu::VertexFormat::Unorm8x4,
953                field(core::mem::offset_of!(crate::mesh::MeshInstance, base_color)),
954            ),
955            (
956                location::JOINTS_BASE,
957                wgpu::VertexFormat::Uint32,
958                field(core::mem::offset_of!(
959                    crate::mesh::MeshInstance,
960                    joints_base
961                )),
962            ),
963            (
964                location::METADATA_INDEX,
965                wgpu::VertexFormat::Uint32,
966                field(core::mem::offset_of!(
967                    crate::mesh::MeshInstance,
968                    metadata_index
969                )),
970            ),
971        ];
972        // The cutoff is conditional: the shader declares it only under
973        // ALPHA_CUTOFF, so a variant that never cuts must not declare it
974        // either — the validation layers flag an attribute the vertex stage
975        // never touches.
976        if self.options.alpha_cutoff {
977            fields.push((
978                location::CUTOFF,
979                wgpu::VertexFormat::Float32,
980                field(core::mem::offset_of!(crate::mesh::MeshInstance, cutoff)),
981            ));
982        }
983        fields.push((
984            location::MORPH_BASE,
985            wgpu::VertexFormat::Uint32,
986            field(core::mem::offset_of!(crate::mesh::MeshInstance, morph_base)),
987        ));
988        // The other conditional instance input, on the same terms: only an
989        // expanding variant's shader declares the hull width.
990        if self.options.expanded {
991            fields.push((
992                location::HULL_WIDTH,
993                wgpu::VertexFormat::Float32,
994                field(core::mem::offset_of!(crate::mesh::MeshInstance, hull_width)),
995            ));
996        }
997        for (shader_location, format, offset) in fields {
998            attributes.push(wgpu::VertexAttribute {
999                format,
1000                offset,
1001                shader_location,
1002            });
1003        }
1004        VertexBufferLayoutDesc {
1005            // The stride is the record's whole size rather than the sum of the
1006            // declared formats, so a variant that declares a subset still steps
1007            // over the fields it omits, which keeps every instance aligned in
1008            // one shared buffer.
1009            array_stride: core::mem::size_of::<crate::mesh::MeshInstance>() as u64,
1010            step_mode: wgpu::VertexStepMode::Instance,
1011            attributes,
1012        }
1013    }
1014}
1015
1016impl PipelineVariant<wgpu::RenderPipeline> for LitVariant {
1017    type Descriptor = Self;
1018
1019    fn descriptor(&self, _device: &wgpu::Device) -> Self {
1020        self.clone()
1021    }
1022}
1023
1024impl PipelineDescriptor<wgpu::RenderPipeline> for LitVariant {
1025    /// Compose this variant's entry shader and build the pipeline, so a variant
1026    /// compiles through the same [PipelineDescriptor] mechanism a caller's own
1027    /// descriptor does.
1028    ///
1029    /// The entry shader is the built-in `lit.wesl`, with the options' own
1030    /// snippets ([`LitOptions::shader`]) spliced into it at its marks.
1031    fn create(&self, device: &wgpu::Device) -> wgpu::RenderPipeline {
1032        let texel_arrays = !supports_storage_buffers(device);
1033        let label = match &self.options.shader {
1034            Some(shader) => shader.label(),
1035            None => BUILT_IN_SHADER_LABEL,
1036        };
1037        let wgsl =
1038            compose_shader(self, texel_arrays).unwrap_or_else(|error| panic!("{label}: {error}"));
1039        let module = crate::util::shader_module(device, &wgsl, label);
1040
1041        let layouts = self.bind_group_layouts(device);
1042        let layout_refs = [
1043            Some(&layouts.global),
1044            layouts.material.as_ref(),
1045            layouts.mesh.as_ref(),
1046        ];
1047        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1048            label: Some("unlit_wgpu::lit::layout"),
1049            bind_group_layouts: &layout_refs,
1050            immediate_size: 0,
1051        });
1052
1053        // The caller's policy is the base; the target and the mesh's strip
1054        // index format are applied here, because the variant carries them
1055        // rather than the caller. The policy is read field by field rather
1056        // than rewritten in place: the options are keyed on, and the target is
1057        // applied after hashing rather than into it.
1058        let options = &*self.options;
1059        let depth_stencil = self
1060            .surface
1061            .depth_stencil_format
1062            .map(|format| options.depth_stencil.state(format));
1063        let color_target = wgpu::ColorTargetState {
1064            format: self.surface.color_format,
1065            blend: options.blend,
1066            write_mask: options.write_mask,
1067        };
1068
1069        let vertex_buffers = self.vertex_buffer_layouts();
1070        let vertex_buffers: Vec<Option<wgpu::VertexBufferLayout<'_>>> = vertex_buffers
1071            .iter()
1072            .map(|layout| layout.as_ref().map(VertexBufferLayoutDesc::as_wgpu))
1073            .collect();
1074        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1075            label: Some("unlit_wgpu::lit"),
1076            layout: Some(&pipeline_layout),
1077            vertex: wgpu::VertexState {
1078                module: &module,
1079                entry_point: Some(VS_MAIN),
1080                compilation_options: Default::default(),
1081                buffers: &vertex_buffers,
1082            },
1083            primitive: {
1084                let mut primitive = options.primitive;
1085                primitive.strip_index_format = self.strip_index_format;
1086                primitive
1087            },
1088            depth_stencil,
1089            multisample: wgpu::MultisampleState {
1090                count: self.surface.sample_count,
1091                ..Default::default()
1092            },
1093            fragment: Some(wgpu::FragmentState {
1094                module: &module,
1095                entry_point: Some(FS_MAIN),
1096                compilation_options: Default::default(),
1097                targets: &[Some(color_target)],
1098            }),
1099            multiview_mask: None,
1100            cache: None,
1101        })
1102    }
1103}
1104
1105/// Compose `variant`'s entry shader into one WGSL module.
1106///
1107/// The main module is the built-in `lit.wesl`, with the options' snippets
1108/// spliced into it at its hook marks. It is composed as the package's own
1109/// `lit` module, so a snippet that names `package::light` or
1110/// `unlit_wgpu::light` reaches the modules the shader around it imports, and
1111/// sees the variant's flags.
1112///
1113/// `texel_arrays` is the device answer rather than the variant one, so it is
1114/// passed in from whoever read the device; it selects the shader array path
1115/// and never enters the variant key, because a cache is built for one device.
1116///
1117/// # Panics
1118///
1119/// If the variant contradicts itself: the base-color texture is sampled with a
1120/// per-vertex UV that is not read, a deformation displaces a position that is
1121/// not read, or a luminance layout expands a texel that is never sampled.
1122///
1123/// The per-vertex channels cannot contradict each other the way flags once
1124/// could: an encoding is a property of the channel it encodes, so
1125/// [`LitVertexChannels`] has no state in which a channel is uncompressed
1126/// without being read. The per-instance fields cannot either: the instance
1127/// stream is bound for every variant, so there is no state in which one is
1128/// read without the stream that carries it.
1129fn compose_shader(
1130    variant: &LitVariant,
1131    texel_arrays: bool,
1132) -> Result<String, crate::compose::ComposeError> {
1133    assert!(
1134        !variant.base_color_texture || variant.channels.secondary.contains(SecondaryFlags::UV),
1135        "the base-color texture is sampled with the per-vertex UV, so sampling one requires a UV channel"
1136    );
1137    assert!(
1138        !variant.channels.position.joints || variant.channels.position.position.is_some(),
1139        "the joint pair is part of the position stream, so a skinned variant needs a position to deform"
1140    );
1141    assert!(
1142        !variant.morph || variant.channels.position.position.is_some(),
1143        "a morph target displaces the position, so `MORPH_POSITIONS` requires a position channel"
1144    );
1145    assert!(
1146        variant.options.base_color == BaseColorChannels::Rgba || variant.base_color_texture,
1147        "the luminance flags describe how a sampled base-color texel expands, so `BASE_COLOR_LUMINANCE` and `BASE_COLOR_LUMINANCE_ALPHA` require a sampled base-color texture"
1148    );
1149    assert!(
1150        !variant.options.expanded || variant.channels.secondary.contains(SecondaryFlags::NORMAL),
1151        "an expanding variant pushes the vertex along its normal, so `EXPANDED` requires a normal channel"
1152    );
1153
1154    let features = variant.features(texel_arrays);
1155    let replacement = variant.options.shader.as_ref().map(|shader| {
1156        crate::compose::splice(
1157            crate::shader::lit::MODULE.source,
1158            shader.module(),
1159            shader.vertex(),
1160            shader.fragment(),
1161        )
1162    });
1163    crate::compose::compose(BUILT_IN_MODULE, &features, replacement.as_deref())
1164}
1165
1166/// Which of a source's global buffers a pipeline reads.
1167///
1168/// The camera, frame-globals and view uniforms are always bound; the arrays are
1169/// optional and a pipeline declares only the ones it reads. A caller's own
1170/// pipeline passes the same description to `global_bind_group_layout` as the
1171/// built-in lit variants do, so binding the frame's shared inputs is not a
1172/// privilege of the built-in shader.
1173#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1174pub struct GlobalBindings {
1175    /// Whether the pipeline reads the frame's mesh-metadata array.
1176    pub metadata: bool,
1177    /// Whether the pipeline reads the frame's joint matrices.
1178    pub joints: bool,
1179    /// Whether the pipeline reads the frame's morph weights and displacements.
1180    pub morphs: bool,
1181    /// Whether the pipeline reads the frame's lights: the bounded-light arrays,
1182    /// the cluster uniform and the frame-wide lighting uniform.
1183    ///
1184    /// One flag for all five bindings: a pipeline that lights its fragments
1185    /// reads the three arrays and the two uniforms together, so a finer split
1186    /// would only describe combinations no pipeline can have.
1187    pub lights: bool,
1188}
1189
1190/// The layout entry binding an array of `element_size`-byte elements.
1191///
1192/// On the storage path it is a read-only storage buffer whose binding minimum is
1193/// one element, so the array may grow without invalidating the layout. On the
1194/// texel path it is a non-filterable float texture, which is what
1195/// `textureLoad` reads.
1196pub fn array_entry(
1197    binding: u32,
1198    element_size: u64,
1199    visibility: wgpu::ShaderStages,
1200    texel_arrays: bool,
1201) -> wgpu::BindGroupLayoutEntry {
1202    if texel_arrays {
1203        return crate::texel_array::TexelArrayLayout::binding(binding, visibility);
1204    }
1205    wgpu::BindGroupLayoutEntry {
1206        binding,
1207        visibility,
1208        ty: wgpu::BindingType::Buffer {
1209            ty: wgpu::BufferBindingType::Storage { read_only: true },
1210            has_dynamic_offset: false,
1211            min_binding_size: Some(
1212                core::num::NonZeroU64::new(element_size)
1213                    .expect("an element has a non-zero size, which is a valid binding minimum"),
1214            ),
1215        },
1216        count: None,
1217    }
1218}
1219
1220/// Build the global bind-group layout a pipeline with `bindings` declares.
1221///
1222/// The camera, frame-globals and view uniforms are always present; the arrays
1223/// are present exactly when `bindings` names them. The array path is the
1224/// device's answer rather than the pipeline's, so `texel_arrays` is passed in: a
1225/// device with storage buffers binds each array as one, a device without them —
1226/// WebGL2 — binds the same bytes as a texture.
1227///
1228/// The built-in lit variants build their global layout here, so a caller's own
1229/// pipeline declares the same group by the same rules.
1230pub fn global_bind_group_layout(
1231    device: &wgpu::Device,
1232    bindings: GlobalBindings,
1233    texel_arrays: bool,
1234) -> wgpu::BindGroupLayout {
1235    let mut entries = arrayvec::ArrayVec::<wgpu::BindGroupLayoutEntry, 12>::new();
1236    entries.push(wgpu::BindGroupLayoutEntry {
1237        binding: CAMERA_BINDING,
1238        // The near plane and the eye position are view values a fragment stage
1239        // may want to reason with: the lit pipeline derives its depth slices
1240        // and its fallback geometric normal from them.
1241        visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
1242        ty: wgpu::BindingType::Buffer {
1243            ty: wgpu::BufferBindingType::Uniform,
1244            has_dynamic_offset: false,
1245            min_binding_size: Some(
1246                <crate::globals::Camera as const_shader_layout::ShaderLayout>::SIZE,
1247            ),
1248        },
1249        count: None,
1250    });
1251    entries.push(wgpu::BindGroupLayoutEntry {
1252        binding: FRAME_BINDING,
1253        visibility: wgpu::ShaderStages::VERTEX,
1254        ty: wgpu::BindingType::Buffer {
1255            ty: wgpu::BufferBindingType::Uniform,
1256            has_dynamic_offset: false,
1257            min_binding_size: Some(
1258                <crate::globals::Globals as const_shader_layout::ShaderLayout>::SIZE,
1259            ),
1260        },
1261        count: None,
1262    });
1263    // The pass's view. Every pipeline declares it beside the camera and the
1264    // frame clock, because the group is the frame's shared one and the viewport
1265    // belongs to it as much as the other two: a pipeline that never measures in
1266    // pixels simply does not name the binding in its shader.
1267    entries.push(wgpu::BindGroupLayoutEntry {
1268        binding: VIEW_BINDING,
1269        visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
1270        ty: wgpu::BindingType::Buffer {
1271            ty: wgpu::BufferBindingType::Uniform,
1272            has_dynamic_offset: false,
1273            min_binding_size: Some(
1274                <crate::globals::View as const_shader_layout::ShaderLayout>::SIZE,
1275            ),
1276        },
1277        count: None,
1278    });
1279    if bindings.metadata {
1280        entries.push(array_entry(
1281            MESH_METADATA_BINDING,
1282            <crate::mesh::MeshMetadata as const_shader_layout::ShaderLayout>::SIZE.get(),
1283            wgpu::ShaderStages::VERTEX,
1284            texel_arrays,
1285        ));
1286    }
1287    if bindings.joints {
1288        entries.push(array_entry(
1289            JOINTS_BINDING,
1290            <crate::mesh::JointMatrix as const_shader_layout::ShaderLayout>::SIZE.get(),
1291            wgpu::ShaderStages::VERTEX,
1292            texel_arrays,
1293        ));
1294    }
1295    if bindings.morphs {
1296        entries.push(array_entry(
1297            MORPH_WEIGHTS_BINDING,
1298            wgpu::BufferAddress::from(core::mem::size_of::<f32>() as u64),
1299            wgpu::ShaderStages::VERTEX,
1300            texel_arrays,
1301        ));
1302        // One position component of one target: like every other array entry
1303        // the storage binding minimum is a single element, so a mesh joining or
1304        // leaving the pool never invalidates the layout.
1305        entries.push(array_entry(
1306            MORPH_DELTAS_BINDING,
1307            wgpu::BufferAddress::from(core::mem::size_of::<f32>() as u64),
1308            wgpu::ShaderStages::VERTEX,
1309            texel_arrays,
1310        ));
1311    }
1312    if bindings.lights {
1313        // Lighting happens in the fragment stage, where the view-space position
1314        // and normal are; the vertex stage reads none of these, so declaring
1315        // them there would only spend the vertex stage's array quota.
1316        let visibility = wgpu::ShaderStages::FRAGMENT;
1317        entries.push(array_entry(
1318            LIGHTS_BINDING,
1319            <crate::light::LightGpu as const_shader_layout::ShaderLayout>::SIZE.get(),
1320            visibility,
1321            texel_arrays,
1322        ));
1323        // One cell is a fixed number of words rather than one: the record lists
1324        // the lights assigned to the cell, so the binding minimum is a whole
1325        // record and a fragment reads the cell's own slice.
1326        entries.push(array_entry(
1327            LIGHT_CELLS_BINDING,
1328            wgpu::BufferAddress::from(
1329                (crate::light::CELL_WORDS * core::mem::size_of::<u32>()) as u64,
1330            ),
1331            visibility,
1332            texel_arrays,
1333        ));
1334        entries.push(array_entry(
1335            LIGHT_VERTICES_BINDING,
1336            wgpu::BufferAddress::from(core::mem::size_of::<glam::Vec4>() as u64),
1337            visibility,
1338            texel_arrays,
1339        ));
1340        entries.push(wgpu::BindGroupLayoutEntry {
1341            binding: CLUSTER_BINDING,
1342            visibility,
1343            ty: wgpu::BindingType::Buffer {
1344                ty: wgpu::BufferBindingType::Uniform,
1345                has_dynamic_offset: false,
1346                min_binding_size: Some(
1347                    <crate::light::ClusterUniform as const_shader_layout::ShaderLayout>::SIZE,
1348                ),
1349            },
1350            count: None,
1351        });
1352        entries.push(wgpu::BindGroupLayoutEntry {
1353            binding: LIGHTING_BINDING,
1354            visibility,
1355            ty: wgpu::BindingType::Buffer {
1356                ty: wgpu::BufferBindingType::Uniform,
1357                has_dynamic_offset: false,
1358                min_binding_size: Some(
1359                    <crate::light::LightingUniform as const_shader_layout::ShaderLayout>::SIZE,
1360                ),
1361            },
1362            count: None,
1363        });
1364    }
1365    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1366        label: Some("unlit_wgpu::lit::globals"),
1367        entries: &entries,
1368    })
1369}
1370
1371/// The bind-group layouts a [`SpecializedLitPipeline`] variant declares.
1372///
1373/// Built by [`LitVariant::bind_group_layouts`] from the same variant a
1374/// pipeline is built from, so the two agree: the global group always exists and
1375/// the material group only for a variant that samples a base-color texture. The
1376/// mesh group is always `None` — the built-in variants bind nothing there — but
1377/// stays in the structure because the index is a general extension point.
1378#[derive(Clone, Debug)]
1379pub struct LitBindGroupLayouts {
1380    /// Layout of the global bind group (index 0).
1381    pub global: wgpu::BindGroupLayout,
1382    /// Layout of the material bind group (index 1), for a variant that
1383    /// samples a base-color texture.
1384    pub material: Option<wgpu::BindGroupLayout>,
1385    /// Layout of the mesh bind group (index 2).
1386    ///
1387    /// Always `None` for the built-in variants: every input they read is in the
1388    /// global group or on the instance stream.
1389    pub mesh: Option<wgpu::BindGroupLayout>,
1390}
1391
1392/// The built-in lit pipeline: a compiled [wgpu::RenderPipeline] together
1393/// with the [`LitVariant`] it was built from.
1394///
1395/// [`LitVariant`] is its own [`PipelineVariant`] and [`PipelineDescriptor`],
1396/// so the built-in shader compiles through the same cache a caller's own
1397/// variant does, and the layouts it declares are derived from the variant
1398/// rather than stored beside the pipeline.
1399pub type SpecializedLitPipeline =
1400    crate::specialize::SpecializedPipeline<wgpu::RenderPipeline, LitVariant>;
1401
1402/// Whether `device` can read a storage buffer from a shader.
1403///
1404/// WebGL2 cannot: it has no storage buffers at all, and a bind-group layout
1405/// naming one is rejected outright. Such a device reports a limit of zero,
1406/// which is the only signal available — [`wgpu::Device`] exposes its limits and
1407/// features but not the downlevel capabilities that would say so directly.
1408///
1409/// A device that can bind storage buffers reports a non-zero limit, so this is
1410/// what decides between the built-in shader's buffer and texel array paths.
1411pub fn supports_storage_buffers(device: &wgpu::Device) -> bool {
1412    let limits = device.limits();
1413    limits.max_storage_buffers_per_shader_stage > 0 && limits.max_storage_buffer_binding_size > 0
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    use super::*;
1419    use crate::compose::{FRAGMENT_HOOK, MODULE_HOOK, VERTEX_HOOK};
1420    use crate::specialize::Variants;
1421
1422    /// A caller composes their own entry shader against the built-in package
1423    /// with `wesl` directly: [`crate::shader`] is the `StaticPackage` that
1424    /// resolves `import unlit_wgpu::…`.
1425    #[test]
1426    fn a_caller_composes_against_the_built_in_package() {
1427        let source = "\
1428import unlit_wgpu::camera::Camera;
1429import unlit_wgpu::mesh_compression;
1430import unlit_wgpu::mesh_metadata::MeshMetadata;
1431
1432@group(0) @binding(0) var<uniform> camera: Camera;
1433@group(0) @binding(3) var<storage, read> mesh_meta: array<MeshMetadata>;
1434
1435@vertex
1436fn vs_main(@location(0) position: vec4<f32>) -> @builtin(position) vec4<f32> {
1437    let decoded = mesh_compression::decode_position(position.xyz, mesh_meta[0]);
1438    return camera.clip_from_world * vec4<f32>(decoded, 1.0);
1439}
1440";
1441        // The caller's own module is virtual here; a real one would come from
1442        // a `FileResolver`. Anything not under the local namespace falls
1443        // through to the built-in package, which is how
1444        // `import unlit_wgpu::mesh_compression;` resolves.
1445        let namespace = wesl::syntax::ModulePath::new(
1446            wesl::syntax::PathOrigin::Absolute,
1447            vec!["app".to_owned()],
1448        );
1449        let main_path = wesl::syntax::ModulePath::new(
1450            wesl::syntax::PathOrigin::Absolute,
1451            vec!["app".to_owned(), "main".to_owned()],
1452        );
1453        let mut virtual_modules = wesl::resolver::VirtualResolver::new();
1454        virtual_modules.add_module(
1455            wesl::syntax::ModulePath::new(wesl::syntax::PathOrigin::Absolute, vec!["main".into()]),
1456            source.into(),
1457        );
1458
1459        let mut resolver = wesl::resolver::Router::new();
1460        resolver.mount_resolver(namespace, virtual_modules);
1461        resolver.mount_fallback_resolver({
1462            let mut packages = wesl::resolver::PackageResolver::new();
1463            packages.add_package(&crate::shader::PACKAGE);
1464            packages
1465        });
1466
1467        let options = wesl::CompileOptions {
1468            keep_main: true,
1469            ..Default::default()
1470        };
1471        let wgsl = wesl::Compiler::new_with_resolver(options, resolver)
1472            .compile_module(&main_path)
1473            .expect("the caller's module composes")
1474            .syntax
1475            .to_string();
1476
1477        // The imports resolved into the composed module.
1478        assert!(wgsl.contains("fn vs_main"));
1479        assert!(wgsl.contains("decode_position"));
1480    }
1481
1482    /// A snippet is spliced into the built-in entry shader rather than
1483    /// replacing it: the built-in stages still run, and the snippet's own
1484    /// statements are what the marks were replaced with.
1485    #[test]
1486    fn a_caller_shader_is_spliced_into_the_built_in_source() {
1487        let shader = LitShader::new(
1488            "caller",
1489            "fn marker_module() -> f32 { return 0.5; }",
1490            "let marker_vertex = marker_module() * 0.25; world = world + vec3<f32>(0.0, marker_vertex, 0.0);",
1491            "let marker_fragment = 0.25; color = vec4<f32>(marker_fragment, color.g, color.b, color.a);",
1492        );
1493        let mut options = LitOptions::standard();
1494        options.shader = Some(shader);
1495        let candidate = variant(
1496            options,
1497            channels(
1498                Some(ChannelEncoding::UncompressedPosition),
1499                false,
1500                SecondaryFlags::empty(),
1501            ),
1502            false,
1503            false,
1504        );
1505
1506        let wgsl = compose_shader(&candidate, false).expect("the caller's snippets compose");
1507        // The built-in stages are still there: a snippet takes part in them
1508        // rather than standing in for them.
1509        assert!(wgsl.contains("fn vs_main"));
1510        assert!(wgsl.contains("fn fs_main"));
1511        // Each snippet landed in the place it belongs to -- module scope before
1512        // either stage, the vertex stage before the fragment one -- and the
1513        // marks are gone rather than left in the composed module. A module
1514        // snippet declares what the stages call, which is the whole reason it
1515        // exists: here the vertex snippet uses a function it declared.
1516        let module_at = wgsl
1517            .find("fn marker_module")
1518            .expect("the module snippet spliced");
1519        let vertex_at = wgsl
1520            .find("marker_vertex")
1521            .expect("the vertex snippet spliced");
1522        let fragment_at = wgsl
1523            .find("marker_fragment")
1524            .expect("the fragment snippet spliced");
1525        assert!(module_at < vertex_at);
1526        assert!(vertex_at < fragment_at);
1527        assert!(!wgsl.contains(MODULE_HOOK));
1528        assert!(!wgsl.contains(VERTEX_HOOK));
1529        assert!(!wgsl.contains(FRAGMENT_HOOK));
1530    }
1531
1532    /// An empty snippet leaves its stage exactly as the built-in shader has it.
1533    #[test]
1534    fn an_empty_snippet_leaves_the_built_in_stage_alone() {
1535        let built_in = variant(
1536            LitOptions::standard(),
1537            channels(
1538                Some(ChannelEncoding::UncompressedPosition),
1539                false,
1540                SecondaryFlags::empty(),
1541            ),
1542            false,
1543            false,
1544        );
1545        let mut options = LitOptions::standard();
1546        options.shader = Some(LitShader::new("empty", "", "", ""));
1547        let with_snippets = variant(
1548            options,
1549            channels(
1550                Some(ChannelEncoding::UncompressedPosition),
1551                false,
1552                SecondaryFlags::empty(),
1553            ),
1554            false,
1555            false,
1556        );
1557
1558        assert_eq!(
1559            code_lines(&compose_shader(&built_in, false).expect("the built-in shader composes")),
1560            code_lines(&compose_shader(&with_snippets, false).expect("empty snippets compose")),
1561            "an empty snippet adds nothing to either stage"
1562        );
1563    }
1564
1565    /// The non-blank lines of a composed module, in a fixed order.
1566    ///
1567    /// The order is not the module's: the composed declarations come out of a
1568    /// hash map, so two compilations of the same source can list them
1569    /// differently. Line sets compare what a snippet adds or takes away, which
1570    /// is the question here.
1571    fn code_lines(wgsl: &str) -> Vec<&str> {
1572        let mut lines: Vec<&str> = wgsl
1573            .lines()
1574            .map(str::trim)
1575            .filter(|line| !line.is_empty())
1576            .collect();
1577        lines.sort_unstable();
1578        lines
1579    }
1580
1581    /// The render target the test variants are built for.
1582    fn surface() -> SurfaceKey {
1583        SurfaceKey {
1584            color_format: wgpu::TextureFormat::Rgba8UnormSrgb,
1585            depth_stencil_format: Some(wgpu::TextureFormat::Depth24PlusStencil8),
1586            sample_count: 4,
1587        }
1588    }
1589
1590    /// A variant from its caller policy and the facts a draw carries.
1591    fn variant(
1592        mut options: LitOptions,
1593        channels: LitVertexChannels,
1594        base_color_texture: bool,
1595        morph: bool,
1596    ) -> LitVariant {
1597        // These variants draw without lights: their global group carries the
1598        // camera, the frame clock and the mesh metadata, and a lit one would
1599        // also demand the light arrays and the cluster. The lighting dimension
1600        // is enumerated on its own where a test composes both.
1601        options.unlit = true;
1602        LitVariant {
1603            options: Hashed::new(options),
1604            surface: surface(),
1605            channels,
1606            base_color_texture,
1607            morph,
1608            strip_index_format: None,
1609        }
1610    }
1611
1612    /// The vertex channels with a compressed position and nothing else.
1613    fn channels(
1614        position: Option<ChannelEncoding>,
1615        joints: bool,
1616        secondary: SecondaryFlags,
1617    ) -> LitVertexChannels {
1618        LitVertexChannels {
1619            position: PositionStreamChannels { position, joints },
1620            secondary,
1621        }
1622    }
1623
1624    /// Every vertex-channel and policy combination that composes.
1625    ///
1626    /// A sampled base-color texture implies a UV, and the joint and morph
1627    /// channels imply the position they deform, so the invalid combinations are
1628    /// skipped. Each surviving combination is then enumerated once per luminance
1629    /// layout, because those describe the sampled texel rather than the vertex
1630    /// layout and so are orthogonal to everything above — but only where a texel
1631    /// is sampled at all.
1632    ///
1633    /// The instance stream is not a dimension: every built-in variant binds one,
1634    /// so a per-instance field never has to be declared separately. The array path
1635    /// is not one either: it is the device's answer and is passed to
1636    /// [`compose_shader`] rather than carried by the variant.
1637    fn all_variants() -> Vec<LitVariant> {
1638        // A position is absent — leaving the geometry a point at the instance
1639        // origin — or present in one of the three encodings.
1640        let positions: &[Option<ChannelEncoding>] = &[
1641            None,
1642            Some(ChannelEncoding::CompressedPosition),
1643            Some(ChannelEncoding::UncompressedPosition),
1644            Some(ChannelEncoding::PlanarPosition),
1645        ];
1646        // The secondary stream carries a UV in either encoding, a color, a
1647        // normal, or any combination; an uncompressed UV is still a UV.
1648        let secondaries: &[SecondaryFlags] = &[
1649            SecondaryFlags::empty(),
1650            SecondaryFlags::UV,
1651            SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV,
1652            SecondaryFlags::COLOR,
1653            SecondaryFlags::UV | SecondaryFlags::COLOR,
1654            SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV | SecondaryFlags::COLOR,
1655            SecondaryFlags::NORMAL,
1656            SecondaryFlags::UV | SecondaryFlags::NORMAL,
1657            SecondaryFlags::COLOR | SecondaryFlags::NORMAL,
1658            SecondaryFlags::UV | SecondaryFlags::COLOR | SecondaryFlags::NORMAL,
1659        ];
1660        let mut variants = Vec::new();
1661        for position in positions {
1662            for joints in [false, true] {
1663                for secondary in secondaries {
1664                    for base_color_texture in [false, true] {
1665                        for morph in [false, true] {
1666                            // The joint stream is part of the position stream,
1667                            // and a morph target displaces the position.
1668                            if (joints || morph) && position.is_none() {
1669                                continue;
1670                            }
1671                            // The base-color texture is sampled with the
1672                            // per-vertex UV.
1673                            if base_color_texture && !secondary.contains(SecondaryFlags::UV) {
1674                                continue;
1675                            }
1676                            let base = variant(
1677                                LitOptions::standard(),
1678                                channels(*position, joints, *secondary),
1679                                base_color_texture,
1680                                morph,
1681                            );
1682                            // A texel is expanded by one of the two luminance
1683                            // layouts, or not at all, and only a sampled texel
1684                            // can be expanded.
1685                            let luminance_layouts: &[BaseColorChannels] = if base_color_texture {
1686                                &[
1687                                    BaseColorChannels::Rgba,
1688                                    BaseColorChannels::Luminance,
1689                                    BaseColorChannels::LuminanceAlpha,
1690                                ]
1691                            } else {
1692                                &[BaseColorChannels::Rgba]
1693                            };
1694                            for base_color in luminance_layouts {
1695                                // A cutoff compares its instance's alpha against
1696                                // the instance's own cutoff, so any variant can
1697                                // cut — textured or not.
1698                                for alpha_cutoff in [false, true] {
1699                                    let mut candidate = base.clone();
1700                                    candidate.options.update(|options| {
1701                                        options.base_color = *base_color;
1702                                        options.alpha_cutoff = alpha_cutoff;
1703                                    });
1704                                    variants.push(candidate);
1705                                }
1706                            }
1707                        }
1708                    }
1709                }
1710            }
1711        }
1712        variants
1713    }
1714
1715    /// `srgb_to_linear_output` decides what the fragment writes: plain values
1716    /// when the inputs are already linear, a linear encoding when they are
1717    /// sRGB-encoded. Both variants carry the same single entry point.
1718    #[test]
1719    fn srgb_output_flag_switches_the_conversion() {
1720        let composed = |srgb_to_linear_output: bool| {
1721            let mut options = LitOptions::standard();
1722            options.srgb_to_linear_output = srgb_to_linear_output;
1723            compose_shader(
1724                &variant(options, LitVertexChannels::empty(), false, false),
1725                false,
1726            )
1727            .expect("compose")
1728        };
1729
1730        let plain = composed(false);
1731        assert!(plain.contains("fn fs_main"), "one entry point either way");
1732        assert!(
1733            !plain.contains("srgb_to_linear(color.r)"),
1734            "a plain target stores what it is given"
1735        );
1736
1737        let converted = composed(true);
1738        // Only RGB converts: alpha is coverage, not color.
1739        assert!(converted.contains("srgb_to_linear(color.r)"));
1740        assert!(converted.contains("srgb_to_linear(color.g)"));
1741        assert!(converted.contains("srgb_to_linear(color.b)"));
1742        assert!(converted.contains("color.a"), "alpha passes through");
1743        assert!(!converted.contains("srgb_to_linear(color.a)"));
1744    }
1745
1746    /// The two luminance layouts say how a sampled texel expands, so they reach
1747    /// the fragment as the expansion itself — and only the one texel the variant
1748    /// samples, never the color the target writes.
1749    #[test]
1750    fn luminance_layouts_expand_a_sampled_texel() {
1751        let composed = |base_color: BaseColorChannels| {
1752            let mut options = LitOptions::standard();
1753            options.base_color = base_color;
1754            compose_shader(
1755                &variant(
1756                    options,
1757                    channels(
1758                        Some(ChannelEncoding::CompressedPosition),
1759                        false,
1760                        SecondaryFlags::UV,
1761                    ),
1762                    true,
1763                    false,
1764                ),
1765                false,
1766            )
1767            .expect("compose")
1768        };
1769
1770        let full = composed(BaseColorChannels::Rgba);
1771        assert!(
1772            !full.contains("srgb_to_linear(texel.r)"),
1773            "a four-channel texture is uploaded in an sRGB format, so the \
1774             sampler decodes it"
1775        );
1776
1777        let luma = composed(BaseColorChannels::Luminance);
1778        assert!(
1779            luma.contains("vec3<f32>(srgb_to_linear(texel.r))"),
1780            "a luminance texel feeds every color channel, decoded"
1781        );
1782        assert!(
1783            luma.contains("texel.a"),
1784            "a luminance texture has no alpha, so the channel reads as one"
1785        );
1786
1787        let luma_alpha = composed(BaseColorChannels::LuminanceAlpha);
1788        assert!(luma_alpha.contains("vec3<f32>(srgb_to_linear(texel.r))"));
1789        assert!(
1790            luma_alpha.contains("texel.g") && !luma_alpha.contains("texel.a"),
1791            "the second channel of a luminance-alpha texture is its alpha, and \
1792             coverage never converts"
1793        );
1794    }
1795
1796    /// The cutoff rides the instance stream, so the fragment that discards reads
1797    /// the instance attribute. Every built-in variant declares the instance
1798    /// stream, so the attribute is present whether or not the variant cuts.
1799    #[test]
1800    fn alpha_cutoff_flag_discards_a_fragment_below_the_cutoff() {
1801        let composed = |alpha_cutoff: bool| {
1802            let mut options = LitOptions::standard();
1803            options.alpha_cutoff = alpha_cutoff;
1804            compose_shader(
1805                &variant(
1806                    options,
1807                    channels(
1808                        Some(ChannelEncoding::CompressedPosition),
1809                        false,
1810                        SecondaryFlags::empty(),
1811                    ),
1812                    false,
1813                    false,
1814                ),
1815                false,
1816            )
1817            .expect("compose")
1818        };
1819
1820        let plain = composed(false);
1821        assert!(
1822            !plain.contains("discard"),
1823            "an opaque material keeps every fragment it is given"
1824        );
1825        assert!(
1826            !plain.contains("cutoff"),
1827            "a variant that never cuts declares no cutoff input"
1828        );
1829
1830        let cut = composed(true);
1831        let layouts = variant(
1832            {
1833                let mut options = LitOptions::standard();
1834                options.alpha_cutoff = true;
1835                options
1836            },
1837            channels(
1838                Some(ChannelEncoding::CompressedPosition),
1839                false,
1840                SecondaryFlags::empty(),
1841            ),
1842            false,
1843            false,
1844        )
1845        .vertex_buffer_layouts();
1846        let instance = layouts[INSTANCE_SLOT as usize]
1847            .as_ref()
1848            .expect("a built-in variant always reads the instance stream");
1849        assert!(
1850            instance
1851                .attributes
1852                .iter()
1853                .any(|attribute| attribute.shader_location == location::CUTOFF
1854                    && attribute.format == wgpu::VertexFormat::Float32),
1855            "the cutoff is one instance's own value, so it rides the instance stream"
1856        );
1857        assert!(
1858            cut.contains("@location(11)\n    cutoff: f32"),
1859            "the shader reads the cutoff from its instance attribute"
1860        );
1861        assert!(
1862            cut.contains("if color.a < in.cutoff"),
1863            "a fragment below the cutoff is dropped rather than blended"
1864        );
1865        assert!(cut.contains("discard"), "dropped fragments write nothing");
1866    }
1867
1868    /// A luminance layout describes a sampled texel, so it is meaningless without
1869    /// one. The two layouts can no longer be selected together, because the
1870    /// channel count is an enum rather than two independent flags.
1871    #[test]
1872    #[should_panic(
1873        expected = "`BASE_COLOR_LUMINANCE` and `BASE_COLOR_LUMINANCE_ALPHA` require \
1874                    a sampled base-color texture"
1875    )]
1876    fn a_luminance_layout_without_a_texture_is_rejected() {
1877        let mut options = LitOptions::standard();
1878        options.base_color = BaseColorChannels::Luminance;
1879        let _ = compose_shader(
1880            &variant(
1881                options,
1882                channels(
1883                    Some(ChannelEncoding::CompressedPosition),
1884                    false,
1885                    SecondaryFlags::UV,
1886                ),
1887                false,
1888                false,
1889            ),
1890            false,
1891        );
1892    }
1893
1894    /// A target with no depth attachment yields a pipeline with no depth state,
1895    /// so `wgpu`'s compatibility check against the pass cannot fail: a pass with
1896    /// no depth attachment rejects any pipeline that declares one, regardless of
1897    /// what that state compares or writes. The policy survives untouched: a
1898    /// target that has an attachment runs it against that attachment's format.
1899    #[test]
1900    fn a_depth_less_surface_drops_the_state_and_a_deep_one_restores_it() {
1901        let surface = |depth: Option<wgpu::TextureFormat>| SurfaceKey {
1902            color_format: wgpu::TextureFormat::Rgba8UnormSrgb,
1903            depth_stencil_format: depth,
1904            sample_count: 1,
1905        };
1906        let policy = LitOptions::standard().depth_stencil;
1907
1908        let with_depth = surface(Some(wgpu::TextureFormat::Depth32Float));
1909        let state = policy.state(with_depth.depth_stencil_format.expect("the test's premise"));
1910        assert_eq!(state.format, wgpu::TextureFormat::Depth32Float);
1911        assert_eq!(state.depth_write_enabled, Some(true));
1912        assert_eq!(state.depth_compare, Some(wgpu::CompareFunction::Greater));
1913    }
1914
1915    /// The variant is a cache key like any other: one pipeline per target,
1916    /// reused when the same target comes back.
1917    #[test]
1918    fn a_surface_variant_caches_one_pipeline_per_target() {
1919        let (device, _queue) = crate::util::test::noop_device();
1920        let surface = SurfaceKey {
1921            color_format: wgpu::TextureFormat::Rgba8UnormSrgb,
1922            depth_stencil_format: Some(wgpu::TextureFormat::Depth24Plus),
1923            sample_count: 4,
1924        };
1925        let make = |surface: SurfaceKey| {
1926            let mut candidate = variant(
1927                LitOptions::standard(),
1928                LitVertexChannels::empty(),
1929                false,
1930                false,
1931            );
1932            candidate.surface = surface;
1933            candidate
1934        };
1935
1936        let mut variants = Variants::<wgpu::RenderPipeline, LitVariant>::new(&device);
1937        let first = variants.specialize(make(surface));
1938        assert_eq!(
1939            variants.specialize(make(surface)),
1940            first,
1941            "the same target reuses its pipeline"
1942        );
1943
1944        // A different color format is a different target, so it compiles its own
1945        // variant rather than reusing the first one.
1946        let other_format = SurfaceKey {
1947            color_format: wgpu::TextureFormat::Bgra8Unorm,
1948            ..surface
1949        };
1950        assert_ne!(variants.specialize(make(other_format)), first);
1951        assert_eq!(
1952            variants.get(first).descriptor().surface.color_format,
1953            surface.color_format
1954        );
1955    }
1956
1957    #[test]
1958    fn vertex_channels_for_vertex_buffer_maps_each_slot() {
1959        use crate::specialize::VertexBufferLayoutDesc;
1960
1961        let attribute = |format, shader_location| wgpu::VertexAttribute {
1962            format,
1963            offset: 0,
1964            shader_location,
1965        };
1966        let layout = |step_mode, attributes: &[wgpu::VertexAttribute]| VertexBufferLayoutDesc {
1967            array_stride: 8,
1968            step_mode,
1969            attributes: attributes.into(),
1970        };
1971        let channels = |slot, layout: &VertexBufferLayoutDesc| {
1972            LitVertexChannels::for_vertex_buffer(slot, layout)
1973        };
1974        let position_of =
1975            |slot, layout: &VertexBufferLayoutDesc| channels(slot, layout).position.position;
1976
1977        // The position slot: presence, and the compressed/uncompressed split.
1978        let compressed = layout(
1979            wgpu::VertexStepMode::Vertex,
1980            &[attribute(wgpu::VertexFormat::Snorm16x4, location::POSITION)],
1981        );
1982        assert_eq!(
1983            position_of(PRIMARY_SLOT, &compressed),
1984            Some(ChannelEncoding::CompressedPosition)
1985        );
1986        let uncompressed = layout(
1987            wgpu::VertexStepMode::Vertex,
1988            &[attribute(wgpu::VertexFormat::Float32x3, location::POSITION)],
1989        );
1990        assert_eq!(
1991            position_of(PRIMARY_SLOT, &uncompressed),
1992            Some(ChannelEncoding::UncompressedPosition)
1993        );
1994
1995        // The joint pair rides the position stream, so it is this slot's answer
1996        // rather than a slot of its own.
1997        let skinned_layout = layout(
1998            wgpu::VertexStepMode::Vertex,
1999            &[
2000                attribute(wgpu::VertexFormat::Snorm16x4, location::POSITION),
2001                attribute(wgpu::VertexFormat::Uint32, location::JOINTS),
2002            ],
2003        );
2004        let skinned = channels(PRIMARY_SLOT, &skinned_layout);
2005        assert_eq!(
2006            skinned.position.position,
2007            Some(ChannelEncoding::CompressedPosition)
2008        );
2009        assert!(
2010            skinned.position.joints,
2011            "the joint indices are the evidence"
2012        );
2013
2014        // The secondary slot: each channel is independent.
2015        let secondary = layout(
2016            wgpu::VertexStepMode::Vertex,
2017            &[
2018                attribute(wgpu::VertexFormat::Snorm16x2, location::UV),
2019                attribute(wgpu::VertexFormat::Unorm8x4, location::COLOR),
2020                attribute(wgpu::VertexFormat::Snorm16x2, location::NORMAL),
2021            ],
2022        );
2023        assert_eq!(
2024            channels(SECONDARY_SLOT, &secondary).secondary,
2025            SecondaryFlags::UV | SecondaryFlags::COLOR | SecondaryFlags::NORMAL
2026        );
2027        let normal_only = layout(
2028            wgpu::VertexStepMode::Vertex,
2029            &[attribute(wgpu::VertexFormat::Snorm16x2, location::NORMAL)],
2030        );
2031        assert_eq!(
2032            channels(SECONDARY_SLOT, &normal_only).secondary,
2033            SecondaryFlags::NORMAL
2034        );
2035        let uncompressed_uv = layout(
2036            wgpu::VertexStepMode::Vertex,
2037            &[attribute(wgpu::VertexFormat::Float32x2, location::UV)],
2038        );
2039        assert_eq!(
2040            channels(SECONDARY_SLOT, &uncompressed_uv).secondary,
2041            SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV
2042        );
2043
2044        // The instance slot contributes nothing: the per-instance record is not
2045        // a vertex channel.
2046        let instance = layout(
2047            wgpu::VertexStepMode::Instance,
2048            &[attribute(
2049                wgpu::VertexFormat::Uint32,
2050                location::METADATA_INDEX,
2051            )],
2052        );
2053        assert_eq!(
2054            channels(INSTANCE_SLOT, &instance),
2055            LitVertexChannels::empty(),
2056            "the per-instance record is not a vertex channel"
2057        );
2058
2059        // A whole layout is the union of its slots, and a slot that is not one of
2060        // the pipeline's own contributes nothing either way.
2061        let foreign = layout(
2062            wgpu::VertexStepMode::Vertex,
2063            &[attribute(wgpu::VertexFormat::Float32x3, 9)],
2064        );
2065        let whole = LitVertexChannels::for_vertex_layout(&[
2066            (PRIMARY_SLOT, skinned_layout),
2067            (SECONDARY_SLOT, uncompressed_uv),
2068            (INSTANCE_SLOT, instance),
2069            (7, foreign),
2070        ]);
2071        assert_eq!(
2072            whole.position.position,
2073            Some(ChannelEncoding::CompressedPosition)
2074        );
2075        assert!(whole.position.joints);
2076        assert_eq!(
2077            whole.secondary,
2078            SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV
2079        );
2080    }
2081
2082    #[test]
2083    fn composes_each_variant_to_valid_wgsl() {
2084        for mut candidate in all_variants() {
2085            // Lighting is the one dimension the variant's channels do not already
2086            // cover: a lit draw reads the frame's lights and clusters with them,
2087            // which is why both are compiled here.
2088            for unlit in [false, true] {
2089                candidate.options.update(|options| options.unlit = unlit);
2090                // The array path is the device's, so both are exercised here.
2091                for texel_arrays in [false, true] {
2092                    let wgsl = compose_shader(&candidate, texel_arrays)
2093                        .unwrap_or_else(|error| panic!("variant {candidate:?} failed: {error}"));
2094                    assert!(wgsl.contains("fn vs_main"), "variant {candidate:?}");
2095                    assert!(wgsl.contains("fn fs_main"), "variant {candidate:?}");
2096                    // Disabled features must leave no conditional attributes behind.
2097                    assert!(!wgsl.contains("@if"), "variant {candidate:?} kept @if");
2098
2099                    let vertex = candidate.channels;
2100                    // Each channel appears exactly when the geometry carries it.
2101                    assert_eq!(
2102                        wgsl.contains("base_color_tex"),
2103                        candidate.base_color_texture,
2104                        "variant {candidate:?}"
2105                    );
2106                    let compressed_uv = vertex.secondary.contains(SecondaryFlags::UV)
2107                        && !vertex.secondary.contains(SecondaryFlags::UNCOMPRESSED_UV);
2108                    assert_eq!(
2109                        wgsl.contains("decode_uv"),
2110                        compressed_uv,
2111                        "variant {candidate:?}"
2112                    );
2113                    let compressed_position = matches!(
2114                        vertex.position.position,
2115                        Some(ChannelEncoding::CompressedPosition)
2116                    );
2117                    assert_eq!(
2118                        wgsl.contains("decode_position"),
2119                        compressed_position,
2120                        "variant {candidate:?}"
2121                    );
2122                    // A normal is decoded only where it shades, and only a lit
2123                    // fragment shades with one.
2124                    assert_eq!(
2125                        wgsl.contains("decode_normal"),
2126                        !candidate.options.unlit
2127                            && vertex.secondary.contains(SecondaryFlags::NORMAL),
2128                        "variant {candidate:?}"
2129                    );
2130                    // The light arrays, the cluster lookup and the shading terms are
2131                    // a lit draw's own.
2132                    assert_eq!(
2133                        wgsl.contains("fragment_cluster_index"),
2134                        !candidate.options.unlit,
2135                        "variant {candidate:?}"
2136                    );
2137                    assert_eq!(
2138                        wgsl.contains("load_cell_word") && wgsl.contains("load_light"),
2139                        !candidate.options.unlit,
2140                        "variant {candidate:?}"
2141                    );
2142                    assert_eq!(
2143                        wgsl.contains("polygon_form_factor"),
2144                        !candidate.options.unlit,
2145                        "variant {candidate:?}"
2146                    );
2147                    // Every array read goes through an accessor, whose name survives
2148                    // assembly unmangled as a suffix: the binding it reads is mangled
2149                    // with its module path, so the accessor is what a variant can be
2150                    // checked against.
2151                    assert_eq!(
2152                        wgsl.contains("load_mesh_metadata"),
2153                        candidate.needs_metadata(),
2154                        "variant {candidate:?}"
2155                    );
2156                    // The pose arrays are the frame's, so they live in the global
2157                    // group alongside the camera and the metadata.
2158                    assert_eq!(
2159                        wgsl.contains("load_joint_matrix"),
2160                        candidate.needs_joints(),
2161                        "variant {candidate:?}"
2162                    );
2163                    assert_eq!(
2164                        wgsl.contains("load_morph_weight"),
2165                        candidate.needs_morphs(),
2166                        "variant {candidate:?}"
2167                    );
2168                    assert_eq!(
2169                        wgsl.contains("load_morph_delta"),
2170                        candidate.needs_morphs(),
2171                        "variant {candidate:?}"
2172                    );
2173                    // The path decides the resource type: storage buffers when the
2174                    // device has them, textures when it does not. Neither path ever
2175                    // leaves the other's declarations behind.
2176                    let reads_an_array = candidate.needs_metadata()
2177                        || candidate.needs_joints()
2178                        || !candidate.options.unlit;
2179                    if texel_arrays {
2180                        assert_eq!(
2181                            wgsl.matches("var<storage, read>").count(),
2182                            0,
2183                            "variant {candidate:?} kept a storage array"
2184                        );
2185                    }
2186                    assert_eq!(
2187                        wgsl.contains("texel_of"),
2188                        texel_arrays && reads_an_array,
2189                        "variant {candidate:?}"
2190                    );
2191                    // Without a position stream the vertex input declares no position
2192                    // attribute, so the shader cannot read one.
2193                    let vertex_input = wgsl
2194                        .split_once("struct VertexInput {")
2195                        .expect("the variant declares vertex inputs")
2196                        .1;
2197                    let vertex_input = vertex_input
2198                        .split_once('}')
2199                        .expect("the vertex input struct ends")
2200                        .0;
2201                    assert_eq!(
2202                        vertex_input.contains("position:"),
2203                        vertex.position.position.is_some(),
2204                        "variant {candidate:?}"
2205                    );
2206                    assert_eq!(
2207                        vertex_input.contains("joints:") && vertex_input.contains("weights:"),
2208                        candidate.needs_joints(),
2209                        "variant {candidate:?}"
2210                    );
2211                    assert_eq!(
2212                        vertex_input.contains("normal:"),
2213                        vertex.secondary.contains(SecondaryFlags::NORMAL),
2214                        "variant {candidate:?}"
2215                    );
2216                    // The instance record is unconditional, so its fields are
2217                    // declared as plain attributes with no condition on them.
2218                    assert!(
2219                        vertex_input.contains("joints_base:"),
2220                        "variant {candidate:?} declares no instance stream"
2221                    );
2222                    assert!(
2223                        vertex_input.contains("morph_base:"),
2224                        "variant {candidate:?} declares no instance stream"
2225                    );
2226                }
2227            }
2228        }
2229    }
2230
2231    /// A variant that contradicts itself must be rejected rather than composed
2232    /// into a shader that cannot work.
2233    ///
2234    /// The impossible states are no longer flags disagreeing with each other: an
2235    /// encoding is a property of the channel it encodes, and the instance stream
2236    /// is always bound, so the rejections left are the ones where a resource is
2237    /// read that the variant does not carry — a texture sampled without a UV, a
2238    /// deformation without a position, and a luminance layout without a texel.
2239    #[test]
2240    fn contradictory_variants_are_rejected() {
2241        let shape = LitOptions::standard;
2242        let compressed = Some(ChannelEncoding::CompressedPosition);
2243        let mut luminance_without_a_texture = shape();
2244        luminance_without_a_texture.base_color = BaseColorChannels::Luminance;
2245        for candidate in [
2246            // The base-color texture is sampled with the per-vertex UV.
2247            variant(
2248                shape(),
2249                channels(compressed, false, SecondaryFlags::empty()),
2250                true,
2251                false,
2252            ),
2253            // A deformation displaces the position.
2254            variant(
2255                shape(),
2256                channels(None, true, SecondaryFlags::empty()),
2257                false,
2258                false,
2259            ),
2260            variant(
2261                shape(),
2262                channels(None, false, SecondaryFlags::empty()),
2263                false,
2264                true,
2265            ),
2266            // A luminance layout describes a texel that is sampled.
2267            variant(
2268                luminance_without_a_texture,
2269                channels(compressed, false, SecondaryFlags::UV),
2270                false,
2271                false,
2272            ),
2273        ] {
2274            let result = std::panic::catch_unwind(|| compose_shader(&candidate, false));
2275            assert!(result.is_err(), "variant {candidate:?} must be rejected");
2276        }
2277    }
2278
2279    #[test]
2280    fn vertex_strides_follow_the_attribute_formats() {
2281        let layouts = variant(
2282            LitOptions::standard(),
2283            channels(
2284                Some(ChannelEncoding::CompressedPosition),
2285                false,
2286                SecondaryFlags::UV,
2287            ),
2288            false,
2289            false,
2290        )
2291        .vertex_buffer_layouts();
2292        let position = layouts[PRIMARY_SLOT as usize]
2293            .as_ref()
2294            .expect("position slot");
2295        assert_eq!(position.array_stride, wgpu::VertexFormat::Snorm16x4.size());
2296
2297        let secondary = layouts[SECONDARY_SLOT as usize].as_ref().expect("uv slot");
2298        assert_eq!(secondary.array_stride, wgpu::VertexFormat::Snorm16x2.size());
2299
2300        let instance = layouts[INSTANCE_SLOT as usize]
2301            .as_ref()
2302            .expect("instance slot");
2303        // Every variant reads the same instance buffer, so the stride is the
2304        // whole record even when the variant declares only some of its fields.
2305        assert_eq!(
2306            instance.array_stride,
2307            std::mem::size_of::<crate::mesh::MeshInstance>() as u64
2308        );
2309        assert_eq!(instance.step_mode, wgpu::VertexStepMode::Instance);
2310    }
2311
2312    /// An uncompressed channel widens its slot to the full-precision format and
2313    /// drops the metadata bindings it no longer needs — but the instance stream
2314    /// is still bound, because every built-in variant binds one.
2315    #[test]
2316    fn uncompressed_channels_widen_their_slots() {
2317        let candidate = variant(
2318            LitOptions::standard(),
2319            channels(
2320                Some(ChannelEncoding::UncompressedPosition),
2321                false,
2322                SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV | SecondaryFlags::COLOR,
2323            ),
2324            true,
2325            false,
2326        );
2327        assert!(!candidate.needs_metadata());
2328
2329        let layouts = candidate.vertex_buffer_layouts();
2330        let position = layouts[PRIMARY_SLOT as usize]
2331            .as_ref()
2332            .expect("position slot");
2333        assert_eq!(position.array_stride, wgpu::VertexFormat::Float32x3.size());
2334        assert_eq!(position.attributes[0].format, wgpu::VertexFormat::Float32x3);
2335
2336        let secondary = layouts[SECONDARY_SLOT as usize].as_ref().expect("uv slot");
2337        assert_eq!(
2338            secondary.array_stride,
2339            wgpu::VertexFormat::Float32x2.size() + wgpu::VertexFormat::Unorm8x4.size()
2340        );
2341
2342        // The instance stream is bound for every variant, so the slot exists even
2343        // though these channels are read entirely from full-precision attributes.
2344        assert!(layouts[INSTANCE_SLOT as usize].is_some());
2345    }
2346
2347    /// A planar channel narrows its slot to the two-component format and needs
2348    /// no metadata either, exactly as any other full-precision position.
2349    #[test]
2350    fn planar_channels_narrow_their_slots() {
2351        let candidate = variant(
2352            LitOptions::standard(),
2353            channels(
2354                Some(ChannelEncoding::PlanarPosition),
2355                false,
2356                SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV | SecondaryFlags::COLOR,
2357            ),
2358            true,
2359            false,
2360        );
2361        assert!(!candidate.needs_metadata());
2362
2363        let layouts = candidate.vertex_buffer_layouts();
2364        let position = layouts[PRIMARY_SLOT as usize]
2365            .as_ref()
2366            .expect("position slot");
2367        assert_eq!(position.array_stride, wgpu::VertexFormat::Float32x2.size());
2368        assert_eq!(position.attributes[0].format, wgpu::VertexFormat::Float32x2);
2369    }
2370
2371    #[test]
2372    fn secondary_slot_follows_its_channels() {
2373        let stride = |secondary: SecondaryFlags| {
2374            variant(
2375                LitOptions::standard(),
2376                channels(None, false, secondary),
2377                false,
2378                false,
2379            )
2380            .vertex_buffer_layouts()[SECONDARY_SLOT as usize]
2381                .as_ref()
2382                .map(|layout| layout.array_stride)
2383        };
2384
2385        let uv = wgpu::VertexFormat::Snorm16x2.size();
2386        let uv_uncompressed = wgpu::VertexFormat::Float32x2.size();
2387        let color = wgpu::VertexFormat::Unorm8x4.size();
2388        let normal = wgpu::VertexFormat::Snorm16x2.size();
2389
2390        // No channel: the slot disappears entirely, so a variant never binds a
2391        // buffer the shader does not declare.
2392        assert_eq!(stride(SecondaryFlags::empty()), None);
2393        assert_eq!(stride(SecondaryFlags::UV), Some(uv));
2394        assert_eq!(
2395            stride(SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV),
2396            Some(uv_uncompressed)
2397        );
2398        assert_eq!(stride(SecondaryFlags::COLOR), Some(color));
2399        assert_eq!(
2400            stride(SecondaryFlags::UV | SecondaryFlags::COLOR),
2401            Some(uv + color)
2402        );
2403        assert_eq!(
2404            stride(SecondaryFlags::UV | SecondaryFlags::UNCOMPRESSED_UV | SecondaryFlags::COLOR),
2405            Some(uv_uncompressed + color)
2406        );
2407        // A normal is always the same two lanes, whatever else the stream
2408        // carries, and it sits in attribute order last.
2409        assert_eq!(stride(SecondaryFlags::NORMAL), Some(normal));
2410        assert_eq!(
2411            stride(SecondaryFlags::UV | SecondaryFlags::COLOR | SecondaryFlags::NORMAL),
2412            Some(uv + color + normal)
2413        );
2414    }
2415
2416    /// The position slot follows its channels, so a position-less variant binds
2417    /// no position buffer at all.
2418    #[test]
2419    fn position_slot_follows_its_channels() {
2420        let position = |position: Option<ChannelEncoding>, joints: bool| {
2421            variant(
2422                LitOptions::standard(),
2423                channels(position, joints, SecondaryFlags::empty()),
2424                false,
2425                false,
2426            )
2427            .vertex_buffer_layouts()[PRIMARY_SLOT as usize]
2428                .as_ref()
2429                .map(|layout| (layout.array_stride, layout.step_mode))
2430        };
2431
2432        assert_eq!(
2433            position(None, false),
2434            None,
2435            "no position stream means no position slot"
2436        );
2437        assert_eq!(
2438            position(Some(ChannelEncoding::CompressedPosition), false),
2439            Some((
2440                wgpu::VertexFormat::Snorm16x4.size(),
2441                wgpu::VertexStepMode::Vertex
2442            ))
2443        );
2444        assert_eq!(
2445            position(Some(ChannelEncoding::UncompressedPosition), false),
2446            Some((
2447                wgpu::VertexFormat::Float32x3.size(),
2448                wgpu::VertexStepMode::Vertex
2449            ))
2450        );
2451    }
2452
2453    /// The instance slot's GPU attribute offsets and stride must match the CPU
2454    /// struct. This is the vertex-buffer equivalent of the shader-layout
2455    /// validation that `const_shader_layout` gives the uniform and storage
2456    /// structs: vertex attributes are addressed by the offsets declared here, not
2457    /// by WGSL alignment rules.
2458    #[test]
2459    fn instance_vertex_layout_matches_the_struct() {
2460        use crate::mesh::MeshInstance;
2461        use core::mem::{offset_of, size_of};
2462
2463        let layouts = variant(
2464            LitOptions::standard(),
2465            channels(
2466                Some(ChannelEncoding::CompressedPosition),
2467                false,
2468                SecondaryFlags::empty(),
2469            ),
2470            false,
2471            false,
2472        )
2473        .vertex_buffer_layouts();
2474        let instance = layouts[INSTANCE_SLOT as usize]
2475            .as_ref()
2476            .expect("instance slot");
2477
2478        assert_eq!(
2479            instance.array_stride,
2480            size_of::<MeshInstance>() as u64,
2481            "the instance stride must step exactly one MeshInstance"
2482        );
2483
2484        let model = offset_of!(MeshInstance, model) as u64;
2485        let vec4 = size_of::<[f32; 4]>() as u64;
2486        // The record has to be tightly packed: `IntoBytes` rejects a type with
2487        // padding, so the sum of the record's field formats is the whole
2488        // stride. This variant neither cuts nor expands, so its layout omits
2489        // both words the shader would not declare; the declared formats plus
2490        // those two omitted words still cover the record exactly.
2491        let declared: u64 = instance
2492            .attributes
2493            .iter()
2494            .map(|attribute| attribute.format.size())
2495            .sum();
2496        assert_eq!(
2497            declared + 2 * wgpu::VertexFormat::Float32.size(),
2498            instance.array_stride,
2499            "the instance record carries no padding"
2500        );
2501
2502        let expected = [
2503            model,
2504            model + vec4,
2505            model + 2 * vec4,
2506            offset_of!(MeshInstance, base_color) as u64,
2507            offset_of!(MeshInstance, joints_base) as u64,
2508            offset_of!(MeshInstance, metadata_index) as u64,
2509            offset_of!(MeshInstance, morph_base) as u64,
2510        ];
2511        let actual: Vec<u64> = instance.attributes.iter().map(|a| a.offset).collect();
2512        assert_eq!(
2513            actual, expected,
2514            "attribute offsets must line up with the MeshInstance fields"
2515        );
2516
2517        let locations: Vec<u32> = instance
2518            .attributes
2519            .iter()
2520            .map(|a| a.shader_location)
2521            .collect();
2522        assert_eq!(
2523            locations,
2524            [
2525                location::MODEL_0,
2526                location::MODEL_1,
2527                location::MODEL_2,
2528                location::BASE_COLOR,
2529                location::JOINTS_BASE,
2530                location::METADATA_INDEX,
2531                location::MORPH_BASE,
2532            ]
2533        );
2534        // The matrix columns are floats, the color is quantized, and the two
2535        // bases and the metadata index are integers: each addresses a slot in
2536        // one of the frame's shared arrays.
2537        let expected_formats = [
2538            wgpu::VertexFormat::Float32x4,
2539            wgpu::VertexFormat::Float32x4,
2540            wgpu::VertexFormat::Float32x4,
2541            wgpu::VertexFormat::Unorm8x4,
2542            wgpu::VertexFormat::Uint32,
2543            wgpu::VertexFormat::Uint32,
2544            wgpu::VertexFormat::Uint32,
2545        ];
2546        let formats: Vec<wgpu::VertexFormat> =
2547            instance.attributes.iter().map(|a| a.format).collect();
2548        assert_eq!(formats, expected_formats, "{instance:?}");
2549    }
2550
2551    /// A cutting variant's instance layout declares the cutoff and an expanding
2552    /// one declares the hull width; a plain one omits both, matching the
2553    /// shader's own conditional input declarations. The other instance inputs
2554    /// are unconditional in the shader, so the layout declares them for every
2555    /// variant.
2556    #[test]
2557    fn instance_layout_declares_the_cutoff_and_the_hull_width_only_when_read() {
2558        let locations = |alpha_cutoff: bool, expanded: bool| {
2559            let mut options = LitOptions::standard();
2560            options.alpha_cutoff = alpha_cutoff;
2561            options.expanded = expanded;
2562            variant(
2563                options,
2564                channels(None, false, SecondaryFlags::COLOR | SecondaryFlags::NORMAL),
2565                false,
2566                false,
2567            )
2568            .vertex_buffer_layouts()[INSTANCE_SLOT as usize]
2569                .as_ref()
2570                .expect("instance slot")
2571                .attributes
2572                .iter()
2573                .map(|a| a.shader_location)
2574                .collect::<Vec<u32>>()
2575        };
2576
2577        let plain = [
2578            location::MODEL_0,
2579            location::MODEL_1,
2580            location::MODEL_2,
2581            location::BASE_COLOR,
2582            location::JOINTS_BASE,
2583            location::METADATA_INDEX,
2584            location::MORPH_BASE,
2585        ];
2586        let mut cutting = plain.to_vec();
2587        cutting.insert(6, location::CUTOFF);
2588        let mut expanding = plain.to_vec();
2589        expanding.push(location::HULL_WIDTH);
2590        let mut both = cutting.clone();
2591        both.push(location::HULL_WIDTH);
2592
2593        assert_eq!(
2594            locations(false, false),
2595            plain,
2596            "a plain variant declares neither the cutoff nor the hull width"
2597        );
2598        assert_eq!(
2599            locations(true, false),
2600            cutting,
2601            "a cutting variant declares the cutoff"
2602        );
2603        assert_eq!(
2604            locations(false, true),
2605            expanding,
2606            "an expanding variant declares the hull width"
2607        );
2608        assert_eq!(
2609            locations(true, true),
2610            both,
2611            "one variant can do both, and the width stays last"
2612        );
2613    }
2614
2615    /// The hull width rides the instance stream, so the vertex stage reads the
2616    /// instance attribute and pushes the vertex along the transformed normal.
2617    #[test]
2618    fn expanded_flag_pushes_the_vertex_along_its_normal() {
2619        let composed = |expanded: bool| {
2620            let mut options = LitOptions::standard();
2621            options.expanded = expanded;
2622            compose_shader(
2623                &variant(
2624                    options,
2625                    channels(
2626                        Some(ChannelEncoding::CompressedPosition),
2627                        false,
2628                        SecondaryFlags::NORMAL,
2629                    ),
2630                    false,
2631                    false,
2632                ),
2633                false,
2634            )
2635            .expect("compose")
2636        };
2637
2638        let plain = composed(false);
2639        assert!(
2640            !plain.contains("hull_width"),
2641            "a variant that expands by nothing declares no hull width"
2642        );
2643
2644        let expanded = composed(true);
2645        assert!(
2646            expanded.contains("@location(14)\n    hull_width: f32"),
2647            "the shader reads the hull width from its instance attribute"
2648        );
2649        assert!(
2650            expanded.contains("let reach = length(outward);"),
2651            "a degenerate instance matrix is checked before it can be normalized"
2652        );
2653        assert!(
2654            expanded.contains("world += outward / reach * in.hull_width"),
2655            "the vertex is pushed along its transformed normal"
2656        );
2657    }
2658
2659    /// A variant cannot expand without a direction to expand in, so composing
2660    /// one over a mesh with no normal channel is a mistake worth stopping at.
2661    #[test]
2662    #[should_panic(expected = "`EXPANDED` requires a normal channel")]
2663    fn an_expanding_variant_without_normals_is_rejected() {
2664        let mut options = LitOptions::standard();
2665        options.expanded = true;
2666        let _ = compose_shader(
2667            &variant(
2668                options,
2669                channels(
2670                    Some(ChannelEncoding::CompressedPosition),
2671                    false,
2672                    SecondaryFlags::empty(),
2673                ),
2674                false,
2675                false,
2676            ),
2677            false,
2678        );
2679    }
2680}