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. Each object
9//! also gets a [`Meta`] record (kind, sizes, layout entries) in both modes:
10//! textures, samplers, layouts and dynamic offsets are validated against it,
11//! so they trap the same way with and without a GPU (and like the JS runner).
12
13use std::sync::Arc;
14
15use serde_json::Value;
16use wgpu::util::DeviceExt;
17
18pub const SAMPLE_COUNT: u32 = 4;
19const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24Plus;
20
21/// Largest texture side (WebGPU's default `maxTextureDimension2D`).
22pub const MAX_TEXTURE_SIZE: u32 = 8192;
23/// Dynamic offsets must be multiples of this (WebGPU's default alignment limits).
24pub const OFFSET_ALIGNMENT: u32 = 256;
25
26enum Obj {
27    Shader(wgpu::ShaderModule),
28    Buffer(wgpu::Buffer),
29    Pipeline(wgpu::RenderPipeline),
30    BindGroup(wgpu::BindGroup),
31    Layout(wgpu::BindGroupLayout),
32    Texture(wgpu::Texture, wgpu::TextureView),
33    Sampler(wgpu::Sampler),
34    Null,
35}
36
37/// Backend-independent description of an object, used for validation.
38#[derive(Clone)]
39enum Meta {
40    Shader,
41    Buffer { size: u64, usage: u32 },
42    Pipeline,
43    /// dynamic-offset entries in binding order
44    BindGroup { dynamic: Vec<DynEntry> },
45    Layout { entries: Vec<LayoutEntry> },
46    Texture { width: u32, height: u32, mips: u32 },
47    Sampler,
48}
49
50impl Meta {
51    fn kind(&self) -> &'static str {
52        match self {
53            Meta::Shader => "shader",
54            Meta::Buffer { .. } => "buffer",
55            Meta::Pipeline => "pipeline",
56            Meta::BindGroup { .. } => "bind group",
57            Meta::Layout { .. } => "bind group layout",
58            Meta::Texture { .. } => "texture",
59            Meta::Sampler => "sampler",
60        }
61    }
62}
63
64#[derive(Clone, Copy)]
65struct DynEntry {
66    buffer_size: u64,
67    offset: u64,
68    size: u64,
69}
70
71#[derive(Clone, Copy, PartialEq)]
72enum Slot {
73    Uniform,
74    Storage,
75    Texture,
76    Sampler,
77}
78
79#[derive(Clone)]
80struct LayoutEntry {
81    binding: u32,
82    visibility: u32,
83    slot: Slot,
84    dynamic: bool,
85    min_binding_size: u64,
86    /// texture: "float" (filterable) or "unfilterable-float"; sampler: "filtering" or "non-filtering"
87    filterable: bool,
88}
89
90pub struct Gpu {
91    pub device: wgpu::Device,
92    pub queue: wgpu::Queue,
93    pub format: wgpu::TextureFormat,
94}
95
96pub enum Target {
97    Window { surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration },
98    Offscreen { texture: wgpu::Texture },
99}
100
101struct Frame {
102    pass: wgpu::RenderPass<'static>,
103    encoder: wgpu::CommandEncoder,
104    surface_texture: Option<wgpu::SurfaceTexture>,
105    /// viewport or scissor clamped to nothing: draws are skipped
106    empty_viewport: bool,
107    empty_scissor: bool,
108}
109
110struct Blit {
111    pipeline: wgpu::RenderPipeline,
112    sampler: wgpu::Sampler,
113    texture: Option<(wgpu::Texture, wgpu::BindGroup, u32, u32)>,
114}
115
116pub struct Gfx {
117    gpu: Option<Gpu>,
118    target: Option<Target>,
119    width: u32,
120    height: u32,
121    objects: Vec<Obj>,
122    meta: Vec<Meta>,
123    msaa: Option<wgpu::TextureView>,
124    depth: Option<wgpu::TextureView>,
125    frame: Option<Frame>,
126    blit: Option<Blit>,
127    /// set by the guest calling any gfx draw API this frame (suppresses the 2D blit)
128    pub used: bool,
129}
130
131type R<T> = Result<T, String>;
132
133impl Gfx {
134    /// No GPU: ids only (headless runs without screenshots, CI).
135    pub fn null() -> Gfx {
136        Gfx { gpu: None, target: None, width: 1280, height: 720, objects: Vec::new(), meta: Vec::new(), msaa: None, depth: None, frame: None, blit: None, used: false }
137    }
138
139    /// Render into a window.
140    pub fn for_window(window: Arc<winit::window::Window>) -> R<Gfx> {
141        let size = window.inner_size();
142        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
143        let surface = instance.create_surface(window).map_err(|e| e.to_string())?;
144        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
145            power_preference: wgpu::PowerPreference::HighPerformance,
146            compatible_surface: Some(&surface),
147            ..Default::default()
148        }))
149        .map_err(|e| format!("no GPU adapter: {e}"))?;
150        let gpu = open_device(&adapter, |caps_formats| pick_format(caps_formats), &surface)?;
151        let mut config = surface
152            .get_default_config(&adapter, size.width.max(1), size.height.max(1))
153            .ok_or("surface not supported by adapter")?;
154        config.format = gpu.format;
155        config.present_mode = wgpu::PresentMode::AutoVsync;
156        surface.configure(&gpu.device, &config);
157        eprintln!("[gasm] gpu: {} ({:?}), surface {:?}", adapter.get_info().name, adapter.get_info().backend, gpu.format);
158        let mut g = Gfx::with_gpu(gpu, Target::Window { surface, config }, size.width.max(1), size.height.max(1));
159        g.recreate_attachments();
160        Ok(g)
161    }
162
163    /// Render into an RGBA texture that can be read back (headless screenshots).
164    pub fn offscreen(width: u32, height: u32) -> R<Gfx> {
165        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
166        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
167            .map_err(|e| format!("no GPU adapter: {e}"))?;
168        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))
169            .map_err(|e| e.to_string())?;
170        device.on_uncaptured_error(Arc::new(|e| eprintln!("[gasm] gpu error: {e}")));
171        let format = wgpu::TextureFormat::Rgba8Unorm;
172        let texture = device.create_texture(&wgpu::TextureDescriptor {
173            label: Some("offscreen"),
174            size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
175            mip_level_count: 1,
176            sample_count: 1,
177            dimension: wgpu::TextureDimension::D2,
178            format,
179            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
180            view_formats: &[],
181        });
182        let mut g = Gfx::with_gpu(Gpu { device, queue, format }, Target::Offscreen { texture }, width, height);
183        g.recreate_attachments();
184        Ok(g)
185    }
186
187    fn with_gpu(gpu: Gpu, target: Target, width: u32, height: u32) -> Gfx {
188        let blit = Some(Blit::new(&gpu));
189        Gfx { gpu: Some(gpu), target: Some(target), width, height, objects: Vec::new(), meta: Vec::new(), msaa: None, depth: None, frame: None, blit, used: false }
190    }
191
192    pub fn size(&self) -> (u32, u32) {
193        (self.width, self.height)
194    }
195
196    pub fn resize(&mut self, width: u32, height: u32) {
197        if width == 0 || height == 0 || (width, height) == (self.width, self.height) {
198            return;
199        }
200        self.width = width;
201        self.height = height;
202        if let (Some(gpu), Some(Target::Window { surface, config })) = (&self.gpu, &mut self.target) {
203            config.width = width;
204            config.height = height;
205            surface.configure(&gpu.device, config);
206        }
207        self.recreate_attachments();
208    }
209
210    fn recreate_attachments(&mut self) {
211        let Some(gpu) = &self.gpu else { return };
212        let make = |format, label| {
213            gpu.device
214                .create_texture(&wgpu::TextureDescriptor {
215                    label: Some(label),
216                    size: wgpu::Extent3d { width: self.width, height: self.height, depth_or_array_layers: 1 },
217                    mip_level_count: 1,
218                    sample_count: SAMPLE_COUNT,
219                    dimension: wgpu::TextureDimension::D2,
220                    format,
221                    usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
222                    view_formats: &[],
223                })
224                .create_view(&Default::default())
225        };
226        self.msaa = Some(make(gpu.format, "msaa"));
227        self.depth = Some(make(DEPTH_FORMAT, "depth"));
228    }
229
230    // ---- object table ------------------------------------------------------------
231
232    fn add(&mut self, obj: Obj, meta: Meta) -> u32 {
233        self.objects.push(obj);
234        self.meta.push(meta);
235        self.objects.len() as u32
236    }
237
238    fn meta(&self, h: u32) -> R<&Meta> {
239        self.meta.get((h as usize).wrapping_sub(1)).ok_or_else(|| format!("gfx: invalid handle {h}"))
240    }
241
242    /// Check that `h` is an object of kind `want` (see [`Meta::kind`]).
243    fn expect(&self, h: u32, want: &str) -> R<&Meta> {
244        let m = self.meta(h)?;
245        if m.kind() != want {
246            return Err(format!("gfx: handle {h} is a {}, not a {want}", m.kind()));
247        }
248        Ok(m)
249    }
250
251    fn get(&self, h: u32) -> R<&Obj> {
252        self.objects.get((h as usize).wrapping_sub(1)).ok_or_else(|| format!("gfx: invalid handle {h}"))
253    }
254
255    fn shader(&self, h: u32) -> R<&wgpu::ShaderModule> {
256        match self.get(h)? { Obj::Shader(s) => Ok(s), _ => Err(format!("gfx: handle {h} is not a shader")) }
257    }
258    fn buffer(&self, h: u32) -> R<&wgpu::Buffer> {
259        match self.get(h)? { Obj::Buffer(b) => Ok(b), _ => Err(format!("gfx: handle {h} is not a buffer")) }
260    }
261    fn pipeline(&self, h: u32) -> R<&wgpu::RenderPipeline> {
262        match self.get(h)? { Obj::Pipeline(p) => Ok(p), _ => Err(format!("gfx: handle {h} is not a pipeline")) }
263    }
264    fn bind_group(&self, h: u32) -> R<&wgpu::BindGroup> {
265        match self.get(h)? { Obj::BindGroup(b) => Ok(b), _ => Err(format!("gfx: handle {h} is not a bind group")) }
266    }
267    fn layout(&self, h: u32) -> R<&wgpu::BindGroupLayout> {
268        match self.get(h)? { Obj::Layout(l) => Ok(l), _ => Err(format!("gfx: handle {h} is not a bind group layout")) }
269    }
270
271    /// Run `f` inside a validation error scope; wgpu errors become `Err`.
272    fn scoped<T>(&self, what: &str, f: impl FnOnce(&Gpu) -> R<T>) -> R<T> {
273        let gpu = self.gpu.as_ref().unwrap();
274        let scope = gpu.device.push_error_scope(wgpu::ErrorFilter::Validation);
275        let out = f(gpu);
276        if let Some(e) = pollster::block_on(scope.pop()) {
277            return Err(format!("gfx: {what}: {e}"));
278        }
279        out
280    }
281
282    // ---- creation ------------------------------------------------------------------------
283
284    pub fn create_shader(&mut self, wgsl: &str) -> R<u32> {
285        if self.gpu.is_none() {
286            return Ok(self.add(Obj::Null, Meta::Shader));
287        }
288        let m = self.scoped("create_shader", |gpu| {
289            Ok(gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
290                label: None,
291                source: wgpu::ShaderSource::Wgsl(wgsl.into()),
292            }))
293        })?;
294        Ok(self.add(Obj::Shader(m), Meta::Shader))
295    }
296
297    pub fn create_buffer(&mut self, size: u32, usage: u32) -> R<u32> {
298        if size == 0 || size % 4 != 0 {
299            return Err(format!("gfx.create_buffer: size {size} must be a non-zero multiple of 4"));
300        }
301        let meta = Meta::Buffer { size: size as u64, usage: usage | 0x08 };
302        let Some(gpu) = &self.gpu else { return Ok(self.add(Obj::Null, meta)) };
303        // WebGPU usage bits == wgpu BufferUsages bits; COPY_DST is always added for write_buffer.
304        let usage = wgpu::BufferUsages::from_bits_truncate(usage) | wgpu::BufferUsages::COPY_DST;
305        let b = gpu.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: size as u64, usage, mapped_at_creation: false });
306        Ok(self.add(Obj::Buffer(b), meta))
307    }
308
309    pub fn write_buffer(&mut self, h: u32, offset: u32, data: &[u8]) -> R<()> {
310        if offset % 4 != 0 || data.len() % 4 != 0 {
311            return Err(format!("gfx.write_buffer: offset {offset} and length {} must be multiples of 4", data.len()));
312        }
313        if self.gpu.is_none() {
314            return self.get(h).map(|_| ());
315        }
316        let buf = self.buffer(h)?;
317        if offset as u64 + data.len() as u64 > buf.size() {
318            return Err(format!("gfx.write_buffer: {}+{} exceeds buffer size {}", offset, data.len(), buf.size()));
319        }
320        self.gpu.as_ref().unwrap().queue.write_buffer(buf, offset as u64, data);
321        Ok(())
322    }
323
324    pub fn create_pipeline(&mut self, json: &str) -> R<u32> {
325        let d: Value = serde_json::from_str(json).map_err(|e| format!("gfx.create_pipeline: invalid JSON: {e}"))?;
326        let layouts = self.pipeline_layouts(&d)?;
327        if self.gpu.is_none() {
328            return Ok(self.add(Obj::Null, Meta::Pipeline));
329        }
330        let p = self.build_pipeline(&d, layouts)?;
331        Ok(self.add(Obj::Pipeline(p), Meta::Pipeline))
332    }
333
334    /// `"layout"`: omitted / `"auto"` (None), or an array of bind group layout handles.
335    fn pipeline_layouts(&self, d: &Value) -> R<Option<Vec<u32>>> {
336        match d.get("layout") {
337            None => Ok(None),
338            Some(Value::String(s)) if s == "auto" => Ok(None),
339            Some(Value::Array(a)) => {
340                let mut out = Vec::new();
341                for v in a {
342                    let h = v.as_u64().ok_or("pipeline: layout entries must be bind group layout handles")? as u32;
343                    self.expect(h, "bind group layout")?;
344                    out.push(h);
345                }
346                Ok(Some(out))
347            }
348            Some(o) => Err(format!("pipeline: layout must be \"auto\" or an array of bind group layout handles, got {o}")),
349        }
350    }
351
352    pub fn create_bind_group(&mut self, json: &str) -> R<u32> {
353        let d: Value = serde_json::from_str(json).map_err(|e| format!("gfx.create_bind_group: invalid JSON: {e}"))?;
354        let entries_json = d.get("entries").and_then(Value::as_array).cloned().unwrap_or_default();
355        // Resolve and check every entry against the object table (both modes).
356        let mut resolved = Vec::new(); // (binding, Slot of the resource, handle, offset, size)
357        for e in &entries_json {
358            let binding = uint(e, "binding")?;
359            if resolved.iter().any(|r: &(u32, Slot, u32, u64, Option<u64>)| r.0 == binding) {
360                return Err(format!("bind group: binding {binding} appears twice"));
361            }
362            if let Some(h) = e.get("buffer").and_then(Value::as_u64) {
363                let Meta::Buffer { size, .. } = self.expect(h as u32, "buffer")? else { unreachable!() };
364                let offset = e.get("offset").and_then(Value::as_u64).unwrap_or(0);
365                let bsize = e.get("size").and_then(Value::as_u64);
366                if offset + bsize.unwrap_or(0) > *size || offset > *size {
367                    return Err(format!("bind group: binding {binding}: range {offset}+{} exceeds buffer size {size}", bsize.unwrap_or(0)));
368                }
369                resolved.push((binding, Slot::Uniform, h as u32, offset, bsize));
370            } else if let Some(h) = e.get("texture").and_then(Value::as_u64) {
371                self.expect(h as u32, "texture")?;
372                resolved.push((binding, Slot::Texture, h as u32, 0, None));
373            } else if let Some(h) = e.get("sampler").and_then(Value::as_u64) {
374                self.expect(h as u32, "sampler")?;
375                resolved.push((binding, Slot::Sampler, h as u32, 0, None));
376            } else {
377                return Err(format!("bind group: binding {binding} needs a \"buffer\", \"texture\" or \"sampler\""));
378            }
379        }
380        let mut dynamic = Vec::new();
381        let explicit = d.get("layout").and_then(Value::as_u64).map(|h| h as u32);
382        if let Some(lh) = explicit {
383            let Meta::Layout { entries } = self.expect(lh, "bind group layout")?.clone() else { unreachable!() };
384            if resolved.len() != entries.len() {
385                return Err(format!("bind group: layout {lh} has {} entries, got {}", entries.len(), resolved.len()));
386            }
387            for le in &entries {
388                let r = resolved.iter().find(|r| r.0 == le.binding).ok_or_else(|| format!("bind group: missing binding {}", le.binding))?;
389                let is_buffer = matches!(le.slot, Slot::Uniform | Slot::Storage);
390                if (is_buffer && r.1 != Slot::Uniform) || (!is_buffer && r.1 != le.slot) {
391                    return Err(format!("bind group: binding {} has the wrong resource kind for its layout", le.binding));
392                }
393                if is_buffer {
394                    let Meta::Buffer { size, usage } = self.meta(r.2)?.clone() else { unreachable!() };
395                    let need = if le.slot == Slot::Uniform { 0x40 } else { 0x80 };
396                    if usage & need == 0 {
397                        return Err(format!("bind group: binding {}: buffer {} lacks {} usage", le.binding, r.2, if need == 0x40 { "UNIFORM" } else { "STORAGE" }));
398                    }
399                    let bsize = r.4.unwrap_or(size - r.3);
400                    if bsize < le.min_binding_size {
401                        return Err(format!("bind group: binding {}: size {bsize} is below minBindingSize {}", le.binding, le.min_binding_size));
402                    }
403                    if le.dynamic {
404                        dynamic.push((le.binding, DynEntry { buffer_size: size, offset: r.3, size: bsize }));
405                    }
406                }
407            }
408            dynamic.sort_by_key(|d| d.0);
409        } else {
410            self.expect(uint(&d, "pipeline").map_err(|_| "bind group: needs \"layout\" or \"pipeline\"".to_string())?, "pipeline")?;
411        }
412        let meta = Meta::BindGroup { dynamic: dynamic.into_iter().map(|d| d.1).collect() };
413        if self.gpu.is_none() {
414            return Ok(self.add(Obj::Null, meta));
415        }
416        let layout = match explicit {
417            Some(lh) => self.layout(lh)?.clone(),
418            None => self.pipeline(uint(&d, "pipeline")?)?.get_bind_group_layout(d.get("group").and_then(Value::as_u64).unwrap_or(0) as u32),
419        };
420        let mut entries = Vec::new();
421        for (binding, slot, h, offset, size) in &resolved {
422            let resource = match slot {
423                Slot::Texture => match self.get(*h)? { Obj::Texture(_, v) => wgpu::BindingResource::TextureView(v), _ => unreachable!() },
424                Slot::Sampler => match self.get(*h)? { Obj::Sampler(s) => wgpu::BindingResource::Sampler(s), _ => unreachable!() },
425                _ => wgpu::BindingResource::Buffer(wgpu::BufferBinding {
426                    buffer: self.buffer(*h)?,
427                    offset: *offset,
428                    size: size.and_then(wgpu::BufferSize::new),
429                }),
430            };
431            entries.push(wgpu::BindGroupEntry { binding: *binding, resource });
432        }
433        let bg = self.scoped("create_bind_group", |gpu| {
434            Ok(gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { label: None, layout: &layout, entries: &entries }))
435        })?;
436        Ok(self.add(Obj::BindGroup(bg), meta))
437    }
438
439    pub fn create_bind_group_layout(&mut self, json: &str) -> R<u32> {
440        let d: Value = serde_json::from_str(json).map_err(|e| format!("gfx.create_bind_group_layout: invalid JSON: {e}"))?;
441        let mut entries: Vec<LayoutEntry> = Vec::new();
442        for e in d.get("entries").and_then(Value::as_array).cloned().unwrap_or_default() {
443            let binding = uint(&e, "binding")?;
444            if entries.iter().any(|x| x.binding == binding) {
445                return Err(format!("bind group layout: binding {binding} appears twice"));
446            }
447            let visibility = uint(&e, "visibility")?;
448            if visibility == 0 || visibility & !3 != 0 {
449                return Err(format!("bind group layout: binding {binding}: visibility {visibility} must be GASM_STAGE_VERTEX (1) and/or GASM_STAGE_FRAGMENT (2)"));
450            }
451            let entry = if let Some(b) = e.get("buffer") {
452                let slot = match b.get("type").and_then(Value::as_str).unwrap_or("uniform") {
453                    "uniform" => Slot::Uniform,
454                    "read-only-storage" => Slot::Storage,
455                    o => return Err(format!("bind group layout: binding {binding}: unsupported buffer type {o:?}")),
456                };
457                LayoutEntry {
458                    binding, visibility, slot,
459                    dynamic: b.get("hasDynamicOffset").and_then(Value::as_bool).unwrap_or(false),
460                    min_binding_size: b.get("minBindingSize").and_then(Value::as_u64).unwrap_or(0),
461                    filterable: false,
462                }
463            } else if let Some(t) = e.get("texture") {
464                let filterable = match t.get("sampleType").and_then(Value::as_str).unwrap_or("float") {
465                    "float" => true,
466                    "unfilterable-float" => false,
467                    o => return Err(format!("bind group layout: binding {binding}: unsupported sampleType {o:?}")),
468                };
469                if let Some(v) = t.get("viewDimension").and_then(Value::as_str)
470                    && v != "2d"
471                {
472                    return Err(format!("bind group layout: binding {binding}: only viewDimension \"2d\" is supported, got {v:?}"));
473                }
474                if t.get("multisampled").and_then(Value::as_bool).unwrap_or(false) {
475                    return Err(format!("bind group layout: binding {binding}: multisampled textures are not supported"));
476                }
477                LayoutEntry { binding, visibility, slot: Slot::Texture, dynamic: false, min_binding_size: 0, filterable }
478            } else if let Some(sm) = e.get("sampler") {
479                let filterable = match sm.get("type").and_then(Value::as_str).unwrap_or("filtering") {
480                    "filtering" => true,
481                    "non-filtering" => false,
482                    o => return Err(format!("bind group layout: binding {binding}: unsupported sampler type {o:?}")),
483                };
484                LayoutEntry { binding, visibility, slot: Slot::Sampler, dynamic: false, min_binding_size: 0, filterable }
485            } else {
486                return Err(format!("bind group layout: binding {binding} needs \"buffer\", \"texture\" or \"sampler\""));
487            };
488            entries.push(entry);
489        }
490        entries.sort_by_key(|e| e.binding);
491        let meta = Meta::Layout { entries: entries.clone() };
492        if self.gpu.is_none() {
493            return Ok(self.add(Obj::Null, meta));
494        }
495        let wentries: Vec<wgpu::BindGroupLayoutEntry> = entries
496            .iter()
497            .map(|e| wgpu::BindGroupLayoutEntry {
498                binding: e.binding,
499                visibility: wgpu::ShaderStages::from_bits_truncate(e.visibility),
500                ty: match e.slot {
501                    Slot::Uniform | Slot::Storage => wgpu::BindingType::Buffer {
502                        ty: if e.slot == Slot::Uniform {
503                            wgpu::BufferBindingType::Uniform
504                        } else {
505                            wgpu::BufferBindingType::Storage { read_only: true }
506                        },
507                        has_dynamic_offset: e.dynamic,
508                        min_binding_size: wgpu::BufferSize::new(e.min_binding_size),
509                    },
510                    Slot::Texture => wgpu::BindingType::Texture {
511                        sample_type: wgpu::TextureSampleType::Float { filterable: e.filterable },
512                        view_dimension: wgpu::TextureViewDimension::D2,
513                        multisampled: false,
514                    },
515                    Slot::Sampler => wgpu::BindingType::Sampler(if e.filterable {
516                        wgpu::SamplerBindingType::Filtering
517                    } else {
518                        wgpu::SamplerBindingType::NonFiltering
519                    }),
520                },
521                count: None,
522            })
523            .collect();
524        let l = self.scoped("create_bind_group_layout", |gpu| {
525            Ok(gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: None, entries: &wentries }))
526        })?;
527        Ok(self.add(Obj::Layout(l), meta))
528    }
529
530    pub fn create_texture(&mut self, json: &str) -> R<u32> {
531        let d: Value = serde_json::from_str(json).map_err(|e| format!("gfx.create_texture: invalid JSON: {e}"))?;
532        let size = d.get("size").and_then(Value::as_array).ok_or("texture: missing \"size\": [width, height]")?;
533        let dim = |i: usize| size.get(i).and_then(Value::as_u64).unwrap_or(0);
534        let (w, h) = (dim(0), dim(1));
535        if size.len() != 2 || w == 0 || h == 0 || w > MAX_TEXTURE_SIZE as u64 || h > MAX_TEXTURE_SIZE as u64 {
536            return Err(format!("texture: size must be [width, height], each 1-{MAX_TEXTURE_SIZE}"));
537        }
538        let (w, h) = (w as u32, h as u32);
539        let format = match d.get("format").and_then(Value::as_str).unwrap_or("rgba8unorm") {
540            "rgba8unorm" => wgpu::TextureFormat::Rgba8Unorm,
541            "rgba8unorm-srgb" => wgpu::TextureFormat::Rgba8UnormSrgb,
542            o => return Err(format!("texture: unsupported format {o:?} (rgba8unorm, rgba8unorm-srgb)")),
543        };
544        let max_mips = 32 - w.max(h).leading_zeros();
545        let mips = d.get("mipLevelCount").and_then(Value::as_u64).unwrap_or(1);
546        if mips == 0 || mips > max_mips as u64 {
547            return Err(format!("texture: mipLevelCount {mips} must be 1-{max_mips} for {w}x{h}"));
548        }
549        let meta = Meta::Texture { width: w, height: h, mips: mips as u32 };
550        let Some(gpu) = &self.gpu else { return Ok(self.add(Obj::Null, meta)) };
551        let t = gpu.device.create_texture(&wgpu::TextureDescriptor {
552            label: None,
553            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
554            mip_level_count: mips as u32,
555            sample_count: 1,
556            dimension: wgpu::TextureDimension::D2,
557            format,
558            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
559            view_formats: &[],
560        });
561        let view = t.create_view(&Default::default());
562        Ok(self.add(Obj::Texture(t, view), meta))
563    }
564
565    #[allow(clippy::too_many_arguments)]
566    pub fn write_texture(&mut self, h: u32, mip: u32, x: u32, y: u32, w: u32, ht: u32, data: &[u8]) -> R<()> {
567        let Meta::Texture { width, height, mips } = *self.expect(h, "texture")? else { unreachable!() };
568        if mip >= mips {
569            return Err(format!("gfx.write_texture: mip {mip} out of range (texture has {mips})"));
570        }
571        let (lw, lh) = ((width >> mip).max(1), (height >> mip).max(1));
572        if w == 0 || ht == 0 || x as u64 + w as u64 > lw as u64 || y as u64 + ht as u64 > lh as u64 {
573            return Err(format!("gfx.write_texture: region {x},{y} {w}x{ht} is outside mip {mip} ({lw}x{lh})"));
574        }
575        if data.len() as u64 != w as u64 * ht as u64 * 4 {
576            return Err(format!("gfx.write_texture: len {} must be width*height*4 = {}", data.len(), w as u64 * ht as u64 * 4));
577        }
578        let (Some(gpu), Obj::Texture(t, _)) = (&self.gpu, self.get(h)?) else { return Ok(()) };
579        gpu.queue.write_texture(
580            wgpu::TexelCopyTextureInfo { texture: t, mip_level: mip, origin: wgpu::Origin3d { x, y, z: 0 }, aspect: wgpu::TextureAspect::All },
581            data,
582            wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * 4), rows_per_image: Some(ht) },
583            wgpu::Extent3d { width: w, height: ht, depth_or_array_layers: 1 },
584        );
585        Ok(())
586    }
587
588    pub fn create_sampler(&mut self, json: &str) -> R<u32> {
589        let d: Value = serde_json::from_str(json).map_err(|e| format!("gfx.create_sampler: invalid JSON: {e}"))?;
590        let address = |k: &str| -> R<wgpu::AddressMode> {
591            Ok(match d.get(k).and_then(Value::as_str).unwrap_or("clamp-to-edge") {
592                "clamp-to-edge" => wgpu::AddressMode::ClampToEdge,
593                "repeat" => wgpu::AddressMode::Repeat,
594                "mirror-repeat" => wgpu::AddressMode::MirrorRepeat,
595                o => return Err(format!("sampler: unsupported {k} {o:?}")),
596            })
597        };
598        let linear = |k: &str| -> R<bool> {
599            match d.get(k).and_then(Value::as_str).unwrap_or("nearest") {
600                "nearest" => Ok(false),
601                "linear" => Ok(true),
602                o => Err(format!("sampler: unsupported {k} {o:?}")),
603            }
604        };
605        let (u, v) = (address("addressModeU")?, address("addressModeV")?);
606        let (mag, min, mip) = (linear("magFilter")?, linear("minFilter")?, linear("mipmapFilter")?);
607        let lod_min = d.get("lodMinClamp").and_then(Value::as_f64).unwrap_or(0.0) as f32;
608        let lod_max = d.get("lodMaxClamp").and_then(Value::as_f64).unwrap_or(32.0) as f32;
609        if !(lod_min >= 0.0 && lod_max >= lod_min) {
610            return Err(format!("sampler: lodMinClamp {lod_min} / lodMaxClamp {lod_max} must satisfy 0 <= min <= max"));
611        }
612        let aniso = d.get("maxAnisotropy").and_then(Value::as_u64).unwrap_or(1);
613        if !(1..=16).contains(&aniso) {
614            return Err(format!("sampler: maxAnisotropy {aniso} must be 1-16"));
615        }
616        if aniso > 1 && !(mag && min && mip) {
617            return Err("sampler: maxAnisotropy > 1 needs linear magFilter, minFilter and mipmapFilter".into());
618        }
619        let Some(gpu) = &self.gpu else { return Ok(self.add(Obj::Null, Meta::Sampler)) };
620        let f = |l: bool| if l { wgpu::FilterMode::Linear } else { wgpu::FilterMode::Nearest };
621        let smp = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
622            label: None,
623            address_mode_u: u,
624            address_mode_v: v,
625            address_mode_w: wgpu::AddressMode::ClampToEdge,
626            mag_filter: f(mag),
627            min_filter: f(min),
628            mipmap_filter: if mip { wgpu::MipmapFilterMode::Linear } else { wgpu::MipmapFilterMode::Nearest },
629            lod_min_clamp: lod_min,
630            lod_max_clamp: lod_max,
631            compare: None,
632            anisotropy_clamp: aniso as u16,
633            border_color: None,
634        });
635        Ok(self.add(Obj::Sampler(smp), Meta::Sampler))
636    }
637
638    fn build_pipeline(&self, d: &Value, group_layouts: Option<Vec<u32>>) -> R<wgpu::RenderPipeline> {
639        let gpu = self.gpu.as_ref().unwrap();
640        let v = d.get("vertex").ok_or("pipeline: missing vertex")?;
641        let vmod = self.shader(uint(v, "module")?)?;
642        let buffers_json = v.get("buffers").and_then(Value::as_array).cloned().unwrap_or_default();
643        let mut attrs: Vec<Vec<wgpu::VertexAttribute>> = Vec::new();
644        for b in &buffers_json {
645            let mut list = Vec::new();
646            for a in b.get("attributes").and_then(Value::as_array).cloned().unwrap_or_default() {
647                list.push(wgpu::VertexAttribute {
648                    format: vertex_format(string(&a, "format")?)?,
649                    offset: a.get("offset").and_then(Value::as_u64).unwrap_or(0),
650                    shader_location: uint(&a, "shaderLocation")?,
651                });
652            }
653            attrs.push(list);
654        }
655        let layouts: Vec<Option<wgpu::VertexBufferLayout>> = buffers_json
656            .iter()
657            .zip(&attrs)
658            .map(|(b, a)| {
659                Some(wgpu::VertexBufferLayout {
660                    array_stride: b.get("arrayStride").and_then(Value::as_u64).unwrap_or(0),
661                    step_mode: match b.get("stepMode").and_then(Value::as_str) {
662                        Some("instance") => wgpu::VertexStepMode::Instance,
663                        _ => wgpu::VertexStepMode::Vertex,
664                    },
665                    attributes: a,
666                })
667            })
668            .collect();
669
670        let mut targets = Vec::new();
671        let fragment_module;
672        let f = d.get("fragment");
673        if let Some(f) = f {
674            fragment_module = Some(self.shader(uint(f, "module")?)?);
675            for t in f.get("targets").and_then(Value::as_array).cloned().unwrap_or_default() {
676                let format = match t.get("format").and_then(Value::as_str) {
677                    Some("surface") | None => gpu.format,
678                    Some(other) => return Err(format!("pipeline: color target format must be \"surface\", got {other:?}")),
679                };
680                let blend = match t.get("blend") {
681                    Some(b) => Some(wgpu::BlendState {
682                        color: blend_component(b.get("color"))?,
683                        alpha: blend_component(b.get("alpha"))?,
684                    }),
685                    None => None,
686                };
687                let write_mask = t
688                    .get("writeMask")
689                    .and_then(Value::as_u64)
690                    .map(|m| wgpu::ColorWrites::from_bits_truncate(m as u32))
691                    .unwrap_or(wgpu::ColorWrites::ALL);
692                targets.push(Some(wgpu::ColorTargetState { format, blend, write_mask }));
693            }
694        } else {
695            fragment_module = None;
696        }
697
698        let p = d.get("primitive");
699        let ps = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_str);
700        let primitive = wgpu::PrimitiveState {
701            topology: match ps("topology").unwrap_or("triangle-list") {
702                "point-list" => wgpu::PrimitiveTopology::PointList,
703                "line-list" => wgpu::PrimitiveTopology::LineList,
704                "line-strip" => wgpu::PrimitiveTopology::LineStrip,
705                "triangle-list" => wgpu::PrimitiveTopology::TriangleList,
706                "triangle-strip" => wgpu::PrimitiveTopology::TriangleStrip,
707                o => return Err(format!("pipeline: unknown topology {o:?}")),
708            },
709            strip_index_format: match ps("stripIndexFormat") {
710                Some("uint16") => Some(wgpu::IndexFormat::Uint16),
711                Some("uint32") => Some(wgpu::IndexFormat::Uint32),
712                _ => None,
713            },
714            front_face: match ps("frontFace").unwrap_or("ccw") {
715                "cw" => wgpu::FrontFace::Cw,
716                _ => wgpu::FrontFace::Ccw,
717            },
718            cull_mode: match ps("cullMode").unwrap_or("none") {
719                "front" => Some(wgpu::Face::Front),
720                "back" => Some(wgpu::Face::Back),
721                _ => None,
722            },
723            ..Default::default()
724        };
725
726        let depth_stencil = match d.get("depthStencil") {
727            Some(ds) => {
728                if let Some(f) = ds.get("format").and_then(Value::as_str)
729                    && f != "depth24plus"
730                {
731                    return Err(format!("pipeline: depthStencil format must be \"depth24plus\", got {f:?}"));
732                }
733                Some(wgpu::DepthStencilState {
734                    format: DEPTH_FORMAT,
735                    depth_write_enabled: Some(ds.get("depthWriteEnabled").and_then(Value::as_bool).unwrap_or(false)),
736                    depth_compare: Some(compare(ds.get("depthCompare").and_then(Value::as_str).unwrap_or("always"))?),
737                    stencil: Default::default(),
738                    bias: wgpu::DepthBiasState {
739                        constant: ds.get("depthBias").and_then(Value::as_i64).unwrap_or(0) as i32,
740                        slope_scale: ds.get("depthBiasSlopeScale").and_then(Value::as_f64).unwrap_or(0.0) as f32,
741                        clamp: ds.get("depthBiasClamp").and_then(Value::as_f64).unwrap_or(0.0) as f32,
742                    },
743                })
744            }
745            // The render pass always has a depth attachment, so a pipeline without
746            // depthStencil gets a no-op one (no test, no writes) to stay compatible.
747            None => Some(wgpu::DepthStencilState {
748                format: DEPTH_FORMAT,
749                depth_write_enabled: Some(false),
750                depth_compare: Some(wgpu::CompareFunction::Always),
751                stencil: Default::default(),
752                bias: Default::default(),
753            }),
754        };
755
756        let entry = |s: &Value| s.get("entryPoint").and_then(Value::as_str).map(str::to_owned);
757        let (v_entry, f_entry) = (entry(v), f.and_then(entry));
758        let bgls = match &group_layouts {
759            Some(hs) => Some(hs.iter().map(|h| self.layout(*h)).collect::<R<Vec<_>>>()?),
760            None => None,
761        };
762        self.scoped("create_pipeline", |gpu| {
763            let layout = bgls.map(|ls| {
764                let refs: Vec<Option<&wgpu::BindGroupLayout>> = ls.into_iter().map(Some).collect();
765                gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &refs, immediate_size: 0 })
766            });
767            Ok(gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
768                label: None,
769                layout: layout.as_ref(), // None = "auto"
770                vertex: wgpu::VertexState {
771                    module: vmod,
772                    entry_point: v_entry.as_deref(),
773                    compilation_options: Default::default(),
774                    buffers: &layouts,
775                },
776                primitive,
777                depth_stencil,
778                multisample: wgpu::MultisampleState { count: SAMPLE_COUNT, ..Default::default() },
779                fragment: fragment_module.map(|m| wgpu::FragmentState {
780                    module: m,
781                    entry_point: f_entry.as_deref(),
782                    compilation_options: Default::default(),
783                    targets: &targets,
784                }),
785                multiview_mask: None,
786                cache: None,
787            }))
788        })
789    }
790
791    // ---- frame -----------------------------------------------------------------------------
792
793    /// Start a frame. `show`: whether the runner will display it.
794    pub fn begin_frame(&mut self, clear: [f32; 4], show: bool) -> R<bool> {
795        if self.frame.is_some() {
796            return Err("gfx.begin_frame called twice without end_frame".into());
797        }
798        self.used = true;
799        let (Some(gpu), Some(target), true) = (&self.gpu, &self.target, show) else { return Ok(false) };
800        let (resolve_view, surface_texture) = match target {
801            Target::Window { surface, config } => {
802                let st = match surface.get_current_texture() {
803                    wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
804                    wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
805                        surface.configure(&gpu.device, config);
806                        return Ok(false);
807                    }
808                    _ => return Ok(false), // timeout / occluded: skip this frame
809                };
810                (st.texture.create_view(&Default::default()), Some(st))
811            }
812            Target::Offscreen { texture } => (texture.create_view(&Default::default()), None),
813        };
814        let mut encoder = gpu.device.create_command_encoder(&Default::default());
815        let c = clear.map(|v| v as f64);
816        let pass = encoder
817            .begin_render_pass(&wgpu::RenderPassDescriptor {
818                label: Some("guest"),
819                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
820                    view: self.msaa.as_ref().unwrap(),
821                    depth_slice: None,
822                    resolve_target: Some(&resolve_view),
823                    ops: wgpu::Operations {
824                        load: wgpu::LoadOp::Clear(wgpu::Color { r: c[0], g: c[1], b: c[2], a: c[3] }),
825                        store: wgpu::StoreOp::Discard,
826                    },
827                })],
828                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
829                    view: self.depth.as_ref().unwrap(),
830                    depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }),
831                    stencil_ops: None,
832                }),
833                timestamp_writes: None,
834                occlusion_query_set: None,
835                multiview_mask: None,
836            })
837            .forget_lifetime();
838        self.frame = Some(Frame { pass, encoder, surface_texture, empty_viewport: false, empty_scissor: false });
839        Ok(true)
840    }
841
842    pub fn set_pipeline(&mut self, h: u32) -> R<()> {
843        if self.gpu.is_none() { return self.get(h).map(|_| ()) }
844        let p = self.pipeline(h)?.clone();
845        if let Some(f) = &mut self.frame { f.pass.set_pipeline(&p); }
846        Ok(())
847    }
848
849    pub fn set_bind_group(&mut self, index: u32, h: u32) -> R<()> {
850        if self.gpu.is_none() { return self.get(h).map(|_| ()) }
851        let bg = self.bind_group(h)?.clone();
852        if let Some(f) = &mut self.frame { f.pass.set_bind_group(index, &bg, &[]); }
853        Ok(())
854    }
855
856    pub fn set_bind_group_offsets(&mut self, index: u32, h: u32, offsets: &[u32]) -> R<()> {
857        let Meta::BindGroup { dynamic } = self.expect(h, "bind group")? else { unreachable!() };
858        if offsets.len() != dynamic.len() {
859            return Err(format!("gfx.set_bind_group_offsets: bind group {h} has {} dynamic entries, got {} offsets", dynamic.len(), offsets.len()));
860        }
861        for (o, d) in offsets.iter().zip(dynamic) {
862            if o % OFFSET_ALIGNMENT != 0 {
863                return Err(format!("gfx.set_bind_group_offsets: offset {o} is not a multiple of {OFFSET_ALIGNMENT}"));
864            }
865            if *o as u64 + d.offset + d.size > d.buffer_size {
866                return Err(format!("gfx.set_bind_group_offsets: offset {o} + binding {}+{} exceeds buffer size {}", d.offset, d.size, d.buffer_size));
867            }
868        }
869        if self.gpu.is_none() { return Ok(()) }
870        let bg = self.bind_group(h)?.clone();
871        if let Some(f) = &mut self.frame { f.pass.set_bind_group(index, &bg, offsets); }
872        Ok(())
873    }
874
875    /// Clamp `[x, x+w) x [y, y+h)` to the drawable.
876    fn clamp_rect(&self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64, f64, f64) {
877        let (dw, dh) = (self.width as f64, self.height as f64);
878        let (x0, y0) = (x.clamp(0.0, dw), y.clamp(0.0, dh));
879        let (x1, y1) = ((x + w).clamp(0.0, dw), (y + h).clamp(0.0, dh));
880        (x0, y0, x1 - x0, y1 - y0)
881    }
882
883    pub fn set_viewport(&mut self, x: f32, y: f32, w: f32, h: f32, min_depth: f32, max_depth: f32) -> R<()> {
884        if ![x, y, w, h, min_depth, max_depth].iter().all(|v| v.is_finite()) || w < 0.0 || h < 0.0 {
885            return Err(format!("gfx.set_viewport: invalid rectangle {x},{y} {w}x{h}"));
886        }
887        if !(0.0..=1.0).contains(&min_depth) || !(0.0..=1.0).contains(&max_depth) || min_depth > max_depth {
888            return Err(format!("gfx.set_viewport: depth range {min_depth}..{max_depth} must be within 0..1"));
889        }
890        let (x, y, w, h) = self.clamp_rect(x as f64, y as f64, w as f64, h as f64);
891        if let Some(f) = &mut self.frame {
892            f.empty_viewport = w <= 0.0 || h <= 0.0;
893            if !f.empty_viewport {
894                f.pass.set_viewport(x as f32, y as f32, w as f32, h as f32, min_depth, max_depth);
895            }
896        }
897        Ok(())
898    }
899
900    pub fn set_scissor_rect(&mut self, x: u32, y: u32, w: u32, h: u32) -> R<()> {
901        let (x, y, w, h) = self.clamp_rect(x as f64, y as f64, w as f64, h as f64);
902        if let Some(f) = &mut self.frame {
903            f.empty_scissor = w <= 0.0 || h <= 0.0;
904            if !f.empty_scissor {
905                f.pass.set_scissor_rect(x as u32, y as u32, w as u32, h as u32);
906            }
907        }
908        Ok(())
909    }
910
911    pub fn set_vertex_buffer(&mut self, slot: u32, h: u32, offset: u32) -> R<()> {
912        if self.gpu.is_none() { return self.get(h).map(|_| ()) }
913        let b = self.buffer(h)?.clone();
914        if let Some(f) = &mut self.frame { f.pass.set_vertex_buffer(slot, b.slice(offset as u64..)); }
915        Ok(())
916    }
917
918    pub fn set_index_buffer(&mut self, h: u32, format: u32, offset: u32) -> R<()> {
919        if self.gpu.is_none() { return self.get(h).map(|_| ()) }
920        let b = self.buffer(h)?.clone();
921        let fmt = if format == 1 { wgpu::IndexFormat::Uint32 } else { wgpu::IndexFormat::Uint16 };
922        if let Some(f) = &mut self.frame { f.pass.set_index_buffer(b.slice(offset as u64..), fmt); }
923        Ok(())
924    }
925
926    pub fn draw(&mut self, vc: u32, ic: u32, fv: u32, fi: u32) {
927        if let Some(f) = &mut self.frame
928            && !f.empty_viewport
929            && !f.empty_scissor
930        {
931            f.pass.draw(fv..fv + vc, fi..fi + ic);
932        }
933    }
934
935    pub fn draw_indexed(&mut self, ic: u32, inst: u32, first: u32, base: i32, fi: u32) {
936        if let Some(f) = &mut self.frame
937            && !f.empty_viewport
938            && !f.empty_scissor
939        {
940            f.pass.draw_indexed(first..first + ic, base, fi..fi + inst);
941        }
942    }
943
944    pub fn end_frame(&mut self) -> R<()> {
945        let Some(Frame { pass, encoder, surface_texture, .. }) = self.frame.take() else { return Ok(()) };
946        drop(pass);
947        let gpu = self.gpu.as_ref().unwrap();
948        gpu.queue.submit([encoder.finish()]);
949        if let Some(st) = surface_texture {
950            gpu.queue.present(st);
951        }
952        Ok(())
953    }
954
955    // ---- 2D path: show a video_present frame -----------------------------------------------
956
957    /// Letterboxed nearest-neighbour blit of an RGBA frame to the window.
958    pub fn present_video(&mut self, rgba: &[u8], w: u32, h: u32) {
959        let (Some(gpu), Some(Target::Window { surface, config }), Some(blit)) = (&self.gpu, &self.target, &mut self.blit) else { return };
960        let st = match surface.get_current_texture() {
961            wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
962            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
963                surface.configure(&gpu.device, config);
964                return;
965            }
966            _ => return,
967        };
968        blit.upload(gpu, rgba, w, h);
969        let view = st.texture.create_view(&Default::default());
970        let mut encoder = gpu.device.create_command_encoder(&Default::default());
971        {
972            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
973                label: Some("blit"),
974                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
975                    view: &view,
976                    depth_slice: None,
977                    resolve_target: None,
978                    ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
979                })],
980                ..Default::default()
981            });
982            // letterbox: largest rect with the frame's aspect ratio
983            let (sw, sh) = (self.width as f32, self.height as f32);
984            let scale = (sw / w as f32).min(sh / h as f32);
985            let (vw, vh) = (w as f32 * scale, h as f32 * scale);
986            pass.set_viewport((sw - vw) / 2.0, (sh - vh) / 2.0, vw, vh, 0.0, 1.0);
987            pass.set_pipeline(&blit.pipeline);
988            pass.set_bind_group(0, &blit.texture.as_ref().unwrap().1, &[]);
989            pass.draw(0..3, 0..1);
990        }
991        gpu.queue.submit([encoder.finish()]);
992        gpu.queue.present(st);
993    }
994
995    /// Read back the offscreen target as tightly packed RGBA8 (headless screenshots).
996    pub fn read_offscreen(&self) -> Option<(u32, u32, Vec<u8>)> {
997        let (Some(gpu), Some(Target::Offscreen { texture })) = (&self.gpu, &self.target) else { return None };
998        let (w, h) = (self.width, self.height);
999        let row = (w * 4).div_ceil(256) * 256;
1000        let buf = gpu.device.create_buffer(&wgpu::BufferDescriptor {
1001            label: Some("readback"),
1002            size: (row * h) as u64,
1003            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1004            mapped_at_creation: false,
1005        });
1006        let mut enc = gpu.device.create_command_encoder(&Default::default());
1007        enc.copy_texture_to_buffer(
1008            texture.as_image_copy(),
1009            wgpu::TexelCopyBufferInfo {
1010                buffer: &buf,
1011                layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(h) },
1012            },
1013            wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
1014        );
1015        gpu.queue.submit([enc.finish()]);
1016        buf.slice(..).map_async(wgpu::MapMode::Read, |_| {});
1017        gpu.device.poll(wgpu::PollType::wait_indefinitely()).ok()?;
1018        let data = buf.slice(..).get_mapped_range().ok()?;
1019        let mut out = Vec::with_capacity((w * h * 4) as usize);
1020        for y in 0..h as usize {
1021            out.extend_from_slice(&data[y * row as usize..y * row as usize + (w * 4) as usize]);
1022        }
1023        Some((w, h, out))
1024    }
1025}
1026
1027fn open_device(
1028    adapter: &wgpu::Adapter,
1029    choose: impl FnOnce(&[wgpu::TextureFormat]) -> wgpu::TextureFormat,
1030    surface: &wgpu::Surface,
1031) -> R<Gpu> {
1032    let (device, queue) =
1033        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).map_err(|e| e.to_string())?;
1034    device.on_uncaptured_error(Arc::new(|e| eprintln!("[gasm] gpu error: {e}")));
1035    let caps = surface.get_capabilities(adapter);
1036    let format = choose(&caps.formats);
1037    Ok(Gpu { device, queue, format })
1038}
1039
1040/// Prefer a non-sRGB 8-bit format: browsers' canvas formats are non-sRGB, so
1041/// guests look the same on both runners.
1042fn pick_format(formats: &[wgpu::TextureFormat]) -> wgpu::TextureFormat {
1043    use wgpu::TextureFormat as F;
1044    [F::Bgra8Unorm, F::Rgba8Unorm].into_iter().find(|f| formats.contains(f)).unwrap_or(formats[0])
1045}
1046
1047fn uint(v: &Value, key: &str) -> R<u32> {
1048    v.get(key).and_then(Value::as_u64).map(|n| n as u32).ok_or_else(|| format!("descriptor: missing number {key:?}"))
1049}
1050
1051fn string<'a>(v: &'a Value, key: &str) -> R<&'a str> {
1052    v.get(key).and_then(Value::as_str).ok_or_else(|| format!("descriptor: missing string {key:?}"))
1053}
1054
1055fn vertex_format(s: &str) -> R<wgpu::VertexFormat> {
1056    use wgpu::VertexFormat as V;
1057    Ok(match s {
1058        "float32" => V::Float32,
1059        "float32x2" => V::Float32x2,
1060        "float32x3" => V::Float32x3,
1061        "float32x4" => V::Float32x4,
1062        "uint32" => V::Uint32,
1063        "uint32x2" => V::Uint32x2,
1064        "uint32x3" => V::Uint32x3,
1065        "uint32x4" => V::Uint32x4,
1066        "sint32" => V::Sint32,
1067        "sint32x2" => V::Sint32x2,
1068        "sint32x3" => V::Sint32x3,
1069        "sint32x4" => V::Sint32x4,
1070        "uint8x4" => V::Uint8x4,
1071        "unorm8x4" => V::Unorm8x4,
1072        "uint16x2" => V::Uint16x2,
1073        "uint16x4" => V::Uint16x4,
1074        "float16x2" => V::Float16x2,
1075        "float16x4" => V::Float16x4,
1076        o => return Err(format!("unsupported vertex format {o:?}")),
1077    })
1078}
1079
1080fn blend_component(v: Option<&Value>) -> R<wgpu::BlendComponent> {
1081    let Some(v) = v else { return Ok(wgpu::BlendComponent::REPLACE) };
1082    let factor = |k: &str, default: wgpu::BlendFactor| -> R<wgpu::BlendFactor> {
1083        use wgpu::BlendFactor as B;
1084        Ok(match v.get(k).and_then(Value::as_str) {
1085            None => default,
1086            Some("zero") => B::Zero,
1087            Some("one") => B::One,
1088            Some("src") => B::Src,
1089            Some("one-minus-src") => B::OneMinusSrc,
1090            Some("src-alpha") => B::SrcAlpha,
1091            Some("one-minus-src-alpha") => B::OneMinusSrcAlpha,
1092            Some("dst") => B::Dst,
1093            Some("one-minus-dst") => B::OneMinusDst,
1094            Some("dst-alpha") => B::DstAlpha,
1095            Some("one-minus-dst-alpha") => B::OneMinusDstAlpha,
1096            Some("src-alpha-saturated") => B::SrcAlphaSaturated,
1097            Some("constant") => B::Constant,
1098            Some("one-minus-constant") => B::OneMinusConstant,
1099            Some(o) => return Err(format!("unsupported blend factor {o:?}")),
1100        })
1101    };
1102    use wgpu::BlendOperation as O;
1103    Ok(wgpu::BlendComponent {
1104        src_factor: factor("srcFactor", wgpu::BlendFactor::One)?,
1105        dst_factor: factor("dstFactor", wgpu::BlendFactor::Zero)?,
1106        operation: match v.get("operation").and_then(Value::as_str).unwrap_or("add") {
1107            "add" => O::Add,
1108            "subtract" => O::Subtract,
1109            "reverse-subtract" => O::ReverseSubtract,
1110            "min" => O::Min,
1111            "max" => O::Max,
1112            o => return Err(format!("unsupported blend operation {o:?}")),
1113        },
1114    })
1115}
1116
1117fn compare(s: &str) -> R<wgpu::CompareFunction> {
1118    use wgpu::CompareFunction as C;
1119    Ok(match s {
1120        "never" => C::Never,
1121        "less" => C::Less,
1122        "equal" => C::Equal,
1123        "less-equal" => C::LessEqual,
1124        "greater" => C::Greater,
1125        "not-equal" => C::NotEqual,
1126        "greater-equal" => C::GreaterEqual,
1127        "always" => C::Always,
1128        o => return Err(format!("unsupported compare function {o:?}")),
1129    })
1130}
1131
1132impl Blit {
1133    const SHADER: &str = r#"
1134@group(0) @binding(0) var t: texture_2d<f32>;
1135@group(0) @binding(1) var s: sampler;
1136struct VO { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32> };
1137@vertex fn vs(@builtin(vertex_index) i: u32) -> VO {
1138  let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
1139  var o: VO;
1140  o.pos = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
1141  o.uv = uv;
1142  return o;
1143}
1144@fragment fn fs(i: VO) -> @location(0) vec4<f32> { return textureSample(t, s, i.uv); }
1145"#;
1146
1147    fn new(gpu: &Gpu) -> Blit {
1148        let module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
1149            label: Some("blit"),
1150            source: wgpu::ShaderSource::Wgsl(Self::SHADER.into()),
1151        });
1152        let pipeline = gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1153            label: Some("blit"),
1154            layout: None,
1155            vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[] },
1156            primitive: Default::default(),
1157            depth_stencil: None,
1158            multisample: Default::default(),
1159            fragment: Some(wgpu::FragmentState {
1160                module: &module,
1161                entry_point: Some("fs"),
1162                compilation_options: Default::default(),
1163                targets: &[Some(gpu.format.into())],
1164            }),
1165            multiview_mask: None,
1166            cache: None,
1167        });
1168        let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
1169            mag_filter: wgpu::FilterMode::Nearest,
1170            min_filter: wgpu::FilterMode::Nearest,
1171            ..Default::default()
1172        });
1173        Blit { pipeline, sampler, texture: None }
1174    }
1175
1176    fn upload(&mut self, gpu: &Gpu, rgba: &[u8], w: u32, h: u32) {
1177        if !matches!(&self.texture, Some((_, _, tw, th)) if (*tw, *th) == (w, h)) {
1178            let tex = gpu.device.create_texture_with_data(
1179                &gpu.queue,
1180                &wgpu::TextureDescriptor {
1181                    label: Some("video"),
1182                    size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
1183                    mip_level_count: 1,
1184                    sample_count: 1,
1185                    dimension: wgpu::TextureDimension::D2,
1186                    format: wgpu::TextureFormat::Rgba8Unorm,
1187                    usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1188                    view_formats: &[],
1189                },
1190                Default::default(),
1191                rgba,
1192            );
1193            let bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1194                label: None,
1195                layout: &self.pipeline.get_bind_group_layout(0),
1196                entries: &[
1197                    wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&tex.create_view(&Default::default())) },
1198                    wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) },
1199                ],
1200            });
1201            self.texture = Some((tex, bg, w, h));
1202            return;
1203        }
1204        let tex = &self.texture.as_ref().unwrap().0;
1205        gpu.queue.write_texture(
1206            tex.as_image_copy(),
1207            rgba,
1208            wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * 4), rows_per_image: Some(h) },
1209            wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
1210        );
1211    }
1212}