Skip to main content

engawa_wgpu/
dispatcher.rs

1//! `WgpuDispatcher` — engawa's `Dispatcher` trait realised
2//! against wgpu.
3//!
4//! **Per-call dispatch is the canonical path.** Construct once
5//! with `new` (the device/queue handles are cloned in — wgpu
6//! handles are internally reference-counted, so this shares the
7//! underlying device, it does not duplicate it), then call
8//! [`WgpuDispatcher::dispatch_with`] per frame with the graph,
9//! the bindings, the live wgpu handles, and the per-frame
10//! [`FrameUniforms`].
11//!
12//! LAW (2026-06-12): the former `WgpuDispatcher<'a>` struct-level
13//! `&'a Device + &'a Queue` borrow is deleted. It forced mado to
14//! bypass the dispatcher entirely for post-process + snow
15//! rendering (mado's `TerminalRenderer` does not own the device,
16//! so it could not hold a dispatcher that borrowed one). Owned
17//! Arc-backed handles + per-call `dispatch_with` is the
18//! successor; do not reintroduce a lifetime here.
19//!
20//! Pipeline cache keyed by Material name; pipelines compile
21//! once per Material per `WgpuDispatcher` lifetime (see
22//! [`WgpuDispatcher::invalidate_material`] for the hot-reload
23//! seam). Each `dispatch_node` call begins one render pass +
24//! draws one fullscreen triangle.
25//!
26//! Bind-group construction lives at the boundary: callers
27//! pass a `BoundResources` map (engawa `ResourceId` →
28//! `BoundResource` containing the live wgpu handle), and this
29//! crate constructs the bind group on demand from the Material's
30//! declared bindings. The consumer owns the wgpu textures /
31//! buffers / samplers; this crate orchestrates the dispatch.
32
33use 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/// Typed dispatch-failure surface. Every variant here is
44/// CONSTRUCTED by the dispatch path — the M3 review (2026-06-12)
45/// found five declared-but-never-built variants advertising a typed
46/// API the error paths didn't deliver; `MissingMaterial` (materialless
47/// nodes are clear nodes by design, so the state was unreachable) is
48/// deleted and the rest now flow through [`Dispatcher::dispatch_node`]
49/// via Display at the trait seam (the engawa trait returns
50/// `DispatchError`, so typed variants stringify exactly once there).
51#[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
114/// The one stringify seam: the engawa `Dispatcher` trait returns
115/// `DispatchError`, so typed `WgpuDispatcherError` values constructed
116/// inside the walk convert here — typed at the source, Display'd once
117/// at the boundary, never `format!`-composed inline.
118fn backend(err: &WgpuDispatcherError) -> DispatchError {
119    DispatchError::Backend(err.to_string())
120}
121
122/// Live wgpu handle wrapped in a tagged enum so the dispatcher
123/// can match the bind type the Material declared. Operators
124/// build this from their own wgpu resources at dispatch time.
125#[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    /// Variant name for typed error reporting.
138    #[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/// Per-frame map of engawa `ResourceId` → live wgpu handle.
150/// The consumer (mado, future ayatsuri) populates this before
151/// calling `dispatch_with`. Engawa already validated at
152/// compile time that every node references a resource that's
153/// either an input or another node's output; the dispatcher
154/// validates that every referenced resource has a `BoundResource`
155/// entry at dispatch time.
156#[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/// Per-frame uniform payloads — a typed map of engawa
198/// `ResourceId` → `bytemuck`-encoded bytes that
199/// [`WgpuDispatcher::dispatch_with`] writes into the
200/// corresponding [`BoundResource::Uniform`] buffers *before*
201/// any pass of that dispatch is encoded, so every node in the
202/// graph walk sees the same frame data.
203///
204/// Entries are inserted through the typed [`FrameUniforms::set`]
205/// / [`FrameUniforms::with`] surface (`bytemuck::Pod` values
206/// only) — there is no raw-bytes ingress, so a non-Pod or
207/// padding-carrying struct cannot enter the map (compile error
208/// at the bound, not a runtime check).
209#[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    /// Builder-style insert of one Pod params value.
221    #[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    /// Insert (or replace) one Pod params value.
232    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    /// Iterate entries in deterministic (`BTreeMap`) order.
248    pub fn iter(&self) -> impl Iterator<Item = (&ResourceId, &[u8])> {
249        self.inner.iter().map(|(id, bytes)| (id, bytes.as_slice()))
250    }
251}
252
253/// Per-Material wgpu pipeline cache entry.
254struct CachedPipeline {
255    pipeline: wgpu::RenderPipeline,
256    bind_group_layout: wgpu::BindGroupLayout,
257}
258
259/// Dispatcher that compiles engawa render graphs to wgpu
260/// commands. Construct once; call `dispatch_with` per frame.
261pub struct WgpuDispatcher {
262    device: wgpu::Device,
263    queue: wgpu::Queue,
264    target_format: wgpu::TextureFormat,
265    pipelines: BTreeMap<String, CachedPipeline>,
266    /// Encoder used for the current `dispatch_with` call; the
267    /// dispatcher uses it for every per-node render pass, then
268    /// finishes it into the returned `CommandBuffer`.
269    encoder: Option<wgpu::CommandEncoder>,
270    /// Per-frame bound resources. Set by `dispatch_with` before
271    /// the graph walk.
272    bound: Option<BoundResources>,
273}
274
275impl WgpuDispatcher {
276    /// Construct a dispatcher. The device/queue handles are
277    /// cloned (wgpu handles are internally reference-counted) —
278    /// the dispatcher holds no lifetime borrow, so a consumer
279    /// that does not own its device (mado's `TerminalRenderer`)
280    /// can still own a dispatcher.
281    #[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    /// Number of Materials with a compiled pipeline in the
298    /// cache. Pipelines compile once per Material name; a
299    /// second `dispatch_with` over the same graph must not grow
300    /// this count.
301    #[must_use]
302    pub fn cached_pipeline_count(&self) -> usize {
303        self.pipelines.len()
304    }
305
306    /// Drop the cached pipeline for one Material name. The
307    /// cache is keyed by name only — a hot-reload that swaps a
308    /// Material's shader under the same name MUST call this or
309    /// the stale pipeline keeps dispatching.
310    pub fn invalidate_material(&mut self, name: &str) {
311        self.pipelines.remove(name);
312    }
313
314    /// Canonical per-call dispatch: write the per-frame
315    /// uniforms, compile any uncached Materials, walk the
316    /// graph, return the recorded `CommandBuffer` ready to
317    /// submit.
318    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        // Pre-compile every Material referenced in the graph. A
326        // Path-sourced shader that cannot be read is a typed error
327        // HERE — never a silently-compiling placeholder pipeline.
328        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        // Per-frame uniform writes happen before any pass is
338        // encoded so every node in this dispatch sees the same
339        // frame data.
340        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            // wgpu's write_buffer PANICS on a data size that is not
353            // a COPY_BUFFER_ALIGNMENT multiple — reject it as a typed
354            // error before the panic path is reachable (M3 review
355            // 2026-06-12). Size must match EXACTLY: an under-sized
356            // write passes wgpu validation but leaves the buffer tail
357            // holding the previous frame's bytes — a silent wrong
358            // answer, not an error anyone sees.
359            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        // Encoder live for the entire graph walk; one submit at
376        // the end.
377        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        // Walk via the engawa trait's default impl — it validates
386        // ResourceBindings + delegates each node to dispatch_node.
387        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        // LAW (2026-06-12): an unreadable Path shader is a typed
399        // error, never a fallback. The deleted red-tint placeholder
400        // was valid WGSL — the pipeline compiled and dispatched wrong
401        // pixels with only a stderr line as signal (the silent-wrong-
402        // answer anti-pattern the typed-spec rules forbid).
403        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        // Bind-group layout from the Material's declared bindings.
419        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
474/// First-output texture view lookup shared by the clear + effect
475/// paths. Associated fn (not a method) so callers can keep a
476/// disjoint `&mut self.encoder` borrow alive alongside it.
477fn 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
493/// Build the wgpu bind-group entries a Material's declared
494/// bindings resolve to against the per-frame `BoundResources`.
495fn 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            // v0.1 scope. Compute / Blit land next iteration.
579            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        // Clear-only nodes (no material): paint a black load+clear
588        // into the first output. Mado typically uses this as the
589        // first node in the graph.
590        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        // Fullscreen-effect node: bind group + draw 3 vertices.
613        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                    // Clear, not Load: every effect node is a
639                    // fullscreen triangle with blending disabled, so
640                    // the previous attachment contents are always
641                    // fully overwritten. On tile-based GPUs (Apple
642                    // Silicon) Load forces a full attachment restore
643                    // into tile memory per pass — pure bandwidth
644                    // waste at scene-sized textures (M3 review
645                    // 2026-06-12). Clear resolves in tile memory.
646                    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}