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/// 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 label(mut self, label: &'static str) -> Self {
69        self.label = Some(label);
70        self
71    }
72
73    pub fn 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 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 .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        .label(desc.label)
182        .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 Asset<WGPUBackend> for GPUCompute {
234    type Source = Compute;
235    type Deps<'a> = crate::ecs::system::Res<'a, super::layout::GlobalLayoutPool>;
236
237    fn upload<'a>(
238        source: &Compute,
239        backend: &WGPUBackend,
240        pool: &crate::ecs::system::Res<'a, super::layout::GlobalLayoutPool>,
241    ) -> Option<Self> {
242        let (pipeline, layout) = build_compute(backend, source, pool)?;
243        let entries =
244            super::layout::find_own_entries(source.label, super::layout::PipelineKind::Compute, &source.groups)
245                .to_vec();
246
247        Some(Self { pipeline, layout, entries })
248    }
249}
250
251crate::wgpu::plugin_macros::asset_plugin! {
252    /// Registers the [`GPUCompute`] asset pipeline (`Assets<Compute>`
253    /// → `ProcessedAssets<GPUCompute>`). Included by
254    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
255    /// assembling the `wgpu` module's plugins by hand.
256    ComputePlugin, GPUCompute
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::wgpu::binding::{BindingEntry, BindingKind};
263    use crate::wgpu::test_util::with_device;
264
265    const MINIMAL_COMPUTE_SHADER: &str = r#"
266        @compute @workgroup_size(1)
267        fn cs_main() {}
268    "#;
269
270    #[test]
271    fn a_fragment_visible_own_entry_panics_before_touching_the_device() {
272        with_device!(device, _queue, {
273            let pool = super::super::layout::GlobalLayoutPool::new();
274            let desc = ComputeBuilder::new(MINIMAL_COMPUTE_SHADER)
275                .entries(vec![super::super::layout::GroupEntry::Own(vec![BindingEntry {
276                    name: "bad",
277                    binding: 0,
278                    kind: BindingKind::sampler(ShaderStages::FRAGMENT),
279                }])])
280                .build();
281            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
282                build_compute_raw(&device, &desc, &pool);
283            }));
284            assert!(result.is_err(), "expected a panic for a non-COMPUTE-visible compute entry");
285        });
286    }
287
288    #[test]
289    fn a_vertex_fragment_visible_own_entry_also_panics() {
290        // Not just "wrong stage" but "wrong stage in addition to COMPUTE" —
291        // build_compute requires visibility == exactly COMPUTE, so a
292        // COMPUTE | FRAGMENT entry (reused from a material by mistake, say)
293        // must panic too, not just entries missing COMPUTE entirely.
294        with_device!(device, _queue, {
295            let pool = super::super::layout::GlobalLayoutPool::new();
296            let desc = ComputeBuilder::new(MINIMAL_COMPUTE_SHADER)
297                .entries(vec![super::super::layout::GroupEntry::Own(vec![BindingEntry {
298                    name: "bad",
299                    binding: 0,
300                    kind: BindingKind::storage_buffer_read_write(
301                        ShaderStages::COMPUTE | ShaderStages::FRAGMENT,
302                    ),
303                }])])
304                .build();
305            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
306                build_compute_raw(&device, &desc, &pool);
307            }));
308            assert!(result.is_err(), "expected a panic for a COMPUTE | FRAGMENT compute entry");
309        });
310    }
311
312    #[test]
313    fn no_entries_at_all_builds_without_panicking() {
314        with_device!(device, _queue, {
315            let pool = super::super::layout::GlobalLayoutPool::new();
316            let desc = ComputeBuilder::new(MINIMAL_COMPUTE_SHADER).build();
317            build_compute_raw(&device, &desc, &pool).unwrap();
318        });
319    }
320
321    #[test]
322    fn a_layout_pulled_from_the_global_pool_ends_up_in_the_pipeline_layout() {
323        with_device!(device, _queue, {
324            let mut pool = super::super::layout::GlobalLayoutPool::new();
325            pool.register("camera", crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device));
326
327            let desc = ComputeBuilder::new(MINIMAL_COMPUTE_SHADER)
328                .entries(vec![super::super::layout::GroupEntry::Layout(pool.get("camera").unwrap())])
329                .build();
330
331            build_compute_raw(&device, &desc, &pool).unwrap();
332        });
333    }
334
335    #[test]
336    fn a_global_entry_resolves_from_the_pool_at_build_time() {
337        with_device!(device, _queue, {
338            let mut pool = super::super::layout::GlobalLayoutPool::new();
339            pool.register("camera", crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device));
340
341            let desc = ComputeBuilder::new(MINIMAL_COMPUTE_SHADER)
342                .entries(vec![super::super::layout::GroupEntry::Global("camera")])
343                .build();
344
345            build_compute_raw(&device, &desc, &pool).unwrap();
346        });
347    }
348
349    #[test]
350    fn a_global_entry_not_yet_registered_returns_none_instead_of_panicking() {
351        with_device!(device, _queue, {
352            let pool = super::super::layout::GlobalLayoutPool::new(); // "camera" never registered
353            let desc = ComputeBuilder::new(MINIMAL_COMPUTE_SHADER)
354                .entries(vec![super::super::layout::GroupEntry::Global("camera")])
355                .build();
356
357            assert!(build_compute_raw(&device, &desc, &pool).is_none());
358        });
359    }
360
361    #[test]
362    fn own_and_layout_groups_are_ordered_by_position() {
363        with_device!(device, _queue, {
364            let pool = super::super::layout::GlobalLayoutPool::new();
365            let extra = crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device);
366            let desc = ComputeBuilder::new(MINIMAL_COMPUTE_SHADER)
367                .entries(vec![
368                    super::super::layout::GroupEntry::Own(vec![]),
369                    super::super::layout::GroupEntry::Layout(extra),
370                ])
371                .build();
372
373            build_compute_raw(&device, &desc, &pool).unwrap();
374        });
375    }
376
377    #[test]
378    fn more_than_one_own_group_panics() {
379        with_device!(device, _queue, {
380            let pool = super::super::layout::GlobalLayoutPool::new();
381            let desc = ComputeBuilder::new(MINIMAL_COMPUTE_SHADER)
382                .entries(vec![
383                    super::super::layout::GroupEntry::Own(vec![]),
384                    super::super::layout::GroupEntry::Own(vec![]),
385                ])
386                .build();
387
388            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
389                build_compute_raw(&device, &desc, &pool);
390            }));
391            assert!(result.is_err(), "expected a panic for more than one Own group");
392        });
393    }
394
395    #[test]
396    fn exceeding_max_bind_groups_panics() {
397        with_device!(device, _queue, {
398            let pool = super::super::layout::GlobalLayoutPool::new();
399            // This device's real max_bind_groups is at least 4, so 5 groups always exceeds it.
400            let groups: Vec<super::super::layout::GroupEntry> = (0..5)
401                .map(|_| {
402                    super::super::layout::GroupEntry::Layout(
403                        crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device),
404                    )
405                })
406                .collect();
407            let desc = ComputeBuilder::new(MINIMAL_COMPUTE_SHADER).entries(groups).build();
408
409            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
410                build_compute_raw(&device, &desc, &pool);
411            }));
412            assert!(result.is_err(), "expected a panic for exceeding max_bind_groups");
413        });
414    }
415}