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.
133///
134/// Returns `None` — not a panic — if `desc.entries` contains a
135/// [`GroupEntry::Global`](super::layout::GroupEntry::Global) not yet registered in `pool`; the
136/// caller (`GPUCompute::upload`) treats that exactly like any other unmet `Deps` and retries
137/// next tick.
138pub fn build_compute(
139    backend: &WGPUBackend,
140    desc: &Compute,
141    pool: &super::layout::GlobalLayoutPool,
142) -> Option<(ComputePipeline, BindGroupLayout)> {
143    build_compute_raw(&backend.device, desc, pool)
144}
145
146/// Internal primitive behind [`build_compute`] — used directly only by
147/// tests, which have a raw `wgpu::Device` but no full [`WGPUBackend`].
148pub(crate) fn build_compute_raw(
149    device: &wgpu::Device,
150    desc: &Compute,
151    pool: &super::layout::GlobalLayoutPool,
152) -> Option<(ComputePipeline, BindGroupLayout)> {
153    let own_entries =
154        super::layout::find_own_entries(desc.label, super::layout::PipelineKind::Compute, &desc.groups);
155    for entry in own_entries {
156        if entry.kind.visibility() != ShaderStages::COMPUTE {
157            panic!(
158                "compute pass{}: entry '{}' is not visible to exactly the compute stage — \
159                 compute bind group entries must be visible to exactly COMPUTE",
160                desc.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
161                entry.name,
162            );
163        }
164    }
165
166    let layout = BindGroupLayoutBuilder::new()
167        .label(desc.label)
168        .entries(own_entries.iter().cloned())
169        .build_raw(device);
170
171    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
172        label: desc.label,
173        source: wgpu::ShaderSource::Wgsl(desc.shader_source.into()),
174    });
175
176    let bind_group_layouts = super::layout::assemble_group_layouts(
177        desc.label,
178        &desc.groups,
179        &layout,
180        pool,
181        device.limits().max_bind_groups,
182    )?;
183
184    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
185        label: desc.label,
186        bind_group_layouts: &bind_group_layouts,
187        immediate_size: 0,
188    });
189
190    let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
191        label: desc.label,
192        layout: Some(&pipeline_layout),
193        module: &module,
194        entry_point: desc.entry_point,
195        compilation_options: Default::default(),
196        cache: None,
197    });
198
199    Some((ComputePipeline(pipeline), layout))
200}
201
202/// A compute pass uploaded to the GPU: a compute pipeline plus the bind
203/// group layout entries it expects.
204pub struct GPUCompute {
205    pub pipeline: ComputePipeline,
206    layout: BindGroupLayout,
207    entries: Vec<BindingEntry>,
208}
209
210impl super::binding::BindGroupTarget for GPUCompute {
211    fn bind_group_layout(&self) -> &BindGroupLayout {
212        &self.layout
213    }
214    fn binding_entries(&self) -> &[BindingEntry] {
215        &self.entries
216    }
217}
218
219impl Asset<WGPUBackend> for GPUCompute {
220    type Source = Compute;
221    type Deps<'a> = crate::ecs::system::Res<'a, super::layout::GlobalLayoutPool>;
222
223    fn upload<'a>(
224        source: &Compute,
225        backend: &WGPUBackend,
226        pool: &crate::ecs::system::Res<'a, super::layout::GlobalLayoutPool>,
227    ) -> Option<Self> {
228        let (pipeline, layout) = build_compute(backend, source, pool)?;
229        let entries =
230            super::layout::find_own_entries(source.label, super::layout::PipelineKind::Compute, &source.groups)
231                .to_vec();
232
233        Some(Self { pipeline, layout, entries })
234    }
235}
236
237crate::wgpu::plugin_macros::asset_plugin! {
238    /// Registers the [`GPUCompute`] asset pipeline (`Assets<Compute>`
239    /// → `ProcessedAssets<GPUCompute>`). Included by
240    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
241    /// assembling the `wgpu` module's plugins by hand.
242    ComputePlugin, GPUCompute
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::wgpu::binding::{BindingEntry, BindingKind};
249    use crate::wgpu::test_util::with_device;
250
251    const MINIMAL_COMPUTE_SHADER: &str = r#"
252        @compute @workgroup_size(1)
253        fn cs_main() {}
254    "#;
255
256    #[test]
257    fn a_fragment_visible_own_entry_panics_before_touching_the_device() {
258        with_device!(device, _queue, {
259            let pool = super::super::layout::GlobalLayoutPool::new();
260            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
261                .entries(vec![super::super::layout::GroupEntry::Own(vec![BindingEntry {
262                    name: "bad",
263                    binding: 0,
264                    kind: BindingKind::sampler(ShaderStages::FRAGMENT),
265                }])])
266                .build();
267            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
268                build_compute_raw(&device, &desc, &pool);
269            }));
270            assert!(result.is_err(), "expected a panic for a non-COMPUTE-visible compute entry");
271        });
272    }
273
274    #[test]
275    fn a_vertex_fragment_visible_own_entry_also_panics() {
276        // Not just "wrong stage" but "wrong stage in addition to COMPUTE" —
277        // build_compute requires visibility == exactly COMPUTE, so a
278        // COMPUTE | FRAGMENT entry (reused from a material by mistake, say)
279        // must panic too, not just entries missing COMPUTE entirely.
280        with_device!(device, _queue, {
281            let pool = super::super::layout::GlobalLayoutPool::new();
282            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
283                .entries(vec![super::super::layout::GroupEntry::Own(vec![BindingEntry {
284                    name: "bad",
285                    binding: 0,
286                    kind: BindingKind::storage_buffer_read_write(
287                        ShaderStages::COMPUTE | ShaderStages::FRAGMENT,
288                    ),
289                }])])
290                .build();
291            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
292                build_compute_raw(&device, &desc, &pool);
293            }));
294            assert!(result.is_err(), "expected a panic for a COMPUTE | FRAGMENT compute entry");
295        });
296    }
297
298    #[test]
299    fn no_entries_at_all_builds_without_panicking() {
300        with_device!(device, _queue, {
301            let pool = super::super::layout::GlobalLayoutPool::new();
302            let desc = Compute::new(MINIMAL_COMPUTE_SHADER).build();
303            build_compute_raw(&device, &desc, &pool).unwrap();
304        });
305    }
306
307    #[test]
308    fn a_layout_pulled_from_the_global_pool_ends_up_in_the_pipeline_layout() {
309        with_device!(device, _queue, {
310            let mut pool = super::super::layout::GlobalLayoutPool::new();
311            pool.register("camera", crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device));
312
313            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
314                .entries(vec![super::super::layout::GroupEntry::Layout(pool.get("camera").unwrap())])
315                .build();
316
317            build_compute_raw(&device, &desc, &pool).unwrap();
318        });
319    }
320
321    #[test]
322    fn a_global_entry_resolves_from_the_pool_at_build_time() {
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 = Compute::new(MINIMAL_COMPUTE_SHADER)
328                .entries(vec![super::super::layout::GroupEntry::Global("camera")])
329                .build();
330
331            build_compute_raw(&device, &desc, &pool).unwrap();
332        });
333    }
334
335    #[test]
336    fn a_global_entry_not_yet_registered_returns_none_instead_of_panicking() {
337        with_device!(device, _queue, {
338            let pool = super::super::layout::GlobalLayoutPool::new(); // "camera" never registered
339            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
340                .entries(vec![super::super::layout::GroupEntry::Global("camera")])
341                .build();
342
343            assert!(build_compute_raw(&device, &desc, &pool).is_none());
344        });
345    }
346
347    #[test]
348    fn own_and_layout_groups_are_ordered_by_position() {
349        with_device!(device, _queue, {
350            let pool = super::super::layout::GlobalLayoutPool::new();
351            let extra = crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device);
352            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
353                .entries(vec![
354                    super::super::layout::GroupEntry::Own(vec![]),
355                    super::super::layout::GroupEntry::Layout(extra),
356                ])
357                .build();
358
359            build_compute_raw(&device, &desc, &pool).unwrap();
360        });
361    }
362
363    #[test]
364    fn more_than_one_own_group_panics() {
365        with_device!(device, _queue, {
366            let pool = super::super::layout::GlobalLayoutPool::new();
367            let desc = Compute::new(MINIMAL_COMPUTE_SHADER)
368                .entries(vec![
369                    super::super::layout::GroupEntry::Own(vec![]),
370                    super::super::layout::GroupEntry::Own(vec![]),
371                ])
372                .build();
373
374            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
375                build_compute_raw(&device, &desc, &pool);
376            }));
377            assert!(result.is_err(), "expected a panic for more than one Own group");
378        });
379    }
380
381    #[test]
382    fn exceeding_max_bind_groups_panics() {
383        with_device!(device, _queue, {
384            let pool = super::super::layout::GlobalLayoutPool::new();
385            // This device's real max_bind_groups is at least 4, so 5 groups always exceeds it.
386            let groups: Vec<super::super::layout::GroupEntry> = (0..5)
387                .map(|_| {
388                    super::super::layout::GroupEntry::Layout(
389                        crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device),
390                    )
391                })
392                .collect();
393            let desc = Compute::new(MINIMAL_COMPUTE_SHADER).entries(groups).build();
394
395            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
396                build_compute_raw(&device, &desc, &pool);
397            }));
398            assert!(result.is_err(), "expected a panic for exceeding max_bind_groups");
399        });
400    }
401}