Skip to main content

pebble/wgpu/
compute.rs

1use crate::{
2    assets::{handle::Handle, storage::Assets, upload::Asset},
3    wgpu::{
4        backend::WGPUBackend,
5        binding::{BindGroupLayout, BindGroupLayoutBuilder, BindingEntry},
6        flags::ShaderStages,
7    },
8};
9
10/// A `wgpu::ComputePipeline`, opaque — built only via [`build_compute`]/
11/// [`GPUCompute`]'s `Asset::upload`. Bind it against a
12/// [`ComputePass`](super::compute_pass::ComputePass) via
13/// [`ComputePass::set_pipeline`](super::compute_pass::ComputePass::set_pipeline);
14/// there's no way to reach the underlying `wgpu::ComputePipeline` from
15/// outside this crate.
16pub struct ComputePipeline(wgpu::ComputePipeline);
17
18impl ComputePipeline {
19    pub(crate) fn raw(&self) -> &wgpu::ComputePipeline {
20        &self.0
21    }
22}
23
24/// Describes a compute pipeline + its own bind group, the source type
25/// [`GPUCompute`] is built from via [`build_compute`]. Fields are private —
26/// start from [`Compute::new`] and chain the setters below rather than
27/// constructing one as a struct literal.
28pub struct Compute {
29    /// Debug label, threaded through to the shader module, pipeline, and
30    /// bind group layout.
31    label: Option<&'static str>,
32    /// WGSL source for the compute stage.
33    shader_source: &'static str,
34    /// Compute stage entry point. Defaults to `"cs_main"`.
35    entry_point: Option<&'static str>,
36    /// This compute pass's bind groups, in `@group(N)` order — set via
37    /// [`entries`](Self::entries), whose docs cover the full shape.
38    groups: Vec<super::layout::GroupEntry>,
39}
40
41impl Default for Compute {
42    fn default() -> Self {
43        Self {
44            label: None,
45            shader_source: "",
46            entry_point: Some("cs_main"),
47            groups: Vec::new(),
48        }
49    }
50}
51
52impl Compute {
53    /// Start building a compute pass with the given WGSL shader source.
54    /// All other fields are set to their defaults (see [`Default`]).
55    pub fn new(shader_source: &'static str) -> Self {
56        Self { shader_source, ..Self::default() }
57    }
58
59    pub fn label(mut self, label: &'static str) -> Self {
60        self.label = Some(label);
61        self
62    }
63
64    pub fn entry_point(mut self, entry: &'static str) -> Self {
65        self.entry_point = Some(entry);
66        self
67    }
68
69    /// This compute pass's bind groups, in `@group(N)` order — position in `groups` *is* the
70    /// `@group(N)` index a shader must declare to match: the first element is `@group(0)`,
71    /// the second `@group(1)`, and so on. Each element is either:
72    ///
73    /// - [`GroupEntry::Own`](super::layout::GroupEntry::Own) — this compute pass's own bind
74    ///   group entries, built into a fresh layout internally. At most one of these is
75    ///   allowed — the one group a
76    ///   [`GPUComputeInstance`](super::instance::GPUComputeInstance) binds concrete resources
77    ///   against — `build_compute` panics on a second one.
78    /// - [`GroupEntry::Layout`](super::layout::GroupEntry::Layout) — an already-built layout
79    ///   occupying this position directly: any external bind group layout, e.g. pulled from a
80    ///   [`GlobalLayoutPool`](super::layout::GlobalLayoutPool) via
81    ///   [`GlobalLayoutPool::get`](super::layout::GlobalLayoutPool::get).
82    ///
83    /// `build_compute` also panics if any `Own` entry isn't visible to exactly the compute
84    /// stage, or if `groups` needs more bind groups than the device's `max_bind_groups`
85    /// allows (`wgpu` guarantees only 4) — list only the groups this pass's shader actually
86    /// declares.
87    pub fn entries(mut self, groups: Vec<super::layout::GroupEntry>) -> Self {
88        self.groups = groups;
89        self
90    }
91
92    /// Logs a WARN if this pass has no bind groups at all — not fatal, since a shader could
93    /// legitimately need no bindings, but a compute pass with nothing to read or write is
94    /// unusual enough to flag.
95    fn validate(&self) {
96        if self.groups.is_empty() {
97            tracing::warn!(
98                "Compute{}: no bind groups at all — this pass can't read or write anything; \
99                 consider calling .entries(...)",
100                self.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
101            );
102        }
103    }
104
105    /// Consume the builder and return the finished [`Compute`] value.
106    pub fn build(self) -> Self {
107        self.validate();
108        self
109    }
110
111    /// Consume the builder, insert into `assets` under `name`, and return
112    /// the resulting [`Handle<Compute>`].
113    pub fn build_asset(self, name: &str, assets: &mut Assets<Self>) -> Handle<Self> {
114        self.validate();
115        assets.insert(name, self)
116    }
117}
118
119/// Builds a compute pipeline and its own bind group layout from `desc`.
120///
121/// Panics if the one [`GroupEntry::Own`](super::layout::GroupEntry::Own) in `desc.entries`
122/// (if any) isn't visible to exactly the compute stage —
123/// [`BindingKind`](super::binding::BindingKind) is shared with
124/// [`Material`](super::material::Material), and this is the check that catches a material
125/// entry (`FRAGMENT`/`VERTEX_FRAGMENT`) accidentally reused in a compute pass instead of
126/// letting it fail deep inside wgpu with a less specific error. The bind group layout itself
127/// comes from [`binding::BindGroupLayoutBuilder`](super::binding::BindGroupLayoutBuilder). The
128/// pipeline layout is assembled directly from `desc.entries`, in order — position is the
129/// `@group(N)` index — panicking if `desc.entries` contains more than one `GroupEntry::Own`,
130/// or needs more bind groups than the device's `max_bind_groups` allows, turning either
131/// mistake into an immediate, specific error instead of an opaque wgpu validation failure at
132/// draw time.
133pub fn build_compute(backend: &WGPUBackend, desc: &Compute) -> (ComputePipeline, BindGroupLayout) {
134    build_compute_raw(&backend.device, desc)
135}
136
137/// Internal primitive behind [`build_compute`] — used directly only by
138/// tests, which have a raw `wgpu::Device` but no full [`WGPUBackend`].
139pub(crate) fn build_compute_raw(
140    device: &wgpu::Device,
141    desc: &Compute,
142) -> (ComputePipeline, BindGroupLayout) {
143    let own_entries =
144        super::layout::find_own_entries(desc.label, super::layout::PipelineKind::Compute, &desc.groups);
145    for entry in own_entries {
146        if entry.kind.visibility() != ShaderStages::COMPUTE {
147            panic!(
148                "compute pass{}: entry '{}' is not visible to exactly the compute stage — \
149                 compute bind group entries must be visible to exactly COMPUTE",
150                desc.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
151                entry.name,
152            );
153        }
154    }
155
156    let layout = BindGroupLayoutBuilder::new()
157        .label(desc.label)
158        .entries(own_entries.iter().cloned())
159        .build_raw(device);
160
161    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
162        label: desc.label,
163        source: wgpu::ShaderSource::Wgsl(desc.shader_source.into()),
164    });
165
166    let bind_group_layouts = super::layout::assemble_group_layouts(
167        desc.label,
168        &desc.groups,
169        &layout,
170        device.limits().max_bind_groups,
171    );
172
173    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
174        label: desc.label,
175        bind_group_layouts: &bind_group_layouts,
176        immediate_size: 0,
177    });
178
179    let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
180        label: desc.label,
181        layout: Some(&pipeline_layout),
182        module: &module,
183        entry_point: desc.entry_point,
184        compilation_options: Default::default(),
185        cache: None,
186    });
187
188    (ComputePipeline(pipeline), layout)
189}
190
191/// A compute pass uploaded to the GPU: a compute pipeline plus the bind
192/// group layout entries it expects.
193pub struct GPUCompute {
194    pub pipeline: ComputePipeline,
195    layout: BindGroupLayout,
196    entries: Vec<BindingEntry>,
197}
198
199impl super::binding::BindGroupTarget for GPUCompute {
200    fn bind_group_layout(&self) -> &BindGroupLayout {
201        &self.layout
202    }
203    fn binding_entries(&self) -> &[BindingEntry] {
204        &self.entries
205    }
206}
207
208impl Asset<WGPUBackend> for GPUCompute {
209    type Source = Compute;
210    type Deps<'a> = ();
211
212    fn upload<'a>(source: &Compute, backend: &WGPUBackend, _deps: &()) -> Option<Self> {
213        let (pipeline, layout) = build_compute(backend, source);
214        let entries =
215            super::layout::find_own_entries(source.label, super::layout::PipelineKind::Compute, &source.groups)
216                .to_vec();
217
218        Some(Self { pipeline, layout, entries })
219    }
220}
221
222crate::wgpu::plugin_macros::asset_plugin! {
223    /// Registers the [`GPUCompute`] asset pipeline (`Assets<Compute>`
224    /// → `ProcessedAssets<GPUCompute>`). Included by
225    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
226    /// assembling the `wgpu` module's plugins by hand.
227    ComputePlugin, GPUCompute
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::wgpu::binding::{BindingEntry, BindingKind};
234    use crate::wgpu::test_util::with_device;
235
236    const MINIMAL_COMPUTE_SHADER: &str = r#"
237        @compute @workgroup_size(1)
238        fn cs_main() {}
239    "#;
240
241    #[test]
242    fn a_fragment_visible_own_entry_panics_before_touching_the_device() {
243        with_device!(device, _queue, {
244            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
245                .entries(vec![super::super::layout::GroupEntry::Own(vec![BindingEntry {
246                    name: "bad",
247                    binding: 0,
248                    kind: BindingKind::sampler(ShaderStages::FRAGMENT),
249                }])])
250                .build();
251            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
252                build_compute_raw(&device, &desc);
253            }));
254            assert!(result.is_err(), "expected a panic for a non-COMPUTE-visible compute entry");
255        });
256    }
257
258    #[test]
259    fn a_vertex_fragment_visible_own_entry_also_panics() {
260        // Not just "wrong stage" but "wrong stage in addition to COMPUTE" —
261        // build_compute requires visibility == exactly COMPUTE, so a
262        // COMPUTE | FRAGMENT entry (reused from a material by mistake, say)
263        // must panic too, not just entries missing COMPUTE entirely.
264        with_device!(device, _queue, {
265            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
266                .entries(vec![super::super::layout::GroupEntry::Own(vec![BindingEntry {
267                    name: "bad",
268                    binding: 0,
269                    kind: BindingKind::storage_buffer_read_write(
270                        ShaderStages::COMPUTE | ShaderStages::FRAGMENT,
271                    ),
272                }])])
273                .build();
274            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
275                build_compute_raw(&device, &desc);
276            }));
277            assert!(result.is_err(), "expected a panic for a COMPUTE | FRAGMENT compute entry");
278        });
279    }
280
281    #[test]
282    fn no_entries_at_all_builds_without_panicking() {
283        with_device!(device, _queue, {
284            let desc = Compute::new(MINIMAL_COMPUTE_SHADER).build();
285            build_compute_raw(&device, &desc);
286        });
287    }
288
289    #[test]
290    fn a_layout_pulled_from_the_global_pool_ends_up_in_the_pipeline_layout() {
291        with_device!(device, _queue, {
292            let mut pool = super::super::layout::GlobalLayoutPool::new();
293            pool.register("camera", crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device));
294
295            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
296                .entries(vec![super::super::layout::GroupEntry::Layout(pool.get("camera").unwrap())])
297                .build();
298
299            build_compute_raw(&device, &desc);
300        });
301    }
302
303    #[test]
304    fn own_and_layout_groups_are_ordered_by_position() {
305        with_device!(device, _queue, {
306            let extra = crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device);
307            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
308                .entries(vec![
309                    super::super::layout::GroupEntry::Own(vec![]),
310                    super::super::layout::GroupEntry::Layout(extra),
311                ])
312                .build();
313
314            build_compute_raw(&device, &desc);
315        });
316    }
317
318    #[test]
319    fn more_than_one_own_group_panics() {
320        with_device!(device, _queue, {
321            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
322                .entries(vec![
323                    super::super::layout::GroupEntry::Own(vec![]),
324                    super::super::layout::GroupEntry::Own(vec![]),
325                ])
326                .build();
327
328            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
329                build_compute_raw(&device, &desc);
330            }));
331            assert!(result.is_err(), "expected a panic for more than one Own group");
332        });
333    }
334
335    #[test]
336    fn exceeding_max_bind_groups_panics() {
337        with_device!(device, _queue, {
338            // This device's real max_bind_groups is at least 4, so 5 groups always exceeds it.
339            let groups: Vec<super::super::layout::GroupEntry> = (0..5)
340                .map(|_| {
341                    super::super::layout::GroupEntry::Layout(
342                        crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device),
343                    )
344                })
345                .collect();
346            let desc = Compute::new(MINIMAL_COMPUTE_SHADER).entries(groups).build();
347
348            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
349                build_compute_raw(&device, &desc);
350            }));
351            assert!(result.is_err(), "expected a panic for exceeding max_bind_groups");
352        });
353    }
354}