Skip to main content

engawa_wgpu/
dispatcher.rs

1//! `WgpuDispatcher` — engawa's `Dispatcher` trait realised
2//! against wgpu.
3//!
4//! Pipeline cache keyed by Material name; pipelines compile
5//! once per Material per WgpuDispatcher lifetime. Each
6//! dispatch_node call begins one render pass + draws one
7//! fullscreen triangle.
8//!
9//! Bind-group construction lives at the boundary: callers
10//! pass a `BoundResources` map (engawa `ResourceId` →
11//! `BoundResource` containing the live wgpu handle), and this
12//! crate constructs the bind group on demand from the Material's
13//! declared bindings. The consumer owns the wgpu textures /
14//! buffers / samplers; this crate orchestrates the dispatch.
15
16use std::collections::BTreeMap;
17
18use engawa::{
19    BindingKind, CompiledGraph, DispatchError, Dispatcher, Material, Node, NodeId,
20    PassKind, ResourceBindings, ResourceId,
21};
22use thiserror::Error;
23
24use crate::pipeline::combined_shader_source;
25
26#[derive(Debug, Error)]
27pub enum WgpuDispatcherError {
28    #[error("engawa dispatch error: {0}")]
29    Dispatch(#[from] DispatchError),
30    #[error("unsupported pass kind for v0.1: {0:?}; only Render is implemented today")]
31    UnsupportedPass(PassKind),
32    #[error("node {node:?} has no material but pass kind requires one")]
33    MissingMaterial { node: NodeId },
34    #[error(
35        "node {node:?} binding {binding} expects {expected:?} but bound resource for {resource:?} is {actual:?}"
36    )]
37    BindingKindMismatch {
38        node: NodeId,
39        binding: u32,
40        resource: ResourceId,
41        expected: BindingKind,
42        actual: &'static str,
43    },
44    #[error(
45        "node {node:?} output {resource:?} has no bound wgpu::TextureView (output bindings must be textures)"
46    )]
47    OutputNotBound {
48        node: NodeId,
49        resource: ResourceId,
50    },
51    #[error("node {node:?} binding {binding} resource {resource:?} not present in BoundResources")]
52    BoundResourceMissing {
53        node: NodeId,
54        binding: u32,
55        resource: ResourceId,
56    },
57}
58
59/// Live wgpu handle wrapped in a tagged enum so the dispatcher
60/// can match the bind type the Material declared. Operators
61/// build this from their own wgpu resources at dispatch time.
62#[derive(Clone)]
63pub enum BoundResource {
64    Texture {
65        view: wgpu::TextureView,
66        format: wgpu::TextureFormat,
67    },
68    Uniform(wgpu::Buffer),
69    Storage(wgpu::Buffer),
70    Sampler(wgpu::Sampler),
71}
72
73/// Per-frame map of engawa `ResourceId` → live wgpu handle.
74/// The consumer (mado, future ayatsuri) populates this before
75/// calling `dispatch_graph`. Engawa already validated at
76/// compile time that every node references a resource that's
77/// either an input or another node's output; the dispatcher
78/// validates that every referenced resource has a `BoundResource`
79/// entry at dispatch time.
80#[derive(Default, Clone)]
81pub struct BoundResources {
82    inner: BTreeMap<ResourceId, BoundResource>,
83}
84
85impl BoundResources {
86    #[must_use]
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    #[must_use]
92    pub fn with(
93        mut self,
94        id: impl Into<ResourceId>,
95        resource: BoundResource,
96    ) -> Self {
97        self.inner.insert(id.into(), resource);
98        self
99    }
100
101    pub fn insert(&mut self, id: impl Into<ResourceId>, resource: BoundResource) {
102        self.inner.insert(id.into(), resource);
103    }
104
105    #[must_use]
106    pub fn get(&self, id: &ResourceId) -> Option<&BoundResource> {
107        self.inner.get(id)
108    }
109
110    #[must_use]
111    pub fn len(&self) -> usize {
112        self.inner.len()
113    }
114
115    #[must_use]
116    pub fn is_empty(&self) -> bool {
117        self.inner.is_empty()
118    }
119}
120
121/// Per-Material wgpu pipeline cache entry.
122struct CachedPipeline {
123    pipeline: wgpu::RenderPipeline,
124    bind_group_layout: wgpu::BindGroupLayout,
125}
126
127/// Dispatcher that compiles engawa render graphs to wgpu
128/// commands. Construct once; call `dispatch_graph` per frame.
129pub struct WgpuDispatcher<'a> {
130    device: &'a wgpu::Device,
131    queue: &'a wgpu::Queue,
132    target_format: wgpu::TextureFormat,
133    pipelines: BTreeMap<String, CachedPipeline>,
134    /// Encoder used for the current `dispatch_graph` call. The
135    /// caller passes their own encoder via `set_encoder`; the
136    /// dispatcher uses it for every per-node render pass, then
137    /// the caller submits.
138    encoder: Option<wgpu::CommandEncoder>,
139    /// Per-frame bound resources. Set by `dispatch_with` before
140    /// the graph walk.
141    bound: Option<BoundResources>,
142}
143
144impl<'a> WgpuDispatcher<'a> {
145    #[must_use]
146    pub fn new(
147        device: &'a wgpu::Device,
148        queue: &'a wgpu::Queue,
149        target_format: wgpu::TextureFormat,
150    ) -> Self {
151        Self {
152            device,
153            queue,
154            target_format,
155            pipelines: BTreeMap::new(),
156            encoder: None,
157            bound: None,
158        }
159    }
160
161    /// One-shot helper: compile (if needed), build bindings,
162    /// walk the graph, return the recorded `CommandBuffer`
163    /// ready to submit. Wraps the trait's `dispatch_graph` +
164    /// encoder lifecycle so the call site stays one line.
165    pub fn dispatch_with(
166        &mut self,
167        graph: &CompiledGraph,
168        bindings: ResourceBindings,
169        bound: BoundResources,
170    ) -> Result<wgpu::CommandBuffer, WgpuDispatcherError> {
171        // Pre-compile every Material referenced in the graph.
172        for node in graph.iter_nodes() {
173            if let Some(material) = &node.material {
174                if !self.pipelines.contains_key(&material.name) {
175                    let cached = self.build_pipeline(material)?;
176                    self.pipelines.insert(material.name.clone(), cached);
177                }
178            }
179        }
180
181        // Encoder live for the entire graph walk; one submit at
182        // the end.
183        self.encoder = Some(
184            self.device
185                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
186                    label: Some("engawa-wgpu graph"),
187                }),
188        );
189        self.bound = Some(bound);
190
191        // Walk via the engawa trait's default impl — it validates
192        // ResourceBindings + delegates each node to dispatch_node.
193        self.dispatch_graph(graph, &bindings)?;
194
195        let encoder = self.encoder.take().expect("encoder set");
196        self.bound = None;
197        Ok(encoder.finish())
198    }
199
200    fn build_pipeline(
201        &self,
202        material: &Material,
203    ) -> Result<CachedPipeline, WgpuDispatcherError> {
204        let fragment_wgsl = match &material.shader {
205            engawa::ShaderSource::Inline { wgsl } => wgsl.clone(),
206            engawa::ShaderSource::Path { path } => {
207                std::fs::read_to_string(path).unwrap_or_else(|e| {
208                    // Surface error via tracing; pipeline will
209                    // fail to compile and the wgpu error scope
210                    // will catch it.
211                    eprintln!(
212                        "engawa-wgpu: failed to read shader at {path}: {e}; \
213                         falling back to red-tint placeholder"
214                    );
215                    "@fragment fn fs_main() -> @location(0) vec4<f32> { \
216                     return vec4<f32>(1.0, 0.0, 0.0, 1.0); }"
217                        .to_string()
218                })
219            }
220        };
221        let combined = combined_shader_source(&fragment_wgsl);
222        let shader = self.device.create_shader_module(wgpu::ShaderModuleDescriptor {
223            label: Some(&material.name),
224            source: wgpu::ShaderSource::Wgsl(combined.into()),
225        });
226
227        // Bind-group layout from the Material's declared bindings.
228        let entries: Vec<wgpu::BindGroupLayoutEntry> = material
229            .bindings
230            .iter()
231            .map(|b| wgpu::BindGroupLayoutEntry {
232                binding: b.binding,
233                visibility: wgpu::ShaderStages::FRAGMENT,
234                ty: binding_kind_to_wgpu(b.kind),
235                count: None,
236            })
237            .collect();
238        let bind_group_layout =
239            self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
240                label: Some(&material.name),
241                entries: &entries,
242            });
243        let pipeline_layout =
244            self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
245                label: Some(&material.name),
246                bind_group_layouts: &[&bind_group_layout],
247                push_constant_ranges: &[],
248            });
249
250        let pipeline = self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
251            label: Some(&material.name),
252            layout: Some(&pipeline_layout),
253            vertex: wgpu::VertexState {
254                module: &shader,
255                entry_point: Some("vs_main"),
256                buffers: &[],
257                compilation_options: wgpu::PipelineCompilationOptions::default(),
258            },
259            fragment: Some(wgpu::FragmentState {
260                module: &shader,
261                entry_point: Some("fs_main"),
262                targets: &[Some(wgpu::ColorTargetState {
263                    format: self.target_format,
264                    blend: None,
265                    write_mask: wgpu::ColorWrites::ALL,
266                })],
267                compilation_options: wgpu::PipelineCompilationOptions::default(),
268            }),
269            primitive: wgpu::PrimitiveState::default(),
270            depth_stencil: None,
271            multisample: wgpu::MultisampleState::default(),
272            multiview: None,
273            cache: None,
274        });
275
276        Ok(CachedPipeline {
277            pipeline,
278            bind_group_layout,
279        })
280    }
281}
282
283fn binding_kind_to_wgpu(kind: BindingKind) -> wgpu::BindingType {
284    match kind {
285        BindingKind::Uniform => wgpu::BindingType::Buffer {
286            ty: wgpu::BufferBindingType::Uniform,
287            has_dynamic_offset: false,
288            min_binding_size: None,
289        },
290        BindingKind::StorageRead => wgpu::BindingType::Buffer {
291            ty: wgpu::BufferBindingType::Storage { read_only: true },
292            has_dynamic_offset: false,
293            min_binding_size: None,
294        },
295        BindingKind::StorageReadWrite => wgpu::BindingType::Buffer {
296            ty: wgpu::BufferBindingType::Storage { read_only: false },
297            has_dynamic_offset: false,
298            min_binding_size: None,
299        },
300        BindingKind::Texture => wgpu::BindingType::Texture {
301            sample_type: wgpu::TextureSampleType::Float { filterable: true },
302            view_dimension: wgpu::TextureViewDimension::D2,
303            multisampled: false,
304        },
305        BindingKind::Sampler => wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
306    }
307}
308
309impl<'a> Dispatcher for WgpuDispatcher<'a> {
310    fn dispatch_node(
311        &mut self,
312        node: &Node,
313        _bindings: &ResourceBindings,
314    ) -> Result<(), DispatchError> {
315        if node.pass != PassKind::Render {
316            // v0.1 scope. Compute / Blit land next iteration.
317            return Err(DispatchError::Backend(format!(
318                "engawa-wgpu v0.1 only supports Render; node {:?} requested {:?}",
319                node.id, node.pass
320            )));
321        }
322
323        // Clear-only nodes (no material): paint a black load+clear
324        // into the first output. Mado typically uses this as the
325        // first node in the graph.
326        let Some(material) = node.material.as_ref() else {
327            let output_id = node.outputs.first().ok_or_else(|| {
328                DispatchError::Backend(format!(
329                    "clear node {:?} has no outputs",
330                    node.id
331                ))
332            })?;
333            let bound = self.bound.as_ref().ok_or_else(|| {
334                DispatchError::Backend("dispatch called without bound resources".into())
335            })?;
336            let view = match bound.get(output_id) {
337                Some(BoundResource::Texture { view, .. }) => view,
338                _ => {
339                    return Err(DispatchError::Backend(format!(
340                        "clear node {:?} output {:?} is not a Texture binding",
341                        node.id, output_id
342                    )));
343                }
344            };
345            let encoder = self
346                .encoder
347                .as_mut()
348                .ok_or_else(|| DispatchError::Backend("no encoder live".into()))?;
349            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
350                label: Some(node.id.as_str()),
351                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
352                    view,
353                    resolve_target: None,
354                    ops: wgpu::Operations {
355                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
356                        store: wgpu::StoreOp::Store,
357                    },
358                })],
359                depth_stencil_attachment: None,
360                timestamp_writes: None,
361                occlusion_query_set: None,
362            });
363            return Ok(());
364        };
365
366        // Fullscreen-effect node: bind group + draw 3 vertices.
367        let cached = self.pipelines.get(&material.name).ok_or_else(|| {
368            DispatchError::Backend(format!(
369                "pipeline not built for material {} — call dispatch_with",
370                material.name
371            ))
372        })?;
373
374        let bound = self.bound.as_ref().ok_or_else(|| {
375            DispatchError::Backend("dispatch called without bound resources".into())
376        })?;
377
378        // Build bind group from declared bindings.
379        let entries: Vec<wgpu::BindGroupEntry> = material
380            .bindings
381            .iter()
382            .map(|b| {
383                let resource = bound.get(&b.resource).ok_or_else(|| {
384                    DispatchError::Backend(format!(
385                        "node {:?} binding {} references resource {:?} not in BoundResources",
386                        node.id, b.binding, b.resource
387                    ))
388                })?;
389                let binding_resource = match (b.kind, resource) {
390                    (BindingKind::Uniform, BoundResource::Uniform(buf))
391                    | (BindingKind::StorageRead, BoundResource::Storage(buf))
392                    | (BindingKind::StorageReadWrite, BoundResource::Storage(buf)) => {
393                        wgpu::BindingResource::Buffer(wgpu::BufferBinding {
394                            buffer: buf,
395                            offset: 0,
396                            size: None,
397                        })
398                    }
399                    (BindingKind::Texture, BoundResource::Texture { view, .. }) => {
400                        wgpu::BindingResource::TextureView(view)
401                    }
402                    (BindingKind::Sampler, BoundResource::Sampler(s)) => {
403                        wgpu::BindingResource::Sampler(s)
404                    }
405                    _ => {
406                        return Err(DispatchError::Backend(format!(
407                            "node {:?} binding {} kind mismatch (expected {:?})",
408                            node.id, b.binding, b.kind
409                        )));
410                    }
411                };
412                Ok(wgpu::BindGroupEntry {
413                    binding: b.binding,
414                    resource: binding_resource,
415                })
416            })
417            .collect::<Result<Vec<_>, DispatchError>>()?;
418
419        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
420            label: Some(node.id.as_str()),
421            layout: &cached.bind_group_layout,
422            entries: &entries,
423        });
424
425        // Target view = first output (we don't support MRT in v0.1).
426        let output_id = node.outputs.first().ok_or_else(|| {
427            DispatchError::Backend(format!(
428                "fullscreen-effect node {:?} has no outputs",
429                node.id
430            ))
431        })?;
432        let view = match bound.get(output_id) {
433            Some(BoundResource::Texture { view, .. }) => view,
434            _ => {
435                return Err(DispatchError::Backend(format!(
436                    "node {:?} output {:?} is not a Texture binding",
437                    node.id, output_id
438                )));
439            }
440        };
441
442        let encoder = self
443            .encoder
444            .as_mut()
445            .ok_or_else(|| DispatchError::Backend("no encoder live".into()))?;
446
447        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
448            label: Some(node.id.as_str()),
449            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
450                view,
451                resolve_target: None,
452                ops: wgpu::Operations {
453                    load: wgpu::LoadOp::Load,
454                    store: wgpu::StoreOp::Store,
455                },
456            })],
457            depth_stencil_attachment: None,
458            timestamp_writes: None,
459            occlusion_query_set: None,
460        });
461        pass.set_pipeline(&cached.pipeline);
462        pass.set_bind_group(0, &bind_group, &[]);
463        pass.draw(0..3, 0..1);
464
465        // queue is captured for future per-frame uniform writes;
466        // silence the unused-field lint for now.
467        let _ = self.queue;
468
469        Ok(())
470    }
471}