Skip to main content

gasm_host/
gfx.rs

1//! gasm:gfx on wgpu, plus the 2D `video_present` blit.
2//!
3//! Handles index a single object table. Creation descriptors are JSON that
4//! mirrors WebGPU (see spec/ABI.md#gasmgfx). Validation errors from wgpu are
5//! caught with error scopes and returned as `Err`, which traps the guest.
6//!
7//! Without a GPU (`Gfx::null`) every call still validates handles and
8//! allocates ids, so guests behave identically in headless runs.
9
10use std::sync::Arc;
11
12use serde_json::Value;
13use wgpu::util::DeviceExt;
14
15pub const SAMPLE_COUNT: u32 = 4;
16const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24Plus;
17
18enum Obj {
19    Shader(wgpu::ShaderModule),
20    Buffer(wgpu::Buffer),
21    Pipeline(wgpu::RenderPipeline),
22    BindGroup(wgpu::BindGroup),
23    Null,
24}
25
26pub struct Gpu {
27    pub device: wgpu::Device,
28    pub queue: wgpu::Queue,
29    pub format: wgpu::TextureFormat,
30}
31
32pub enum Target {
33    Window { surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration },
34    Offscreen { texture: wgpu::Texture },
35}
36
37struct Frame {
38    pass: wgpu::RenderPass<'static>,
39    encoder: wgpu::CommandEncoder,
40    surface_texture: Option<wgpu::SurfaceTexture>,
41}
42
43struct Blit {
44    pipeline: wgpu::RenderPipeline,
45    sampler: wgpu::Sampler,
46    texture: Option<(wgpu::Texture, wgpu::BindGroup, u32, u32)>,
47}
48
49pub struct Gfx {
50    gpu: Option<Gpu>,
51    target: Option<Target>,
52    width: u32,
53    height: u32,
54    objects: Vec<Obj>,
55    msaa: Option<wgpu::TextureView>,
56    depth: Option<wgpu::TextureView>,
57    frame: Option<Frame>,
58    blit: Option<Blit>,
59    /// set by the guest calling any gfx draw API this frame (suppresses the 2D blit)
60    pub used: bool,
61}
62
63type R<T> = Result<T, String>;
64
65impl Gfx {
66    /// No GPU: ids only (headless runs without screenshots, CI).
67    pub fn null() -> Gfx {
68        Gfx { gpu: None, target: None, width: 1280, height: 720, objects: Vec::new(), msaa: None, depth: None, frame: None, blit: None, used: false }
69    }
70
71    /// Render into a window.
72    pub fn for_window(window: Arc<winit::window::Window>) -> R<Gfx> {
73        let size = window.inner_size();
74        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
75        let surface = instance.create_surface(window).map_err(|e| e.to_string())?;
76        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
77            power_preference: wgpu::PowerPreference::HighPerformance,
78            compatible_surface: Some(&surface),
79            ..Default::default()
80        }))
81        .map_err(|e| format!("no GPU adapter: {e}"))?;
82        let gpu = open_device(&adapter, |caps_formats| pick_format(caps_formats), &surface)?;
83        let mut config = surface
84            .get_default_config(&adapter, size.width.max(1), size.height.max(1))
85            .ok_or("surface not supported by adapter")?;
86        config.format = gpu.format;
87        config.present_mode = wgpu::PresentMode::AutoVsync;
88        surface.configure(&gpu.device, &config);
89        eprintln!("[gasm] gpu: {} ({:?}), surface {:?}", adapter.get_info().name, adapter.get_info().backend, gpu.format);
90        let mut g = Gfx::with_gpu(gpu, Target::Window { surface, config }, size.width.max(1), size.height.max(1));
91        g.recreate_attachments();
92        Ok(g)
93    }
94
95    /// Render into an RGBA texture that can be read back (headless screenshots).
96    pub fn offscreen(width: u32, height: u32) -> R<Gfx> {
97        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
98        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
99            .map_err(|e| format!("no GPU adapter: {e}"))?;
100        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))
101            .map_err(|e| e.to_string())?;
102        device.on_uncaptured_error(Arc::new(|e| eprintln!("[gasm] gpu error: {e}")));
103        let format = wgpu::TextureFormat::Rgba8Unorm;
104        let texture = device.create_texture(&wgpu::TextureDescriptor {
105            label: Some("offscreen"),
106            size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
107            mip_level_count: 1,
108            sample_count: 1,
109            dimension: wgpu::TextureDimension::D2,
110            format,
111            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
112            view_formats: &[],
113        });
114        let mut g = Gfx::with_gpu(Gpu { device, queue, format }, Target::Offscreen { texture }, width, height);
115        g.recreate_attachments();
116        Ok(g)
117    }
118
119    fn with_gpu(gpu: Gpu, target: Target, width: u32, height: u32) -> Gfx {
120        let blit = Some(Blit::new(&gpu));
121        Gfx { gpu: Some(gpu), target: Some(target), width, height, objects: Vec::new(), msaa: None, depth: None, frame: None, blit, used: false }
122    }
123
124    pub fn size(&self) -> (u32, u32) {
125        (self.width, self.height)
126    }
127
128    pub fn resize(&mut self, width: u32, height: u32) {
129        if width == 0 || height == 0 || (width, height) == (self.width, self.height) {
130            return;
131        }
132        self.width = width;
133        self.height = height;
134        if let (Some(gpu), Some(Target::Window { surface, config })) = (&self.gpu, &mut self.target) {
135            config.width = width;
136            config.height = height;
137            surface.configure(&gpu.device, config);
138        }
139        self.recreate_attachments();
140    }
141
142    fn recreate_attachments(&mut self) {
143        let Some(gpu) = &self.gpu else { return };
144        let make = |format, label| {
145            gpu.device
146                .create_texture(&wgpu::TextureDescriptor {
147                    label: Some(label),
148                    size: wgpu::Extent3d { width: self.width, height: self.height, depth_or_array_layers: 1 },
149                    mip_level_count: 1,
150                    sample_count: SAMPLE_COUNT,
151                    dimension: wgpu::TextureDimension::D2,
152                    format,
153                    usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
154                    view_formats: &[],
155                })
156                .create_view(&Default::default())
157        };
158        self.msaa = Some(make(gpu.format, "msaa"));
159        self.depth = Some(make(DEPTH_FORMAT, "depth"));
160    }
161
162    // ---- object table ------------------------------------------------------------
163
164    fn add(&mut self, obj: Obj) -> u32 {
165        self.objects.push(obj);
166        self.objects.len() as u32
167    }
168
169    fn get(&self, h: u32) -> R<&Obj> {
170        self.objects.get((h as usize).wrapping_sub(1)).ok_or_else(|| format!("gfx: invalid handle {h}"))
171    }
172
173    fn shader(&self, h: u32) -> R<&wgpu::ShaderModule> {
174        match self.get(h)? { Obj::Shader(s) => Ok(s), _ => Err(format!("gfx: handle {h} is not a shader")) }
175    }
176    fn buffer(&self, h: u32) -> R<&wgpu::Buffer> {
177        match self.get(h)? { Obj::Buffer(b) => Ok(b), _ => Err(format!("gfx: handle {h} is not a buffer")) }
178    }
179    fn pipeline(&self, h: u32) -> R<&wgpu::RenderPipeline> {
180        match self.get(h)? { Obj::Pipeline(p) => Ok(p), _ => Err(format!("gfx: handle {h} is not a pipeline")) }
181    }
182    fn bind_group(&self, h: u32) -> R<&wgpu::BindGroup> {
183        match self.get(h)? { Obj::BindGroup(b) => Ok(b), _ => Err(format!("gfx: handle {h} is not a bind group")) }
184    }
185
186    /// Run `f` inside a validation error scope; wgpu errors become `Err`.
187    fn scoped<T>(&self, what: &str, f: impl FnOnce(&Gpu) -> R<T>) -> R<T> {
188        let gpu = self.gpu.as_ref().unwrap();
189        let scope = gpu.device.push_error_scope(wgpu::ErrorFilter::Validation);
190        let out = f(gpu);
191        if let Some(e) = pollster::block_on(scope.pop()) {
192            return Err(format!("gfx: {what}: {e}"));
193        }
194        out
195    }
196
197    // ---- creation ------------------------------------------------------------------------
198
199    pub fn create_shader(&mut self, wgsl: &str) -> R<u32> {
200        if self.gpu.is_none() {
201            return Ok(self.add(Obj::Null));
202        }
203        let m = self.scoped("create_shader", |gpu| {
204            Ok(gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
205                label: None,
206                source: wgpu::ShaderSource::Wgsl(wgsl.into()),
207            }))
208        })?;
209        Ok(self.add(Obj::Shader(m)))
210    }
211
212    pub fn create_buffer(&mut self, size: u32, usage: u32) -> R<u32> {
213        if size == 0 || size % 4 != 0 {
214            return Err(format!("gfx.create_buffer: size {size} must be a non-zero multiple of 4"));
215        }
216        let Some(gpu) = &self.gpu else { return Ok(self.add(Obj::Null)) };
217        // WebGPU usage bits == wgpu BufferUsages bits; COPY_DST is always added for write_buffer.
218        let usage = wgpu::BufferUsages::from_bits_truncate(usage) | wgpu::BufferUsages::COPY_DST;
219        let b = gpu.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: size as u64, usage, mapped_at_creation: false });
220        Ok(self.add(Obj::Buffer(b)))
221    }
222
223    pub fn write_buffer(&mut self, h: u32, offset: u32, data: &[u8]) -> R<()> {
224        if offset % 4 != 0 || data.len() % 4 != 0 {
225            return Err(format!("gfx.write_buffer: offset {offset} and length {} must be multiples of 4", data.len()));
226        }
227        if self.gpu.is_none() {
228            return self.get(h).map(|_| ());
229        }
230        let buf = self.buffer(h)?;
231        if offset as u64 + data.len() as u64 > buf.size() {
232            return Err(format!("gfx.write_buffer: {}+{} exceeds buffer size {}", offset, data.len(), buf.size()));
233        }
234        self.gpu.as_ref().unwrap().queue.write_buffer(buf, offset as u64, data);
235        Ok(())
236    }
237
238    pub fn create_pipeline(&mut self, json: &str) -> R<u32> {
239        let d: Value = serde_json::from_str(json).map_err(|e| format!("gfx.create_pipeline: invalid JSON: {e}"))?;
240        if self.gpu.is_none() {
241            return Ok(self.add(Obj::Null));
242        }
243        let p = self.build_pipeline(&d)?;
244        Ok(self.add(Obj::Pipeline(p)))
245    }
246
247    pub fn create_bind_group(&mut self, json: &str) -> R<u32> {
248        let d: Value = serde_json::from_str(json).map_err(|e| format!("gfx.create_bind_group: invalid JSON: {e}"))?;
249        if self.gpu.is_none() {
250            return Ok(self.add(Obj::Null));
251        }
252        let pipeline = self.pipeline(uint(&d, "pipeline")?)?;
253        let layout = pipeline.get_bind_group_layout(d.get("group").and_then(Value::as_u64).unwrap_or(0) as u32);
254        let entries_json = d.get("entries").and_then(Value::as_array).ok_or("bind group: missing entries")?;
255        let mut entries = Vec::new();
256        for e in entries_json {
257            let buffer = self.buffer(uint(e, "buffer")?)?;
258            let size = e.get("size").and_then(Value::as_u64).and_then(wgpu::BufferSize::new);
259            entries.push(wgpu::BindGroupEntry {
260                binding: uint(e, "binding")?,
261                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
262                    buffer,
263                    offset: e.get("offset").and_then(Value::as_u64).unwrap_or(0),
264                    size,
265                }),
266            });
267        }
268        let bg = self.scoped("create_bind_group", |gpu| {
269            Ok(gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { label: None, layout: &layout, entries: &entries }))
270        })?;
271        Ok(self.add(Obj::BindGroup(bg)))
272    }
273
274    fn build_pipeline(&self, d: &Value) -> R<wgpu::RenderPipeline> {
275        let gpu = self.gpu.as_ref().unwrap();
276        let v = d.get("vertex").ok_or("pipeline: missing vertex")?;
277        let vmod = self.shader(uint(v, "module")?)?;
278        let buffers_json = v.get("buffers").and_then(Value::as_array).cloned().unwrap_or_default();
279        let mut attrs: Vec<Vec<wgpu::VertexAttribute>> = Vec::new();
280        for b in &buffers_json {
281            let mut list = Vec::new();
282            for a in b.get("attributes").and_then(Value::as_array).cloned().unwrap_or_default() {
283                list.push(wgpu::VertexAttribute {
284                    format: vertex_format(string(&a, "format")?)?,
285                    offset: a.get("offset").and_then(Value::as_u64).unwrap_or(0),
286                    shader_location: uint(&a, "shaderLocation")?,
287                });
288            }
289            attrs.push(list);
290        }
291        let layouts: Vec<Option<wgpu::VertexBufferLayout>> = buffers_json
292            .iter()
293            .zip(&attrs)
294            .map(|(b, a)| {
295                Some(wgpu::VertexBufferLayout {
296                    array_stride: b.get("arrayStride").and_then(Value::as_u64).unwrap_or(0),
297                    step_mode: match b.get("stepMode").and_then(Value::as_str) {
298                        Some("instance") => wgpu::VertexStepMode::Instance,
299                        _ => wgpu::VertexStepMode::Vertex,
300                    },
301                    attributes: a,
302                })
303            })
304            .collect();
305
306        let mut targets = Vec::new();
307        let fragment_module;
308        let f = d.get("fragment");
309        if let Some(f) = f {
310            fragment_module = Some(self.shader(uint(f, "module")?)?);
311            for t in f.get("targets").and_then(Value::as_array).cloned().unwrap_or_default() {
312                let format = match t.get("format").and_then(Value::as_str) {
313                    Some("surface") | None => gpu.format,
314                    Some(other) => return Err(format!("pipeline: color target format must be \"surface\", got {other:?}")),
315                };
316                let blend = match t.get("blend") {
317                    Some(b) => Some(wgpu::BlendState {
318                        color: blend_component(b.get("color"))?,
319                        alpha: blend_component(b.get("alpha"))?,
320                    }),
321                    None => None,
322                };
323                let write_mask = t
324                    .get("writeMask")
325                    .and_then(Value::as_u64)
326                    .map(|m| wgpu::ColorWrites::from_bits_truncate(m as u32))
327                    .unwrap_or(wgpu::ColorWrites::ALL);
328                targets.push(Some(wgpu::ColorTargetState { format, blend, write_mask }));
329            }
330        } else {
331            fragment_module = None;
332        }
333
334        let p = d.get("primitive");
335        let ps = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_str);
336        let primitive = wgpu::PrimitiveState {
337            topology: match ps("topology").unwrap_or("triangle-list") {
338                "point-list" => wgpu::PrimitiveTopology::PointList,
339                "line-list" => wgpu::PrimitiveTopology::LineList,
340                "line-strip" => wgpu::PrimitiveTopology::LineStrip,
341                "triangle-list" => wgpu::PrimitiveTopology::TriangleList,
342                "triangle-strip" => wgpu::PrimitiveTopology::TriangleStrip,
343                o => return Err(format!("pipeline: unknown topology {o:?}")),
344            },
345            strip_index_format: match ps("stripIndexFormat") {
346                Some("uint16") => Some(wgpu::IndexFormat::Uint16),
347                Some("uint32") => Some(wgpu::IndexFormat::Uint32),
348                _ => None,
349            },
350            front_face: match ps("frontFace").unwrap_or("ccw") {
351                "cw" => wgpu::FrontFace::Cw,
352                _ => wgpu::FrontFace::Ccw,
353            },
354            cull_mode: match ps("cullMode").unwrap_or("none") {
355                "front" => Some(wgpu::Face::Front),
356                "back" => Some(wgpu::Face::Back),
357                _ => None,
358            },
359            ..Default::default()
360        };
361
362        let depth_stencil = match d.get("depthStencil") {
363            Some(ds) => {
364                if let Some(f) = ds.get("format").and_then(Value::as_str)
365                    && f != "depth24plus"
366                {
367                    return Err(format!("pipeline: depthStencil format must be \"depth24plus\", got {f:?}"));
368                }
369                Some(wgpu::DepthStencilState {
370                    format: DEPTH_FORMAT,
371                    depth_write_enabled: Some(ds.get("depthWriteEnabled").and_then(Value::as_bool).unwrap_or(false)),
372                    depth_compare: Some(compare(ds.get("depthCompare").and_then(Value::as_str).unwrap_or("always"))?),
373                    stencil: Default::default(),
374                    bias: Default::default(),
375                })
376            }
377            // The render pass always has a depth attachment, so a pipeline without
378            // depthStencil gets a no-op one (no test, no writes) to stay compatible.
379            None => Some(wgpu::DepthStencilState {
380                format: DEPTH_FORMAT,
381                depth_write_enabled: Some(false),
382                depth_compare: Some(wgpu::CompareFunction::Always),
383                stencil: Default::default(),
384                bias: Default::default(),
385            }),
386        };
387
388        let entry = |s: &Value| s.get("entryPoint").and_then(Value::as_str).map(str::to_owned);
389        let (v_entry, f_entry) = (entry(v), f.and_then(entry));
390        self.scoped("create_pipeline", |gpu| {
391            Ok(gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
392                label: None,
393                layout: None, // "auto"
394                vertex: wgpu::VertexState {
395                    module: vmod,
396                    entry_point: v_entry.as_deref(),
397                    compilation_options: Default::default(),
398                    buffers: &layouts,
399                },
400                primitive,
401                depth_stencil,
402                multisample: wgpu::MultisampleState { count: SAMPLE_COUNT, ..Default::default() },
403                fragment: fragment_module.map(|m| wgpu::FragmentState {
404                    module: m,
405                    entry_point: f_entry.as_deref(),
406                    compilation_options: Default::default(),
407                    targets: &targets,
408                }),
409                multiview_mask: None,
410                cache: None,
411            }))
412        })
413    }
414
415    // ---- frame -----------------------------------------------------------------------------
416
417    /// Start a frame. `show`: whether the runner will display it.
418    pub fn begin_frame(&mut self, clear: [f32; 4], show: bool) -> R<bool> {
419        if self.frame.is_some() {
420            return Err("gfx.begin_frame called twice without end_frame".into());
421        }
422        self.used = true;
423        let (Some(gpu), Some(target), true) = (&self.gpu, &self.target, show) else { return Ok(false) };
424        let (resolve_view, surface_texture) = match target {
425            Target::Window { surface, config } => {
426                let st = match surface.get_current_texture() {
427                    wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
428                    wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
429                        surface.configure(&gpu.device, config);
430                        return Ok(false);
431                    }
432                    _ => return Ok(false), // timeout / occluded: skip this frame
433                };
434                (st.texture.create_view(&Default::default()), Some(st))
435            }
436            Target::Offscreen { texture } => (texture.create_view(&Default::default()), None),
437        };
438        let mut encoder = gpu.device.create_command_encoder(&Default::default());
439        let c = clear.map(|v| v as f64);
440        let pass = encoder
441            .begin_render_pass(&wgpu::RenderPassDescriptor {
442                label: Some("guest"),
443                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
444                    view: self.msaa.as_ref().unwrap(),
445                    depth_slice: None,
446                    resolve_target: Some(&resolve_view),
447                    ops: wgpu::Operations {
448                        load: wgpu::LoadOp::Clear(wgpu::Color { r: c[0], g: c[1], b: c[2], a: c[3] }),
449                        store: wgpu::StoreOp::Discard,
450                    },
451                })],
452                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
453                    view: self.depth.as_ref().unwrap(),
454                    depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }),
455                    stencil_ops: None,
456                }),
457                timestamp_writes: None,
458                occlusion_query_set: None,
459                multiview_mask: None,
460            })
461            .forget_lifetime();
462        self.frame = Some(Frame { pass, encoder, surface_texture });
463        Ok(true)
464    }
465
466    pub fn set_pipeline(&mut self, h: u32) -> R<()> {
467        if self.gpu.is_none() { return self.get(h).map(|_| ()) }
468        let p = self.pipeline(h)?.clone();
469        if let Some(f) = &mut self.frame { f.pass.set_pipeline(&p); }
470        Ok(())
471    }
472
473    pub fn set_bind_group(&mut self, index: u32, h: u32) -> R<()> {
474        if self.gpu.is_none() { return self.get(h).map(|_| ()) }
475        let bg = self.bind_group(h)?.clone();
476        if let Some(f) = &mut self.frame { f.pass.set_bind_group(index, &bg, &[]); }
477        Ok(())
478    }
479
480    pub fn set_vertex_buffer(&mut self, slot: u32, h: u32, offset: u32) -> R<()> {
481        if self.gpu.is_none() { return self.get(h).map(|_| ()) }
482        let b = self.buffer(h)?.clone();
483        if let Some(f) = &mut self.frame { f.pass.set_vertex_buffer(slot, b.slice(offset as u64..)); }
484        Ok(())
485    }
486
487    pub fn set_index_buffer(&mut self, h: u32, format: u32, offset: u32) -> R<()> {
488        if self.gpu.is_none() { return self.get(h).map(|_| ()) }
489        let b = self.buffer(h)?.clone();
490        let fmt = if format == 1 { wgpu::IndexFormat::Uint32 } else { wgpu::IndexFormat::Uint16 };
491        if let Some(f) = &mut self.frame { f.pass.set_index_buffer(b.slice(offset as u64..), fmt); }
492        Ok(())
493    }
494
495    pub fn draw(&mut self, vc: u32, ic: u32, fv: u32, fi: u32) {
496        if let Some(f) = &mut self.frame { f.pass.draw(fv..fv + vc, fi..fi + ic); }
497    }
498
499    pub fn draw_indexed(&mut self, ic: u32, inst: u32, first: u32, base: i32, fi: u32) {
500        if let Some(f) = &mut self.frame { f.pass.draw_indexed(first..first + ic, base, fi..fi + inst); }
501    }
502
503    pub fn end_frame(&mut self) -> R<()> {
504        let Some(Frame { pass, encoder, surface_texture }) = self.frame.take() else { return Ok(()) };
505        drop(pass);
506        let gpu = self.gpu.as_ref().unwrap();
507        gpu.queue.submit([encoder.finish()]);
508        if let Some(st) = surface_texture {
509            gpu.queue.present(st);
510        }
511        Ok(())
512    }
513
514    // ---- 2D path: show a video_present frame -----------------------------------------------
515
516    /// Letterboxed nearest-neighbour blit of an RGBA frame to the window.
517    pub fn present_video(&mut self, rgba: &[u8], w: u32, h: u32) {
518        let (Some(gpu), Some(Target::Window { surface, config }), Some(blit)) = (&self.gpu, &self.target, &mut self.blit) else { return };
519        let st = match surface.get_current_texture() {
520            wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
521            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
522                surface.configure(&gpu.device, config);
523                return;
524            }
525            _ => return,
526        };
527        blit.upload(gpu, rgba, w, h);
528        let view = st.texture.create_view(&Default::default());
529        let mut encoder = gpu.device.create_command_encoder(&Default::default());
530        {
531            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
532                label: Some("blit"),
533                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
534                    view: &view,
535                    depth_slice: None,
536                    resolve_target: None,
537                    ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
538                })],
539                ..Default::default()
540            });
541            // letterbox: largest rect with the frame's aspect ratio
542            let (sw, sh) = (self.width as f32, self.height as f32);
543            let scale = (sw / w as f32).min(sh / h as f32);
544            let (vw, vh) = (w as f32 * scale, h as f32 * scale);
545            pass.set_viewport((sw - vw) / 2.0, (sh - vh) / 2.0, vw, vh, 0.0, 1.0);
546            pass.set_pipeline(&blit.pipeline);
547            pass.set_bind_group(0, &blit.texture.as_ref().unwrap().1, &[]);
548            pass.draw(0..3, 0..1);
549        }
550        gpu.queue.submit([encoder.finish()]);
551        gpu.queue.present(st);
552    }
553
554    /// Read back the offscreen target as tightly packed RGBA8 (headless screenshots).
555    pub fn read_offscreen(&self) -> Option<(u32, u32, Vec<u8>)> {
556        let (Some(gpu), Some(Target::Offscreen { texture })) = (&self.gpu, &self.target) else { return None };
557        let (w, h) = (self.width, self.height);
558        let row = (w * 4).div_ceil(256) * 256;
559        let buf = gpu.device.create_buffer(&wgpu::BufferDescriptor {
560            label: Some("readback"),
561            size: (row * h) as u64,
562            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
563            mapped_at_creation: false,
564        });
565        let mut enc = gpu.device.create_command_encoder(&Default::default());
566        enc.copy_texture_to_buffer(
567            texture.as_image_copy(),
568            wgpu::TexelCopyBufferInfo {
569                buffer: &buf,
570                layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(h) },
571            },
572            wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
573        );
574        gpu.queue.submit([enc.finish()]);
575        buf.slice(..).map_async(wgpu::MapMode::Read, |_| {});
576        gpu.device.poll(wgpu::PollType::wait_indefinitely()).ok()?;
577        let data = buf.slice(..).get_mapped_range().ok()?;
578        let mut out = Vec::with_capacity((w * h * 4) as usize);
579        for y in 0..h as usize {
580            out.extend_from_slice(&data[y * row as usize..y * row as usize + (w * 4) as usize]);
581        }
582        Some((w, h, out))
583    }
584}
585
586fn open_device(
587    adapter: &wgpu::Adapter,
588    choose: impl FnOnce(&[wgpu::TextureFormat]) -> wgpu::TextureFormat,
589    surface: &wgpu::Surface,
590) -> R<Gpu> {
591    let (device, queue) =
592        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).map_err(|e| e.to_string())?;
593    device.on_uncaptured_error(Arc::new(|e| eprintln!("[gasm] gpu error: {e}")));
594    let caps = surface.get_capabilities(adapter);
595    let format = choose(&caps.formats);
596    Ok(Gpu { device, queue, format })
597}
598
599/// Prefer a non-sRGB 8-bit format: browsers' canvas formats are non-sRGB, so
600/// guests look the same on both runners.
601fn pick_format(formats: &[wgpu::TextureFormat]) -> wgpu::TextureFormat {
602    use wgpu::TextureFormat as F;
603    [F::Bgra8Unorm, F::Rgba8Unorm].into_iter().find(|f| formats.contains(f)).unwrap_or(formats[0])
604}
605
606fn uint(v: &Value, key: &str) -> R<u32> {
607    v.get(key).and_then(Value::as_u64).map(|n| n as u32).ok_or_else(|| format!("descriptor: missing number {key:?}"))
608}
609
610fn string<'a>(v: &'a Value, key: &str) -> R<&'a str> {
611    v.get(key).and_then(Value::as_str).ok_or_else(|| format!("descriptor: missing string {key:?}"))
612}
613
614fn vertex_format(s: &str) -> R<wgpu::VertexFormat> {
615    use wgpu::VertexFormat as V;
616    Ok(match s {
617        "float32" => V::Float32,
618        "float32x2" => V::Float32x2,
619        "float32x3" => V::Float32x3,
620        "float32x4" => V::Float32x4,
621        "uint32" => V::Uint32,
622        "uint32x2" => V::Uint32x2,
623        "uint32x3" => V::Uint32x3,
624        "uint32x4" => V::Uint32x4,
625        "sint32" => V::Sint32,
626        "sint32x2" => V::Sint32x2,
627        "sint32x3" => V::Sint32x3,
628        "sint32x4" => V::Sint32x4,
629        "uint8x4" => V::Uint8x4,
630        "unorm8x4" => V::Unorm8x4,
631        "uint16x2" => V::Uint16x2,
632        "uint16x4" => V::Uint16x4,
633        "float16x2" => V::Float16x2,
634        "float16x4" => V::Float16x4,
635        o => return Err(format!("unsupported vertex format {o:?}")),
636    })
637}
638
639fn blend_component(v: Option<&Value>) -> R<wgpu::BlendComponent> {
640    let Some(v) = v else { return Ok(wgpu::BlendComponent::REPLACE) };
641    let factor = |k: &str, default: wgpu::BlendFactor| -> R<wgpu::BlendFactor> {
642        use wgpu::BlendFactor as B;
643        Ok(match v.get(k).and_then(Value::as_str) {
644            None => default,
645            Some("zero") => B::Zero,
646            Some("one") => B::One,
647            Some("src") => B::Src,
648            Some("one-minus-src") => B::OneMinusSrc,
649            Some("src-alpha") => B::SrcAlpha,
650            Some("one-minus-src-alpha") => B::OneMinusSrcAlpha,
651            Some("dst") => B::Dst,
652            Some("one-minus-dst") => B::OneMinusDst,
653            Some("dst-alpha") => B::DstAlpha,
654            Some("one-minus-dst-alpha") => B::OneMinusDstAlpha,
655            Some("src-alpha-saturated") => B::SrcAlphaSaturated,
656            Some("constant") => B::Constant,
657            Some("one-minus-constant") => B::OneMinusConstant,
658            Some(o) => return Err(format!("unsupported blend factor {o:?}")),
659        })
660    };
661    use wgpu::BlendOperation as O;
662    Ok(wgpu::BlendComponent {
663        src_factor: factor("srcFactor", wgpu::BlendFactor::One)?,
664        dst_factor: factor("dstFactor", wgpu::BlendFactor::Zero)?,
665        operation: match v.get("operation").and_then(Value::as_str).unwrap_or("add") {
666            "add" => O::Add,
667            "subtract" => O::Subtract,
668            "reverse-subtract" => O::ReverseSubtract,
669            "min" => O::Min,
670            "max" => O::Max,
671            o => return Err(format!("unsupported blend operation {o:?}")),
672        },
673    })
674}
675
676fn compare(s: &str) -> R<wgpu::CompareFunction> {
677    use wgpu::CompareFunction as C;
678    Ok(match s {
679        "never" => C::Never,
680        "less" => C::Less,
681        "equal" => C::Equal,
682        "less-equal" => C::LessEqual,
683        "greater" => C::Greater,
684        "not-equal" => C::NotEqual,
685        "greater-equal" => C::GreaterEqual,
686        "always" => C::Always,
687        o => return Err(format!("unsupported compare function {o:?}")),
688    })
689}
690
691impl Blit {
692    const SHADER: &str = r#"
693@group(0) @binding(0) var t: texture_2d<f32>;
694@group(0) @binding(1) var s: sampler;
695struct VO { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32> };
696@vertex fn vs(@builtin(vertex_index) i: u32) -> VO {
697  let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
698  var o: VO;
699  o.pos = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
700  o.uv = uv;
701  return o;
702}
703@fragment fn fs(i: VO) -> @location(0) vec4<f32> { return textureSample(t, s, i.uv); }
704"#;
705
706    fn new(gpu: &Gpu) -> Blit {
707        let module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
708            label: Some("blit"),
709            source: wgpu::ShaderSource::Wgsl(Self::SHADER.into()),
710        });
711        let pipeline = gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
712            label: Some("blit"),
713            layout: None,
714            vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[] },
715            primitive: Default::default(),
716            depth_stencil: None,
717            multisample: Default::default(),
718            fragment: Some(wgpu::FragmentState {
719                module: &module,
720                entry_point: Some("fs"),
721                compilation_options: Default::default(),
722                targets: &[Some(gpu.format.into())],
723            }),
724            multiview_mask: None,
725            cache: None,
726        });
727        let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
728            mag_filter: wgpu::FilterMode::Nearest,
729            min_filter: wgpu::FilterMode::Nearest,
730            ..Default::default()
731        });
732        Blit { pipeline, sampler, texture: None }
733    }
734
735    fn upload(&mut self, gpu: &Gpu, rgba: &[u8], w: u32, h: u32) {
736        if !matches!(&self.texture, Some((_, _, tw, th)) if (*tw, *th) == (w, h)) {
737            let tex = gpu.device.create_texture_with_data(
738                &gpu.queue,
739                &wgpu::TextureDescriptor {
740                    label: Some("video"),
741                    size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
742                    mip_level_count: 1,
743                    sample_count: 1,
744                    dimension: wgpu::TextureDimension::D2,
745                    format: wgpu::TextureFormat::Rgba8Unorm,
746                    usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
747                    view_formats: &[],
748                },
749                Default::default(),
750                rgba,
751            );
752            let bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
753                label: None,
754                layout: &self.pipeline.get_bind_group_layout(0),
755                entries: &[
756                    wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&tex.create_view(&Default::default())) },
757                    wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) },
758                ],
759            });
760            self.texture = Some((tex, bg, w, h));
761            return;
762        }
763        let tex = &self.texture.as_ref().unwrap().0;
764        gpu.queue.write_texture(
765            tex.as_image_copy(),
766            rgba,
767            wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * 4), rows_per_image: Some(h) },
768            wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
769        );
770    }
771}