1use 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#[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#[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
121struct CachedPipeline {
123 pipeline: wgpu::RenderPipeline,
124 bind_group_layout: wgpu::BindGroupLayout,
125}
126
127pub 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: Option<wgpu::CommandEncoder>,
139 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 pub fn dispatch_with(
166 &mut self,
167 graph: &CompiledGraph,
168 bindings: ResourceBindings,
169 bound: BoundResources,
170 ) -> Result<wgpu::CommandBuffer, WgpuDispatcherError> {
171 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 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 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 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 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 return Err(DispatchError::Backend(format!(
318 "engawa-wgpu v0.1 only supports Render; node {:?} requested {:?}",
319 node.id, node.pass
320 )));
321 }
322
323 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 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 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 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 let _ = self.queue;
468
469 Ok(())
470 }
471}