Skip to main content

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    /// A layout looked up by name in the [`GlobalLayoutPool`] resource, resolved lazily at
22    /// *upload* time rather than when `.entries(...)` is called — so the material/compute
23    /// doesn't need `name` to already be registered while it's being described, only by the
24    /// time it actually uploads. If `name` isn't registered yet, upload quietly returns `None`
25    /// and retries next tick, the same "not ready" convention as any other `Deps`. Prefer this
26    /// over resolving `GlobalLayoutPool::get` yourself and wrapping the result in
27    /// [`Layout`](Self::Layout) — that requires the pool to already have `name` at the point
28    /// you build the descriptor, which is a race the `setup` system authoring a material has
29    /// no natural way to wait out on its own.
30    Global(&'static str),
31}
32
33/// A named pool of bind group layouts shared across materials/compute passes — register a
34/// layout once (e.g. a camera's, under `"camera"`) as soon as it exists, then anywhere a
35/// material/compute wants it, pull it with [`get`](Self::get) and wrap it in
36/// [`GroupEntry::Layout`] at whatever position that material/compute's shader declares it.
37///
38/// [`WGPUPlugin`](super::backend::WGPUPlugin) inserts an empty pool as a resource, so it's
39/// always there from the start — grab it with `Res<GlobalLayoutPool>`/
40/// `ResMut<GlobalLayoutPool>` rather than constructing your own; a `LazyResource` that builds a
41/// shared layout (a camera, lights, ...) registers it into that same pool from its own
42/// `construct` (or a follow-up system, once it has `ResMut<GlobalLayoutPool>` alongside it) —
43/// there's no separate "finished pool" step, entries just accumulate as their sources become
44/// ready.
45#[derive(Default)]
46pub struct GlobalLayoutPool {
47    entries: std::collections::HashMap<&'static str, BindGroupLayout>,
48}
49
50impl GlobalLayoutPool {
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// Register `layout` under `name`. Panics if `name` is already registered — almost always
56    /// two sources registering under the same name by mistake, not an intentional overwrite.
57    pub fn register(&mut self, name: &'static str, layout: BindGroupLayout) {
58        if self.entries.insert(name, layout).is_some() {
59            panic!("global layout pool: '{name}' is already registered");
60        }
61    }
62
63    /// The layout registered under `name`, if any — clone it into a [`GroupEntry::Layout`] at
64    /// whatever position your shader declares it. `None` if nothing has registered under that
65    /// name (yet, or ever — a typo'd name and "not built yet" look the same from here, so
66    /// callers with a hard requirement on a given global should treat a miss as "not ready"
67    /// the same way any other `Option`-returning lookup in this engine does). Reach for
68    /// [`GroupEntry::Global`] instead of calling this directly wherever possible — it defers
69    /// this same lookup to upload time, so `name` doesn't need to be registered yet at the
70    /// point a material/compute is described.
71    pub fn get(&self, name: &str) -> Option<BindGroupLayout> {
72        self.entries.get(name).cloned()
73    }
74
75    /// Same lookup as [`get`](Self::get) without cloning — used internally by
76    /// [`assemble_group_layouts`] to resolve [`GroupEntry::Global`] against a borrowed pool.
77    pub(crate) fn get_ref(&self, name: &str) -> Option<&BindGroupLayout> {
78        self.entries.get(name)
79    }
80}
81
82/// Which pipeline kind a panic message from [`find_own_entries`] is describing — only used
83/// for wording those messages (`Material`'s bind group entries are validated differently than
84/// `Compute`'s, but both funnel through the same shared function).
85#[derive(Clone, Copy)]
86pub(crate) enum PipelineKind {
87    Material,
88    Compute,
89}
90
91impl std::fmt::Display for PipelineKind {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.write_str(match self {
94            PipelineKind::Material => "material",
95            PipelineKind::Compute => "compute pass",
96        })
97    }
98}
99
100/// Finds the single [`GroupEntry::Own`] in `groups`, if any, returning its entries (or `&[]`
101/// if there isn't one — a shader with no `@group` of its own). Panics if there's more than
102/// one — a material/compute can only expose one concrete bind group for a
103/// [`GPUBindingInstance`](super::instance::GPUBindingInstance) to bind resources against, so
104/// at most one position in `.entries(...)` may be `Own`.
105pub(crate) fn find_own_entries<'a>(
106    label: Option<&str>,
107    kind: PipelineKind,
108    groups: &'a [GroupEntry],
109) -> &'a [BindingEntry] {
110    let mut found: Option<&[BindingEntry]> = None;
111    for g in groups {
112        if let GroupEntry::Own(entries) = g {
113            if found.is_some() {
114                panic!(
115                    "{kind}{}: more than one GroupEntry::Own(...) in .entries(...) — a {kind} \
116                     can only have one group of its own bind group entries",
117                    label.map(|l| format!(" '{l}'")).unwrap_or_default()
118                );
119            }
120            found = Some(entries);
121        }
122    }
123    found.unwrap_or(&[])
124}
125
126/// Assembles the ordered pipeline-layout slots from `groups` — position in `groups` is the
127/// `@group(N)` index, with `own_layout` (built by the caller from
128/// [`find_own_entries`]'s result) filling in wherever [`GroupEntry::Own`] appeared, and `pool`
129/// resolving wherever [`GroupEntry::Global`] appeared.
130///
131/// Returns `None` — not a panic — if any `GroupEntry::Global` name isn't registered in `pool`
132/// yet: unlike every other failure mode here, a missing global is a timing issue (the
133/// `LazyResource` that registers it hasn't run yet), not a caller mistake, so it gets the same
134/// "not ready, retry next tick" treatment as any other unmet `Deps`.
135///
136/// Panics if `groups` needs more bind groups than `max_bind_groups` allows — `wgpu` guarantees
137/// only 4 (`@group(0..=3)`) unless a device explicitly requests/supports more, so this is the
138/// difference between a clear message here (the actual limit and how many groups were
139/// requested) and an opaque wgpu validation panic at pipeline-layout creation. This is the
140/// reason to only list the groups a shader actually declares in `.entries(...)` — a
141/// [`GlobalLayoutPool`] registration you don't need is one you shouldn't reach for.
142pub(crate) fn assemble_group_layouts<'a>(
143    label: Option<&str>,
144    groups: &'a [GroupEntry],
145    own_layout: &'a BindGroupLayout,
146    pool: &'a GlobalLayoutPool,
147    max_bind_groups: u32,
148) -> Option<Vec<Option<&'a wgpu::BindGroupLayout>>> {
149    if groups.len() as u32 > max_bind_groups {
150        panic!(
151            "pipeline layout{} needs {} bind groups, but this device only supports \
152             {max_bind_groups} — trim .entries(...) to only the groups actually used",
153            label.map(|l| format!(" '{l}'")).unwrap_or_default(),
154            groups.len(),
155        );
156    }
157
158    groups
159        .iter()
160        .map(|g| {
161            let layout = match g {
162                GroupEntry::Own(_) => own_layout,
163                GroupEntry::Layout(l) => l,
164                GroupEntry::Global(name) => pool.get_ref(name)?,
165            };
166            Some(Some(layout.raw()))
167        })
168        .collect()
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::wgpu::binding::BindGroupLayoutBuilder;
175    use crate::wgpu::test_util::with_device;
176
177    fn empty_layout(device: &wgpu::Device) -> BindGroupLayout {
178        BindGroupLayoutBuilder::new().build_raw(device)
179    }
180
181    #[test]
182    fn global_layout_pool_get_round_trips_through_register() {
183        with_device!(device, _queue, {
184            let mut pool = GlobalLayoutPool::new();
185            pool.register("camera", empty_layout(&device));
186
187            assert!(pool.get("camera").is_some());
188            assert!(pool.get("missing").is_none());
189        });
190    }
191
192    #[test]
193    fn global_layout_pool_panics_on_duplicate_name() {
194        with_device!(device, _queue, {
195            let mut pool = GlobalLayoutPool::new();
196            pool.register("camera", empty_layout(&device));
197            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
198                pool.register("camera", empty_layout(&device));
199            }));
200            assert!(result.is_err(), "expected a panic for a duplicate name registered in the pool");
201        });
202    }
203
204    #[test]
205    fn find_own_entries_returns_empty_slice_when_there_is_no_own_group() {
206        let entries = find_own_entries(None, PipelineKind::Material, &[]);
207        assert!(entries.is_empty());
208    }
209
210    #[test]
211    fn find_own_entries_panics_on_more_than_one_own_group() {
212        with_device!(device, _queue, {
213            let groups =
214                vec![GroupEntry::Own(vec![]), GroupEntry::Layout(empty_layout(&device)), GroupEntry::Own(vec![])];
215            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
216                find_own_entries(None, PipelineKind::Material, &groups);
217            }));
218            assert!(result.is_err(), "expected a panic for more than one GroupEntry::Own");
219        });
220    }
221
222    #[test]
223    fn assemble_group_layouts_orders_slots_by_position() {
224        with_device!(device, _queue, {
225            let own = empty_layout(&device);
226            let a = empty_layout(&device);
227            let b = empty_layout(&device);
228            let groups =
229                vec![GroupEntry::Layout(a), GroupEntry::Own(vec![]), GroupEntry::Layout(b)];
230            let pool = GlobalLayoutPool::new();
231
232            let assembled = assemble_group_layouts(None, &groups, &own, &pool, 4).unwrap();
233
234            assert_eq!(assembled.len(), 3);
235            let GroupEntry::Layout(a) = &groups[0] else { unreachable!() };
236            let GroupEntry::Layout(b) = &groups[2] else { unreachable!() };
237            assert!(std::ptr::eq(assembled[0].unwrap(), a.raw()));
238            assert!(std::ptr::eq(assembled[1].unwrap(), own.raw()));
239            assert!(std::ptr::eq(assembled[2].unwrap(), b.raw()));
240        });
241    }
242
243    #[test]
244    fn assemble_group_layouts_resolves_global_entries_from_the_pool() {
245        with_device!(device, _queue, {
246            let own = empty_layout(&device);
247            let mut pool = GlobalLayoutPool::new();
248            pool.register("camera", empty_layout(&device));
249            let groups = vec![GroupEntry::Own(vec![]), GroupEntry::Global("camera")];
250
251            let assembled = assemble_group_layouts(None, &groups, &own, &pool, 4).unwrap();
252
253            assert_eq!(assembled.len(), 2);
254            assert!(std::ptr::eq(assembled[1].unwrap(), pool.get_ref("camera").unwrap().raw()));
255        });
256    }
257
258    #[test]
259    fn assemble_group_layouts_returns_none_for_an_unregistered_global() {
260        with_device!(device, _queue, {
261            let own = empty_layout(&device);
262            let pool = GlobalLayoutPool::new(); // "camera" never registered
263            let groups = vec![GroupEntry::Global("camera")];
264
265            let assembled = assemble_group_layouts(None, &groups, &own, &pool, 4);
266
267            assert!(assembled.is_none(), "expected None (not ready), not a panic, for an unresolved global");
268        });
269    }
270
271    #[test]
272    fn exceeding_max_bind_groups_panics() {
273        with_device!(device, _queue, {
274            let own = empty_layout(&device);
275            let groups = vec![GroupEntry::Layout(empty_layout(&device)), GroupEntry::Layout(empty_layout(&device))];
276            let pool = GlobalLayoutPool::new();
277            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
278                assemble_group_layouts(None, &groups, &own, &pool, 1);
279            }));
280            assert!(result.is_err(), "expected a panic for exceeding max_bind_groups");
281        });
282    }
283}