pebble/wgpu/layout.rs
1use super::binding::{BindGroupLayout, BindingEntry};
2
3/// One `@group(N)` slot in a material/compute pipeline layout — position in the
4/// [`Material::entries`](super::material::Material::entries)/
5/// [`Compute::entries`](super::compute::Compute::entries) list *is* its `@group(N)` index, so
6/// there's no separate group number to keep in sync with the shader by hand: the first element
7/// occupies `@group(0)`, the second `@group(1)`, and so on.
8pub enum GroupEntry {
9 /// This material/compute's own bind group entries — built into a fresh layout
10 /// internally, and the one group [`GPUMaterial`](super::material::GPUMaterial)/
11 /// [`GPUCompute`](super::compute::GPUCompute) hand to a
12 /// [`GPUBindingInstance`](super::instance::GPUBindingInstance) to bind concrete resources
13 /// against at draw/dispatch time. At most one `Own` entry is allowed in a single
14 /// `.entries(...)` list — `build_material`/`build_compute` panic on a second one, since
15 /// there's only one instance-bindable group per material/compute.
16 Own(Vec<BindingEntry>),
17 /// An already-built layout occupying this position directly — a camera, lights, or any
18 /// other external bind group layout, e.g. pulled from a [`GlobalLayoutPool`] via
19 /// [`GlobalLayoutPool::get`].
20 Layout(BindGroupLayout),
21}
22
23/// A named pool of bind group layouts shared across materials/compute passes — register a
24/// layout once (e.g. a camera's, under `"camera"`) as soon as it exists, then anywhere a
25/// material/compute wants it, pull it with [`get`](Self::get) and wrap it in
26/// [`GroupEntry::Layout`] at whatever position that material/compute's shader declares it.
27///
28/// [`WGPUPlugin`](super::backend::WGPUPlugin) inserts an empty pool as a resource, so it's
29/// always there from the start — grab it with `Res<GlobalLayoutPool>`/
30/// `ResMut<GlobalLayoutPool>` rather than constructing your own; a `LazyResource` that builds a
31/// shared layout (a camera, lights, ...) registers it into that same pool from its own
32/// `construct` (or a follow-up system, once it has `ResMut<GlobalLayoutPool>` alongside it) —
33/// there's no separate "finished pool" step, entries just accumulate as their sources become
34/// ready.
35#[derive(Default)]
36pub struct GlobalLayoutPool {
37 entries: std::collections::HashMap<&'static str, BindGroupLayout>,
38}
39
40impl GlobalLayoutPool {
41 pub fn new() -> Self {
42 Self::default()
43 }
44
45 /// Register `layout` under `name`. Panics if `name` is already registered — almost always
46 /// two sources registering under the same name by mistake, not an intentional overwrite.
47 pub fn register(&mut self, name: &'static str, layout: BindGroupLayout) {
48 if self.entries.insert(name, layout).is_some() {
49 panic!("global layout pool: '{name}' is already registered");
50 }
51 }
52
53 /// The layout registered under `name`, if any — clone it into a [`GroupEntry::Layout`] at
54 /// whatever position your shader declares it. `None` if nothing has registered under that
55 /// name (yet, or ever — a typo'd name and "not built yet" look the same from here, so
56 /// callers with a hard requirement on a given global should treat a miss as "not ready"
57 /// the same way any other `Option`-returning lookup in this engine does).
58 pub fn get(&self, name: &str) -> Option<BindGroupLayout> {
59 self.entries.get(name).cloned()
60 }
61}
62
63/// Which pipeline kind a panic message from [`find_own_entries`] is describing — only used
64/// for wording those messages (`Material`'s bind group entries are validated differently than
65/// `Compute`'s, but both funnel through the same shared function).
66#[derive(Clone, Copy)]
67pub(crate) enum PipelineKind {
68 Material,
69 Compute,
70}
71
72impl std::fmt::Display for PipelineKind {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.write_str(match self {
75 PipelineKind::Material => "material",
76 PipelineKind::Compute => "compute pass",
77 })
78 }
79}
80
81/// Finds the single [`GroupEntry::Own`] in `groups`, if any, returning its entries (or `&[]`
82/// if there isn't one — a shader with no `@group` of its own). Panics if there's more than
83/// one — a material/compute can only expose one concrete bind group for a
84/// [`GPUBindingInstance`](super::instance::GPUBindingInstance) to bind resources against, so
85/// at most one position in `.entries(...)` may be `Own`.
86pub(crate) fn find_own_entries<'a>(
87 label: Option<&str>,
88 kind: PipelineKind,
89 groups: &'a [GroupEntry],
90) -> &'a [BindingEntry] {
91 let mut found: Option<&[BindingEntry]> = None;
92 for g in groups {
93 if let GroupEntry::Own(entries) = g {
94 if found.is_some() {
95 panic!(
96 "{kind}{}: more than one GroupEntry::Own(...) in .entries(...) — a {kind} \
97 can only have one group of its own bind group entries",
98 label.map(|l| format!(" '{l}'")).unwrap_or_default()
99 );
100 }
101 found = Some(entries);
102 }
103 }
104 found.unwrap_or(&[])
105}
106
107/// Assembles the ordered pipeline-layout slots from `groups` — position in `groups` is the
108/// `@group(N)` index, with `own_layout` (built by the caller from
109/// [`find_own_entries`]'s result) filling in wherever [`GroupEntry::Own`] appeared.
110///
111/// Panics if `groups` needs more bind groups than `max_bind_groups` allows — `wgpu` guarantees
112/// only 4 (`@group(0..=3)`) unless a device explicitly requests/supports more, so this is the
113/// difference between a clear message here (the actual limit and how many groups were
114/// requested) and an opaque wgpu validation panic at pipeline-layout creation. This is the
115/// reason to only list the groups a shader actually declares in `.entries(...)` — a
116/// [`GlobalLayoutPool`] registration you don't need is one you shouldn't reach for.
117pub(crate) fn assemble_group_layouts<'a>(
118 label: Option<&str>,
119 groups: &'a [GroupEntry],
120 own_layout: &'a BindGroupLayout,
121 max_bind_groups: u32,
122) -> Vec<Option<&'a wgpu::BindGroupLayout>> {
123 if groups.len() as u32 > max_bind_groups {
124 panic!(
125 "pipeline layout{} needs {} bind groups, but this device only supports \
126 {max_bind_groups} — trim .entries(...) to only the groups actually used",
127 label.map(|l| format!(" '{l}'")).unwrap_or_default(),
128 groups.len(),
129 );
130 }
131
132 groups
133 .iter()
134 .map(|g| {
135 Some(match g {
136 GroupEntry::Own(_) => own_layout.raw(),
137 GroupEntry::Layout(l) => l.raw(),
138 })
139 })
140 .collect()
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use crate::wgpu::binding::BindGroupLayoutBuilder;
147 use crate::wgpu::test_util::with_device;
148
149 fn empty_layout(device: &wgpu::Device) -> BindGroupLayout {
150 BindGroupLayoutBuilder::new().build_raw(device)
151 }
152
153 #[test]
154 fn global_layout_pool_get_round_trips_through_register() {
155 with_device!(device, _queue, {
156 let mut pool = GlobalLayoutPool::new();
157 pool.register("camera", empty_layout(&device));
158
159 assert!(pool.get("camera").is_some());
160 assert!(pool.get("missing").is_none());
161 });
162 }
163
164 #[test]
165 fn global_layout_pool_panics_on_duplicate_name() {
166 with_device!(device, _queue, {
167 let mut pool = GlobalLayoutPool::new();
168 pool.register("camera", empty_layout(&device));
169 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
170 pool.register("camera", empty_layout(&device));
171 }));
172 assert!(result.is_err(), "expected a panic for a duplicate name registered in the pool");
173 });
174 }
175
176 #[test]
177 fn find_own_entries_returns_empty_slice_when_there_is_no_own_group() {
178 let entries = find_own_entries(None, PipelineKind::Material, &[]);
179 assert!(entries.is_empty());
180 }
181
182 #[test]
183 fn find_own_entries_panics_on_more_than_one_own_group() {
184 with_device!(device, _queue, {
185 let groups =
186 vec![GroupEntry::Own(vec![]), GroupEntry::Layout(empty_layout(&device)), GroupEntry::Own(vec![])];
187 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
188 find_own_entries(None, PipelineKind::Material, &groups);
189 }));
190 assert!(result.is_err(), "expected a panic for more than one GroupEntry::Own");
191 });
192 }
193
194 #[test]
195 fn assemble_group_layouts_orders_slots_by_position() {
196 with_device!(device, _queue, {
197 let own = empty_layout(&device);
198 let a = empty_layout(&device);
199 let b = empty_layout(&device);
200 let groups =
201 vec![GroupEntry::Layout(a), GroupEntry::Own(vec![]), GroupEntry::Layout(b)];
202
203 let assembled = assemble_group_layouts(None, &groups, &own, 4);
204
205 assert_eq!(assembled.len(), 3);
206 let GroupEntry::Layout(a) = &groups[0] else { unreachable!() };
207 let GroupEntry::Layout(b) = &groups[2] else { unreachable!() };
208 assert!(std::ptr::eq(assembled[0].unwrap(), a.raw()));
209 assert!(std::ptr::eq(assembled[1].unwrap(), own.raw()));
210 assert!(std::ptr::eq(assembled[2].unwrap(), b.raw()));
211 });
212 }
213
214 #[test]
215 fn exceeding_max_bind_groups_panics() {
216 with_device!(device, _queue, {
217 let own = empty_layout(&device);
218 let groups = vec![GroupEntry::Layout(empty_layout(&device)), GroupEntry::Layout(empty_layout(&device))];
219 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
220 assemble_group_layouts(None, &groups, &own, 1);
221 }));
222 assert!(result.is_err(), "expected a panic for exceeding max_bind_groups");
223 });
224 }
225}