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}