1use std::collections::BTreeMap;
34
35use engawa::{
36 BindingKind, CompiledGraph, DispatchError, Dispatcher, Material, Node, NodeId,
37 PassKind, ResourceBindings, ResourceId,
38};
39use thiserror::Error;
40
41use crate::pipeline::combined_shader_source;
42
43#[derive(Debug, Error)]
52pub enum WgpuDispatcherError {
53 #[error("engawa dispatch error: {0}")]
54 Dispatch(#[from] DispatchError),
55 #[error("unsupported pass kind for v0.1: {0:?}; only Render is implemented today")]
56 UnsupportedPass(PassKind),
57 #[error(
58 "node {node:?} binding {binding} expects {expected:?} but bound resource for {resource:?} is {actual:?}"
59 )]
60 BindingKindMismatch {
61 node: NodeId,
62 binding: u32,
63 resource: ResourceId,
64 expected: BindingKind,
65 actual: &'static str,
66 },
67 #[error(
68 "node {node:?} output {resource:?} has no bound wgpu::TextureView (output bindings must be textures)"
69 )]
70 OutputNotBound {
71 node: NodeId,
72 resource: ResourceId,
73 },
74 #[error("node {node:?} binding {binding} resource {resource:?} not present in BoundResources")]
75 BoundResourceMissing {
76 node: NodeId,
77 binding: u32,
78 resource: ResourceId,
79 },
80 #[error(
81 "material {material} shader at {path} is unreadable: {source} — refusing to dispatch a placeholder"
82 )]
83 ShaderUnreadable {
84 material: String,
85 path: String,
86 source: std::io::Error,
87 },
88 #[error(
89 "frame uniform for {resource:?} has no BoundResources entry — bind the uniform buffer before dispatch"
90 )]
91 FrameUniformUnbound { resource: ResourceId },
92 #[error(
93 "frame uniform for {resource:?} expects a Uniform buffer but the bound resource is {actual}"
94 )]
95 FrameUniformKindMismatch {
96 resource: ResourceId,
97 actual: &'static str,
98 },
99 #[error(
100 "frame uniform for {resource:?} is {actual} bytes but wgpu writes must be multiples of {} bytes — pad the Pod struct to the next 4-byte boundary",
101 wgpu::COPY_BUFFER_ALIGNMENT
102 )]
103 FrameUniformMisaligned { resource: ResourceId, actual: usize },
104 #[error(
105 "frame uniform for {resource:?} is {actual} bytes but the bound buffer holds exactly {capacity} — partial writes leave stale tail bytes and are never intended"
106 )]
107 FrameUniformSizeMismatch {
108 resource: ResourceId,
109 actual: usize,
110 capacity: u64,
111 },
112}
113
114fn backend(err: &WgpuDispatcherError) -> DispatchError {
119 DispatchError::Backend(err.to_string())
120}
121
122#[derive(Clone)]
126pub enum BoundResource {
127 Texture {
128 view: wgpu::TextureView,
129 format: wgpu::TextureFormat,
130 },
131 Uniform(wgpu::Buffer),
132 Storage(wgpu::Buffer),
133 Sampler(wgpu::Sampler),
134}
135
136impl BoundResource {
137 #[must_use]
139 pub fn kind_name(&self) -> &'static str {
140 match self {
141 BoundResource::Texture { .. } => "Texture",
142 BoundResource::Uniform(_) => "Uniform",
143 BoundResource::Storage(_) => "Storage",
144 BoundResource::Sampler(_) => "Sampler",
145 }
146 }
147}
148
149#[derive(Default, Clone)]
157pub struct BoundResources {
158 inner: BTreeMap<ResourceId, BoundResource>,
159}
160
161impl BoundResources {
162 #[must_use]
163 pub fn new() -> Self {
164 Self::default()
165 }
166
167 #[must_use]
168 pub fn with(
169 mut self,
170 id: impl Into<ResourceId>,
171 resource: BoundResource,
172 ) -> Self {
173 self.inner.insert(id.into(), resource);
174 self
175 }
176
177 pub fn insert(&mut self, id: impl Into<ResourceId>, resource: BoundResource) {
178 self.inner.insert(id.into(), resource);
179 }
180
181 #[must_use]
182 pub fn get(&self, id: &ResourceId) -> Option<&BoundResource> {
183 self.inner.get(id)
184 }
185
186 #[must_use]
187 pub fn len(&self) -> usize {
188 self.inner.len()
189 }
190
191 #[must_use]
192 pub fn is_empty(&self) -> bool {
193 self.inner.is_empty()
194 }
195}
196
197#[derive(Default, Clone)]
210pub struct FrameUniforms {
211 inner: BTreeMap<ResourceId, Vec<u8>>,
212}
213
214impl FrameUniforms {
215 #[must_use]
216 pub fn new() -> Self {
217 Self::default()
218 }
219
220 #[must_use]
222 pub fn with<P: bytemuck::Pod>(
223 mut self,
224 id: impl Into<ResourceId>,
225 params: &P,
226 ) -> Self {
227 self.set(id, params);
228 self
229 }
230
231 pub fn set<P: bytemuck::Pod>(&mut self, id: impl Into<ResourceId>, params: &P) {
233 self.inner
234 .insert(id.into(), bytemuck::bytes_of(params).to_vec());
235 }
236
237 #[must_use]
238 pub fn len(&self) -> usize {
239 self.inner.len()
240 }
241
242 #[must_use]
243 pub fn is_empty(&self) -> bool {
244 self.inner.is_empty()
245 }
246
247 pub fn iter(&self) -> impl Iterator<Item = (&ResourceId, &[u8])> {
249 self.inner.iter().map(|(id, bytes)| (id, bytes.as_slice()))
250 }
251}
252
253struct CachedPipeline {
255 pipeline: wgpu::RenderPipeline,
256 bind_group_layout: wgpu::BindGroupLayout,
257}
258
259pub struct WgpuDispatcher {
262 device: wgpu::Device,
263 queue: wgpu::Queue,
264 target_format: wgpu::TextureFormat,
265 pipelines: BTreeMap<String, CachedPipeline>,
266 encoder: Option<wgpu::CommandEncoder>,
270 bound: Option<BoundResources>,
273}
274
275impl WgpuDispatcher {
276 #[must_use]
282 pub fn new(
283 device: &wgpu::Device,
284 queue: &wgpu::Queue,
285 target_format: wgpu::TextureFormat,
286 ) -> Self {
287 Self {
288 device: device.clone(),
289 queue: queue.clone(),
290 target_format,
291 pipelines: BTreeMap::new(),
292 encoder: None,
293 bound: None,
294 }
295 }
296
297 #[must_use]
302 pub fn cached_pipeline_count(&self) -> usize {
303 self.pipelines.len()
304 }
305
306 pub fn invalidate_material(&mut self, name: &str) {
311 self.pipelines.remove(name);
312 }
313
314 pub fn dispatch_with(
319 &mut self,
320 graph: &CompiledGraph,
321 bindings: &ResourceBindings,
322 bound: BoundResources,
323 frame: &FrameUniforms,
324 ) -> Result<wgpu::CommandBuffer, WgpuDispatcherError> {
325 for node in graph.iter_nodes() {
329 if let Some(material) = &node.material
330 && !self.pipelines.contains_key(&material.name)
331 {
332 let cached = self.build_pipeline(material)?;
333 self.pipelines.insert(material.name.clone(), cached);
334 }
335 }
336
337 for (id, bytes) in frame.iter() {
341 let Some(resource) = bound.get(id) else {
342 return Err(WgpuDispatcherError::FrameUniformUnbound {
343 resource: id.clone(),
344 });
345 };
346 let BoundResource::Uniform(buf) = resource else {
347 return Err(WgpuDispatcherError::FrameUniformKindMismatch {
348 resource: id.clone(),
349 actual: resource.kind_name(),
350 });
351 };
352 if !(bytes.len() as u64).is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT) {
360 return Err(WgpuDispatcherError::FrameUniformMisaligned {
361 resource: id.clone(),
362 actual: bytes.len(),
363 });
364 }
365 if bytes.len() as u64 != buf.size() {
366 return Err(WgpuDispatcherError::FrameUniformSizeMismatch {
367 resource: id.clone(),
368 actual: bytes.len(),
369 capacity: buf.size(),
370 });
371 }
372 self.queue.write_buffer(buf, 0, bytes);
373 }
374
375 self.encoder = Some(
378 self.device
379 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
380 label: Some("engawa-wgpu graph"),
381 }),
382 );
383 self.bound = Some(bound);
384
385 let walked = self.dispatch_graph(graph, bindings);
388 self.bound = None;
389 let encoder = self.encoder.take().expect("encoder set");
390 walked?;
391 Ok(encoder.finish())
392 }
393
394 fn build_pipeline(
395 &self,
396 material: &Material,
397 ) -> Result<CachedPipeline, WgpuDispatcherError> {
398 let fragment_wgsl = match &material.shader {
404 engawa::ShaderSource::Inline { wgsl } => wgsl.clone(),
405 engawa::ShaderSource::Path { path } => std::fs::read_to_string(path)
406 .map_err(|source| WgpuDispatcherError::ShaderUnreadable {
407 material: material.name.clone(),
408 path: path.clone(),
409 source,
410 })?,
411 };
412 let combined = combined_shader_source(&fragment_wgsl);
413 let shader = self.device.create_shader_module(wgpu::ShaderModuleDescriptor {
414 label: Some(&material.name),
415 source: wgpu::ShaderSource::Wgsl(combined.into()),
416 });
417
418 let entries: Vec<wgpu::BindGroupLayoutEntry> = material
420 .bindings
421 .iter()
422 .map(|b| wgpu::BindGroupLayoutEntry {
423 binding: b.binding,
424 visibility: wgpu::ShaderStages::FRAGMENT,
425 ty: binding_kind_to_wgpu(b.kind),
426 count: None,
427 })
428 .collect();
429 let bind_group_layout =
430 self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
431 label: Some(&material.name),
432 entries: &entries,
433 });
434 let pipeline_layout =
435 self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
436 label: Some(&material.name),
437 bind_group_layouts: &[&bind_group_layout],
438 push_constant_ranges: &[],
439 });
440
441 let pipeline = self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
442 label: Some(&material.name),
443 layout: Some(&pipeline_layout),
444 vertex: wgpu::VertexState {
445 module: &shader,
446 entry_point: Some("vs_main"),
447 buffers: &[],
448 compilation_options: wgpu::PipelineCompilationOptions::default(),
449 },
450 fragment: Some(wgpu::FragmentState {
451 module: &shader,
452 entry_point: Some("fs_main"),
453 targets: &[Some(wgpu::ColorTargetState {
454 format: self.target_format,
455 blend: None,
456 write_mask: wgpu::ColorWrites::ALL,
457 })],
458 compilation_options: wgpu::PipelineCompilationOptions::default(),
459 }),
460 primitive: wgpu::PrimitiveState::default(),
461 depth_stencil: None,
462 multisample: wgpu::MultisampleState::default(),
463 multiview: None,
464 cache: None,
465 });
466
467 Ok(CachedPipeline {
468 pipeline,
469 bind_group_layout,
470 })
471 }
472}
473
474fn first_output_view<'b>(
478 node: &Node,
479 bound: &'b BoundResources,
480) -> Result<&'b wgpu::TextureView, DispatchError> {
481 let output_id = node.outputs.first().ok_or_else(|| {
482 DispatchError::Backend(format!("node {:?} has no outputs", node.id))
483 })?;
484 let Some(BoundResource::Texture { view, .. }) = bound.get(output_id) else {
485 return Err(backend(&WgpuDispatcherError::OutputNotBound {
486 node: node.id.clone(),
487 resource: output_id.clone(),
488 }));
489 };
490 Ok(view)
491}
492
493fn bind_group_entries<'b>(
496 node: &Node,
497 material: &Material,
498 bound: &'b BoundResources,
499) -> Result<Vec<wgpu::BindGroupEntry<'b>>, DispatchError> {
500 material
501 .bindings
502 .iter()
503 .map(|b| {
504 let resource = bound.get(&b.resource).ok_or_else(|| {
505 backend(&WgpuDispatcherError::BoundResourceMissing {
506 node: node.id.clone(),
507 binding: b.binding,
508 resource: b.resource.clone(),
509 })
510 })?;
511 let binding_resource = match (b.kind, resource) {
512 (BindingKind::Uniform, BoundResource::Uniform(buf))
513 | (
514 BindingKind::StorageRead | BindingKind::StorageReadWrite,
515 BoundResource::Storage(buf),
516 ) => wgpu::BindingResource::Buffer(wgpu::BufferBinding {
517 buffer: buf,
518 offset: 0,
519 size: None,
520 }),
521 (BindingKind::Texture, BoundResource::Texture { view, .. }) => {
522 wgpu::BindingResource::TextureView(view)
523 }
524 (BindingKind::Sampler, BoundResource::Sampler(s)) => {
525 wgpu::BindingResource::Sampler(s)
526 }
527 _ => {
528 return Err(backend(&WgpuDispatcherError::BindingKindMismatch {
529 node: node.id.clone(),
530 binding: b.binding,
531 resource: b.resource.clone(),
532 expected: b.kind,
533 actual: resource.kind_name(),
534 }));
535 }
536 };
537 Ok(wgpu::BindGroupEntry {
538 binding: b.binding,
539 resource: binding_resource,
540 })
541 })
542 .collect::<Result<Vec<_>, DispatchError>>()
543}
544
545fn binding_kind_to_wgpu(kind: BindingKind) -> wgpu::BindingType {
546 match kind {
547 BindingKind::Uniform => wgpu::BindingType::Buffer {
548 ty: wgpu::BufferBindingType::Uniform,
549 has_dynamic_offset: false,
550 min_binding_size: None,
551 },
552 BindingKind::StorageRead => wgpu::BindingType::Buffer {
553 ty: wgpu::BufferBindingType::Storage { read_only: true },
554 has_dynamic_offset: false,
555 min_binding_size: None,
556 },
557 BindingKind::StorageReadWrite => wgpu::BindingType::Buffer {
558 ty: wgpu::BufferBindingType::Storage { read_only: false },
559 has_dynamic_offset: false,
560 min_binding_size: None,
561 },
562 BindingKind::Texture => wgpu::BindingType::Texture {
563 sample_type: wgpu::TextureSampleType::Float { filterable: true },
564 view_dimension: wgpu::TextureViewDimension::D2,
565 multisampled: false,
566 },
567 BindingKind::Sampler => wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
568 }
569}
570
571impl Dispatcher for WgpuDispatcher {
572 fn dispatch_node(
573 &mut self,
574 node: &Node,
575 _bindings: &ResourceBindings,
576 ) -> Result<(), DispatchError> {
577 if node.pass != PassKind::Render {
578 return Err(backend(&WgpuDispatcherError::UnsupportedPass(node.pass)));
580 }
581
582 let bound = self.bound.as_ref().ok_or_else(|| {
583 DispatchError::Backend("dispatch called without bound resources".into())
584 })?;
585 let view = first_output_view(node, bound)?;
586
587 let Some(material) = node.material.as_ref() else {
591 let encoder = self
592 .encoder
593 .as_mut()
594 .ok_or_else(|| DispatchError::Backend("no encoder live".into()))?;
595 let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
596 label: Some(node.id.as_str()),
597 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
598 view,
599 resolve_target: None,
600 ops: wgpu::Operations {
601 load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
602 store: wgpu::StoreOp::Store,
603 },
604 })],
605 depth_stencil_attachment: None,
606 timestamp_writes: None,
607 occlusion_query_set: None,
608 });
609 return Ok(());
610 };
611
612 let cached = self.pipelines.get(&material.name).ok_or_else(|| {
614 DispatchError::Backend(format!(
615 "pipeline not built for material {} — call dispatch_with",
616 material.name
617 ))
618 })?;
619
620 let entries = bind_group_entries(node, material, bound)?;
621 let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
622 label: Some(node.id.as_str()),
623 layout: &cached.bind_group_layout,
624 entries: &entries,
625 });
626
627 let encoder = self
628 .encoder
629 .as_mut()
630 .ok_or_else(|| DispatchError::Backend("no encoder live".into()))?;
631
632 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
633 label: Some(node.id.as_str()),
634 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
635 view,
636 resolve_target: None,
637 ops: wgpu::Operations {
638 load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
647 store: wgpu::StoreOp::Store,
648 },
649 })],
650 depth_stencil_attachment: None,
651 timestamp_writes: None,
652 occlusion_query_set: None,
653 });
654 pass.set_pipeline(&cached.pipeline);
655 pass.set_bind_group(0, &bind_group, &[]);
656 pass.draw(0..3, 0..1);
657
658 Ok(())
659 }
660}