Skip to main content

pebble/wgpu/
compute.rs

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