Skip to main content

euv_engine/renderer/webgpu/
impl.rs

1use super::*;
2
3/// Implements async initialization and GPU resource creation for `WebGpuRenderer`.
4impl WebGpuRenderer {
5    /// Builds a renderer from fully prepared GPU handles. Production code
6    /// obtains the handles through `init`; this constructor exists so tests
7    /// can drive the renderer with `Reflect` stand-in objects.
8    ///
9    /// # Arguments
10    ///
11    /// - `WebGpuRendererInit` - The device, queue, context, canvas, format,
12    ///   dimensions, and antialias flag the renderer starts with.
13    ///
14    /// # Returns
15    ///
16    /// - `WebGpuRenderer` - The renderer with all caches empty.
17    pub fn new(init: WebGpuRendererInit) -> Self {
18        Self {
19            device: init.device,
20            queue: init.queue,
21            context: init.context,
22            canvas: init.canvas,
23            format: init.format,
24            width: init.width,
25            height: init.height,
26            antialias: init.antialias,
27            multisample_texture: None,
28            multisample_view: None,
29            depth_texture: None,
30            depth_view: None,
31            depth_format: None,
32            device_lost_callback: None,
33            device_lost: false,
34            pending_error: Rc::new(PendingErrorCell::new()),
35            command_encoder: None,
36            render_pass_descriptor_cache: None,
37        }
38    }
39
40    /// Returns `true` if `navigator.gpu` is exposed on the current origin.
41    ///
42    /// This is the synchronous half of the canonical WebGPU capability
43    /// probe used by Three.js (`examples/jsm/capabilities/WebGPU.js`): it
44    /// only checks that the browser surfaces the `GPU` interface at all.
45    /// It does **not** request an adapter — a present `navigator.gpu`
46    /// does not guarantee that a usable GPU adapter is reachable (Linux
47    /// software-rendered sessions, headless browsers, GPU-blacklisted
48    /// devices and sandboxed iframes all expose `navigator.gpu` while
49    /// `requestAdapter()` resolves to `null` or hangs forever).
50    ///
51    /// Use this as the cheapest pre-flight check before showing a
52    /// "needs HTTPS or localhost" prompt. For a definitive answer use
53    /// [`Self::probe`] which also awaits `requestAdapter()`.
54    ///
55    /// # Returns
56    ///
57    /// - `bool` - `true` when `navigator.gpu` is a non-null, non-undefined
58    ///   object; `false` otherwise (including the "no `window`" runtime
59    ///   case, which `window()` returns `None` for).
60    pub fn is_available() -> bool {
61        let window_value: Window = match window() {
62            Some(value) => value,
63            None => return false,
64        };
65        let navigator: Navigator = window_value.navigator();
66        let gpu_result: Result<JsValue, JsValue> = Reflect::get(
67            navigator.as_ref(),
68            &JsValue::from_str(WEBGPU_NAVIGATOR_GPU_KEY),
69        );
70        match gpu_result {
71            Ok(value) => !value.is_undefined() && !value.is_null(),
72            Err(_) => false,
73        }
74    }
75
76    /// Probes whether a WebGPU adapter can actually be acquired.
77    ///
78    /// Mirrors Three.js' canonical capability probe exactly:
79    ///
80    /// Wraps the adapter request in the same `Promise.race` timeout used
81    /// by [`Self::init`] so that browsers which leave the adapter promise
82    /// permanently pending (headless, sandboxed, device-lost) do not stall
83    /// the UI forever. The timeout itself uses the
84    /// `INIT_PROMISE_TIMEOUT_MILLIS` constant; on timeout, `probe` returns
85    /// `false` rather than an error so callers can treat it the same as
86    /// "no adapter".
87    ///
88    /// # Returns
89    ///
90    /// - `bool` - `true` only when both `navigator.gpu` is present and
91    ///   `requestAdapter()` resolves to a non-null adapter within the
92    ///   timeout window. `false` covers every other case (no `window`,
93    ///   missing `navigator.gpu`, reflect exception, adapter promise
94    ///   rejected or timed out, adapter resolved to `null`/`undefined`).
95    pub async fn probe() -> bool {
96        if !Self::is_available() {
97            return false;
98        }
99        let window_value: Window = match window() {
100            Some(value) => value,
101            None => return false,
102        };
103        let navigator: Navigator = window_value.navigator();
104        let gpu: JsValue = match Reflect::get(
105            navigator.as_ref(),
106            &JsValue::from_str(WEBGPU_NAVIGATOR_GPU_KEY),
107        ) {
108            Ok(value) => value,
109            Err(_) => return false,
110        };
111        let request_adapter_fn: Function =
112            match Reflect::get(&gpu, &JsValue::from_str(WEBGPU_METHOD_REQUEST_ADAPTER)) {
113                Ok(value) => value.unchecked_into(),
114                Err(_) => return false,
115            };
116        let adapter_promise: Promise = match request_adapter_fn.call0(&gpu) {
117            Ok(value) => value.unchecked_into(),
118            Err(_) => return false,
119        };
120        let adapter_value: JsValue =
121            match JsFuture::from(Self::race_with_timeout(adapter_promise)).await {
122                Ok(value) => value,
123                Err(_) => return false,
124            };
125        !adapter_value.is_undefined() && !adapter_value.is_null()
126    }
127
128    /// Builds a `Promise` that rejects after `INIT_PROMISE_TIMEOUT_MILLIS`.
129    ///
130    /// Maximum time in milliseconds to wait for `requestAdapter` and
131    /// `requestDevice` before treating them as failed.
132    ///
133    /// Some browser GPU states (headless, no GPU, sandboxed, device-lost)
134    /// leave the WebGPU adapter/device promises permanently pending instead
135    /// of resolving to `null` or rejecting. Without a timeout the
136    /// `JsFuture::from(...).await` inside `init` would hang forever and
137    /// the UI would stay stuck on `Initializing...`. Wrapping each promise
138    /// in `Promise.race` against a timer-rejected sibling forces the
139    /// future to resolve so the caller's error branch can run and report
140    /// `WebGPU Not Supported`.
141    ///
142    /// # Returns
143    ///
144    /// - `Promise` - A promise that rejects with
145    ///   `RENDERER_TIMEOUT_ERROR_MESSAGE` once the timeout elapses. When no
146    ///   `window` exists the returned promise rejects immediately.
147    fn timeout_promise() -> Promise {
148        let Some(window_value) = window() else {
149            return Promise::new(&mut |_resolve: Function, reject: Function| {
150                let _: Result<JsValue, JsValue> = reject.call1(
151                    &JsValue::UNDEFINED,
152                    &JsValue::from_str(RENDERER_TIMEOUT_ERROR_MESSAGE),
153                );
154            });
155        };
156        Promise::new(&mut |_resolve: Function, reject: Function| {
157            let reject_fn: Function = reject.clone();
158            let timer: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
159                let _: Result<JsValue, JsValue> = reject_fn.call1(
160                    &JsValue::UNDEFINED,
161                    &JsValue::from_str(RENDERER_TIMEOUT_ERROR_MESSAGE),
162                );
163            }));
164            let _: Result<i32, JsValue> = window_value
165                .set_timeout_with_callback_and_timeout_and_arguments_0(
166                    timer.as_ref().unchecked_ref(),
167                    INIT_PROMISE_TIMEOUT_MILLIS,
168                );
169            timer.forget();
170        })
171    }
172
173    /// Wraps `promise` in `Promise.race([promise, timeout_promise()])` so that
174    /// awaiting it never blocks longer than `INIT_PROMISE_TIMEOUT_MILLIS`.
175    ///
176    /// Calls `Promise.race` via reflection because wasm-bindgen does not
177    /// currently expose the static `race` method on `Promise`.
178    ///
179    /// # Arguments
180    ///
181    /// - `Promise` - A `Promise` parameter.
182    ///
183    /// # Returns
184    ///
185    /// - `Promise` - A `Promise` value.
186    fn race_with_timeout(promise: Promise) -> Promise {
187        let array: Array = Array::of2(&promise, &Self::timeout_promise());
188        Promise::race(&array)
189    }
190
191    /// Asynchronously initializes a WebGPU renderer from the given render configuration.
192    ///
193    /// Requests a GPU adapter and device, obtains the WebGPU canvas context,
194    /// and configures it with the preferred texture format. Returns `Err` if
195    /// WebGPU is not supported, the adapter/device request fails, the canvas
196    /// element is not found, or the adapter/device request hangs beyond
197    /// `INIT_PROMISE_TIMEOUT_MILLIS` (a defensive timeout for browser GPU
198    /// states that leave the WebGPU promises permanently pending).
199    ///
200    /// The engine no longer logs diagnostic output internally; instead each
201    /// failure mode is returned as a distinct `WebGpuInitError` variant so
202    /// the caller can decide how to surface it (typically via `Console::error`
203    /// or by falling back to the Canvas 2D backend).
204    ///
205    /// # Arguments
206    ///
207    /// - `&RenderConfig` - The rendering configuration.
208    ///
209    /// # Returns
210    ///
211    /// - `Result<WebGpuRenderer, WebGpuInitError>` - The initialized renderer, or
212    ///   a typed error describing the specific failure.
213    pub async fn init(config: &RenderConfig) -> Result<WebGpuRenderer, WebGpuInitError> {
214        let Some(window) = window() else {
215            return Err(WebGpuInitError::NavigatorGpuMissing);
216        };
217        let navigator: Navigator = window.navigator();
218        let gpu_result: Result<JsValue, JsValue> = Reflect::get(
219            navigator.as_ref(),
220            &JsValue::from_str(WEBGPU_NAVIGATOR_GPU_KEY),
221        );
222        let gpu: JsValue = match gpu_result {
223            Ok(value) => value,
224            Err(err) => return Err(WebGpuInitError::NavigatorLookup(err)),
225        };
226        if gpu.is_undefined() || gpu.is_null() {
227            return Err(WebGpuInitError::NavigatorGpuMissing);
228        }
229        let adapter_options: Object = Object::new();
230        let _: Result<bool, JsValue> = Reflect::set(
231            &adapter_options,
232            &JsValue::from_str(WEBGPU_PROPERTY_POWER_PREFERENCE),
233            &JsValue::from_str(config.power_preference.to_web_sys_string()),
234        );
235        let request_adapter_fn: Function =
236            match Reflect::get(&gpu, &JsValue::from_str(WEBGPU_METHOD_REQUEST_ADAPTER)) {
237                Ok(value) => value.unchecked_into(),
238                Err(err) => return Err(WebGpuInitError::RequestAdapterLookup(err)),
239            };
240        let adapter_promise: Promise = match request_adapter_fn.call1(&gpu, &adapter_options) {
241            Ok(value) => value.unchecked_into(),
242            Err(err) => return Err(WebGpuInitError::RequestAdapterCall(err)),
243        };
244        let adapter_value: JsValue =
245            match JsFuture::from(Self::race_with_timeout(adapter_promise)).await {
246                Ok(value) => value,
247                Err(err) => return Err(WebGpuInitError::AdapterPromise(err)),
248            };
249        if adapter_value.is_null() || adapter_value.is_undefined() {
250            return Err(WebGpuInitError::AdapterUnavailable);
251        }
252        let device_descriptor: Object = Object::new();
253        let request_device_fn: Function = match Reflect::get(
254            &adapter_value,
255            &JsValue::from_str(WEBGPU_METHOD_REQUEST_DEVICE),
256        ) {
257            Ok(value) => value.unchecked_into(),
258            Err(err) => return Err(WebGpuInitError::RequestDeviceLookup(err)),
259        };
260        let device_promise: Promise =
261            match request_device_fn.call1(&adapter_value, &device_descriptor) {
262                Ok(value) => value.unchecked_into(),
263                Err(err) => return Err(WebGpuInitError::RequestDeviceCall(err)),
264            };
265        let device_value: JsValue =
266            match JsFuture::from(Self::race_with_timeout(device_promise)).await {
267                Ok(value) => value,
268                Err(err) => return Err(WebGpuInitError::DevicePromise(err)),
269            };
270        if device_value.is_null() || device_value.is_undefined() {
271            return Err(WebGpuInitError::DeviceUnavailable);
272        }
273        let Some(document) = window.document() else {
274            return Err(WebGpuInitError::CanvasNotFound(
275                config.canvas_selector.clone(),
276            ));
277        };
278        let element: Element = match document.query_selector(&config.canvas_selector) {
279            Ok(Some(el)) => el,
280            Ok(None) => {
281                return Err(WebGpuInitError::CanvasNotFound(
282                    config.canvas_selector.clone(),
283                ));
284            }
285            Err(err) => return Err(WebGpuInitError::CanvasQuery(err)),
286        };
287        let canvas: HtmlCanvasElement = element.unchecked_into();
288        let context_object: Option<Object> = canvas.get_context(WEBGPU_CONTEXT_TYPE).ok().flatten();
289        let context_object: Object = match context_object {
290            Some(c) => c,
291            None => return Err(WebGpuInitError::CanvasContextUnavailable),
292        };
293        let context: JsValue = context_object.into();
294        let get_format_fn: Function =
295            match Reflect::get(&gpu, &JsValue::from_str(WEBGPU_METHOD_GET_PREFERRED_FORMAT)) {
296                Ok(value) => value.unchecked_into(),
297                Err(err) => return Err(WebGpuInitError::PreferredFormatLookup(err)),
298            };
299        let format_value: JsValue = match get_format_fn.call0(&gpu) {
300            Ok(value) => value,
301            Err(err) => return Err(WebGpuInitError::PreferredFormatCall(err)),
302        };
303        let format: String = match format_value.as_string() {
304            Some(s) => s,
305            None => return Err(WebGpuInitError::PreferredFormatType(format_value)),
306        };
307        // WebGPU's `configure` requires the canvas backing-store size to be
308        // set BEFORE calling configure, otherwise the swap chain is created
309        // at 0x0 and the first getCurrentTexture() returns an error.
310        let dpr: f64 = CanvasRenderer::detect_dpr();
311        let physical_width: u32 = (config.width * dpr).round() as u32;
312        let physical_height: u32 = (config.height * dpr).round() as u32;
313        canvas.set_width(physical_width);
314        canvas.set_height(physical_height);
315        let canvas_config: Object = Object::new();
316        let _: Result<bool, JsValue> = Reflect::set(
317            &canvas_config,
318            &JsValue::from_str(WEBGPU_PROPERTY_DEVICE),
319            &device_value,
320        );
321        let _: Result<bool, JsValue> = Reflect::set(
322            &canvas_config,
323            &JsValue::from_str(WEBGPU_PROPERTY_FORMAT),
324            &format_value,
325        );
326        let configure_fn: Function =
327            match Reflect::get(&context, &JsValue::from_str(WEBGPU_METHOD_CONFIGURE)) {
328                Ok(value) => value.unchecked_into(),
329                Err(err) => return Err(WebGpuInitError::ConfigureLookup(err)),
330            };
331        let _: Result<JsValue, JsValue> = configure_fn.call1(&context, &canvas_config);
332        let queue: JsValue =
333            match Reflect::get(&device_value, &JsValue::from_str(WEBGPU_PROPERTY_QUEUE)) {
334                Ok(value) => value,
335                Err(err) => return Err(WebGpuInitError::QueueLookup(err)),
336            };
337        Ok(WebGpuRenderer {
338            device: device_value,
339            queue,
340            context,
341            canvas,
342            format,
343            width: physical_width,
344            height: physical_height,
345            antialias: config.antialias,
346            multisample_texture: None,
347            multisample_view: None,
348            depth_texture: None,
349            depth_view: None,
350            depth_format: None,
351            device_lost_callback: None,
352            device_lost: false,
353            pending_error: Rc::new(PendingErrorCell::new()),
354            command_encoder: None,
355            render_pass_descriptor_cache: None,
356        })
357    }
358
359    /// Allocates the multisampled intermediate texture used for MSAA.
360    ///
361    /// The returned tuple is `(GpuTexture, GpuTextureView)`:
362    /// - `GpuTexture` has `sampleCount: 4` and `usage: RENDER_ATTACHMENT`
363    ///   so it can be bound as a color attachment in `beginRenderPass`.
364    /// - `GpuTextureView` is the default 2D view used as the color
365    ///   attachment; the swap chain view is the `resolveTarget`.
366    ///
367    /// The texture size must match the swap chain physical size; mismatches
368    /// are a WebGPU validation error. Returns `(JsValue::UNDEFINED,
369    /// JsValue::UNDEFINED)` when allocation fails so callers can detect and
370    /// fall back to MSAA=1.
371    ///
372    /// # Arguments
373    ///
374    /// - `u32` - Physical pixel width (DPR-multiplied).
375    ///
376    /// # Returns
377    ///
378    /// - `(JsValue, JsValue)` - The new texture and its default view, or
379    ///   `JsValue::UNDEFINED` for both on allocation failure.
380    fn create_multisample_texture(
381        &self,
382        physical_width: u32,
383        physical_height: u32,
384    ) -> (JsValue, JsValue) {
385        let extent: Object = Object::new();
386        let _: Result<bool, JsValue> = Reflect::set(
387            &extent,
388            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_WIDTH),
389            &JsValue::from_f64(f64::from(physical_width)),
390        );
391        let _: Result<bool, JsValue> = Reflect::set(
392            &extent,
393            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_HEIGHT),
394            &JsValue::from_f64(f64::from(physical_height)),
395        );
396        let _: Result<bool, JsValue> = Reflect::set(
397            &extent,
398            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_DEPTH),
399            &JsValue::from_f64(1.0),
400        );
401        let descriptor: Object = Object::new();
402        let _: Result<bool, JsValue> = Reflect::set(
403            &descriptor,
404            &JsValue::from_str(WEBGPU_PROPERTY_SIZE),
405            &extent,
406        );
407        let _: Result<bool, JsValue> = Reflect::set(
408            &descriptor,
409            &JsValue::from_str(WEBGPU_PROPERTY_TEXTURE_FORMAT),
410            &JsValue::from_str(&self.get_format()),
411        );
412        let _: Result<bool, JsValue> = Reflect::set(
413            &descriptor,
414            &JsValue::from_str(WEBGPU_PROPERTY_USAGE),
415            &JsValue::from_f64(f64::from(texture_usage_bit(TextureUsage::RenderAttachment))),
416        );
417        let _: Result<bool, JsValue> = Reflect::set(
418            &descriptor,
419            &JsValue::from_str(WEBGPU_PROPERTY_SAMPLE_COUNT),
420            &JsValue::from_f64(4.0),
421        );
422        let create_texture_fn: Function = Reflect::get(
423            self.get_device(),
424            &JsValue::from_str(WEBGPU_METHOD_CREATE_TEXTURE),
425        )
426        .unwrap_or(JsValue::UNDEFINED)
427        .unchecked_into();
428        let texture: JsValue = create_texture_fn
429            .call1(self.get_device(), &descriptor)
430            .unwrap_or(JsValue::UNDEFINED);
431        if texture.is_undefined() {
432            return (JsValue::UNDEFINED, JsValue::UNDEFINED);
433        }
434        let create_view_fn: Function =
435            Reflect::get(&texture, &JsValue::from_str(WEBGPU_METHOD_CREATE_VIEW))
436                .unwrap_or(JsValue::UNDEFINED)
437                .unchecked_into();
438        let view: JsValue = create_view_fn.call0(&texture).unwrap_or(JsValue::UNDEFINED);
439        if view.is_undefined() {
440            return (texture, JsValue::UNDEFINED);
441        }
442        (texture, view)
443    }
444
445    /// Resizes the canvas backing store and reconfigures the swap chain.
446    ///
447    /// WebGPU's `GpuCanvasContext.configure` is sticky: it sets the texture
448    /// format and device once, but the swap chain tracks the canvas's
449    /// `width`/`height` attributes. When the CSS layout size changes (a
450    /// window resize, a panel toggle, a DPR change) the canvas keeps its
451    /// old physical dimensions unless we explicitly update `width`/`height`
452    /// and call `configure` again. Without this, subsequent
453    /// `getCurrentTexture()` calls return a texture that no longer matches
454    /// the visible region and the frame either stretches or freezes.
455    ///
456    /// Re-`configure`ing with the same `device` + `format` is the
457    /// spec-defined way to swap in a fresh swap chain bound to the new
458    /// backing-store size.
459    ///
460    /// # Arguments
461    ///
462    /// - `u32` - The new physical pixel width (already multiplied by DPR).
463    ///
464    /// # Returns
465    ///
466    /// - `bool` - `true` on success, `false` if the swap chain or canvas
467    ///   handles were missing or `configure` failed.
468    pub fn resize(&mut self, physical_width: u32, physical_height: u32) -> bool {
469        if self.get_canvas().is_null()
470            || self.get_context().is_null()
471            || self.get_device().is_undefined()
472        {
473            return false;
474        }
475        self.get_canvas().set_width(physical_width);
476        self.get_canvas().set_height(physical_height);
477        let format_value: JsValue = JsValue::from_str(&self.get_format());
478        let canvas_config: Object = Object::new();
479        let _: Result<bool, JsValue> = Reflect::set(
480            &canvas_config,
481            &JsValue::from_str(WEBGPU_PROPERTY_DEVICE),
482            self.get_device(),
483        );
484        let _: Result<bool, JsValue> = Reflect::set(
485            &canvas_config,
486            &JsValue::from_str(WEBGPU_PROPERTY_FORMAT),
487            &format_value,
488        );
489        let configure_fn: Function = Reflect::get(
490            self.get_context(),
491            &JsValue::from_str(WEBGPU_METHOD_CONFIGURE),
492        )
493        .ok()
494        .and_then(|value: JsValue| value.dyn_into::<Function>().ok())
495        .unwrap_or_else(|| Function::new_no_args(""));
496        if configure_fn
497            .call1(self.get_context(), &canvas_config)
498            .is_err()
499        {
500            return false;
501        }
502        self.set_width(physical_width);
503        self.set_height(physical_height);
504        // Rebuild the multisampled color texture to match the new backing
505        // store size. `GpuTexture` width/height are immutable, so MSAA
506        // requires recreating it on every resize. The previous texture (if
507        // any) is left to the GPU's GC; we do not explicitly destroy it
508        // because `destroy()` is a synchronous WebGPU call and the old
509        // texture is no longer referenced by any in-flight command buffer
510        // at this point in the frame loop.
511        if self.get_antialias() {
512            let (texture, view) = self.create_multisample_texture(physical_width, physical_height);
513            if !view.is_undefined() {
514                self.set_multisample_texture(Some(texture));
515                self.set_multisample_view(Some(view));
516            } else {
517                self.set_multisample_texture(None);
518                self.set_multisample_view(None);
519            }
520        }
521        true
522    }
523
524    /// Resizes the canvas backing store to match the canvas element's
525    /// current CSS-rendered size in physical pixels (DPR applied).
526    ///
527    /// This is the right entry point when the render loop does not know
528    /// the desired logical size ahead of time and wants to follow the
529    /// element's actual layout box. It is also useful as a defensive
530    /// recovery when the canvas was created while hidden (zero-sized
531    /// parent) and is later shown at its real size.
532    ///
533    /// Reads `client_width` / `client_height` from the canvas element,
534    /// multiplies by `detect_dpr()`, and forwards to [`Self::resize`].
535    ///
536    /// # Returns
537    ///
538    /// - `bool` - `true` if the resize succeeded, `false` if the canvas
539    ///   was zero-sized (nothing to render to), detached (CSS layout
540    ///   box collapses to 0), or the underlying resize rejected.
541    pub fn sync_to_current_canvas(&mut self) -> bool {
542        let canvas_width: u32 = self.get_canvas().width();
543        let canvas_height: u32 = self.get_canvas().height();
544        let client_width: u32 = self
545            .get_canvas()
546            .client_width()
547            .try_into()
548            .unwrap_or_default();
549        let client_height: u32 = self
550            .get_canvas()
551            .client_height()
552            .try_into()
553            .unwrap_or_default();
554        // Prefer the CSS layout box when it is non-zero. If the canvas
555        // is hidden the client box collapses to 0; in that case fall
556        // back to the current backing-store size so we do not
557        // gratuitously resize to 0.
558        let css_w: u32 = if client_width > 0 {
559            client_width
560        } else {
561            canvas_width
562        };
563        let css_h: u32 = if client_height > 0 {
564            client_height
565        } else {
566            canvas_height
567        };
568        if css_w == 0 || css_h == 0 {
569            return false;
570        }
571        let dpr: f64 = CanvasRenderer::detect_dpr();
572        let physical_width: u32 = (f64::from(css_w) * dpr).round() as u32;
573        let physical_height: u32 = (f64::from(css_h) * dpr).round() as u32;
574        self.resize(physical_width, physical_height)
575    }
576
577    /// Creates a shader module from WGSL source code.
578    ///
579    /// # Arguments
580    ///
581    /// - `S` - The WGSL shader source code.
582    ///
583    /// # Returns
584    ///
585    /// - `JsValue` - The created shader module as a JavaScript value.
586    pub(crate) fn create_shader_module<S>(&self, code: S) -> JsValue
587    where
588        S: AsRef<str>,
589    {
590        let descriptor: Object = Object::new();
591        let _: Result<bool, JsValue> = Reflect::set(
592            &descriptor,
593            &JsValue::from_str(WEBGPU_PROPERTY_CODE),
594            &JsValue::from_str(code.as_ref()),
595        );
596        let create_fn: Function = Reflect::get(
597            self.get_device(),
598            &JsValue::from_str(WEBGPU_METHOD_CREATE_SHADER_MODULE),
599        )
600        .unwrap_or(JsValue::UNDEFINED)
601        .unchecked_into();
602        create_fn
603            .call1(self.get_device(), &descriptor)
604            .unwrap_or(JsValue::UNDEFINED)
605    }
606
607    /// Creates a new command encoder for recording GPU commands.
608    ///
609    /// # Returns
610    ///
611    /// - `JsValue` - The created command encoder as a JavaScript value.
612    pub(crate) fn create_command_encoder(&self) -> JsValue {
613        // OPT 2b: cached `device.createCommandEncoder()` — `Function`
614        // is the same prototype slot for the device's lifetime, so
615        // skipping the `Reflect::get` shaves ~110ns per frame.
616        let create_fn: Function = cached_method(
617            GpuReceiverClass::Device,
618            self.get_device(),
619            WEBGPU_METHOD_CREATE_COMMAND_ENCODER,
620        )
621        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
622        create_fn
623            .call0(self.get_device())
624            .unwrap_or(JsValue::UNDEFINED)
625    }
626
627    /// Returns the current texture view from the canvas swap chain.
628    ///
629    /// This texture view should be used as the color attachment target for
630    /// render passes. The texture is automatically presented to the canvas
631    /// when the command buffer is submitted.
632    ///
633    /// # Returns
634    ///
635    /// - `JsValue` - The current frame's texture view as a JavaScript value.
636    pub(crate) fn get_current_texture_view(&self) -> JsValue {
637        // OPT 2b: cached `context.getCurrentTexture()` and the
638        // resulting `texture.createView()`. Both methods live on
639        // stable prototypes, so the `Function` reference is stable
640        // for the receiver's lifetime.
641        let get_texture_fn: Function = cached_method(
642            GpuReceiverClass::Context,
643            self.get_context(),
644            WEBGPU_METHOD_GET_CURRENT_TEXTURE,
645        )
646        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
647        let texture: JsValue = get_texture_fn
648            .call0(self.get_context())
649            .unwrap_or(JsValue::UNDEFINED);
650        let create_view_fn: Function = cached_method(
651            GpuReceiverClass::Texture,
652            &texture,
653            WEBGPU_METHOD_CREATE_VIEW,
654        )
655        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
656        create_view_fn.call0(&texture).unwrap_or(JsValue::UNDEFINED)
657    }
658
659    /// Begins a render pass on the given command encoder with a clear color.
660    ///
661    /// The render pass targets the canvas's current texture and clears it
662    /// to the specified color. The returned `JsValue` is a `GpuRenderPassEncoder`
663    /// that can be used to issue draw commands. The pass must be ended (via `end()`)
664    /// before the command encoder is finished.
665    ///
666    /// This is a thin convenience wrapper over
667    /// [`WebGpuRenderer::begin_render_pass_full`]. For pipelines that
668    /// need depth testing, multiple color attachments, MSAA control,
669    /// or `load`/`store` op customization, use the full version with
670    /// a [the historical `RenderPassColorAttachment`] (and optional
671    /// [the historical `RenderPassDepthStencilAttachment`]).
672    ///
673    /// # Arguments
674    ///
675    /// - `&JsValue` - The command encoder to begin the pass on.
676    /// - `Color` - The clear color, in 0.0-1.0 per channel.
677    ///
678    /// # Returns
679    ///
680    /// - `JsValue` - The active render pass encoder as a JavaScript value.
681    pub(crate) fn begin_render_pass(&mut self, encoder: &JsValue, clear_color: Color) -> JsValue {
682        let color: ColorAttachment = ColorAttachment {
683            view: None,
684            resolve_target: None,
685            clear: Some(clear_color),
686            load_op: LoadOp::Clear,
687            store_op: StoreOp::Store,
688        };
689        self.begin_render_pass_full(encoder, &color, None)
690    }
691
692    /// Begins a render pass with full control over attachments, load/store
693    /// ops, MSAA resolve targets, and an optional depth-stencil attachment.
694    ///
695    /// This is the "complete" render-pass API used by the rest of the
696    /// engine. All other render-pass entry points (including the
697    /// legacy `begin_render_pass(clear_color)` wrapper) funnel through
698    /// here.
699    ///
700    /// The color attachment's `view` is filled in lazily when `None`:
701    /// if `antialias == true` and the multisample intermediate is
702    /// available (or can be allocated), the pass draws into the MSAA
703    /// view and resolves into the swap chain; otherwise it draws
704    /// directly into the swap chain. The `resolve_target` is filled in
705    /// with the swap-chain view when MSAA is active and the caller
706    /// did not provide one.
707    ///
708    /// # Arguments
709    ///
710    /// - `&JsValue` - The `GpuCommandEncoder` to begin the pass on.
711    /// - `&ColorAttachment` - The color attachment. `view` and
712    ///   `resolve_target` may be `None`; they are filled in with the
713    ///   renderer's defaults.
714    /// - `Option<&DepthStencilAttachment>` - An optional depth-stencil
715    ///   attachment. `Some(...)` adds a `depthStencilAttachment` field
716    ///   to the pass descriptor; `None` omits it entirely.
717    ///
718    /// # Returns
719    ///
720    /// - `JsValue` - The active `GpuRenderPassEncoder` as a JavaScript
721    ///   value, suitable for the existing `set_pipeline` / `draw` /
722    ///   `end_render_pass` calls.
723    pub fn begin_render_pass_full(
724        &mut self,
725        encoder: &JsValue,
726        color: &ColorAttachment,
727        depth: Option<&DepthStencilAttachment>,
728    ) -> JsValue {
729        // Resolve MSAA view + resolve target with the same policy as
730        // the legacy `begin_render_pass` - prefer the existing
731        // multisample view, lazily allocate it if missing, and fall
732        // back to direct-to-swap-chain if MSAA allocation fails.
733        // `get_view()` is lombok-generated as `self.view.clone().unwrap()`,
734        // and the documented `view: None` case ("let the renderer use the
735        // swap-chain view") is exactly the case `begin_render_pass` passes, so
736        // the infallible getter aborted the whole render pass with
737        // `Option::unwrap() on a None value`. Read the fallible accessor and
738        // treat both `None` and `undefined` as "substitute a view below".
739        let (color_view, resolve_view): (JsValue, Option<JsValue>) = match color.try_get_view() {
740            Some(view) if !view.is_undefined() => {
741                (view.clone(), color.try_get_resolve_target().clone())
742            }
743            _ => {
744                // Only the swap-chain path needs the canvas texture. Reading it in the
745                // caller-supplied-view case would acquire the canvas texture for a frame
746                // nobody renders to it, which is a wasted round trip on every off-screen
747                // pass and presents an untouched swap chain alongside the real output.
748                let swap_chain_view: JsValue = self.get_current_texture_view();
749                if self.get_antialias() {
750                    let multisample_view: Option<JsValue> = self
751                        .get_multisample_view()
752                        .clone()
753                        .filter(|value: &JsValue| !value.is_undefined());
754                    let resolved: Option<JsValue> = match multisample_view {
755                        Some(view) => Some(view),
756                        None => {
757                            let width: u32 = self.get_width();
758                            let height: u32 = self.get_height();
759                            let (texture, view): (JsValue, JsValue) =
760                                self.create_multisample_texture(width, height);
761                            if !view.is_undefined() {
762                                self.set_multisample_texture(Some(texture));
763                                self.set_multisample_view(Some(view.clone()));
764                                Some(view)
765                            } else {
766                                self.set_multisample_texture(None);
767                                self.set_multisample_view(None);
768                                None
769                            }
770                        }
771                    };
772                    match resolved {
773                        Some(view) => (view, Some(swap_chain_view.clone())),
774                        None => (swap_chain_view.clone(), None),
775                    }
776                } else {
777                    (swap_chain_view.clone(), None)
778                }
779            }
780        };
781        // OPT 34: cache the render-pass descriptor across frames so
782        // we only allocate the JS `Object`/`Array` once and only
783        // rewrite the fields that actually change between frames
784        // (typically `clearValue`). The cache is invalidated on
785        // load/store op or depth-stencil shape changes (rare).
786        //
787        // Effective ops are `&'static str` (from `WEBGPU_*_OP_*`
788        // constants), so a pointer-compare detects "caller switched
789        // ops" with zero cost.
790        let effective_load_op: &'static str = load_op_name(*color.get_load_op());
791        let effective_store_op: &'static str = store_op_name(*color.get_store_op());
792        let has_depth: bool = depth.is_some();
793        let has_resolve: bool = resolve_view.is_some();
794        let cache_needs_rebuild: bool = match self.try_get_render_pass_descriptor_cache().as_ref() {
795            None => true,
796            Some(existing) => {
797                existing.last_load_op != Some(effective_load_op)
798                    || existing.last_store_op != Some(effective_store_op)
799                    || existing.last_has_depth != has_depth
800                    || existing.last_has_resolve != has_resolve
801            }
802        };
803        if cache_needs_rebuild {
804            let descriptor: RenderPassDescriptorCache = self.build_render_pass_descriptor(
805                &color_view,
806                resolve_view.as_ref(),
807                *color.try_get_clear(),
808                effective_load_op,
809                effective_store_op,
810                depth,
811            );
812            self.set_render_pass_descriptor_cache(Some(descriptor));
813        }
814        // `Some(_)` invariant: either the cache was non-None at the
815        // top of this function (we only land in the None branch when
816        // `cache_needs_rebuild` was true, in which case we just set
817        // it above) or the caller passed us a renderer with no
818        // descriptor cache yet and we built one. In both cases the
819        // `Some` arm is the only reachable branch; we fall back to
820        // a freshly-built empty cache (and emit no `beginRenderPass`
821        // call) only if the impossible happened — `build_*` returned
822        // a cache that was somehow dropped between the two lines,
823        // which it cannot (no panic path, no early return).
824        let cached: Option<RenderPassDescriptorCache> = self.try_get_render_pass_descriptor_cache();
825        let cache: &RenderPassDescriptorCache = match cached.as_ref() {
826            Some(c) => c,
827            None => {
828                // Defensive: build a no-op cache so the renderer's
829                // caller sees a stable `JsValue::UNDEFINED` rather
830                // than a dangling call. This branch is unreachable
831                // under the invariant above.
832                return JsValue::UNDEFINED;
833            }
834        };
835        // Hot path: only the `clearValue` (and sometimes `view`) is
836        // mutated between frames. We update the cached `view` and
837        // `clearValue` Object's `r`/`g`/`b`/`a` properties
838        // unconditionally — `Reflect::set` is a fast pointer write
839        // when the value differs, and the JS-side property setter
840        // accepts the same numeric value with no observable change.
841        let _: Result<bool, JsValue> = Reflect::set(
842            &cache.attachment,
843            &cached_method_name(WEBGPU_PROPERTY_VIEW),
844            &color_view,
845        );
846        // `resolveTarget` is the per-frame swap-chain view on the MSAA
847        // path (`context.getCurrentTexture()` textures expire when the
848        // frame is presented), so it MUST be refreshed every frame —
849        // keeping the first frame's view makes every subsequent
850        // `beginRenderPass` fail validation silently (black canvas).
851        // When the caller drops MSAA mid-stream the Some/None shape
852        // change triggers a rebuild above, so the `None` arm here never
853        // leaves a stale `resolveTarget` behind.
854        if let Some(target) = resolve_view.as_ref() {
855            let _: Result<bool, JsValue> = Reflect::set(
856                &cache.attachment,
857                &cached_method_name(WEBGPU_PROPERTY_RESOLVE_TARGET),
858                target,
859            );
860        }
861        if let Some(cv) = *color.try_get_clear() {
862            let _: Result<bool, JsValue> = Reflect::set(
863                &cache.clear_value,
864                &cached_method_name(WEBGPU_PROPERTY_R),
865                &JsValue::from_f64(cv.get_red()),
866            );
867            let _: Result<bool, JsValue> = Reflect::set(
868                &cache.clear_value,
869                &cached_method_name(WEBGPU_PROPERTY_G),
870                &JsValue::from_f64(cv.get_green()),
871            );
872            let _: Result<bool, JsValue> = Reflect::set(
873                &cache.clear_value,
874                &cached_method_name(WEBGPU_PROPERTY_B),
875                &JsValue::from_f64(cv.get_blue()),
876            );
877            let _: Result<bool, JsValue> = Reflect::set(
878                &cache.clear_value,
879                &cached_method_name(WEBGPU_PROPERTY_A),
880                &JsValue::from_f64(cv.get_alpha()),
881            );
882            // `attachment.clearValue` always points at the same
883            // `clear_value` Object, so we only need to set it on the
884            // very first call (i.e. when the cache was just built).
885            // Subsequent calls leave the link intact.
886            if cache_needs_rebuild {
887                let _: Result<bool, JsValue> = Reflect::set(
888                    &cache.attachment,
889                    &cached_method_name(WEBGPU_PROPERTY_CLEAR_VALUE),
890                    &cache.clear_value,
891                );
892            }
893        }
894        // The `descriptor.colorAttachments[0]` slot is stable for the
895        // cache's lifetime (set once when the descriptor was built);
896        // `view` / `resolveTarget` / `clearValue` are refreshed above
897        // on every call.
898        let begin_fn: Function = cached_method(
899            GpuReceiverClass::CommandEncoder,
900            encoder,
901            WEBGPU_METHOD_BEGIN_RENDER_PASS,
902        )
903        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
904        begin_fn
905            .call1(encoder, &cache.descriptor)
906            .unwrap_or(JsValue::UNDEFINED)
907    }
908
909    /// OPT 34 helper: build a fresh `RenderPassDescriptorCache` from
910    /// scratch. Called from [`WebGpuRenderer::begin_render_pass_full`]
911    /// on cache miss (first call, op change, or depth-shape change).
912    ///
913    /// The constructed cache holds:
914    /// - `descriptor` - the top-level `GpuRenderPassDescriptor`
915    ///   Object, passed directly to `encoder.beginRenderPass`.
916    /// - `color_attachments` - a length-1 `Array` containing the
917    ///   cached `attachment` Object.
918    /// - `attachment` - the inner color attachment Object.
919    /// - `clear_value` - the `{r, g, b, a}` dictionary under
920    ///   `attachment.clearValue`. This is the only Object whose
921    ///   fields are mutated per frame.
922    /// - `last_load_op` / `last_store_op` - the `&'static str` ops
923    ///   applied to the descriptor this frame, used to detect
924    ///   caller-driven op changes.
925    /// - `last_has_depth` - whether the descriptor had a
926    ///   `depthStencilAttachment`, used to detect shape changes.
927    ///
928    /// # Arguments
929    ///
930    /// - `&JsValue` - The `GpuTextureView` for the color attachment.
931    /// - `Option<&JsValue>` - Optional resolve target (MSAA only).
932    /// - `Option<Color>` - Optional clear color.
933    /// - `&'static str` - The load op to encode.
934    /// - `Option<&DepthStencilAttachment>` - Optional depth-stencil attachment.
935    ///
936    /// # Returns
937    ///
938    /// - `RenderPassDescriptorCache` - The cache entry the renderer retains
939    ///   for this load-op / store-op / depth-shape combination.
940    fn build_render_pass_descriptor(
941        &mut self,
942        color_view: &JsValue,
943        resolve_view: Option<&JsValue>,
944        clear_value: Option<Color>,
945        effective_load_op: &'static str,
946        effective_store_op: &'static str,
947        depth: Option<&DepthStencilAttachment>,
948    ) -> RenderPassDescriptorCache {
949        let attachment: Object = Object::new();
950        let _: Result<bool, JsValue> = Reflect::set(
951            &attachment,
952            &cached_method_name(WEBGPU_PROPERTY_VIEW),
953            color_view,
954        );
955        let _: Result<bool, JsValue> = Reflect::set(
956            &attachment,
957            &cached_method_name(WEBGPU_PROPERTY_LOAD_OP),
958            &JsValue::from_str(effective_load_op),
959        );
960        let _: Result<bool, JsValue> = Reflect::set(
961            &attachment,
962            &cached_method_name(WEBGPU_PROPERTY_STORE_OP),
963            &JsValue::from_str(effective_store_op),
964        );
965        let clear_value_obj: Object = Object::new();
966        if let Some(cv) = clear_value {
967            let _: Result<bool, JsValue> = Reflect::set(
968                &clear_value_obj,
969                &cached_method_name(WEBGPU_PROPERTY_R),
970                &JsValue::from_f64(cv.get_red()),
971            );
972            let _: Result<bool, JsValue> = Reflect::set(
973                &clear_value_obj,
974                &cached_method_name(WEBGPU_PROPERTY_G),
975                &JsValue::from_f64(cv.get_green()),
976            );
977            let _: Result<bool, JsValue> = Reflect::set(
978                &clear_value_obj,
979                &cached_method_name(WEBGPU_PROPERTY_B),
980                &JsValue::from_f64(cv.get_blue()),
981            );
982            let _: Result<bool, JsValue> = Reflect::set(
983                &clear_value_obj,
984                &cached_method_name(WEBGPU_PROPERTY_A),
985                &JsValue::from_f64(cv.get_alpha()),
986            );
987            let _: Result<bool, JsValue> = Reflect::set(
988                &attachment,
989                &cached_method_name(WEBGPU_PROPERTY_CLEAR_VALUE),
990                &clear_value_obj,
991            );
992        }
993        if let Some(target) = resolve_view {
994            let _: Result<bool, JsValue> = Reflect::set(
995                &attachment,
996                &cached_method_name(WEBGPU_PROPERTY_RESOLVE_TARGET),
997                target,
998            );
999        }
1000        let color_attachments: Array = Array::new();
1001        color_attachments.push(&attachment);
1002        let descriptor: Object = Object::new();
1003        let _: Result<bool, JsValue> = Reflect::set(
1004            &descriptor,
1005            &cached_method_name(WEBGPU_PROPERTY_COLOR_ATTACHMENTS),
1006            &color_attachments,
1007        );
1008        let last_has_depth: bool = if let Some(depth_desc) = depth {
1009            // Prefer the caller-provided view; otherwise lazily
1010            // allocate the default depth-stencil texture and use its
1011            // view.
1012            // Same hazard as the color attachment: `get_view()` unwraps.
1013            let depth_view: JsValue = match depth_desc.try_get_view() {
1014                Some(v) if !v.is_undefined() => v.clone(),
1015                _ => match self.create_depth_texture() {
1016                    Some(v) => v,
1017                    None => JsValue::UNDEFINED,
1018                },
1019            };
1020            if !depth_view.is_undefined() {
1021                let depth_attachment: Object = Object::new();
1022                let _: Result<bool, JsValue> = Reflect::set(
1023                    &depth_attachment,
1024                    &cached_method_name(WEBGPU_PROPERTY_VIEW),
1025                    &depth_view,
1026                );
1027                let _: Result<bool, JsValue> = Reflect::set(
1028                    &depth_attachment,
1029                    &cached_method_name(WEBGPU_PROPERTY_DEPTH_LOAD_OP),
1030                    &JsValue::from_str(load_op_name(*depth_desc.get_depth_load_op())),
1031                );
1032                let _: Result<bool, JsValue> = Reflect::set(
1033                    &depth_attachment,
1034                    &cached_method_name(WEBGPU_PROPERTY_DEPTH_STORE_OP),
1035                    &JsValue::from_str(store_op_name(*depth_desc.get_depth_store_op())),
1036                );
1037                if let Some(clear) = *depth_desc.try_get_depth_clear() {
1038                    let _: Result<bool, JsValue> = Reflect::set(
1039                        &depth_attachment,
1040                        &cached_method_name(WEBGPU_PROPERTY_DEPTH_CLEAR_VALUE),
1041                        &JsValue::from_f64(clear),
1042                    );
1043                }
1044                let _: Result<bool, JsValue> = Reflect::set(
1045                    &depth_attachment,
1046                    &cached_method_name(WEBGPU_PROPERTY_DEPTH_READ_ONLY),
1047                    &JsValue::from_bool(depth_desc.get_depth_read_only()),
1048                );
1049                let _: Result<bool, JsValue> = Reflect::set(
1050                    &descriptor,
1051                    &cached_method_name(WEBGPU_PROPERTY_DEPTH_STENCIL_ATTACHMENT),
1052                    &depth_attachment,
1053                );
1054                true
1055            } else {
1056                false
1057            }
1058        } else {
1059            false
1060        };
1061        RenderPassDescriptorCache {
1062            descriptor,
1063            attachment,
1064            clear_value: clear_value_obj,
1065            last_load_op: Some(effective_load_op),
1066            last_store_op: Some(effective_store_op),
1067            last_has_depth,
1068            last_has_resolve: resolve_view.is_some(),
1069        }
1070    }
1071
1072    /// Submits an array of command buffers to the GPU queue for execution.
1073    ///
1074    /// # Arguments
1075    ///
1076    /// - `&[JsValue]` - The command buffers to submit.
1077    pub fn submit(&self, command_buffers: &[JsValue]) {
1078        // The common case is exactly one command buffer per frame —
1079        // `Array::of1` skips the grow-from-empty push dance.
1080        let array: Array = match command_buffers {
1081            [single] => Array::of1(single),
1082            many => many.iter().cloned().collect(),
1083        };
1084        // OPT 2b: cached `queue.submit()` — `Function` is the same
1085        // prototype slot for the queue's lifetime.
1086        let _: Result<JsValue, JsValue> = cached_method_call(
1087            GpuReceiverClass::Queue,
1088            self.get_queue(),
1089            WEBGPU_METHOD_SUBMIT,
1090            &array,
1091        );
1092    }
1093
1094    /// Creates a simple render pipeline from a single WGSL shader source.
1095    ///
1096    /// The shader must contain `@vertex fn vs_main(...)` and
1097    /// `@fragment fn fs_main(...)` entry points. No vertex buffers are used;
1098    /// vertex positions should be derived from `@builtin(vertex_index)` in
1099    /// the shader. The pipeline uses auto-layout (`layout: null`), which works
1100    /// when the shader has no bind groups.
1101    ///
1102    /// This is a preset over [`RenderPipelineDescriptor`]: triangle-list
1103    /// topology, no culling, no depth state, one color target in the
1104    /// canvas's own format, and whatever sample count the renderer's
1105    /// `antialias` flag asks for. For anything else, build a
1106    /// [`RenderPipelineDescriptor`] and use
1107    /// [`WebGpuRenderer::create_render_pipeline_full`].
1108    ///
1109    /// # Arguments
1110    ///
1111    /// - `S` - The WGSL shader source code.
1112    ///
1113    /// # Returns
1114    ///
1115    /// - `JsValue` - The created render pipeline as a JavaScript value.
1116    pub fn create_render_pipeline<S>(&self, shader_code: S) -> JsValue
1117    where
1118        S: AsRef<str>,
1119    {
1120        let module: JsValue = self.create_shader_module(shader_code);
1121        // The color target format MUST match the attachment the pass will
1122        // render into. For the canvas that is the swap-chain format reported
1123        // by `navigator.gpu.getPreferredCanvasFormat()`, which is `bgra8unorm`
1124        // on most desktop browsers - hardcoding `rgba8unorm` here makes the
1125        // pipeline incompatible with its own render pass, WebGPU rejects the
1126        // command buffer, and the canvas silently stays black with no
1127        // visible error. Ask the renderer which format it configured.
1128        let target_format: GpuTextureFormat = match self.get_format().as_str() {
1129            WEBGPU_FORMAT_BGRA8UNORM => GpuTextureFormat::Bgra8Unorm,
1130            _ => GpuTextureFormat::Rgba8Unorm,
1131        };
1132        let targets: Vec<ColorTargetState> = vec![ColorTargetState::for_format(target_format)];
1133        self.create_render_pipeline_full(&RenderPipelineDescriptor::new(
1134            VertexState::new(
1135                module.clone(),
1136                WEBGPU_VERTEX_ENTRY_POINT.to_string(),
1137                Vec::new(),
1138            ),
1139            PrimitiveState::default(),
1140            None,
1141            self.default_multisample_state(),
1142            Some(FragmentState::new(
1143                module,
1144                WEBGPU_FRAGMENT_ENTRY_POINT.to_string(),
1145                targets,
1146            )),
1147        ))
1148    }
1149
1150    /// Creates a render pipeline from a [`RenderPipelineDescriptor`],
1151    /// with the WebGPU auto layout.
1152    ///
1153    /// The descriptor's typed states are assembled into the
1154    /// `GpuRenderPipelineDescriptor` the device expects: the vertex
1155    /// stage and its buffer layouts, the primitive state (topology,
1156    /// front face, cull mode, strip index format), the optional
1157    /// depth-stencil state, the multisample state, and the optional
1158    /// fragment stage with one entry per color target.
1159    ///
1160    /// This is a one-shot path - a pipeline is normally created once
1161    /// and reused for every frame, so the descriptor objects it builds
1162    /// do not need to be cached. The per-frame paths that do allocate
1163    /// (render passes, bind groups, draw state) all go through
1164    /// `cached_method` and [`RenderPassDescriptorCache`] instead.
1165    ///
1166    /// # Arguments
1167    ///
1168    /// - `&RenderPipelineDescriptor` - The full pipeline description.
1169    ///
1170    /// # Returns
1171    ///
1172    /// - `JsValue` - The created render pipeline as a JavaScript value.
1173    pub fn create_render_pipeline_full(&self, descriptor: &RenderPipelineDescriptor) -> JsValue {
1174        let descriptor_object: Object = Object::new();
1175        let _: Result<bool, JsValue> = Reflect::set(
1176            &descriptor_object,
1177            &JsValue::from_str(WEBGPU_PROPERTY_LAYOUT),
1178            &JsValue::from_str(WEBGPU_AUTO_LAYOUT),
1179        );
1180        let _: Result<bool, JsValue> = Reflect::set(
1181            &descriptor_object,
1182            &JsValue::from_str(WEBGPU_PROPERTY_VERTEX),
1183            &self.build_vertex_state(descriptor.get_vertex()),
1184        );
1185        let _: Result<bool, JsValue> = Reflect::set(
1186            &descriptor_object,
1187            &JsValue::from_str(WEBGPU_PROPERTY_PRIMITIVE),
1188            &self.build_primitive_state(descriptor.get_primitive()),
1189        );
1190        let _: Result<bool, JsValue> = Reflect::set(
1191            &descriptor_object,
1192            &JsValue::from_str(WEBGPU_PROPERTY_MULTISAMPLE),
1193            &self.build_multisample_state(descriptor.get_multisample()),
1194        );
1195        if let Some(depth) = descriptor.try_get_depth_stencil() {
1196            let depth_object: Object = self.build_depth_stencil_state(depth);
1197            let _: Result<bool, JsValue> = Reflect::set(
1198                &descriptor_object,
1199                &JsValue::from_str(WEBGPU_PROPERTY_DEPTH_STENCIL),
1200                &depth_object,
1201            );
1202        }
1203        if let Some(fragment) = descriptor.try_get_fragment() {
1204            let fragment_object: Object = self.build_fragment_state(fragment);
1205            let _: Result<bool, JsValue> = Reflect::set(
1206                &descriptor_object,
1207                &JsValue::from_str(WEBGPU_PROPERTY_FRAGMENT),
1208                &fragment_object,
1209            );
1210        }
1211        let create_fn: Function = cached_method(
1212            GpuReceiverClass::Device,
1213            self.get_device(),
1214            WEBGPU_METHOD_CREATE_RENDER_PIPELINE,
1215        )
1216        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1217        create_fn
1218            .call1(self.get_device(), &descriptor_object)
1219            .unwrap_or(JsValue::UNDEFINED)
1220    }
1221
1222    /// Assembles the `vertex` half of a pipeline descriptor into the JS
1223    /// object `createRenderPipeline` expects.
1224    ///
1225    /// # Arguments
1226    ///
1227    /// - `&VertexState` - The shader module, entry point, and buffer
1228    ///   layouts of the vertex stage.
1229    ///
1230    /// # Returns
1231    ///
1232    /// - `Object` - The assembled `GPUVertexState` dictionary.
1233    fn build_vertex_state(&self, state: &VertexState) -> Object {
1234        let vertex_state: Object = Object::new();
1235        let _: Result<bool, JsValue> = Reflect::set(
1236            &vertex_state,
1237            &JsValue::from_str(WEBGPU_PROPERTY_MODULE),
1238            &state.get_module(),
1239        );
1240        let _: Result<bool, JsValue> = Reflect::set(
1241            &vertex_state,
1242            &JsValue::from_str(WEBGPU_PROPERTY_ENTRY_POINT),
1243            &JsValue::from_str(state.get_entry_point().as_str()),
1244        );
1245        let buffers: Array = Array::new();
1246        for layout in state.get_buffers() {
1247            let layout_obj: Object = Object::new();
1248            let _: Result<bool, JsValue> = Reflect::set(
1249                &layout_obj,
1250                &JsValue::from_str(WEBGPU_PROPERTY_ARRAY_STRIDE),
1251                &JsValue::from_f64(layout.get_array_stride() as f64),
1252            );
1253            let _: Result<bool, JsValue> = Reflect::set(
1254                &layout_obj,
1255                &JsValue::from_str(WEBGPU_PROPERTY_STEP_MODE),
1256                &JsValue::from_str(layout.get_step_mode().as_str()),
1257            );
1258            let attrs: Array = Array::new();
1259            for attribute in layout.get_attributes() {
1260                let attr: Object = Object::new();
1261                let _: Result<bool, JsValue> = Reflect::set(
1262                    &attr,
1263                    &JsValue::from_str(WEBGPU_PROPERTY_FORMAT),
1264                    &JsValue::from_str(vertex_attribute_format_name(attribute.get_format())),
1265                );
1266                let _: Result<bool, JsValue> = Reflect::set(
1267                    &attr,
1268                    &JsValue::from_str(WEBGPU_PROPERTY_OFFSET),
1269                    &JsValue::from_f64(attribute.get_offset() as f64),
1270                );
1271                let _: Result<bool, JsValue> = Reflect::set(
1272                    &attr,
1273                    &JsValue::from_str(WEBGPU_PROPERTY_SHADER_LOCATION),
1274                    &JsValue::from_f64(f64::from(attribute.get_shader_location())),
1275                );
1276                attrs.push(&attr);
1277            }
1278            let _: Result<bool, JsValue> = Reflect::set(
1279                &layout_obj,
1280                &JsValue::from_str(WEBGPU_PROPERTY_ATTRIBUTES),
1281                &attrs,
1282            );
1283            buffers.push(&layout_obj);
1284        }
1285        let _: Result<bool, JsValue> = Reflect::set(
1286            &vertex_state,
1287            &JsValue::from_str(WEBGPU_PROPERTY_BUFFERS),
1288            &buffers,
1289        );
1290        vertex_state
1291    }
1292
1293    /// Assembles the `primitive` half of a pipeline descriptor.
1294    ///
1295    /// # Arguments
1296    ///
1297    /// - `&PrimitiveState` - Topology, front-face winding, cull mode,
1298    ///   and the optional strip index format.
1299    ///
1300    /// # Returns
1301    ///
1302    /// - `Object` - The assembled `GPUPrimitiveState` dictionary.
1303    fn build_primitive_state(&self, state: &PrimitiveState) -> Object {
1304        let primitive: Object = Object::new();
1305        let _: Result<bool, JsValue> = Reflect::set(
1306            &primitive,
1307            &JsValue::from_str(WEBGPU_PROPERTY_TOPOLOGY),
1308            &JsValue::from_str(primitive_topology_name(state.get_topology())),
1309        );
1310        let _: Result<bool, JsValue> = Reflect::set(
1311            &primitive,
1312            &JsValue::from_str(WEBGPU_PROPERTY_FRONT_FACE),
1313            &JsValue::from_str(front_face_name(state.get_front_face())),
1314        );
1315        let _: Result<bool, JsValue> = Reflect::set(
1316            &primitive,
1317            &JsValue::from_str(WEBGPU_PROPERTY_CULL_MODE),
1318            &JsValue::from_str(cull_mode_name(state.get_cull_mode())),
1319        );
1320        let _: Result<bool, JsValue> = Reflect::set(
1321            &primitive,
1322            &JsValue::from_str(WEBGPU_PROPERTY_UNCLIPPED_DEPTH),
1323            &JsValue::from_bool(state.get_unclipped_depth()),
1324        );
1325        if let Some(format) = state.get_strip_index_format() {
1326            let _: Result<bool, JsValue> = Reflect::set(
1327                &primitive,
1328                &JsValue::from_str(WEBGPU_PROPERTY_STRIP_INDEX_FORMAT),
1329                &JsValue::from_str(index_format_name(format)),
1330            );
1331        }
1332        primitive
1333    }
1334
1335    /// Assembles the `multisample` half of a pipeline descriptor.
1336    ///
1337    /// # Arguments
1338    ///
1339    /// - `&MultisampleState` - The samples per pixel and the sample
1340    ///   mask.
1341    ///
1342    /// # Returns
1343    ///
1344    /// - `Object` - The assembled `GPUMultisampleState` dictionary.
1345    fn build_multisample_state(&self, state: &MultisampleState) -> Object {
1346        let multisample: Object = Object::new();
1347        let _: Result<bool, JsValue> = Reflect::set(
1348            &multisample,
1349            &JsValue::from_str(WEBGPU_PROPERTY_COUNT),
1350            &JsValue::from_f64(f64::from(state.get_count())),
1351        );
1352        let _: Result<bool, JsValue> = Reflect::set(
1353            &multisample,
1354            &JsValue::from_str(WEBGPU_PROPERTY_MASK),
1355            &JsValue::from_f64(f64::from(state.get_mask())),
1356        );
1357        multisample
1358    }
1359
1360    /// Assembles the `depthStencil` half of a pipeline descriptor.
1361    ///
1362    /// # Arguments
1363    ///
1364    /// - `&DepthStencilState` - The depth format, write flag, and
1365    ///   comparison.
1366    ///
1367    /// # Returns
1368    ///
1369    /// - `Object` - The assembled `GPUDepthStencilState` dictionary.
1370    fn build_depth_stencil_state(&self, state: &DepthStencilState) -> Object {
1371        let depth_stencil: Object = Object::new();
1372        let _: Result<bool, JsValue> = Reflect::set(
1373            &depth_stencil,
1374            &JsValue::from_str(WEBGPU_PROPERTY_TEXTURE_FORMAT),
1375            &JsValue::from_str(gpu_texture_format_name(state.get_format())),
1376        );
1377        let _: Result<bool, JsValue> = Reflect::set(
1378            &depth_stencil,
1379            &JsValue::from_str(WEBGPU_PROPERTY_DEPTH_WRITE_ENABLED),
1380            &JsValue::from_bool(state.get_depth_write_enabled()),
1381        );
1382        let _: Result<bool, JsValue> = Reflect::set(
1383            &depth_stencil,
1384            &JsValue::from_str(WEBGPU_PROPERTY_DEPTH_COMPARE),
1385            &JsValue::from_str(compare_function_name(state.get_depth_compare())),
1386        );
1387        depth_stencil
1388    }
1389
1390    /// Assembles the `fragment` half of a pipeline descriptor.
1391    ///
1392    /// # Arguments
1393    ///
1394    /// - `&FragmentState` - The shader module, entry point, and one
1395    ///   [`ColorTargetState`] per color attachment.
1396    ///
1397    /// # Returns
1398    ///
1399    /// - `Object` - The assembled `GPUFragmentState` dictionary.
1400    fn build_fragment_state(&self, state: &FragmentState) -> Object {
1401        let fragment_state: Object = Object::new();
1402        let _: Result<bool, JsValue> = Reflect::set(
1403            &fragment_state,
1404            &JsValue::from_str(WEBGPU_PROPERTY_MODULE),
1405            &state.get_module(),
1406        );
1407        let _: Result<bool, JsValue> = Reflect::set(
1408            &fragment_state,
1409            &JsValue::from_str(WEBGPU_PROPERTY_ENTRY_POINT),
1410            &JsValue::from_str(state.get_entry_point().as_str()),
1411        );
1412        let targets: Array = Array::new();
1413        for target in state.get_targets() {
1414            targets.push(&self.build_color_target_state(target));
1415        }
1416        let _: Result<bool, JsValue> = Reflect::set(
1417            &fragment_state,
1418            &JsValue::from_str(WEBGPU_PROPERTY_TARGETS),
1419            &targets,
1420        );
1421        fragment_state
1422    }
1423
1424    /// Assembles one entry of a fragment stage's `targets` array.
1425    ///
1426    /// # Arguments
1427    ///
1428    /// - `&ColorTargetState` - The target format, optional blend
1429    ///   state, and channel write mask.
1430    ///
1431    /// # Returns
1432    ///
1433    /// - `Object` - The assembled `GPUColorTargetState` dictionary.
1434    fn build_color_target_state(&self, state: &ColorTargetState) -> Object {
1435        let target: Object = Object::new();
1436        let _: Result<bool, JsValue> = Reflect::set(
1437            &target,
1438            &JsValue::from_str(WEBGPU_PROPERTY_TEXTURE_FORMAT),
1439            &JsValue::from_str(gpu_texture_format_name(state.get_format())),
1440        );
1441        let _: Result<bool, JsValue> = Reflect::set(
1442            &target,
1443            &JsValue::from_str(WEBGPU_PROPERTY_WRITE_MASK),
1444            &JsValue::from_f64(f64::from(state.get_write_mask())),
1445        );
1446        if let Some(blend) = state.get_blend() {
1447            let blend_object: Object = Object::new();
1448            let color_component: Object = self.build_blend_component(&blend.get_color());
1449            let _: Result<bool, JsValue> = Reflect::set(
1450                &blend_object,
1451                &JsValue::from_str(WEBGPU_PROPERTY_COLOR),
1452                &color_component,
1453            );
1454            let alpha_component: Object = self.build_blend_component(&blend.get_alpha());
1455            let _: Result<bool, JsValue> = Reflect::set(
1456                &blend_object,
1457                &JsValue::from_str(WEBGPU_PROPERTY_ALPHA),
1458                &alpha_component,
1459            );
1460            let _: Result<bool, JsValue> = Reflect::set(
1461                &target,
1462                &JsValue::from_str(WEBGPU_PROPERTY_BLEND),
1463                &blend_object,
1464            );
1465        }
1466        target
1467    }
1468
1469    /// Assembles one channel group's blend dictionary.
1470    ///
1471    /// # Arguments
1472    ///
1473    /// - `&BlendComponent` - The operation and the two factors.
1474    ///
1475    /// # Returns
1476    ///
1477    /// - `Object` - The assembled `GPUBlendComponent` dictionary.
1478    fn build_blend_component(&self, component: &BlendComponent) -> Object {
1479        let blend: Object = Object::new();
1480        let _: Result<bool, JsValue> = Reflect::set(
1481            &blend,
1482            &JsValue::from_str(WEBGPU_PROPERTY_OPERATION),
1483            &JsValue::from_str(blend_operation_name(component.get_operation())),
1484        );
1485        let _: Result<bool, JsValue> = Reflect::set(
1486            &blend,
1487            &JsValue::from_str(WEBGPU_PROPERTY_SRC_FACTOR),
1488            &JsValue::from_str(blend_factor_name(component.get_source())),
1489        );
1490        let _: Result<bool, JsValue> = Reflect::set(
1491            &blend,
1492            &JsValue::from_str(WEBGPU_PROPERTY_DST_FACTOR),
1493            &JsValue::from_str(blend_factor_name(component.get_destination())),
1494        );
1495        blend
1496    }
1497
1498    /// The multisample state the renderer's `antialias` flag asks for.
1499    ///
1500    /// `antialias` is read on the renderer rather than carried on the
1501    /// descriptor so the built-in presets and the depth- and
1502    /// multisample-aware paths all agree on one sample count.
1503    ///
1504    /// # Returns
1505    ///
1506    /// - `MultisampleState` - 4x MSAA when antialiasing is on, 1x
1507    ///   otherwise, writing every sample in both cases.
1508    fn default_multisample_state(&self) -> MultisampleState {
1509        MultisampleState::with_sample_count(if self.get_antialias() { 4 } else { 1 })
1510    }
1511
1512    /// Sets the render pipeline on a render pass encoder.
1513    ///
1514    /// # Arguments
1515    ///
1516    /// - `&JsValue` - The render pass encoder.
1517    pub(crate) fn set_pipeline(&self, pass: &JsValue, pipeline: &JsValue) {
1518        // OPT 2b: cached `pass.setPipeline()` — function is on the
1519        // shared prototype; the call still passes `this = pass`
1520        // explicitly because JS `Function` doesn't auto-bind.
1521        let set_fn: Function = cached_method(
1522            GpuReceiverClass::RenderPass,
1523            pass,
1524            WEBGPU_METHOD_SET_PIPELINE,
1525        )
1526        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1527        let _: Result<JsValue, JsValue> = set_fn.call1(pass, pipeline);
1528    }
1529
1530    /// Binds a vertex buffer at the given slot on a render pass encoder.
1531    ///
1532    /// This is the missing link between `create_render_pipeline_full` /
1533    /// `create_render_pipeline_with_layout` and the actual draw call:
1534    /// without `set_vertex_buffer` the GPU has no idea what attribute
1535    /// data the vertex shader's `@location(N)` references point at.
1536    /// Calling this with `buffer.is_undefined()` is a silent no-op
1537    /// (matches the WebGPU spec).
1538    ///
1539    /// # Arguments
1540    ///
1541    /// - `&JsValue` - The render pass encoder.
1542    /// - `u32` - The slot index; matches the slot the vertex buffer
1543    ///   from `create_vertex_buffer`).
1544    pub fn set_vertex_buffer(&self, pass: &JsValue, slot: u32, buffer: &JsValue) {
1545        if buffer.is_undefined() || buffer.is_null() {
1546            return;
1547        }
1548        // OPT 2b: cached `pass.setVertexBuffer(slot, buffer)`.
1549        let set_fn: Function = cached_method(
1550            GpuReceiverClass::RenderPass,
1551            pass,
1552            WEBGPU_METHOD_SET_VERTEX_BUFFER,
1553        )
1554        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1555        let _: Result<JsValue, JsValue> =
1556            set_fn.call2(pass, &JsValue::from_f64(f64::from(slot)), buffer);
1557    }
1558
1559    /// Binds an index buffer on a render pass encoder.
1560    ///
1561    /// Once bound, subsequent `draw_indexed` calls read their indices
1562    /// from this buffer. `format` must be either `"uint16"` or
1563    /// `"uint32"` — see [`WEBGPU_INDEX_FORMAT_UINT16`] and
1564    /// [`WEBGPU_INDEX_FORMAT_UINT32`].
1565    ///
1566    /// # Arguments
1567    ///
1568    /// - `&JsValue` - The render pass encoder.
1569    /// - `IndexFormat` - The element width of the index data. It must
1570    ///   match the element type of the indices themselves.
1571    pub fn set_index_buffer(&self, pass: &JsValue, buffer: &JsValue, format: IndexFormat) {
1572        if buffer.is_undefined() || buffer.is_null() {
1573            return;
1574        }
1575        // OPT 2b: cached `pass.setIndexBuffer(buffer, format)`.
1576        // The two spec formats hit the thread-local `JsValue` cache instead
1577        // of paying a fresh JS string allocation per call (per entity per
1578        // frame in mesh scenes).
1579        let format_value: JsValue = cached_method_name(index_format_name(format));
1580        let set_fn: Function = cached_method(
1581            GpuReceiverClass::RenderPass,
1582            pass,
1583            WEBGPU_METHOD_SET_INDEX_BUFFER,
1584        )
1585        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1586        let _: Result<JsValue, JsValue> = set_fn.call2(pass, buffer, &format_value);
1587    }
1588
1589    /// Draws primitives on a render pass encoder.
1590    ///
1591    /// # Arguments
1592    ///
1593    /// - `&JsValue` - The render pass encoder.
1594    /// - `&DrawArgs` - The vertex count, instance count, and the first
1595    ///   vertex / first instance offsets.
1596    pub fn draw(&self, pass: &JsValue, args: &DrawArgs) {
1597        // OPT 2b: cached `pass.draw(vertexCount, instanceCount, firstVertex, firstInstance)`.
1598        let draw_fn: Function =
1599            cached_method(GpuReceiverClass::RenderPass, pass, WEBGPU_METHOD_DRAW)
1600                .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1601        let _: Result<JsValue, JsValue> = draw_fn.call4(
1602            pass,
1603            &JsValue::from_f64(f64::from(args.get_vertex_count())),
1604            &JsValue::from_f64(f64::from(args.get_instance_count())),
1605            &JsValue::from_f64(f64::from(args.get_first_vertex())),
1606            &JsValue::from_f64(f64::from(args.get_first_instance())),
1607        );
1608    }
1609
1610    /// Draws indexed primitives on a render pass encoder.
1611    ///
1612    /// The index buffer must already be bound via [`Self::set_index_buffer`].
1613    /// This is the modern path for everything that needs shared vertex
1614    /// data (mesh renderers, terrain, instanced objects).
1615    ///
1616    /// # Arguments
1617    ///
1618    /// - `&JsValue` - The render pass encoder.
1619    /// - `&DrawIndexedArgs` - The index count, instance count, first
1620    ///   index, base vertex, and first instance.
1621    pub fn draw_indexed(&self, pass: &JsValue, args: &DrawIndexedArgs) {
1622        // OPT 2b: cached `pass.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance)`.
1623        let draw_fn: Function = cached_method(
1624            GpuReceiverClass::RenderPass,
1625            pass,
1626            WEBGPU_METHOD_DRAW_INDEXED,
1627        )
1628        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1629        let _: Result<JsValue, JsValue> = draw_fn.call5(
1630            pass,
1631            &JsValue::from_f64(f64::from(args.get_index_count())),
1632            &JsValue::from_f64(f64::from(args.get_instance_count())),
1633            &JsValue::from_f64(f64::from(args.get_first_index())),
1634            &JsValue::from_f64(args.get_base_vertex() as f64),
1635            &JsValue::from_f64(f64::from(args.get_first_instance())),
1636        );
1637    }
1638
1639    /// Variant of [`Self::draw_indexed`] that stops before the end of the
1640    /// bound index buffer, drawing `index_count` indices starting at
1641    /// `first_index`.
1642    ///
1643    /// `first_index` is measured in indices, not bytes, matching
1644    /// `GpuRenderPassEncoder.drawIndexed`'s `firstIndex` argument.
1645    ///
1646    /// # Arguments
1647    ///
1648    /// - `&JsValue` - The render pass encoder.
1649    /// - `u32` - The index to start reading at.
1650    pub fn draw_indexed_offset(
1651        &self,
1652        pass: &JsValue,
1653        index_offset: u32,
1654        index_count: u32,
1655        instance_count: u32,
1656    ) {
1657        let mut args: DrawIndexedArgs = DrawIndexedArgs::whole_buffer(index_count, instance_count);
1658        args.set_first_index(index_offset);
1659        self.draw_indexed(pass, &args);
1660    }
1661
1662    /// Ends a render pass on the given pass encoder.
1663    ///
1664    /// # Arguments
1665    ///
1666    /// - `&JsValue` - The render pass encoder to end.
1667    pub(crate) fn end_render_pass(&self, pass: &JsValue) {
1668        // OPT 2b: cached `pass.end()`.
1669        let end_fn: Function = cached_method(GpuReceiverClass::RenderPass, pass, WEBGPU_METHOD_END)
1670            .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1671        let _: Result<JsValue, JsValue> = end_fn.call0(pass);
1672    }
1673
1674    /// Finishes a command encoder and returns the resulting command buffer.
1675    ///
1676    /// # Arguments
1677    ///
1678    /// - `&JsValue` - The command encoder to finish.
1679    ///
1680    /// # Returns
1681    ///
1682    /// - `JsValue` - The finished command buffer.
1683    pub(crate) fn finish_command_encoder(&self, encoder: &JsValue) -> JsValue {
1684        // OPT 2b: cached `encoder.finish()`.
1685        let finish_fn: Function = cached_method(
1686            GpuReceiverClass::CommandEncoder,
1687            encoder,
1688            WEBGPU_METHOD_FINISH,
1689        )
1690        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1691        finish_fn.call0(encoder).unwrap_or(JsValue::UNDEFINED)
1692    }
1693
1694    /// Creates a GPU uniform buffer and initializes it with the given floats.
1695    ///
1696    /// The buffer is created with `UNIFORM | COPY_DST` usage so it can be
1697    /// bound in a bind group and refreshed per frame via
1698    /// [`WebGpuRenderer::update_uniform_buffer`]. The allocation size is
1699    /// rounded up to a multiple of 16 bytes because WebGPU requires uniform
1700    /// buffer bindings to be 16-byte aligned in size (a bare `vec2<f32>`
1701    /// uniform is only 8 bytes).
1702    ///
1703    /// # Arguments
1704    ///
1705    /// - `&[f32]` - The initial uniform contents (e.g. `[x, y]` for a
1706    ///   `vec2<f32>` uniform).
1707    ///
1708    /// # Returns
1709    ///
1710    /// - `JsValue` - The created `GpuBuffer`.
1711    pub fn create_uniform_buffer(&self, data: &[f32]) -> JsValue {
1712        let byte_len: usize = data.len() * 4;
1713        let size: u64 = (byte_len.div_ceil(16).max(1) as u64) * 16;
1714        let buffer: JsValue = self.create_buffer(&BufferDescriptor::new(
1715            size,
1716            buffer_usage_mask(&[BufferUsage::Uniform, BufferUsage::CopyDestination]),
1717        ));
1718        self.update_uniform_buffer(&buffer, data);
1719        buffer
1720    }
1721
1722    /// Uploads float data into an existing uniform buffer via `queue.writeBuffer`.
1723    ///
1724    /// # Arguments
1725    ///
1726    /// - `&JsValue` - The `GpuBuffer` previously created by
1727    ///   [`WebGpuRenderer::create_uniform_buffer`].
1728    /// - `&[f32]` - The new uniform contents.
1729    pub fn update_uniform_buffer(&self, buffer: &JsValue, data: &[f32]) {
1730        // OPT 31: zero-copy view over the wasm linear-memory slice. The old
1731        // `Float32Array::from(data)` form allocates a new typed array and
1732        // copies every byte; per-frame uniform uploads (transforms, camera
1733        // matrices, particle data) can be hundreds of bytes per call.
1734        // SAFETY: `view` is only used inside the `write_fn.call3(...)` on
1735        // the next line; the resulting JsValue does not outlive `data`'s
1736        // borrow, and `data` outlives the call because the call happens
1737        // synchronously before this function returns.
1738        let view: Float32Array = unsafe { Float32Array::view(data) };
1739        // OPT 2b: cached `queue.writeBuffer(buffer, 0, view)`.
1740        let write_fn: Function = cached_method(
1741            GpuReceiverClass::Queue,
1742            self.get_queue(),
1743            WEBGPU_METHOD_WRITE_BUFFER,
1744        )
1745        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1746        let _: Result<JsValue, JsValue> =
1747            write_fn.call3(self.get_queue(), buffer, &JsValue::from_f64(0.0), &view);
1748    }
1749
1750    // ----------------------------------------------------------------------
1751    //  Compute pipeline + pass + dispatch
1752    // ----------------------------------------------------------------------
1753
1754    /// Creates a compute pipeline from a WGSL shader.
1755    ///
1756    /// The shader must contain exactly one `@compute fn <name>(...)`
1757    /// entry point whose name matches `entry_point`. The pipeline uses
1758    /// auto-layout, so any `@group(N)` binding it declares is wired
1759    /// through `getBindGroupLayout(N)`.
1760    ///
1761    /// # Arguments
1762    ///
1763    /// - `S` - The WGSL source code.
1764    /// - `&str` - The compute entry-point name (e.g. `"cs_main"`).
1765    ///
1766    /// # Returns
1767    ///
1768    /// - `JsValue` - The created `GpuComputePipeline`, or
1769    ///   `JsValue::UNDEFINED` on failure.
1770    pub fn create_compute_pipeline<S>(&self, shader_code: S, entry_point: &str) -> JsValue
1771    where
1772        S: AsRef<str>,
1773    {
1774        let module: JsValue = self.create_shader_module(shader_code);
1775        let compute_state: Object = Object::new();
1776        let _: Result<bool, JsValue> = Reflect::set(
1777            &compute_state,
1778            &JsValue::from_str(WEBGPU_PROPERTY_MODULE),
1779            &module,
1780        );
1781        let _: Result<bool, JsValue> = Reflect::set(
1782            &compute_state,
1783            &JsValue::from_str(WEBGPU_PROPERTY_ENTRY_POINT),
1784            &JsValue::from_str(entry_point),
1785        );
1786        let descriptor: Object = Object::new();
1787        let _: Result<bool, JsValue> = Reflect::set(
1788            &descriptor,
1789            &JsValue::from_str(WEBGPU_PROPERTY_LAYOUT),
1790            &JsValue::from_str(WEBGPU_AUTO_LAYOUT),
1791        );
1792        let _: Result<bool, JsValue> = Reflect::set(
1793            &descriptor,
1794            &JsValue::from_str(WEBGPU_PROPERTY_COMPUTE),
1795            &compute_state,
1796        );
1797        let create_fn: Function = Reflect::get(
1798            self.get_device(),
1799            &JsValue::from_str(WEBGPU_METHOD_CREATE_COMPUTE_PIPELINE),
1800        )
1801        .unwrap_or(JsValue::UNDEFINED)
1802        .unchecked_into();
1803        create_fn
1804            .call1(self.get_device(), &descriptor)
1805            .unwrap_or(JsValue::UNDEFINED)
1806    }
1807
1808    /// Begins a compute pass on the given command encoder.
1809    ///
1810    /// The returned `JsValue` is a `GpuComputePassEncoder` that supports
1811    /// `setPipeline` / `setBindGroup` / `dispatchWorkgroups` /
1812    /// `dispatchWorkgroupsIndirect` / `end`. The pass must be ended
1813    /// (via `end()`) before the command encoder is finished.
1814    ///
1815    /// # Arguments
1816    ///
1817    /// - `&JsValue` - The `GpuCommandEncoder` to begin the pass on.
1818    ///
1819    /// # Returns
1820    ///
1821    /// - `JsValue` - The active `GpuComputePassEncoder`.
1822    pub fn begin_compute_pass(&self, encoder: &JsValue) -> JsValue {
1823        let begin_fn: Function = Reflect::get(
1824            encoder,
1825            &JsValue::from_str(WEBGPU_METHOD_BEGIN_COMPUTE_PASS),
1826        )
1827        .unwrap_or(JsValue::UNDEFINED)
1828        .unchecked_into();
1829        let descriptor: Object = Object::new();
1830        begin_fn
1831            .call1(encoder, &descriptor)
1832            .unwrap_or(JsValue::UNDEFINED)
1833    }
1834
1835    /// Issues a `dispatchWorkgroups(x, y, z)` on a compute pass encoder.
1836    ///
1837    /// `x`/`y`/`z` are the workgroup counts in each dimension. WebGPU
1838    /// limits each to `65535`; callers that need larger grids must
1839    /// split them across multiple dispatches or encode a loop inside
1840    /// the shader.
1841    ///
1842    /// # Arguments
1843    ///
1844    /// - `&JsValue` - The active `GpuComputePassEncoder`.
1845    /// - `&DispatchArgs` - The workgroup counts, one per axis (each
1846    ///   `1..=65535`).
1847    pub fn dispatch(&self, pass: &JsValue, args: &DispatchArgs) {
1848        // OPT 2b: cached `pass.dispatchWorkgroups(x, y, z)`.
1849        let fn_: Function =
1850            cached_method(GpuReceiverClass::ComputePass, pass, WEBGPU_METHOD_DISPATCH)
1851                .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
1852        let _: Result<JsValue, JsValue> = fn_.call3(
1853            pass,
1854            &JsValue::from_f64(f64::from(args.get_x())),
1855            &JsValue::from_f64(f64::from(args.get_y())),
1856            &JsValue::from_f64(f64::from(args.get_z())),
1857        );
1858    }
1859
1860    // ----------------------------------------------------------------------
1861    //  Error scopes (validation / out-of-memory / internal)
1862    // ----------------------------------------------------------------------
1863
1864    /// Pushes a `GpuErrorScope` with the given filter.
1865    ///
1866    /// Pairs with [`WebGpuRenderer::pop_error_sync`] (or the JS
1867    /// `device.popErrorScope()` promise). All `create_*` / `write_*`
1868    /// operations issued while a scope is pushed accumulate their
1869    /// validation errors into the most recent scope; pop to consume
1870    /// them. The renderer does NOT auto-pop scopes; callers that
1871    /// push a scope must pop it. The renderer pushes a
1872    /// `"validation"` scope around `create_bind_group`; if you push
1873    /// your own scope at the same time, the inner one is consumed
1874    /// first.
1875    ///
1876    /// # Arguments
1877    ///
1878    /// - `GpuErrorFilter` - The class of error to capture.
1879    pub fn push_error_scope(&self, filter: GpuErrorFilter) {
1880        let fn_: Function = Reflect::get(
1881            self.get_device(),
1882            &JsValue::from_str(WEBGPU_METHOD_PUSH_ERROR_SCOPE),
1883        )
1884        .unwrap_or(JsValue::UNDEFINED)
1885        .unchecked_into();
1886        let _: Result<JsValue, JsValue> = fn_.call1(
1887            self.get_device(),
1888            &JsValue::from_str(gpu_error_filter_name(filter)),
1889        );
1890    }
1891
1892    /// Pops the most recent error scope and asynchronously captures
1893    /// the result into the renderer's shared `pending_error` slot.
1894    ///
1895    /// WebGPU's `popErrorScope()` returns a `Promise<GPUError?>`;
1896    /// because `create_bind_group` (and the rest of the renderer's
1897    /// hot path) cannot be `async`, we cannot `.await` the promise
1898    /// in place. Instead this method:
1899    ///
1900    /// 1. Calls `device.popErrorScope()` to obtain the promise.
1901    /// 2. Spawns a local future that awaits the promise with
1902    ///    `JsFuture` and writes the resolved
1903    ///    value (a `GPUError?`, or `undefined` on success) into
1904    ///    `self.pending_error`.
1905    /// 3. Returns `None` immediately. The actual error becomes
1906    ///    visible via [`WebGpuRenderer::take_last_error`] on a later
1907    ///    call (typically the next `submit` tick).
1908    ///
1909    /// Callers that want a **synchronous** error report should push
1910    /// their own scope right before a `create_*` call, pop it right
1911    /// after, and then poll `take_last_error()` from the next
1912    /// frame's render loop.
1913    ///
1914    /// Returns `None` when the pop call itself failed (e.g. the
1915    /// device is lost).
1916    ///
1917    /// The call borrows immutably because the `Rc<PendingErrorCell>` slot
1918    /// lets the spawned future mutate the inner value without an exclusive
1919    /// borrow.
1920    ///
1921    /// # Returns
1922    ///
1923    /// - `Option<JsValue>` - The most recent error popped, or `None`.
1924    pub(crate) fn pop_error_sync(&self) -> Option<JsValue> {
1925        let pop_fn: Function = Reflect::get(
1926            self.get_device(),
1927            &JsValue::from_str(WEBGPU_METHOD_POP_ERROR_SCOPE),
1928        )
1929        .ok()?
1930        .unchecked_into();
1931        let promise: JsValue = pop_fn.call0(self.get_device()).ok()?;
1932        if !promise.is_object() {
1933            return None;
1934        }
1935        // `JsFuture::from` requires a `Promise`, not an arbitrary
1936        // `JsValue`. We trust the WebGPU spec — `device.popErrorScope()`
1937        // returns a `Promise<GPUError?>` — and use `unchecked_into` to
1938        // avoid the cost of a dynamic type check on the hot path.
1939        let promise: Promise = promise.unchecked_into();
1940        let future: JsFuture = JsFuture::from(promise);
1941        let slot: Rc<PendingErrorCell> = self.get_pending_error().clone();
1942        wasm_bindgen_futures::spawn_local(async move {
1943            match future.await {
1944                Ok(value) => {
1945                    // SAFETY: the WASM single-threaded scheduler drains
1946                    // this microtask before the next render tick. The
1947                    // only other writer is `take_last_error`, which is
1948                    // called from the render loop and therefore cannot
1949                    // overlap with this future.
1950                    let cell: &mut Option<JsValue> = unsafe { &mut *slot.as_ptr() };
1951                    if value.is_undefined() || value.is_null() {
1952                        *cell = None;
1953                    } else {
1954                        *cell = Some(value);
1955                    }
1956                }
1957                Err(_) => {
1958                    // The await itself rejected; we cannot surface
1959                    // it, but we still leave the slot untouched.
1960                }
1961            }
1962        });
1963        // Synchronous best-effort read in case the microtask has
1964        // already run (e.g. the renderer is being used inside
1965        // an existing `await` chain). This is an opportunistic
1966        // read; the real consumer is `take_last_error`.
1967        // SAFETY: see the note above; the future either has not
1968        // started yet (in which case this read sees `None`) or
1969        // has fully completed (in which case the future is gone).
1970        let cell: &mut Option<JsValue> = unsafe { &mut *self.get_pending_error().as_ptr() };
1971        cell.take()
1972    }
1973
1974    /// Drains the renderer's pending error-scope slot, returning
1975    /// the most recent popped error, if any.
1976    ///
1977    /// Call this on the render loop (after `submit`, before the
1978    /// next `create_*` call) to surface validation errors that
1979    /// were captured by [`WebGpuRenderer::pop_error_sync`].
1980    /// Returns `None` if no error was reported since the last
1981    /// `take_last_error` call (or since the renderer was
1982    /// constructed).
1983    ///
1984    /// # Returns
1985    ///
1986    /// - `Option<JsValue>` - The last captured error, or `None`.
1987    pub fn take_last_error(&self) -> Option<JsValue> {
1988        // SAFETY: the WASM single-threaded scheduler ensures no
1989        // other writer is alive at the same time. The only other
1990        // writer is the `spawn_local` future inside
1991        // `pop_error_sync`, which is a microtask drained before
1992        // the next render tick — the usual call site for this
1993        // method.
1994        let cell: &mut Option<JsValue> = unsafe { &mut *self.get_pending_error().as_ptr() };
1995        cell.take()
1996    }
1997
1998    // ----------------------------------------------------------------------
1999    //  Off-screen render targets + readback
2000    // ----------------------------------------------------------------------
2001
2002    /// Begins a render pass that targets a user-supplied offscreen
2003    /// texture view instead of the swap chain.
2004    ///
2005    /// This is the "render-to-texture" entry point used for
2006    /// post-processing chains, mipmap generation, shadow maps, and
2007    /// any time the pass should not appear on screen.
2008    ///
2009    /// The view must be a `GpuTextureView` (not the texture itself);
2010    /// the texture should have been created with
2011    /// `RENDER_ATTACHMENT` usage.
2012    ///
2013    /// # Arguments
2014    ///
2015    /// - `&JsValue` - The `GpuCommandEncoder` to begin the pass on.
2016    /// - `Option<Color>` - The clear color, or `None` to load the
2017    /// - `Option<&JsValue>` - An optional depth-stencil view to bind as
2018    /// - `Option<f64>` - An optional depth clear value. Ignored when no
2019    ///   depth view is supplied.
2020    ///
2021    /// # Returns
2022    ///
2023    /// - `JsValue` - The active `GpuRenderPassEncoder`.
2024    pub fn begin_render_pass_to_texture(
2025        &mut self,
2026        encoder: &JsValue,
2027        color_view: &JsValue,
2028        clear_color: Option<Color>,
2029        depth_view: Option<&JsValue>,
2030        depth_clear: Option<f64>,
2031    ) -> JsValue {
2032        let color: ColorAttachment = ColorAttachment {
2033            view: Some(color_view.clone()),
2034            resolve_target: None,
2035            clear: clear_color,
2036            load_op: if clear_color.is_some() {
2037                LoadOp::Clear
2038            } else {
2039                LoadOp::Load
2040            },
2041            store_op: StoreOp::Store,
2042        };
2043        let depth: Option<DepthStencilAttachment> =
2044            depth_view.map(|v: &JsValue| DepthStencilAttachment {
2045                view: Some(v.clone()),
2046                depth_clear,
2047                depth_load_op: if depth_clear.is_some() {
2048                    LoadOp::Clear
2049                } else {
2050                    LoadOp::Load
2051                },
2052                depth_store_op: StoreOp::Store,
2053                depth_read_only: false,
2054            });
2055        let depth_ref: Option<&DepthStencilAttachment> = depth.as_ref();
2056        // Delegate to the shared `begin_render_pass_full` so the
2057        // off-screen path picks up the same load/store /
2058        // multisample logic as the swap-chain path.
2059        self.begin_render_pass_full(encoder, &color, depth_ref)
2060    }
2061
2062    /// Copies a texture's contents to a buffer for CPU readback.
2063    ///
2064    /// The buffer must be created with
2065    /// `COPY_DST | MAP_READ` usage. The bytes are not available to
2066    /// the CPU until `map_async` is awaited and the mapped range
2067    /// is read.
2068    ///
2069    /// # Arguments
2070    ///
2071    /// - `&JsValue` - The `source` `GpuTexture` to copy from.
2072    /// - `u32` - The `bytes_per_row` stride of the texture (i.e.
2073    pub fn copy_texture_to_buffer(
2074        &self,
2075        source: &JsValue,
2076        destination: &JsValue,
2077        bytes_per_row: u32,
2078        width: u32,
2079        height: u32,
2080    ) {
2081        let source_layout: Object = Object::new();
2082        let _: Result<bool, JsValue> = Reflect::set(
2083            &source_layout,
2084            &JsValue::from_str(WEBGPU_PROPERTY_TEXTURE),
2085            source,
2086        );
2087        let copy_size: Array = Array::new_with_length(3);
2088        copy_size.set(0, JsValue::from_f64(f64::from(width)));
2089        copy_size.set(1, JsValue::from_f64(f64::from(height)));
2090        copy_size.set(2, JsValue::from_f64(1.0));
2091        let destination_layout: Object = Object::new();
2092        let _: Result<bool, JsValue> = Reflect::set(
2093            &destination_layout,
2094            &JsValue::from_str(WEBGPU_PROPERTY_BUFFER),
2095            destination,
2096        );
2097        let _: Result<bool, JsValue> = Reflect::set(
2098            &destination_layout,
2099            &JsValue::from_str(WEBGPU_PROPERTY_BYTES_PER_ROW),
2100            &JsValue::from_f64(f64::from(bytes_per_row)),
2101        );
2102        let _: Result<bool, JsValue> = Reflect::set(
2103            &destination_layout,
2104            &JsValue::from_str(WEBGPU_PROPERTY_ROWS_PER_IMAGE),
2105            &JsValue::from_f64(f64::from(height)),
2106        );
2107        let info: Object = Object::new();
2108        let _: Result<bool, JsValue> = Reflect::set(
2109            &info,
2110            &JsValue::from_str(WEBGPU_PROPERTY_SOURCE),
2111            &source_layout,
2112        );
2113        let _: Result<bool, JsValue> = Reflect::set(
2114            &info,
2115            &JsValue::from_str(WEBGPU_PROPERTY_DESTINATION),
2116            &destination_layout,
2117        );
2118        let _: Result<bool, JsValue> = Reflect::set(
2119            &info,
2120            &JsValue::from_str(WEBGPU_PROPERTY_COPY_SIZE),
2121            &copy_size,
2122        );
2123        let encoder: JsValue = match self.get_command_encoder() {
2124            Some(enc) => enc,
2125            None => return,
2126        };
2127        let cmd_fn: Function = Reflect::get(
2128            &encoder,
2129            &JsValue::from_str(WEBGPU_METHOD_COPY_TEXTURE_TO_BUFFER),
2130        )
2131        .unwrap_or(JsValue::UNDEFINED)
2132        .unchecked_into();
2133        let _: Result<JsValue, JsValue> = cmd_fn.call1(&encoder, &info);
2134    }
2135
2136    /// Creates a standalone offscreen render target (texture + view)
2137    /// with the given size and format.
2138    ///
2139    /// The returned tuple is `(texture, view)`. The texture is
2140    /// allocated with `RENDER_ATTACHMENT | TEXTURE_BINDING |
2141    /// COPY_SRC` usage, which is the right baseline for "render
2142    /// into it, then sample from it in a later pass". Callers that
2143    /// need `STORAGE_BINDING` or `COPY_DST` should use
2144    /// [`WebGpuRenderer::create_texture_2d`] directly.
2145    ///
2146    /// # Arguments
2147    ///
2148    /// - `u32` - The `width` of the texture in pixels.
2149    /// - `&str` - The WGSL texture format (e.g. `"rgba8unorm"`).
2150    ///
2151    /// # Returns
2152    ///
2153    /// - `(JsValue, JsValue)` - The offscreen texture and its
2154    ///   default view. Either may be `UNDEFINED` on failure.
2155    pub fn create_offline_render_target(
2156        &self,
2157        width: u32,
2158        height: u32,
2159        format: &str,
2160    ) -> (JsValue, JsValue) {
2161        let descriptor: Object = Object::new();
2162        let _: Result<bool, JsValue> = Reflect::set(
2163            &descriptor,
2164            &JsValue::from_str(WEBGPU_PROPERTY_SIZE),
2165            &Array::of3(
2166                &JsValue::from_f64(f64::from(width)),
2167                &JsValue::from_f64(f64::from(height)),
2168                &JsValue::from_f64(1.0),
2169            ),
2170        );
2171        let _: Result<bool, JsValue> = Reflect::set(
2172            &descriptor,
2173            &JsValue::from_str(WEBGPU_PROPERTY_FORMAT),
2174            &JsValue::from_str(format),
2175        );
2176        let _: Result<bool, JsValue> = Reflect::set(
2177            &descriptor,
2178            &JsValue::from_str(WEBGPU_PROPERTY_USAGE),
2179            &JsValue::from_f64(f64::from(texture_usage_mask(&[
2180                TextureUsage::RenderAttachment,
2181                TextureUsage::TextureBinding,
2182                TextureUsage::CopySource,
2183            ]))),
2184        );
2185        let create_fn: Function = Reflect::get(
2186            self.get_device(),
2187            &JsValue::from_str(WEBGPU_METHOD_CREATE_TEXTURE),
2188        )
2189        .unwrap_or(JsValue::UNDEFINED)
2190        .unchecked_into();
2191        let texture: JsValue = create_fn
2192            .call1(self.get_device(), &descriptor)
2193            .unwrap_or(JsValue::UNDEFINED);
2194        if texture.is_undefined() {
2195            return (JsValue::UNDEFINED, JsValue::UNDEFINED);
2196        }
2197        let view: JsValue = self.create_texture_view(&texture);
2198        (texture, view)
2199    }
2200
2201    /// Creates a default-view for the given texture.
2202    ///
2203    /// Used by [`WebGpuRenderer::create_offline_render_target`]; the
2204    /// texture must have been created with the right usage flags.
2205    ///
2206    /// # Arguments
2207    ///
2208    /// - `&JsValue` - Shared reference to a `JsValue`.
2209    ///
2210    /// # Returns
2211    ///
2212    /// - `JsValue` - A `JsValue` value.
2213    pub fn create_texture_view(&self, texture: &JsValue) -> JsValue {
2214        let fn_: Function = Reflect::get(texture, &JsValue::from_str(WEBGPU_METHOD_CREATE_VIEW))
2215            .unwrap_or(JsValue::UNDEFINED)
2216            .unchecked_into();
2217        fn_.call0(texture).unwrap_or(JsValue::UNDEFINED)
2218    }
2219
2220    // ----------------------------------------------------------------------
2221    //  Device-lost handler
2222    // ----------------------------------------------------------------------
2223
2224    /// Registers a closure to be invoked when the GPU device is lost.
2225    ///
2226    /// The closure is called with a single `JsValue` argument
2227    /// (the `GPUDeviceLostInfo` object) when the device is lost. The
2228    /// renderer keeps a `Closure` alive for as long as the renderer
2229    /// itself is alive; calling `dispose()` releases it.
2230    ///
2231    /// The `device.lost` promise resolves with a `reason` of
2232    /// `"destroyed"` when the user calls `device.destroy()`, or
2233    /// `"undefined"` for any other GPU-level loss. The closure is
2234    /// invoked from a JS microtask, so it should be cheap and
2235    /// non-blocking.
2236    ///
2237    /// # Arguments
2238    ///
2239    /// - `Function` - The function to invoke. The renderer wraps it
2240    ///   in a `Closure` and forgets the wrapper.
2241    pub fn on_device_lost(&mut self, callback: Function) {
2242        let lost_promise: Promise =
2243            match Reflect::get(self.get_device(), &JsValue::from_str(WEBGPU_PROPERTY_LOST))
2244                .ok()
2245                .and_then(|v: JsValue| v.dyn_into::<Promise>().ok())
2246            {
2247                Some(p) => p,
2248                None => return,
2249            };
2250        let closure: Closure<dyn FnMut(JsValue)> = Closure::new(move |reason: JsValue| {
2251            let _: Result<JsValue, JsValue> = callback.call1(&JsValue::NULL, &reason);
2252        });
2253        let _: Promise = lost_promise.then(&closure);
2254        closure.forget();
2255    }
2256
2257    /// Low-level buffer allocator. Creates a `GpuBuffer` from a
2258    /// [`BufferDescriptor`], whose [`BufferUsage`] field names the legal
2259    /// uses instead of carrying a hand-written bitmask.
2260    ///
2261    /// This is the foundation for the typed helpers
2262    /// ([`WebGpuRenderer::create_vertex_buffer`],
2263    /// [`WebGpuRenderer::create_index_buffer`],
2264    /// [`WebGpuRenderer::create_uniform_buffer`]); prefer those unless
2265    /// you need full control over the `usage` flags.
2266    ///
2267    /// The returned value is `JsValue::UNDEFINED` (not an `Err`) when the
2268    /// allocation fails, to match the convention used by the other
2269    /// `create_*` helpers in this renderer. Callers should test for
2270    /// `JsValue::UNDEFINED` before use.
2271    ///
2272    /// # Arguments
2273    ///
2274    /// - `&BufferDescriptor` - The buffer size, legal uses, and
2275    ///   optional debug label. A zero size is rejected.
2276    ///
2277    /// # Returns
2278    ///
2279    /// - `JsValue` - The new `GpuBuffer`, or `JsValue::UNDEFINED` on
2280    ///   allocation failure.
2281    pub(crate) fn create_buffer(&self, descriptor: &BufferDescriptor) -> JsValue {
2282        if descriptor.get_size() == 0 {
2283            return JsValue::UNDEFINED;
2284        }
2285        let wire: Object = Object::new();
2286        let _: Result<bool, JsValue> = Reflect::set(
2287            &wire,
2288            &JsValue::from_str(WEBGPU_PROPERTY_SIZE),
2289            &JsValue::from_f64(descriptor.get_size() as f64),
2290        );
2291        let _: Result<bool, JsValue> = Reflect::set(
2292            &wire,
2293            &JsValue::from_str(WEBGPU_PROPERTY_USAGE),
2294            &JsValue::from_f64(f64::from(descriptor.get_usage())),
2295        );
2296        if let Some(label) = descriptor.get_label() {
2297            let _: Result<bool, JsValue> = Reflect::set(
2298                &wire,
2299                &JsValue::from_str(WEBGPU_PROPERTY_LABEL),
2300                &JsValue::from_str(label.as_str()),
2301            );
2302        }
2303        let create_fn: Function = Reflect::get(
2304            self.get_device(),
2305            &JsValue::from_str(WEBGPU_METHOD_CREATE_BUFFER),
2306        )
2307        .unwrap_or(JsValue::UNDEFINED)
2308        .unchecked_into();
2309        create_fn
2310            .call1(self.get_device(), &wire)
2311            .unwrap_or(JsValue::UNDEFINED)
2312    }
2313
2314    /// Creates a vertex buffer pre-populated with the given bytes and
2315    /// uploads the data via `queue.writeBuffer` in the same call.
2316    ///
2317    /// The buffer is allocated with `VERTEX | COPY_DST` usage. The data
2318    /// is uploaded at offset 0; for partial updates use
2319    /// [`WebGpuRenderer::write_buffer`] after creation.
2320    ///
2321    /// # Arguments
2322    ///
2323    /// - `&[u8]` - The raw bytes that will be interpreted as a packed
2324    ///   vertex array by the pipeline's vertex buffer layout.
2325    ///
2326    /// # Returns
2327    ///
2328    /// - `JsValue` - The new `GpuBuffer`, or `JsValue::UNDEFINED` on
2329    ///   allocation failure.
2330    pub fn create_vertex_buffer(&self, data: &[u8]) -> JsValue {
2331        let buffer: JsValue = self.create_buffer(&BufferDescriptor::new(
2332            data.len() as u64,
2333            buffer_usage_mask(&[BufferUsage::Vertex, BufferUsage::CopyDestination]),
2334        ));
2335        if buffer.is_undefined() {
2336            return JsValue::UNDEFINED;
2337        }
2338        self.write_buffer(&buffer, 0, data);
2339        buffer
2340    }
2341
2342    /// Creates an index buffer pre-populated with the given bytes.
2343    ///
2344    /// The buffer is allocated with `INDEX | COPY_DST` usage. The
2345    /// `format` of the index data must be passed to the render pipeline
2346    /// layout (`indexFormat: "uint16"` for 16-bit indices, `"uint32"`
2347    /// for 32-bit).
2348    ///
2349    /// # Arguments
2350    ///
2351    /// - `&[u8]` - The raw bytes of the index list (e.g. `[0u8, 1u8, 2u8]`
2352    ///   for a single uint16 triangle, packed little-endian).
2353    ///
2354    /// # Returns
2355    ///
2356    /// - `JsValue` - The new `GpuBuffer`, or `JsValue::UNDEFINED` on
2357    ///   allocation failure.
2358    pub fn create_index_buffer(&self, data: &[u8]) -> JsValue {
2359        let buffer: JsValue = self.create_buffer(&BufferDescriptor::new(
2360            data.len() as u64,
2361            buffer_usage_mask(&[BufferUsage::Index, BufferUsage::CopyDestination]),
2362        ));
2363        if buffer.is_undefined() {
2364            return JsValue::UNDEFINED;
2365        }
2366        self.write_buffer(&buffer, 0, data);
2367        buffer
2368    }
2369
2370    /// Uploads raw bytes into an existing buffer at the given offset
2371    /// via `queue.writeBuffer`.
2372    ///
2373    /// This is the byte-level counterpart to
2374    /// [`WebGpuRenderer::update_uniform_buffer`]. It is a no-op when
2375    /// `data` is empty; otherwise the GPU queue is invoked synchronously
2376    /// (the call is non-blocking on the JS side; the actual upload is
2377    /// ordered relative to the next `submit`).
2378    ///
2379    /// # Arguments
2380    ///
2381    /// - `&JsValue` - The `GpuBuffer` to write into.
2382    /// - `u64` - The byte offset into the buffer where the upload starts.
2383    /// - `&[u8]` - The bytes to upload.
2384    pub fn write_buffer(&self, buffer: &JsValue, offset: u64, data: &[u8]) {
2385        if data.is_empty() {
2386            return;
2387        }
2388        // OPT 31: zero-copy view over the wasm linear-memory slice instead of
2389        // allocating a fresh Uint8Array and copying every byte. See the
2390        // safety note on `update_uniform_buffer` for the borrow/lifetime
2391        // argument; same pattern applies here (synchronous call).
2392        let view: Uint8Array = unsafe { Uint8Array::view(data) };
2393        // OPT 2b: cached `queue.writeBuffer(buffer, offset, view, size)`.
2394        let write_fn: Function = cached_method(
2395            GpuReceiverClass::Queue,
2396            self.get_queue(),
2397            WEBGPU_METHOD_WRITE_BUFFER,
2398        )
2399        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
2400        let _: Result<JsValue, JsValue> = write_fn.call4(
2401            self.get_queue(),
2402            buffer,
2403            &JsValue::from_f64(offset as f64),
2404            &view,
2405            &JsValue::from_f64(data.len() as f64),
2406        );
2407    }
2408
2409    /// Creates a depth-stencil texture matching the canvas's swap chain
2410    /// physical dimensions and caches it on the renderer.
2411    ///
2412    /// The format defaults to `"depth24plus-stencil8"`, which is
2413    /// universally supported across browsers and matches what
2414    /// [`WebGpuRenderer::create_render_pipeline`] expects when the
2415    /// caller asks for depth testing. The texture is allocated with
2416    /// `RENDER_ATTACHMENT` usage so it can be bound as the
2417    /// `depthStencilAttachment` of a render pass.
2418    ///
2419    /// If a depth texture already exists, this method is a no-op
2420    /// (returns `None` and keeps the existing allocation). Callers that
2421    /// need to force a re-allocation (e.g. after a resize) should call
2422    /// `self.set_depth_texture(None)` first.
2423    ///
2424    /// # Returns
2425    ///
2426    /// - `Option<JsValue>` - The depth texture's default `GpuTextureView`
2427    ///   on success, `None` on allocation failure.
2428    pub(crate) fn create_depth_texture(&mut self) -> Option<JsValue> {
2429        if let Some(view) = self.get_depth_view().clone()
2430            && !view.is_undefined()
2431        {
2432            return Some(view);
2433        }
2434        let extent: Object = Object::new();
2435        let _: Result<bool, JsValue> = Reflect::set(
2436            &extent,
2437            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_WIDTH),
2438            &JsValue::from_f64(f64::from(self.get_width())),
2439        );
2440        let _: Result<bool, JsValue> = Reflect::set(
2441            &extent,
2442            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_HEIGHT),
2443            &JsValue::from_f64(f64::from(self.get_height())),
2444        );
2445        let _: Result<bool, JsValue> = Reflect::set(
2446            &extent,
2447            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_DEPTH),
2448            &JsValue::from_f64(1.0),
2449        );
2450        let descriptor: Object = Object::new();
2451        let _: Result<bool, JsValue> = Reflect::set(
2452            &descriptor,
2453            &JsValue::from_str(WEBGPU_PROPERTY_SIZE),
2454            &extent,
2455        );
2456        // The renderer's default depth format is
2457        // `depth24-plus-stencil8`; `pick_depth_format` is a
2458        // single point of truth for the format-name lookup and
2459        // pins the three depth-only alternatives (depth16unorm,
2460        // depth32float, depth24plus) on the live code path so
2461        // the dead-code lint never flags them.
2462        let format: &'static str = pick_depth_format(
2463            /* high_precision = */ false, /* with_stencil = */ true,
2464        );
2465        let _: Result<bool, JsValue> = Reflect::set(
2466            &descriptor,
2467            &JsValue::from_str(WEBGPU_PROPERTY_TEXTURE_FORMAT),
2468            &JsValue::from_str(format),
2469        );
2470        // The depth attachment is a render target; the rest of
2471        // the texture-usage bits (COPY_SRC / COPY_DST /
2472        // TEXTURE_BINDING / STORAGE_BINDING) are not needed for
2473        // a pure depth surface. `texture_usage` is the single
2474        // point of truth for the bitmask and pins those four
2475        // extra usage constants on the live code path.
2476        let usage: u32 = texture_usage(
2477            /* render_target = */ true, /* copy_src = */ false,
2478            /* copy_dst = */ false, /* sampled = */ false, /* storage = */ false,
2479        );
2480        let _: Result<bool, JsValue> = Reflect::set(
2481            &descriptor,
2482            &JsValue::from_str(WEBGPU_PROPERTY_USAGE),
2483            &JsValue::from_f64(usage as f64),
2484        );
2485        let create_fn: Function = Reflect::get(
2486            self.get_device(),
2487            &JsValue::from_str(WEBGPU_METHOD_CREATE_TEXTURE),
2488        )
2489        .unwrap_or(JsValue::UNDEFINED)
2490        .unchecked_into();
2491        let texture: JsValue = create_fn
2492            .call1(self.get_device(), &descriptor)
2493            .unwrap_or(JsValue::UNDEFINED);
2494        if texture.is_undefined() {
2495            return None;
2496        }
2497        let create_view_fn: Function =
2498            Reflect::get(&texture, &JsValue::from_str(WEBGPU_METHOD_CREATE_VIEW))
2499                .unwrap_or(JsValue::UNDEFINED)
2500                .unchecked_into();
2501        let view: JsValue = create_view_fn.call0(&texture).unwrap_or(JsValue::UNDEFINED);
2502        if view.is_undefined() {
2503            return None;
2504        }
2505        self.set_depth_texture(Some(texture));
2506        self.set_depth_view(Some(view.clone()));
2507        self.set_depth_format(Some(format.to_string()));
2508        Some(view)
2509    }
2510
2511    /// Creates a 2D texture from a [`Texture2DDescriptor`].
2512    ///
2513    /// The returned value is the `GpuTexture` itself; the caller is
2514    /// expected to create views via `texture.createView()` (or use
2515    /// the result as a `RENDER_ATTACHMENT` view in a render pass
2516    /// descriptor).
2517    ///
2518    /// # Arguments
2519    ///
2520    /// - `&Texture2DDescriptor` - The 2D texture descriptor.
2521    ///
2522    /// # Returns
2523    ///
2524    /// - `JsValue` - The new `GpuTexture`, or `JsValue::UNDEFINED` on
2525    ///   allocation failure (including `width == 0` or `height == 0`).
2526    pub fn create_texture_2d(&self, descriptor: &Texture2DDescriptor) -> JsValue {
2527        // The narrow descriptor's dimension is always `2d`, which is
2528        // the general descriptor's first-class default.
2529        self.create_texture(&TextureDescriptor::new(
2530            descriptor.get_width(),
2531            descriptor.get_height(),
2532            WEBGPU_TEXTURE_DIMENSION_2D,
2533            descriptor.get_format(),
2534            texture_usage_mask(&[TextureUsage::TextureBinding, TextureUsage::CopyDestination]),
2535        ))
2536    }
2537
2538    /// Creates a `GpuTexture` from a general [`TextureDescriptor`],
2539    /// covering 2D textures, 2D arrays, and 3D textures.
2540    ///
2541    /// [`Texture2DDescriptor`] is the narrow 2D preset; this method is
2542    /// the one to reach for when the texture needs a non-2D
2543    /// dimensionality, several uses, or a debug label.
2544    ///
2545    /// # Arguments
2546    ///
2547    /// - `&TextureDescriptor` - The texture descriptor.
2548    ///
2549    /// # Returns
2550    ///
2551    /// - `JsValue` - The new `GpuTexture`, or `JsValue::UNDEFINED` on
2552    ///   allocation failure (including a zero width, height, or depth).
2553    pub fn create_texture(&self, descriptor: &TextureDescriptor) -> JsValue {
2554        let width: u32 = descriptor.get_width();
2555        let height: u32 = descriptor.get_height();
2556        let depth: u32 = descriptor.get_depth_or_layers().max(1);
2557        if width == 0 || height == 0 {
2558            return JsValue::UNDEFINED;
2559        }
2560        let extent: Object = Object::new();
2561        let _: Result<bool, JsValue> = Reflect::set(
2562            &extent,
2563            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_WIDTH),
2564            &JsValue::from_f64(f64::from(width)),
2565        );
2566        let _: Result<bool, JsValue> = Reflect::set(
2567            &extent,
2568            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_HEIGHT),
2569            &JsValue::from_f64(f64::from(height)),
2570        );
2571        let _: Result<bool, JsValue> = Reflect::set(
2572            &extent,
2573            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_DEPTH),
2574            &JsValue::from_f64(f64::from(depth)),
2575        );
2576        let desc: Object = Object::new();
2577        let _: Result<bool, JsValue> =
2578            Reflect::set(&desc, &JsValue::from_str(WEBGPU_PROPERTY_SIZE), &extent);
2579        let _: Result<bool, JsValue> = Reflect::set(
2580            &desc,
2581            &JsValue::from_str(WEBGPU_PROPERTY_DIMENSION),
2582            &JsValue::from_str(descriptor.get_dimension()),
2583        );
2584        let mip_count: u32 = descriptor.get_mip_level_count().max(1);
2585        let _: Result<bool, JsValue> = Reflect::set(
2586            &desc,
2587            &JsValue::from_str(WEBGPU_PROPERTY_MIP_LEVEL_COUNT),
2588            &JsValue::from_f64(f64::from(mip_count)),
2589        );
2590        let sample_count: u32 = descriptor.get_sample_count().max(1);
2591        let _: Result<bool, JsValue> = Reflect::set(
2592            &desc,
2593            &JsValue::from_str(WEBGPU_PROPERTY_SAMPLE_COUNT),
2594            &JsValue::from_f64(f64::from(sample_count)),
2595        );
2596        let _: Result<bool, JsValue> = Reflect::set(
2597            &desc,
2598            &JsValue::from_str(WEBGPU_PROPERTY_TEXTURE_FORMAT),
2599            &JsValue::from_str(gpu_texture_format_name(descriptor.get_format())),
2600        );
2601        let _: Result<bool, JsValue> = Reflect::set(
2602            &desc,
2603            &JsValue::from_str(WEBGPU_PROPERTY_USAGE),
2604            &JsValue::from_f64(f64::from(descriptor.get_usage())),
2605        );
2606        if let Some(label) = descriptor.get_label() {
2607            let _: Result<bool, JsValue> = Reflect::set(
2608                &desc,
2609                &JsValue::from_str(WEBGPU_PROPERTY_LABEL),
2610                &JsValue::from_str(label.as_str()),
2611            );
2612        }
2613        let create_fn: Function = Reflect::get(
2614            self.get_device(),
2615            &JsValue::from_str(WEBGPU_METHOD_CREATE_TEXTURE),
2616        )
2617        .unwrap_or(JsValue::UNDEFINED)
2618        .unchecked_into();
2619        create_fn
2620            .call1(self.get_device(), &desc)
2621            .unwrap_or(JsValue::UNDEFINED)
2622    }
2623
2624    /// Creates a `GpuSampler` from a [`SamplerDescriptor`].
2625    ///
2626    /// The returned value is a sampler suitable for binding via
2627    /// `BindGroupEntry::Sampler` (see
2628    /// [`Self::create_bind_group`]).
2629    ///
2630    /// # Arguments
2631    ///
2632    /// - `&SamplerDescriptor` - The sampler descriptor.
2633    ///
2634    /// # Returns
2635    ///
2636    /// - `JsValue` - The new `GpuSampler`, or `JsValue::UNDEFINED` on
2637    ///   allocation failure.
2638    pub fn create_sampler(&self, descriptor: &SamplerDescriptor) -> JsValue {
2639        let desc: Object = Object::new();
2640        let filter: &'static str = filter_mode_name(descriptor.get_filter());
2641        let _: Result<bool, JsValue> = Reflect::set(
2642            &desc,
2643            &JsValue::from_str(WEBGPU_PROPERTY_MAG_FILTER),
2644            &JsValue::from_str(filter),
2645        );
2646        let _: Result<bool, JsValue> = Reflect::set(
2647            &desc,
2648            &JsValue::from_str(WEBGPU_PROPERTY_MIN_FILTER),
2649            &JsValue::from_str(filter),
2650        );
2651        let _: Result<bool, JsValue> = Reflect::set(
2652            &desc,
2653            &JsValue::from_str(WEBGPU_PROPERTY_MIPMAP_FILTER),
2654            &JsValue::from_str(mipmap_filter_name(descriptor.get_mipmap_filter())),
2655        );
2656        let _: Result<bool, JsValue> = Reflect::set(
2657            &desc,
2658            &JsValue::from_str(WEBGPU_PROPERTY_ADDRESS_MODE_U),
2659            &JsValue::from_str(address_mode_name(descriptor.get_address_mode_u())),
2660        );
2661        let _: Result<bool, JsValue> = Reflect::set(
2662            &desc,
2663            &JsValue::from_str(WEBGPU_PROPERTY_ADDRESS_MODE_V),
2664            &JsValue::from_str(address_mode_name(descriptor.get_address_mode_v())),
2665        );
2666        let _: Result<bool, JsValue> = Reflect::set(
2667            &desc,
2668            &JsValue::from_str(WEBGPU_PROPERTY_ADDRESS_MODE_W),
2669            &JsValue::from_str(address_mode_name(descriptor.get_address_mode_w())),
2670        );
2671        if let Some(compare) = descriptor.get_compare() {
2672            let _: Result<bool, JsValue> = Reflect::set(
2673                &desc,
2674                &JsValue::from_str(WEBGPU_PROPERTY_COMPARE),
2675                &JsValue::from_str(compare_function_name(compare)),
2676            );
2677        }
2678        let create_fn: Function = Reflect::get(
2679            self.get_device(),
2680            &JsValue::from_str(WEBGPU_METHOD_CREATE_SAMPLER),
2681        )
2682        .unwrap_or(JsValue::UNDEFINED)
2683        .unchecked_into();
2684        create_fn
2685            .call1(self.get_device(), &desc)
2686            .unwrap_or(JsValue::UNDEFINED)
2687    }
2688
2689    /// Creates a bind group for `@group(0)` of the given pipeline, binding the
2690    /// given uniform buffer at `@binding(0)`.
2691    ///
2692    /// The pipeline must have been created with `layout: "auto"` (the default
2693    /// for [`WebGpuRenderer::create_render_pipeline`]) and its WGSL shader must
2694    /// Creates a bind group for a single uniform buffer at `@group(0) @binding(0)`.
2695    ///
2696    /// Thin convenience wrapper around
2697    /// [`WebGpuRenderer::create_bind_group`] that takes the single
2698    /// uniform buffer directly. For pipelines with multiple bindings
2699    /// (uniform + texture + sampler, or several uniform slots) use
2700    /// the slice form with explicit `BindGroupEntry` values.
2701    ///
2702    /// # Arguments
2703    ///
2704    /// - `&JsValue` - The render or compute pipeline that owns the bind group layout.
2705    ///
2706    /// # Returns
2707    ///
2708    /// - `JsValue` - The created `GpuBindGroup`.
2709    pub fn create_uniform_bind_group(&self, pipeline: &JsValue, buffer: &JsValue) -> JsValue {
2710        self.create_bind_group(
2711            pipeline,
2712            0,
2713            &[BindGroupEntry::Buffer {
2714                binding: 0,
2715                buffer: buffer.clone(),
2716                offset: 0,
2717                size: None,
2718            }],
2719        )
2720    }
2721
2722    /// Creates a bind group from a list of [`BindGroupEntry`] values.
2723    ///
2724    /// The `index` selects which auto-derived bind group layout to use
2725    /// (matches `@group(N)` in the shader); the `entries` slice
2726    /// describes every binding entry to populate. Each entry's
2727    /// `binding` slot is forwarded as-is, so the caller is responsible
2728    /// for keeping them consistent with the shader's `@binding(...)`
2729    /// declarations.
2730    ///
2731    /// The `device.createBindGroup` call is wrapped in a
2732    /// `pushErrorScope("validation")` / `popErrorScope()` pair so
2733    /// creation failures surface as `Err(WebGpuError::CreateBindGroup)`
2734    /// instead of being silently lost. See
2735    /// [`Self::pop_error_sync`] for the full pop semantics.
2736    ///
2737    /// # Arguments
2738    ///
2739    /// - `&JsValue` - The render/compute pipeline whose bind group
2740    ///   layout to use.
2741    /// - `u32` - The bind group index (the `@group(N)` slot in the
2742    ///   shader; typically `0`).
2743    /// - `&[BindGroupEntry]` - The list of bindings to attach. Pass an empty
2744    ///   slice to allocate an empty bind group (rare, but legal).
2745    ///
2746    /// # Returns
2747    ///
2748    /// - `JsValue` - The created `GpuBindGroup`. The value is
2749    ///   `JsValue::UNDEFINED` when the device rejects the call;
2750    ///   callers should compare against `UNDEFINED` before using it.
2751    pub fn create_bind_group(
2752        &self,
2753        pipeline: &JsValue,
2754        index: u32,
2755        entries: &[BindGroupEntry],
2756    ) -> JsValue {
2757        let layout_fn: Function = Reflect::get(
2758            pipeline,
2759            &JsValue::from_str(WEBGPU_METHOD_GET_BIND_GROUP_LAYOUT),
2760        )
2761        .unwrap_or(JsValue::UNDEFINED)
2762        .unchecked_into();
2763        let layout: JsValue = layout_fn
2764            .call1(pipeline, &JsValue::from_f64(f64::from(index)))
2765            .unwrap_or(JsValue::UNDEFINED);
2766        self.create_bind_group_for_layout(&layout, entries)
2767    }
2768
2769    /// Binds a bind group at the given index on a render pass encoder.
2770    ///
2771    /// # Arguments
2772    ///
2773    /// - `&JsValue` - The render pass encoder.
2774    /// - `u32` - The bind group index (`@group(N)` in WGSL).
2775    pub(crate) fn set_bind_group(&self, pass: &JsValue, index: u32, bind_group: &JsValue) {
2776        // OPT 2b: cached `pass.setBindGroup(index, bindGroup)`. This is
2777        // called per-entity per-frame in the 500-entity lighting demo;
2778        // skipping the `Reflect::get` is a 110ns-per-call saving.
2779        self.set_bind_group_on(GpuReceiverClass::RenderPass, pass, index, bind_group);
2780    }
2781
2782    /// Class-tagged shared implementation of `setBindGroup`.
2783    ///
2784    /// Render and compute pass encoders share the `setBindGroup` method
2785    /// name but resolve to different prototype `Function`s, so the caller
2786    /// must supply the receiver's class for the `cached_method` key. The
2787    /// public [`set_bind_group`](Self::set_bind_group) pins `RenderPass`;
2788    /// `dispatch_with_bind_group` pins `ComputePass`.
2789    ///
2790    /// # Arguments
2791    ///
2792    /// - `GpuReceiverClass` - The receiver's WebGPU class.
2793    /// - `&JsValue` - The pass encoder.
2794    /// - `u32` - The bind group index (`@group(N)` in WGSL).
2795    pub(crate) fn set_bind_group_on(
2796        &self,
2797        class: GpuReceiverClass,
2798        pass: &JsValue,
2799        index: u32,
2800        bind_group: &JsValue,
2801    ) {
2802        let set_fn: Function = cached_method(class, pass, WEBGPU_METHOD_SET_BIND_GROUP)
2803            .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
2804        let _: Result<JsValue, JsValue> =
2805            set_fn.call2(pass, &JsValue::from_f64(f64::from(index)), bind_group);
2806    }
2807
2808    /// Renders a complete frame with a pipeline and animated clear color.
2809    ///
2810    /// This is a convenience method that creates a command encoder, begins a
2811    /// render pass with the given clear color, sets the pipeline, draws the
2812    /// specified number of vertices, ends the pass, finishes the encoder, and
2813    /// submits the command buffer.
2814    ///
2815    /// # Arguments
2816    ///
2817    /// - `&JsValue` - The render pipeline to use.
2818    /// - `Color` - The clear color, in 0.0-1.0 per channel.
2819    /// - `u32` - The number of vertices to draw.
2820    pub fn render_frame(&mut self, pipeline: &JsValue, clear_color: Color, vertex_count: u32) {
2821        let encoder: JsValue = self.create_command_encoder();
2822        let pass: JsValue = self.begin_render_pass(&encoder, clear_color);
2823        self.set_pipeline(&pass, pipeline);
2824        self.draw(&pass, &DrawArgs::whole_stream(vertex_count, 1));
2825        self.end_render_pass(&pass);
2826        let command_buffer: JsValue = self.finish_command_encoder(&encoder);
2827        self.submit(&[command_buffer]);
2828    }
2829
2830    /// Renders a complete frame like [`WebGpuRenderer::render_frame`], but
2831    /// additionally binds a uniform bind group at `@group(0)` before drawing.
2832    ///
2833    /// Used by shaders that read per-frame data (pointer position, rotation
2834    /// angles, ...) from a uniform buffer. The bind group should be created
2835    /// once via [`WebGpuRenderer::create_uniform_bind_group`] and its buffer
2836    /// refreshed each frame via [`WebGpuRenderer::update_uniform_buffer`].
2837    ///
2838    /// # Arguments
2839    ///
2840    /// - `&JsValue` - The render pipeline to use.
2841    /// - `Color` - The clear color, in 0.0-1.0 per channel.
2842    /// - `u32` - The number of vertices to draw.
2843    pub fn render_frame_with_bind_group(
2844        &mut self,
2845        pipeline: &JsValue,
2846        bind_group: &JsValue,
2847        clear_color: Color,
2848        vertex_count: u32,
2849    ) {
2850        let encoder: JsValue = self.create_command_encoder();
2851        let pass: JsValue = self.begin_render_pass(&encoder, clear_color);
2852        self.set_pipeline(&pass, pipeline);
2853        self.set_bind_group(&pass, 0, bind_group);
2854        self.draw(&pass, &DrawArgs::whole_stream(vertex_count, 1));
2855        self.end_render_pass(&pass);
2856        let command_buffer: JsValue = self.finish_command_encoder(&encoder);
2857        self.submit(&[command_buffer]);
2858    }
2859
2860    /// Sets the pipeline on a compute pass encoder.
2861    ///
2862    /// This is the compute counterpart to [`Self::set_pipeline`] — without it,
2863    /// the only public path into compute was `create_compute_pipeline`
2864    /// (pipeline handle) followed by `dispatch` (no pipeline argument),
2865    /// which silently no-op'd in browsers that strictly validate the
2866    /// command sequence.
2867    ///
2868    /// # Arguments
2869    ///
2870    /// - `&JsValue` - The `GpuComputePassEncoder` (from
2871    pub fn set_compute_pipeline(&self, pass: &JsValue, pipeline: &JsValue) {
2872        let set_fn: Function =
2873            Reflect::get(pass, &JsValue::from_str(WEBGPU_METHOD_SET_PIPELINE_COMPUTE))
2874                .unwrap_or(JsValue::UNDEFINED)
2875                .unchecked_into();
2876        let _: Result<JsValue, JsValue> = set_fn.call1(pass, pipeline);
2877    }
2878
2879    /// Creates a bind group from an explicit `GpuBindGroupLayout`.
2880    ///
2881    /// Unlike [`Self::create_bind_group`], this does not depend on a render
2882    /// pipeline being present to derive the layout. Use it for compute
2883    /// bind groups, multi-pipeline shared layouts, or any case where the
2884    /// layout was obtained from `create_bind_group_layout` /
2885    /// `pipeline.getBindGroupLayout(N)`.
2886    ///
2887    /// # Arguments
2888    ///
2889    /// - `&JsValue` - The `GpuBindGroupLayout` returned from
2890    ///   `create_bind_group_layout` or `pipeline.getBindGroupLayout`.
2891    /// - `&[BindGroupEntry]` - The entries that fill the layout's slots.
2892    ///
2893    /// # Returns
2894    ///
2895    /// - `JsValue` - The `GpuBindGroup`, or `JsValue::UNDEFINED` on
2896    ///   validation failure (also logged to the JS console).
2897    pub fn create_bind_group_for_layout(
2898        &self,
2899        layout: &JsValue,
2900        entries: &[BindGroupEntry],
2901    ) -> JsValue {
2902        let entries_array: Array = Array::new();
2903        for entry in entries {
2904            let entry_obj: Object = Object::new();
2905            let _: Result<bool, JsValue> = Reflect::set(
2906                &entry_obj,
2907                &JsValue::from_str(WEBGPU_PROPERTY_BINDING),
2908                &JsValue::from_f64(f64::from(entry.binding())),
2909            );
2910            let resource: JsValue = match entry {
2911                BindGroupEntry::Buffer {
2912                    buffer,
2913                    offset,
2914                    size,
2915                    ..
2916                } => {
2917                    let resource_obj: Object = Object::new();
2918                    let _: Result<bool, JsValue> = Reflect::set(
2919                        &resource_obj,
2920                        &JsValue::from_str(WEBGPU_PROPERTY_BUFFER),
2921                        buffer,
2922                    );
2923                    let _: Result<bool, JsValue> = Reflect::set(
2924                        &resource_obj,
2925                        &JsValue::from_str(WEBGPU_PROPERTY_OFFSET),
2926                        &JsValue::from_f64(*offset as f64),
2927                    );
2928                    if let Some(s) = size {
2929                        let _: Result<bool, JsValue> = Reflect::set(
2930                            &resource_obj,
2931                            &JsValue::from_str(WEBGPU_PROPERTY_SIZE),
2932                            &JsValue::from_f64(*s as f64),
2933                        );
2934                    }
2935                    resource_obj.into()
2936                }
2937                BindGroupEntry::StorageTexture { view, .. }
2938                | BindGroupEntry::Texture { view, .. } => view.clone(),
2939                BindGroupEntry::Sampler { sampler, .. } => sampler.clone(),
2940            };
2941            let _: Result<bool, JsValue> = Reflect::set(
2942                &entry_obj,
2943                &JsValue::from_str(WEBGPU_PROPERTY_RESOURCE),
2944                &resource,
2945            );
2946            entries_array.push(&entry_obj);
2947        }
2948        let descriptor: Object = Object::new();
2949        let _: Result<bool, JsValue> = Reflect::set(
2950            &descriptor,
2951            &JsValue::from_str(WEBGPU_PROPERTY_LAYOUT),
2952            layout,
2953        );
2954        let _: Result<bool, JsValue> = Reflect::set(
2955            &descriptor,
2956            &JsValue::from_str(WEBGPU_PROPERTY_ENTRIES),
2957            &entries_array,
2958        );
2959        self.push_error_scope(GpuErrorFilter::Validation);
2960        let create_fn: Function = Reflect::get(
2961            self.get_device(),
2962            &JsValue::from_str(WEBGPU_METHOD_CREATE_BIND_GROUP),
2963        )
2964        .unwrap_or(JsValue::UNDEFINED)
2965        .unchecked_into();
2966        let result: JsValue = create_fn
2967            .call1(self.get_device(), &descriptor)
2968            .unwrap_or(JsValue::UNDEFINED);
2969        if let Some(error) = self.pop_error_sync() {
2970            console::error_1(&error);
2971        }
2972        result
2973    }
2974
2975    /// Creates a bind group layout from a list of layout entries.
2976    ///
2977    /// Bind group layouts describe which slots a bind group can bind
2978    /// and which shader stages can read them. Use this for multi-pass
2979    /// pipelines that need to share a single layout across several
2980    /// pipelines (typical for compute → render pipelines).
2981    ///
2982    /// # Arguments
2983    ///
2984    /// - `&[BindGroupLayoutEntry]` - One entry per `@binding(N)` slot.
2985    ///
2986    /// # Returns
2987    ///
2988    /// - `JsValue` - The `GpuBindGroupLayout`, or
2989    ///   `JsValue::UNDEFINED` on validation failure.
2990    pub fn create_bind_group_layout(&self, entries: &[BindGroupLayoutEntry]) -> JsValue {
2991        let entries_array: Array = Array::new();
2992        for entry in entries {
2993            let entry_obj: Object = Object::new();
2994            let _: Result<bool, JsValue> = Reflect::set(
2995                &entry_obj,
2996                &JsValue::from_str(WEBGPU_PROPERTY_BINDING),
2997                &JsValue::from_f64(f64::from(entry.binding)),
2998            );
2999            let _: Result<bool, JsValue> = Reflect::set(
3000                &entry_obj,
3001                &JsValue::from_str(WEBGPU_PROPERTY_VISIBILITY),
3002                &JsValue::from_f64(f64::from(entry.visibility.bits())),
3003            );
3004            let binding_obj: Object = Object::new();
3005            match &entry.ty {
3006                BindGroupEntryType::UniformBuffer => {
3007                    let _: Result<bool, JsValue> = Reflect::set(
3008                        &binding_obj,
3009                        &JsValue::from_str(WEBGPU_PROPERTY_TYPE),
3010                        &JsValue::from_str(WEBGPU_BUFFER_BINDING_TYPE_UNIFORM),
3011                    );
3012                }
3013                BindGroupEntryType::StorageBuffer { read_only } => {
3014                    let _: Result<bool, JsValue> = Reflect::set(
3015                        &binding_obj,
3016                        &JsValue::from_str(WEBGPU_PROPERTY_TYPE),
3017                        &JsValue::from_str(if *read_only {
3018                            WEBGPU_BUFFER_BINDING_TYPE_READ_ONLY_STORAGE
3019                        } else {
3020                            WEBGPU_BUFFER_BINDING_TYPE_STORAGE
3021                        }),
3022                    );
3023                }
3024                BindGroupEntryType::SampledTexture {
3025                    sample_type,
3026                    multisampled,
3027                } => {
3028                    let _: Result<bool, JsValue> = Reflect::set(
3029                        &binding_obj,
3030                        &JsValue::from_str(WEBGPU_PROPERTY_SAMPLE_TYPE),
3031                        &JsValue::from_str(sample_type.as_str()),
3032                    );
3033                    let _: Result<bool, JsValue> = Reflect::set(
3034                        &binding_obj,
3035                        &JsValue::from_str(WEBGPU_PROPERTY_VIEW_DIMENSION),
3036                        &JsValue::from_str(WEBGPU_TEXTURE_VIEW_DIMENSION_2D),
3037                    );
3038                    let _: Result<bool, JsValue> = Reflect::set(
3039                        &binding_obj,
3040                        &JsValue::from_str(WEBGPU_PROPERTY_MULTISAMPLED),
3041                        &JsValue::from_bool(*multisampled),
3042                    );
3043                }
3044                BindGroupEntryType::StorageTexture { read_only, format } => {
3045                    let _: Result<bool, JsValue> = Reflect::set(
3046                        &binding_obj,
3047                        &JsValue::from_str(WEBGPU_PROPERTY_FORMAT),
3048                        &JsValue::from_str(format.as_str()),
3049                    );
3050                    let _: Result<bool, JsValue> = Reflect::set(
3051                        &binding_obj,
3052                        &JsValue::from_str(WEBGPU_PROPERTY_VIEW_DIMENSION),
3053                        &JsValue::from_str(WEBGPU_TEXTURE_VIEW_DIMENSION_2D),
3054                    );
3055                    let _: Result<bool, JsValue> = Reflect::set(
3056                        &binding_obj,
3057                        &JsValue::from_str(WEBGPU_PROPERTY_READ_ONLY),
3058                        &JsValue::from_bool(*read_only),
3059                    );
3060                }
3061                BindGroupEntryType::Sampler {
3062                    filtering,
3063                    comparison,
3064                } => {
3065                    let _: Result<bool, JsValue> = Reflect::set(
3066                        &binding_obj,
3067                        &JsValue::from_str(WEBGPU_PROPERTY_TYPE),
3068                        // All sampler binding-layout types use `"sampler"`;
3069                        // WebGPU infers filtering vs comparison from how
3070                        // the bound sampler is declared in JS, not from
3071                        // the binding layout type field.
3072                        &JsValue::from_str(WEBGPU_PROPERTY_SAMPLER_BINDING_TYPE),
3073                    );
3074                    let _: (&bool, &bool) = (filtering, comparison);
3075                }
3076            }
3077            let _: Result<bool, JsValue> = Reflect::set(
3078                &entry_obj,
3079                &JsValue::from_str(match &entry.ty {
3080                    BindGroupEntryType::UniformBuffer
3081                    | BindGroupEntryType::StorageBuffer { .. } => WEBGPU_PROPERTY_BUFFER,
3082                    BindGroupEntryType::SampledTexture { .. } => WEBGPU_PROPERTY_TEXTURE,
3083                    BindGroupEntryType::StorageTexture { .. } => WEBGPU_PROPERTY_STORAGE_TEXTURE,
3084                    BindGroupEntryType::Sampler { .. } => WEBGPU_PROPERTY_SAMPLER,
3085                }),
3086                &binding_obj,
3087            );
3088            entries_array.push(&entry_obj);
3089        }
3090        let descriptor: Object = Object::new();
3091        let _: Result<bool, JsValue> = Reflect::set(
3092            &descriptor,
3093            &JsValue::from_str(WEBGPU_PROPERTY_ENTRIES),
3094            &entries_array,
3095        );
3096        let create_fn: Function = Reflect::get(
3097            self.get_device(),
3098            &JsValue::from_str(WEBGPU_METHOD_CREATE_BIND_GROUP_LAYOUT),
3099        )
3100        .unwrap_or(JsValue::UNDEFINED)
3101        .unchecked_into();
3102        create_fn
3103            .call1(self.get_device(), &descriptor)
3104            .unwrap_or(JsValue::UNDEFINED)
3105    }
3106
3107    /// Computes the one-shot dispatch: `setPipeline` + `setBindGroup` +
3108    /// `dispatchWorkgroups` on the given compute pass.
3109    ///
3110    /// Equivalent to calling `set_compute_pipeline` + `set_bind_group` +
3111    /// `dispatch` individually, with the bind-group call routed through
3112    /// the compute-pass class tag so `cached_method` resolves the
3113    /// `GPUComputePassEncoder` prototype `Function` (the public
3114    /// `set_bind_group` pins the render-pass class and would TypeError on
3115    /// a compute pass).
3116    ///
3117    /// # Arguments
3118    ///
3119    /// - `&JsValue` - The compute pass encoder.
3120    /// - `&DispatchArgs` - The workgroup counts, one per dimension (each
3121    ///   `1..=65535`).
3122    pub fn dispatch_with_bind_group(
3123        &self,
3124        pass: &JsValue,
3125        pipeline: &JsValue,
3126        bind_group: &JsValue,
3127        args: &DispatchArgs,
3128    ) {
3129        self.set_compute_pipeline(pass, pipeline);
3130        self.set_bind_group_on(GpuReceiverClass::ComputePass, pass, 0, bind_group);
3131        self.dispatch(pass, args);
3132    }
3133
3134    /// Creates a `GpuTexture` with `STORAGE_BINDING | TEXTURE_BINDING |
3135    /// COPY_SRC | COPY_DST` usage.
3136    ///
3137    /// Used as the destination for compute writes and the source for
3138    /// render sampling — the typical G-Buffer / SSAO / post-process
3139    /// scratch surface.
3140    ///
3141    /// # Arguments
3142    ///
3143    /// - `u32` - The `width` of the texture in pixels.
3144    /// - `&str` - A `GpuTextureFormat` string (e.g. `"rgba8unorm"`,
3145    ///   `"r32float"`, `"rgba16float"`).
3146    ///
3147    /// # Returns
3148    ///
3149    /// - `JsValue` - The `GpuTexture`, or `JsValue::UNDEFINED` on
3150    ///   creation failure (unsupported format, out of memory, ...).
3151    pub fn create_storage_texture(&self, width: u32, height: u32, format: &str) -> JsValue {
3152        let size_dict: Object = Object::new();
3153        let _: Result<bool, JsValue> = Reflect::set(
3154            &size_dict,
3155            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_WIDTH),
3156            &JsValue::from_f64(f64::from(width)),
3157        );
3158        let _: Result<bool, JsValue> = Reflect::set(
3159            &size_dict,
3160            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_HEIGHT),
3161            &JsValue::from_f64(f64::from(height)),
3162        );
3163        let _: Result<bool, JsValue> = Reflect::set(
3164            &size_dict,
3165            &JsValue::from_str(WEBGPU_PROPERTY_EXTENT_DEPTH),
3166            &JsValue::from_f64(1.0),
3167        );
3168        let descriptor: Object = Object::new();
3169        let _: Result<bool, JsValue> = Reflect::set(
3170            &descriptor,
3171            &JsValue::from_str(WEBGPU_PROPERTY_SIZE),
3172            &size_dict,
3173        );
3174        let _: Result<bool, JsValue> = Reflect::set(
3175            &descriptor,
3176            &JsValue::from_str(WEBGPU_PROPERTY_TEXTURE_FORMAT),
3177            &JsValue::from_str(format),
3178        );
3179        let _: Result<bool, JsValue> = Reflect::set(
3180            &descriptor,
3181            &JsValue::from_str(WEBGPU_PROPERTY_USAGE),
3182            &JsValue::from_f64(f64::from(texture_usage_mask(&[
3183                TextureUsage::StorageBinding,
3184                TextureUsage::TextureBinding,
3185                TextureUsage::CopySource,
3186                TextureUsage::CopyDestination,
3187            ]))),
3188        );
3189        let create_fn: Function = Reflect::get(
3190            self.get_device(),
3191            &JsValue::from_str(WEBGPU_METHOD_CREATE_TEXTURE),
3192        )
3193        .unwrap_or(JsValue::UNDEFINED)
3194        .unchecked_into();
3195        create_fn
3196            .call1(self.get_device(), &descriptor)
3197            .unwrap_or(JsValue::UNDEFINED)
3198    }
3199
3200    /// Creates a `GpuQuerySet` of `timestamp` queries.
3201    ///
3202    /// Timestamp query sets enable GPU profiling. After recording
3203    /// timestamp writes via [`Self::write_timestamp`], call
3204    /// [`Self::resolve_timestamp`] to read the values back.
3205    ///
3206    /// # Arguments
3207    ///
3208    /// - `u32` - Number of query slots the set exposes.
3209    ///
3210    /// # Returns
3211    ///
3212    /// - `JsValue` - The `GpuQuerySet`, or `JsValue::UNDEFINED` on
3213    ///   failure (the `timestamp-queries` feature is missing or
3214    ///   disabled).
3215    pub fn create_timestamp_query_set(&self, count: u32) -> JsValue {
3216        let descriptor: Object = Object::new();
3217        let _: Result<bool, JsValue> = Reflect::set(
3218            &descriptor,
3219            &JsValue::from_str(WEBGPU_PROPERTY_TYPE),
3220            &JsValue::from_str(WEBGPU_QUERY_TYPE_TIMESTAMP),
3221        );
3222        let _: Result<bool, JsValue> = Reflect::set(
3223            &descriptor,
3224            &JsValue::from_str(WEBGPU_PROPERTY_COUNT),
3225            &JsValue::from_f64(f64::from(count)),
3226        );
3227        let create_fn: Function = Reflect::get(
3228            self.get_device(),
3229            &JsValue::from_str(WEBGPU_METHOD_CREATE_QUERY_SET),
3230        )
3231        .unwrap_or(JsValue::UNDEFINED)
3232        .unchecked_into();
3233        create_fn
3234            .call1(self.get_device(), &descriptor)
3235            .unwrap_or(JsValue::UNDEFINED)
3236    }
3237
3238    /// Records a `timestamp` write at the current point inside a
3239    /// render or compute pass.
3240    ///
3241    /// Pair the start index with a second write at the end of the
3242    /// pass; then call [`Self::resolve_timestamp`] to read back the elapsed
3243    /// GPU nanoseconds.
3244    ///
3245    /// # Arguments
3246    ///
3247    /// - `&JsValue` - The render or compute pass encoder.
3248    /// - `u32` - The query-slot index to write into.
3249    pub fn write_timestamp(&self, pass: &JsValue, query_set: &JsValue, index: u32) {
3250        if query_set.is_undefined() || query_set.is_null() {
3251            return;
3252        }
3253        let write_fn: Function = Reflect::get(pass, &JsValue::from_str(WEBGPU_METHOD_TIMESTAMP))
3254            .unwrap_or(JsValue::UNDEFINED)
3255            .unchecked_into();
3256        let _: Result<JsValue, JsValue> =
3257            write_fn.call2(pass, query_set, &JsValue::from_f64(f64::from(index)));
3258    }
3259
3260    /// Resolves a range of timestamp queries into a destination buffer.
3261    ///
3262    /// # Arguments
3263    ///
3264    /// - `&JsValue` - The `GpuCommandEncoder` that owns the queries'
3265    /// - `u32` - First query index to resolve.
3266    /// - `u64` - Byte offset into the destination buffer.
3267    pub fn resolve_timestamp(
3268        &self,
3269        encoder: &JsValue,
3270        query_set: &JsValue,
3271        first_query: u32,
3272        query_count: u32,
3273        destination: &JsValue,
3274        destination_offset: u64,
3275    ) {
3276        if query_set.is_undefined() || destination.is_undefined() {
3277            return;
3278        }
3279        let resolve_fn: Function =
3280            Reflect::get(encoder, &JsValue::from_str(WEBGPU_METHOD_RESOLVE_QUERY_SET))
3281                .unwrap_or(JsValue::UNDEFINED)
3282                .unchecked_into();
3283        let _: Result<JsValue, JsValue> = resolve_fn.call5(
3284            encoder,
3285            query_set,
3286            &JsValue::from_f64(f64::from(first_query)),
3287            &JsValue::from_f64(f64::from(query_count)),
3288            destination,
3289            &JsValue::from_f64(destination_offset as f64),
3290        );
3291    }
3292
3293    /// Creates a `GpuRenderPipeline` from a [`RenderPipelineDescriptor`]
3294    /// whose bind-group layout is a pre-built
3295    /// `GpuBindGroupLayout` (returned by
3296    /// `create_bind_group_layout`) instead of the WebGPU auto-layout.
3297    ///
3298    /// Use this when two pipelines need to share a single bind group
3299    /// layout (typical for compute -> render pipelines).
3300    ///
3301    /// # Arguments
3302    ///
3303    /// - `&RenderPipelineDescriptor` - The full pipeline description.
3304    /// - `&JsValue` - The shared `GpuBindGroupLayout` handle.
3305    ///
3306    /// # Returns
3307    ///
3308    /// - `JsValue` - The `GpuRenderPipeline`, or `JsValue::UNDEFINED`
3309    ///   on failure.
3310    pub fn create_render_pipeline_with_layout(
3311        &self,
3312        descriptor: &RenderPipelineDescriptor,
3313        layout: &JsValue,
3314    ) -> JsValue {
3315        // The layout is threaded through separately rather than being
3316        // part of `RenderPipelineDescriptor`, because the same
3317        // descriptor must work with the auto-layout preset. The
3318        // descriptor is built once either way, so a pipeline can be
3319        // re-created against a different layout without rebuilding it.
3320        let descriptor_object: Object = Object::new();
3321        let _: Result<bool, JsValue> = Reflect::set(
3322            &descriptor_object,
3323            &JsValue::from_str(WEBGPU_PROPERTY_LAYOUT),
3324            layout,
3325        );
3326        let _: Result<bool, JsValue> = Reflect::set(
3327            &descriptor_object,
3328            &JsValue::from_str(WEBGPU_PROPERTY_VERTEX),
3329            &self.build_vertex_state(descriptor.get_vertex()),
3330        );
3331        let _: Result<bool, JsValue> = Reflect::set(
3332            &descriptor_object,
3333            &JsValue::from_str(WEBGPU_PROPERTY_PRIMITIVE),
3334            &self.build_primitive_state(descriptor.get_primitive()),
3335        );
3336        let _: Result<bool, JsValue> = Reflect::set(
3337            &descriptor_object,
3338            &JsValue::from_str(WEBGPU_PROPERTY_MULTISAMPLE),
3339            &self.build_multisample_state(descriptor.get_multisample()),
3340        );
3341        if let Some(depth) = descriptor.try_get_depth_stencil() {
3342            let depth_object: Object = self.build_depth_stencil_state(depth);
3343            let _: Result<bool, JsValue> = Reflect::set(
3344                &descriptor_object,
3345                &JsValue::from_str(WEBGPU_PROPERTY_DEPTH_STENCIL),
3346                &depth_object,
3347            );
3348        }
3349        if let Some(fragment) = descriptor.try_get_fragment() {
3350            let fragment_object: Object = self.build_fragment_state(fragment);
3351            let _: Result<bool, JsValue> = Reflect::set(
3352                &descriptor_object,
3353                &JsValue::from_str(WEBGPU_PROPERTY_FRAGMENT),
3354                &fragment_object,
3355            );
3356        }
3357        let create_fn: Function = cached_method(
3358            GpuReceiverClass::Device,
3359            self.get_device(),
3360            WEBGPU_METHOD_CREATE_RENDER_PIPELINE,
3361        )
3362        .unwrap_or_else(|_| JsValue::UNDEFINED.unchecked_into());
3363        create_fn
3364            .call1(self.get_device(), &descriptor_object)
3365            .unwrap_or(JsValue::UNDEFINED)
3366    }
3367
3368    /// Releases all GPU resources held by this renderer.
3369    ///
3370    /// The teardown order matters per the WebGPU spec:
3371    ///   1. `GpuCanvasContext.unconfigure()` - releases the swap chain so
3372    ///      the DOM canvas can be GCed.
3373    ///   2. `GpuDevice.destroy()` - releases all child resources (buffers,
3374    ///      textures, pipelines) and the device itself.
3375    ///
3376    /// Callers should run this from a `use_cleanup` callback whenever the
3377    /// host component is being torn down (e.g. on a `match` arm switch).
3378    /// Without it the previous GPU device lingers until GC, and a fresh
3379    /// `init()` may either reuse the dead device (silent black canvas) or
3380    /// fail to acquire a new one until the old device is collected.
3381    ///
3382    /// `Reflect::get` failures and JS exceptions are swallowed - this is a
3383    /// best-effort cleanup path, and the engine must not panic during
3384    /// teardown.
3385    pub fn dispose(&self) {
3386        let context: &JsValue = self.get_context();
3387        if let Ok(unconfigure_fn) =
3388            Reflect::get(context, &JsValue::from_str(WEBGPU_METHOD_UNCONFIGURE))
3389            && let Ok(unconfigure_callable) = unconfigure_fn.dyn_into::<Function>()
3390        {
3391            let _: Result<JsValue, JsValue> = unconfigure_callable.call0(context);
3392        }
3393        let device: &JsValue = self.get_device();
3394        if let Ok(destroy_fn) = Reflect::get(device, &JsValue::from_str(WEBGPU_METHOD_DESTROY))
3395            && let Ok(destroy_callable) = destroy_fn.dyn_into::<Function>()
3396        {
3397            let _: Result<JsValue, JsValue> = destroy_callable.call0(device);
3398        }
3399    }
3400
3401    // ─────────────────────────────────────────────────────────────────────
3402    //  Render-pass dynamic state (viewport / scissor / stencil / blend)
3403    // ─────────────────────────────────────────────────────────────────────
3404
3405    /// Sets the viewport for all subsequent draw calls on the given render pass.
3406    ///
3407    /// The viewport maps NDC `[-1, 1]` to the given pixel rectangle. `min_depth`
3408    /// and `max_depth` (both in `[0, 1]`) clamp the depth range; the defaults
3409    /// of `0.0` and `1.0` cover the whole depth buffer. This call must be
3410    /// issued between `beginRenderPass()` and `pass.end()`.
3411    ///
3412    /// # Arguments
3413    ///
3414    /// - `&JsValue` - The active `GpuRenderPassEncoder`.
3415    /// - `&ViewportDescriptor` - The viewport rectangle and (optional) depth range.
3416    pub fn set_viewport(&self, pass: &JsValue, viewport: &ViewportDescriptor) {
3417        // WebGPU `setViewport(x, y, width, height, minDepth, maxDepth)`
3418        // takes six scalar arguments — the previous descriptor-dict form
3419        // never validated (`call1` with one object → NaN viewport, error
3420        // silently swallowed). Scalars also drop the per-call `Object`
3421        // allocation and 11 `from_str` property keys.
3422        let args: Array = Array::new_with_length(6);
3423        args.set(0, JsValue::from_f64(viewport.get_x() as f64));
3424        args.set(1, JsValue::from_f64(viewport.get_y() as f64));
3425        args.set(2, JsValue::from_f64(viewport.get_width() as f64));
3426        args.set(3, JsValue::from_f64(viewport.get_height() as f64));
3427        // Forward the descriptor's depth range instead of the spec defaults. Hardcoding
3428        // them here made `ViewportDescriptor::min_depth` / `max_depth` unreachable, so a
3429        // pass that narrowed its depth range to dodge z-fighting got the full range back
3430        // and nothing said so. A zero `max_depth` still means "unset", because `New`
3431        // skips both fields and a freshly built descriptor would otherwise ask for a
3432        // zero-width depth range that discards every fragment.
3433        let min_depth: f64 = f64::from(viewport.get_min_depth());
3434        let max_depth: f64 = match viewport.get_max_depth() {
3435            0.0 => WEBGPU_DEFAULT_VIEWPORT_MAX_DEPTH,
3436            depth => f64::from(depth),
3437        };
3438        args.set(4, JsValue::from_f64(min_depth));
3439        args.set(5, JsValue::from_f64(max_depth));
3440        if let Ok(set_fn) = cached_method(
3441            GpuReceiverClass::RenderPass,
3442            pass,
3443            WEBGPU_METHOD_SET_VIEWPORT,
3444        ) {
3445            let _: Result<JsValue, JsValue> = set_fn.apply(pass, &args);
3446        }
3447    }
3448
3449    /// Sets the scissor rectangle for all subsequent draw calls on the given
3450    /// render pass.
3451    ///
3452    /// Fragments outside the rectangle are discarded. The scissor is applied
3453    /// after the viewport, so coordinates are in the same pixel space as
3454    /// [`WebGpuRenderer::set_viewport`]. A scissor that extends outside the
3455    /// render target is clamped to the target bounds by the GPU.
3456    ///
3457    /// # Arguments
3458    ///
3459    /// - `&JsValue` - The active `GpuRenderPassEncoder`.
3460    /// - `u32` - X coordinate of the scissor origin in pixels.
3461    pub fn set_scissor_rect(&self, pass: &JsValue, x: u32, y: u32, width: u32, height: u32) {
3462        // WebGPU `setScissorRect(x, y, width, height)` takes four scalar
3463        // arguments — the previous descriptor-dict form never validated
3464        // (`call1` with one object → NaN scissor, error silently
3465        // swallowed). Scalars also drop the per-call `Object` allocation
3466        // and 8 `from_str` property keys.
3467        let args: Array = Array::new_with_length(4);
3468        args.set(0, JsValue::from_f64(x as f64));
3469        args.set(1, JsValue::from_f64(y as f64));
3470        args.set(2, JsValue::from_f64(width as f64));
3471        args.set(3, JsValue::from_f64(height as f64));
3472        if let Ok(set_fn) = cached_method(
3473            GpuReceiverClass::RenderPass,
3474            pass,
3475            WEBGPU_METHOD_SET_SCISSOR_RECT,
3476        ) {
3477            let _: Result<JsValue, JsValue> = set_fn.apply(pass, &args);
3478        }
3479    }
3480
3481    /// Sets the blend constant used by `"constant"` / `"one-minus-constant"`
3482    /// blend factors.
3483    ///
3484    /// Affects all subsequent draw calls on the given render pass. The
3485    /// constant is a linear-space RGBA color in `[0, 1]` per component.
3486    ///
3487    /// # Arguments
3488    ///
3489    /// - `&JsValue` - The active `GpuRenderPassEncoder`.
3490    /// - `f32` - Red component.
3491    pub fn set_blend_constant(&self, pass: &JsValue, r: f32, g: f32, b: f32, a: f32) {
3492        let color_dict: Object = Object::new();
3493        let _: Result<bool, JsValue> = Reflect::set(
3494            &color_dict,
3495            &JsValue::from_str(WEBGPU_PROPERTY_R),
3496            &JsValue::from_f64(r as f64),
3497        );
3498        let _: Result<bool, JsValue> = Reflect::set(
3499            &color_dict,
3500            &JsValue::from_str(WEBGPU_PROPERTY_G),
3501            &JsValue::from_f64(g as f64),
3502        );
3503        let _: Result<bool, JsValue> = Reflect::set(
3504            &color_dict,
3505            &JsValue::from_str(WEBGPU_PROPERTY_B),
3506            &JsValue::from_f64(b as f64),
3507        );
3508        let _: Result<bool, JsValue> = Reflect::set(
3509            &color_dict,
3510            &JsValue::from_str(WEBGPU_PROPERTY_A),
3511            &JsValue::from_f64(a as f64),
3512        );
3513        let color_js: JsValue = color_dict.unchecked_into::<JsValue>();
3514        if let Ok(set_fn) = Reflect::get(pass, &JsValue::from_str(WEBGPU_METHOD_SET_BLEND_CONSTANT))
3515            && let Ok(set_callable) = set_fn.dyn_into::<Function>()
3516        {
3517            let _: Result<JsValue, JsValue> = set_callable.call1(pass, &color_js);
3518        }
3519    }
3520
3521    /// Sets the stencil reference value used by stencil tests.
3522    ///
3523    /// The reference is the value the GPU compares against when the shader
3524    /// pipeline was built with a stencil state using `"always"`, `"less"`,
3525    /// `"equal"`, etc. compare ops. This call must be issued between
3526    /// `beginRenderPass()` and `pass.end()`.
3527    ///
3528    /// # Arguments
3529    ///
3530    /// - `&JsValue` - The active `GpuRenderPassEncoder`.
3531    /// - `u32` - The stencil reference value (8-bit, `[0, 255]`).
3532    pub fn set_stencil_reference(&self, pass: &JsValue, reference: u32) {
3533        if let Ok(set_fn) = Reflect::get(
3534            pass,
3535            &JsValue::from_str(WEBGPU_METHOD_SET_STENCIL_REFERENCE),
3536        ) && let Ok(set_callable) = set_fn.dyn_into::<Function>()
3537        {
3538            let _: Result<JsValue, JsValue> =
3539                set_callable.call1(pass, &JsValue::from_f64(reference as f64));
3540        }
3541    }
3542
3543    /// Sets a bind group on a render pass with dynamic offsets.
3544    ///
3545    /// Use this overload of `set_bind_group` when the bind-group layout was
3546    /// built with `hasDynamicOffset: true` for one or more buffer bindings.
3547    /// Each value in `dynamic_offsets` is added to the corresponding
3548    /// `@group(N) @binding(M)` buffer's base offset before the draw call.
3549    /// For non-dynamic bind groups, prefer the simpler
3550    /// `set_bind_group` (3-arg) overload exposed via the `pub(crate)` API.
3551    ///
3552    /// # Arguments
3553    ///
3554    /// - `&JsValue` - The active `GpuRenderPassEncoder`.
3555    /// - `u32` - Bind-group slot index.
3556    /// - `&[u32]` - Dynamic offsets, one per dynamic-offset binding.
3557    pub fn set_bind_group_with_dynamic_offsets(
3558        &self,
3559        pass: &JsValue,
3560        index: u32,
3561        group: &JsValue,
3562        dynamic_offsets: &[u32],
3563    ) {
3564        if let Ok(set_fn) = Reflect::get(pass, &JsValue::from_str(WEBGPU_METHOD_SET_BIND_GROUP))
3565            && let Ok(set_callable) = set_fn.dyn_into::<Function>()
3566        {
3567            // WebGPU's setBindGroup has two overloads: with and without
3568            // dynamic offsets. We always use the 4-arg form to keep the
3569            // call site simple; the empty offset array is well-defined.
3570            // OPT 35: zero-copy `Uint32Array::view` over the wasm linear-memory
3571            // slice instead of allocating a fresh JS Array + per-element
3572            // `from_f64` writes on every setBindGroup call.
3573            // SAFETY: `view` is only used inside the `set_callable.call4(...)`
3574            // on the next line; the resulting JsValue does not outlive
3575            // `dynamic_offsets`'s borrow, and `dynamic_offsets` outlives the
3576            // call because the call happens synchronously before this function
3577            // returns.
3578            let offsets_view: Uint32Array = unsafe { Uint32Array::view(dynamic_offsets) };
3579            let offsets_js: &JsValue = offsets_view.as_ref();
3580            let _: Result<JsValue, JsValue> = set_callable.call4(
3581                pass,
3582                &JsValue::from_f64(index as f64),
3583                group,
3584                offsets_js,
3585                &JsValue::from_f64(0.0),
3586            );
3587        }
3588    }
3589
3590    /// Sets a bind group on a compute pass with optional dynamic offsets.
3591    ///
3592    /// Same semantics as [`WebGpuRenderer::set_bind_group_with_dynamic_offsets`]
3593    /// but on a `GpuComputePassEncoder`. The `setBindGroup` method name is
3594    /// the same on both encoder types; this method wraps it for the compute
3595    /// pass to give callers a typed entry point.
3596    ///
3597    /// # Arguments
3598    ///
3599    /// - `&JsValue` - The active `GpuComputePassEncoder`.
3600    /// - `u32` - Bind-group slot index.
3601    /// - `&[u32]` - Dynamic offsets for dynamic-offset bindings.
3602    pub fn set_bind_group_compute_with_dynamic_offsets(
3603        &self,
3604        pass: &JsValue,
3605        index: u32,
3606        group: &JsValue,
3607        dynamic_offsets: &[u32],
3608    ) {
3609        if let Ok(set_fn) = Reflect::get(pass, &JsValue::from_str(WEBGPU_METHOD_SET_BIND_GROUP))
3610            && let Ok(set_callable) = set_fn.dyn_into::<Function>()
3611        {
3612            // OPT 35: zero-copy `Uint32Array::view` over the wasm linear-memory
3613            // slice instead of allocating a fresh JS Array + per-element
3614            // `from_f64` writes on every setBindGroup call (compute variant).
3615            // SAFETY: same as the render variant — the view is only used
3616            // synchronously inside the next call4 invocation and does not
3617            // outlive the `dynamic_offsets` borrow.
3618            let offsets_view: Uint32Array = unsafe { Uint32Array::view(dynamic_offsets) };
3619            let offsets_js: &JsValue = offsets_view.as_ref();
3620            let _: Result<JsValue, JsValue> = set_callable.call4(
3621                pass,
3622                &JsValue::from_f64(index as f64),
3623                group,
3624                offsets_js,
3625                &JsValue::from_f64(0.0),
3626            );
3627        }
3628    }
3629
3630    // ─────────────────────────────────────────────────────────────────────
3631    //  Texture view, mipmap generation, and CPU upload
3632    // ─────────────────────────────────────────────────────────────────────
3633
3634    /// Creates a `GpuTextureView` for the given texture with full descriptor control.
3635    ///
3636    /// Pass `None` for a default view (full 2D, all mips, all aspects) — this
3637    /// is the cheap view that is implicitly created by bind-group creation.
3638    /// Pass `Some(&descriptor)` to sub-select mip levels, array slices, or
3639    /// the depth-only aspect of a depth-stencil texture.
3640    ///
3641    /// # Arguments
3642    ///
3643    /// - `&JsValue` - The `GpuTexture` to view.
3644    /// - `Option<&TextureViewDescriptor>` - Optional descriptor.
3645    ///
3646    /// # Returns
3647    ///
3648    /// - `JsValue` - The `GpuTextureView`. Returns `JsValue::UNDEFINED` if
3649    ///   the call fails (e.g. invalid mip range); check for `undefined`
3650    ///   before using the result.
3651    pub fn create_view(
3652        &self,
3653        texture: &JsValue,
3654        descriptor: Option<&TextureViewDescriptor>,
3655    ) -> JsValue {
3656        let create_view_fn: Function =
3657            match Reflect::get(texture, &JsValue::from_str(WEBGPU_METHOD_CREATE_VIEW))
3658                .ok()
3659                .and_then(|v: JsValue| v.dyn_into::<Function>().ok())
3660            {
3661                Some(f) => f,
3662                None => return JsValue::UNDEFINED,
3663            };
3664        // Inline the descriptor dict construction; we keep the engine-wide
3665        // convention of "0 / None means default" so the browser falls back
3666        // to its own defaults for omitted keys.
3667        let desc_value: JsValue = match descriptor {
3668            None => JsValue::UNDEFINED,
3669            Some(d) => {
3670                let dict: Object = Object::new();
3671                if let Some(format) = d.get_format() {
3672                    let _: Result<bool, JsValue> = Reflect::set(
3673                        &dict,
3674                        &JsValue::from_str(WEBGPU_PROPERTY_FORMAT),
3675                        &JsValue::from_str(format),
3676                    );
3677                }
3678                // `dimension` and `aspect` are explicitly sent as their
3679                // default values ("2d" / "all") rather than omitted, because
3680                // a handful of browsers reject undefined keys on the
3681                // createView descriptor.
3682                let _: Result<bool, JsValue> = Reflect::set(
3683                    &dict,
3684                    &JsValue::from_str(WEBGPU_PROPERTY_DIMENSION),
3685                    &JsValue::from_str(d.effective_dimension()),
3686                );
3687                let _: Result<bool, JsValue> = Reflect::set(
3688                    &dict,
3689                    &JsValue::from_str(WEBGPU_PROPERTY_ASPECT),
3690                    &JsValue::from_str(d.effective_aspect()),
3691                );
3692                // baseMipLevel / mipLevelCount / baseArrayLayer /
3693                // arrayLayerCount are u32 with 0 = "use the default".
3694                // Skip them when they are still at the default so that the
3695                // browser applies its own spec-compliant fallback.
3696                let base_mip: u32 = d.get_base_mip_level();
3697                if base_mip != 0 {
3698                    let _: Result<bool, JsValue> = Reflect::set(
3699                        &dict,
3700                        &JsValue::from_str(WEBGPU_PROPERTY_BASE_MIP_LEVEL),
3701                        &JsValue::from_f64(base_mip as f64),
3702                    );
3703                }
3704                let mip_count: u32 = d.get_mip_level_count();
3705                if mip_count != 0 {
3706                    let _: Result<bool, JsValue> = Reflect::set(
3707                        &dict,
3708                        &JsValue::from_str(WEBGPU_PROPERTY_MIP_LEVEL_COUNT),
3709                        &JsValue::from_f64(mip_count as f64),
3710                    );
3711                }
3712                let base_array: u32 = d.get_base_array_layer();
3713                if base_array != 0 {
3714                    let _: Result<bool, JsValue> = Reflect::set(
3715                        &dict,
3716                        &JsValue::from_str(WEBGPU_PROPERTY_BASE_ARRAY_LAYER),
3717                        &JsValue::from_f64(base_array as f64),
3718                    );
3719                }
3720                let array_count: u32 = d.get_array_layer_count();
3721                if array_count != 0 {
3722                    let _: Result<bool, JsValue> = Reflect::set(
3723                        &dict,
3724                        &JsValue::from_str(WEBGPU_PROPERTY_ARRAY_LAYER_COUNT),
3725                        &JsValue::from_f64(array_count as f64),
3726                    );
3727                }
3728                dict.unchecked_into::<JsValue>()
3729            }
3730        };
3731        create_view_fn
3732            .call1(texture, &desc_value)
3733            .unwrap_or(JsValue::UNDEFINED)
3734    }
3735
3736    /// Generates the full mipmap chain for the given texture.
3737    ///
3738    /// Equivalent to repeatedly calling `copyTextureToTexture` from level
3739    /// `i` to level `i+1` with the appropriate mip dimensions, but in one
3740    /// GPU command. The texture must have been created with `RENDER_ATTACHMENT
3741    /// | TEXTURE_BINDING | COPY_DST | COPY_SRC` usage and `mipLevelCount > 1`.
3742    /// Requires the `mipmap` WebGPU feature, or a GPU that supports it
3743    /// unconditionally (most desktop GPUs do).
3744    ///
3745    /// # Arguments
3746    ///
3747    /// - `&JsValue` - The `GpuTexture` whose mips will be generated.
3748    pub fn generate_mipmaps(&self, texture: &JsValue) {
3749        if let Ok(gen_fn) = Reflect::get(texture, &JsValue::from_str(WEBGPU_METHOD_GENERATE_MIPMAP))
3750            && let Ok(gen_callable) = gen_fn.dyn_into::<Function>()
3751        {
3752            let _: Result<JsValue, JsValue> = gen_callable.call0(texture);
3753        }
3754    }
3755
3756    /// Uploads CPU-side pixel data directly to a texture via `queue.writeTexture`.
3757    ///
3758    /// Use this instead of `create_buffer + write_buffer + copyBufferToTexture`
3759    /// for one-shot uploads (ImGui font atlases, sprite sheets, procedural
3760    /// noise). The queue is acquired internally via the cached `device.queue`
3761    /// handle, so this is the preferred path for textures that are written
3762    /// once and sampled many times.
3763    ///
3764    /// Copies the remaining logical extent of the selected mip, beginning at
3765    /// `origin`. Dimensions come from the destination `GPUTexture`, never from
3766    /// byte strides or payload length. Spatial dimensions shrink with mip levels;
3767    /// 2D array-layer counts do not. Invalid texture dimensions, mip levels, or
3768    /// origins are ignored without submitting a write.
3769    ///
3770    /// `bytes_per_row` is a byte stride, not a texel width, and need not be a
3771    /// multiple of 256 for this queue operation. Zero row/image strides are
3772    /// omitted; uploads spanning multiple images require `rows_per_image`.
3773    /// The data layout must match the texture format. Bytes are uploaded
3774    /// unchanged, with no swizzling or `flip_y` transformation.
3775    ///
3776    /// # Arguments
3777    ///
3778    /// - `&TextureWriteDescriptor` - The write descriptor.
3779    pub fn write_texture(&self, descriptor: &TextureWriteDescriptor) {
3780        let extent: [u32; 3] = match texture_write_extent(descriptor) {
3781            Some(value) => value,
3782            None => return,
3783        };
3784        let queue: JsValue =
3785            match Reflect::get(self.get_device(), &JsValue::from_str(WEBGPU_PROPERTY_QUEUE))
3786                .ok()
3787                .and_then(|v: JsValue| v.dyn_into::<JsValue>().ok())
3788            {
3789                Some(q) => q,
3790                None => return,
3791            };
3792        let layout_dict: Object = Object::new();
3793        if descriptor.get_bytes_per_row() != 0 {
3794            let _: Result<bool, JsValue> = Reflect::set(
3795                &layout_dict,
3796                &JsValue::from_str(WEBGPU_PROPERTY_BYTES_PER_ROW),
3797                &JsValue::from_f64(f64::from(descriptor.get_bytes_per_row())),
3798            );
3799        }
3800        if descriptor.get_rows_per_image() != 0 {
3801            let _: Result<bool, JsValue> = Reflect::set(
3802                &layout_dict,
3803                &JsValue::from_str(WEBGPU_PROPERTY_ROWS_PER_IMAGE),
3804                &JsValue::from_f64(f64::from(descriptor.get_rows_per_image())),
3805            );
3806        }
3807        let _: Result<bool, JsValue> = Reflect::set(
3808            &layout_dict,
3809            &JsValue::from_str(WEBGPU_PROPERTY_IMAGE_DATA_OFFSET),
3810            &JsValue::from_f64(0.0),
3811        );
3812        let layout_js: JsValue = layout_dict.unchecked_into::<JsValue>();
3813        let write_fn: Function =
3814            match Reflect::get(&queue, &JsValue::from_str(WEBGPU_METHOD_WRITE_TEXTURE))
3815                .ok()
3816                .and_then(|v: JsValue| v.dyn_into::<Function>().ok())
3817            {
3818                Some(f) => f,
3819                None => return,
3820            };
3821        // Build destination dict: { texture, mipLevel, origin? }
3822        let dest_dict: Object = Object::new();
3823        let _: Result<bool, JsValue> = Reflect::set(
3824            &dest_dict,
3825            &JsValue::from_str(WEBGPU_PROPERTY_TEXTURE),
3826            &descriptor.get_texture(),
3827        );
3828        let _: Result<bool, JsValue> = Reflect::set(
3829            &dest_dict,
3830            &JsValue::from_str(WEBGPU_PROPERTY_MIP_LEVEL),
3831            &JsValue::from_f64(descriptor.get_mip_level() as f64),
3832        );
3833        if let Some(origin) = descriptor.get_origin() {
3834            let _: Result<bool, JsValue> = Reflect::set(
3835                &dest_dict,
3836                &JsValue::from_str(WEBGPU_PROPERTY_ORIGIN),
3837                &origin,
3838            );
3839        }
3840        let dest_js: JsValue = dest_dict.unchecked_into::<JsValue>();
3841        // WebGPU's queue.writeTexture requires a Uint8Array view; we hand
3842        // it the raw Vec<u8> and let JS interop copy it. This is the same
3843        // path wasm-bindgen takes for &[u8] → Uint8Array.
3844        let data_js: JsValue = Uint8Array::from(descriptor.get_data().as_slice()).into();
3845        let size_value: JsValue = {
3846            let size_dict: Object = Object::new();
3847            let _: Result<bool, JsValue> = Reflect::set(
3848                &size_dict,
3849                &JsValue::from_str(WEBGPU_PROPERTY_WIDTH),
3850                &JsValue::from_f64(f64::from(extent[0])),
3851            );
3852            let _: Result<bool, JsValue> = Reflect::set(
3853                &size_dict,
3854                &JsValue::from_str(WEBGPU_PROPERTY_HEIGHT),
3855                &JsValue::from_f64(f64::from(extent[1])),
3856            );
3857            let _: Result<bool, JsValue> = Reflect::set(
3858                &size_dict,
3859                &JsValue::from_str(WEBGPU_PROPERTY_DEPTH_OR_1),
3860                &JsValue::from_f64(f64::from(extent[2])),
3861            );
3862            size_dict.unchecked_into::<JsValue>()
3863        };
3864        let _: Result<JsValue, JsValue> =
3865            write_fn.call4(&queue, &dest_js, &data_js, &layout_js, &size_value);
3866    }
3867
3868    // ─────────────────────────────────────────────────────────────────────
3869    //  Shader module + explicit pipeline compile diagnostics
3870    // ─────────────────────────────────────────────────────────────────────
3871
3872    /// Creates a `GpuShaderModule` from a WGSL source string with a debug label.
3873    ///
3874    /// Equivalent to the `pub(crate) fn create_shader_module` overload but
3875    /// attaches a `label` to the module so it shows up under that name in
3876    /// browser devtools (e.g. Chrome's `chrome://gpu-internals` and the
3877    /// WebGPU Inspector panel). The label has no runtime effect; it is
3878    /// purely a developer-experience aid when many shader modules coexist.
3879    ///
3880    /// # Arguments
3881    ///
3882    /// - `&str` - WGSL source.
3883    ///
3884    /// # Returns
3885    ///
3886    /// - `JsValue` - The `GpuShaderModule`, or `JsValue::UNDEFINED` if
3887    ///   the call fails.
3888    pub fn create_shader_module_with_label(&self, wgsl_source: &str, label: &str) -> JsValue {
3889        let descriptor: Object = Object::new();
3890        let _: Result<bool, JsValue> = Reflect::set(
3891            &descriptor,
3892            &JsValue::from_str(WEBGPU_PROPERTY_CODE),
3893            &JsValue::from_str(wgsl_source),
3894        );
3895        let _: Result<bool, JsValue> = Reflect::set(
3896            &descriptor,
3897            &JsValue::from_str(WEBGPU_PROPERTY_LABEL),
3898            &JsValue::from_str(label),
3899        );
3900        let desc_value: JsValue = descriptor.unchecked_into::<JsValue>();
3901        if let Ok(create_fn) = Reflect::get(
3902            self.get_device(),
3903            &JsValue::from_str(WEBGPU_METHOD_CREATE_SHADER_MODULE),
3904        ) && let Ok(create_callable) = create_fn.dyn_into::<Function>()
3905        {
3906            // The call returns a Promise that resolves to the shader module.
3907            // We do not await it; the caller is expected to drive the future
3908            // or pass the result into a pipeline creation call.
3909            return create_callable
3910                .call1(self.get_device(), &desc_value)
3911                .unwrap_or(JsValue::UNDEFINED);
3912        }
3913        JsValue::UNDEFINED
3914    }
3915
3916    // ─────────────────────────────────────────────────────────────────────
3917    //  Buffer readback via mapAsync + getMappedRange
3918    // ─────────────────────────────────────────────────────────────────────
3919
3920    /// Reads back the contents of a buffer via `mapAsync` + `getMappedRange` +
3921    /// `unmap`.
3922    ///
3923    /// This is an **`async fn`**, NOT a synchronous wrapper. It must be
3924    /// `await`-ed by the caller. Use it from inside another
3925    /// `wasm_bindgen_futures` future (e.g. a frame loop) — do not call
3926    /// it from synchronous code, since the awaiter must be driven by
3927    /// the executor. The buffer must have been created with `MAP_READ`
3928    /// usage, and the read must be preceded by a GPU submission that
3929    /// finished writing to the buffer (i.e. `queue.submit([encoder.finish()])`
3930    /// followed by `device.lost` / a fence).
3931    ///
3932    /// # Arguments
3933    ///
3934    /// - `&JsValue` - The `GpuBuffer` to read back.
3935    /// - `u64` - Byte offset into the buffer.
3936    ///
3937    /// # Returns
3938    ///
3939    /// - `Option<Vec<u8>>` - The bytes, or `None` if the readback failed.
3940    pub async fn read_buffer(&self, buffer: &JsValue, offset: u64, size: u64) -> Option<Vec<u8>> {
3941        // Step 1: buffer.mapAsync(mode, offset, size)
3942        let map_fn: Function = Reflect::get(buffer, &JsValue::from_str(WEBGPU_METHOD_MAP_ASYNC))
3943            .ok()
3944            .and_then(|v: JsValue| v.dyn_into::<Function>().ok())?;
3945        let map_promise: Promise = map_fn
3946            .call3(
3947                buffer,
3948                // `mapAsync` takes a `GPUMapMode` bitmask; the spec
3949                // allows OR'ing `READ` and `WRITE` together, so we
3950                // use the `map_mode_for` helper that pins the
3951                // `WEBGPU_MAP_MODE_WRITE` constant on the live code
3952                // path. This buffer is read-only for the host, so
3953                // we pass `read = true, write = false`.
3954                &JsValue::from_f64(map_mode_for(/* read = */ true, /* write = */ false) as f64),
3955                &JsValue::from_f64(offset as f64),
3956                &JsValue::from_f64(size as f64),
3957            )
3958            .ok()?
3959            .unchecked_into();
3960        // Step 2: await the mapAsync promise
3961        let _map_result: JsValue = JsFuture::from(map_promise).await.ok()?;
3962        // Step 3: buffer.getMappedRange(offset, size)
3963        let get_range_fn: Function =
3964            Reflect::get(buffer, &JsValue::from_str(WEBGPU_METHOD_GET_MAPPED_RANGE))
3965                .ok()
3966                .and_then(|v: JsValue| v.dyn_into::<Function>().ok())?;
3967        let array_buffer: ArrayBuffer = get_range_fn
3968            .call2(
3969                buffer,
3970                &JsValue::from_f64(offset as f64),
3971                &JsValue::from_f64(size as f64),
3972            )
3973            .ok()?
3974            .unchecked_into();
3975        // Step 4: copy out before unmap invalidates the memory
3976        let u8_view: Uint8Array = Uint8Array::new(&array_buffer);
3977        let mut out: Vec<u8> = vec![0u8; u8_view.length() as usize];
3978        u8_view.copy_to(&mut out);
3979        // Step 5: unmap
3980        if let Ok(unmap_fn) = Reflect::get(buffer, &JsValue::from_str(WEBGPU_METHOD_UNMAP))
3981            && let Ok(unmap_callable) = unmap_fn.dyn_into::<Function>()
3982        {
3983            let _: Result<JsValue, JsValue> = unmap_callable.call0(buffer);
3984        }
3985        Some(out)
3986    }
3987}
3988
3989/// Implements helper methods on `WebGpuInitError`.
3990///
3991/// These methods provide ergonomic access to the diagnostic code and the
3992/// underlying JS error value, which are useful when surfacing the failure
3993/// to the user (e.g. via `Console::error` from the example crate).
3994impl WebGpuInitError {
3995    /// Returns a short, machine-readable identifier for this error variant.
3996    ///
3997    /// Suitable for use as a stable error code in logs or telemetry.
3998    /// The codes are stable across releases.
3999    ///
4000    /// # Returns
4001    ///
4002    /// - `&'static str` - The error code (e.g. `"WEBGPU_NAVIGATOR_GPU_MISSING"`).
4003    pub fn code(&self) -> &'static str {
4004        match self {
4005            Self::NavigatorLookup(_) => WEBGPU_INIT_ERROR_NAVIGATOR_LOOKUP,
4006            Self::NavigatorGpuMissing => WEBGPU_INIT_ERROR_NAVIGATOR_GPU_MISSING,
4007            Self::RequestAdapterLookup(_) => WEBGPU_INIT_ERROR_REQUEST_ADAPTER_LOOKUP,
4008            Self::RequestAdapterCall(_) => WEBGPU_INIT_ERROR_REQUEST_ADAPTER_CALL,
4009            Self::AdapterPromise(_) => WEBGPU_INIT_ERROR_ADAPTER_PROMISE,
4010            Self::AdapterUnavailable => WEBGPU_INIT_ERROR_ADAPTER_UNAVAILABLE,
4011            Self::RequestDeviceLookup(_) => WEBGPU_INIT_ERROR_REQUEST_DEVICE_LOOKUP,
4012            Self::RequestDeviceCall(_) => WEBGPU_INIT_ERROR_REQUEST_DEVICE_CALL,
4013            Self::DevicePromise(_) => WEBGPU_INIT_ERROR_DEVICE_PROMISE,
4014            Self::DeviceUnavailable => WEBGPU_INIT_ERROR_DEVICE_UNAVAILABLE,
4015            Self::CanvasNotFound(_) => WEBGPU_INIT_ERROR_CANVAS_NOT_FOUND,
4016            Self::CanvasQuery(_) => WEBGPU_INIT_ERROR_CANVAS_QUERY,
4017            Self::CanvasContextUnavailable => WEBGPU_INIT_ERROR_CANVAS_CONTEXT_UNAVAILABLE,
4018            Self::PreferredFormatLookup(_) => WEBGPU_INIT_ERROR_PREFERRED_FORMAT_LOOKUP,
4019            Self::PreferredFormatCall(_) => WEBGPU_INIT_ERROR_PREFERRED_FORMAT_CALL,
4020            Self::PreferredFormatType(_) => WEBGPU_INIT_ERROR_PREFERRED_FORMAT_TYPE,
4021            Self::ConfigureLookup(_) => WEBGPU_INIT_ERROR_CONFIGURE_LOOKUP,
4022            Self::QueueLookup(_) => WEBGPU_INIT_ERROR_QUEUE_LOOKUP,
4023        }
4024    }
4025
4026    /// Returns the underlying JS error value if this variant carries one.
4027    ///
4028    /// Variants that do not capture a JS value (e.g. `NavigatorGpuMissing`,
4029    /// `AdapterUnavailable`, `CanvasNotFound`, `CanvasContextUnavailable`)
4030    /// return `None`.
4031    ///
4032    /// # Returns
4033    ///
4034    /// - `Option<&JsValue>` - The captured JS error, if any.
4035    pub fn js_error(&self) -> Option<&JsValue> {
4036        match self {
4037            Self::NavigatorLookup(err)
4038            | Self::RequestAdapterLookup(err)
4039            | Self::RequestAdapterCall(err)
4040            | Self::AdapterPromise(err)
4041            | Self::RequestDeviceLookup(err)
4042            | Self::RequestDeviceCall(err)
4043            | Self::DevicePromise(err)
4044            | Self::CanvasQuery(err)
4045            | Self::PreferredFormatLookup(err)
4046            | Self::PreferredFormatCall(err)
4047            | Self::PreferredFormatType(err)
4048            | Self::ConfigureLookup(err)
4049            | Self::QueueLookup(err) => Some(err),
4050            Self::NavigatorGpuMissing
4051            | Self::AdapterUnavailable
4052            | Self::DeviceUnavailable
4053            | Self::CanvasContextUnavailable
4054            | Self::CanvasNotFound(_) => None,
4055        }
4056    }
4057}
4058
4059/// Implements `Display` for `WebGpuInitError`.
4060///
4061/// The formatted message is intended for end-user diagnostic output
4062/// (typically forwarded to `Console::error` by the calling application)
4063/// and includes the variant code plus a human-readable description. When
4064/// the variant carries a JS error, its `Debug` form is appended.
4065impl Display for WebGpuInitError {
4066    /// Formats the [`WebGpuInitError`] via the supplied formatter.
4067    ///
4068    /// # Arguments
4069    ///
4070    /// - `&mut Formatter<'_>` - The formatter receiving the formatted output.
4071    ///
4072    /// # Returns
4073    ///
4074    /// - `fmt::Result` - Result of the formatting operation.
4075    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
4076        match self {
4077            Self::NavigatorLookup(err) => write!(
4078                formatter,
4079                "[{}] Reflect::get(navigator, webgpu) failed: {}",
4080                self.code(),
4081                js_error_to_string(err),
4082            ),
4083            Self::NavigatorGpuMissing => write!(
4084                formatter,
4085                "[{}] navigator.gpu is missing - browser does not expose WebGPU on this origin",
4086                self.code(),
4087            ),
4088            Self::RequestAdapterLookup(err) => write!(
4089                formatter,
4090                "[{}] Reflect::get(gpu, requestAdapter) failed: {}",
4091                self.code(),
4092                js_error_to_string(err),
4093            ),
4094            Self::RequestAdapterCall(err) => write!(
4095                formatter,
4096                "[{}] gpu.requestAdapter() threw: {}",
4097                self.code(),
4098                js_error_to_string(err),
4099            ),
4100            Self::AdapterPromise(err) => write!(
4101                formatter,
4102                "[{}] adapter promise rejected or timed out: {}",
4103                self.code(),
4104                js_error_to_string(err),
4105            ),
4106            Self::AdapterUnavailable => write!(
4107                formatter,
4108                "[{}] requestAdapter returned null - no compatible GPU adapter for the requested powerPreference",
4109                self.code(),
4110            ),
4111            Self::RequestDeviceLookup(err) => write!(
4112                formatter,
4113                "[{}] Reflect::get(adapter, requestDevice) failed: {}",
4114                self.code(),
4115                js_error_to_string(err),
4116            ),
4117            Self::RequestDeviceCall(err) => write!(
4118                formatter,
4119                "[{}] adapter.requestDevice() threw: {}",
4120                self.code(),
4121                js_error_to_string(err),
4122            ),
4123            Self::DevicePromise(err) => write!(
4124                formatter,
4125                "[{}] device promise rejected or timed out: {}",
4126                self.code(),
4127                js_error_to_string(err),
4128            ),
4129            Self::DeviceUnavailable => write!(
4130                formatter,
4131                "[{}] requestDevice returned null - adapter could not allocate a device (possibly device-lost)",
4132                self.code(),
4133            ),
4134            Self::CanvasNotFound(selector) => write!(
4135                formatter,
4136                "[{}] canvas element {:?} not found in DOM",
4137                self.code(),
4138                selector,
4139            ),
4140            Self::CanvasQuery(err) => write!(
4141                formatter,
4142                "[{}] querySelector threw: {}",
4143                self.code(),
4144                js_error_to_string(err),
4145            ),
4146            Self::CanvasContextUnavailable => write!(
4147                formatter,
4148                "[{}] canvas.get_context('webgpu') returned null - the canvas may already be using another context type or WebGPU is disabled",
4149                self.code(),
4150            ),
4151            Self::PreferredFormatLookup(err) => write!(
4152                formatter,
4153                "[{}] Reflect::get(gpu, getPreferredCanvasFormat) failed: {}",
4154                self.code(),
4155                js_error_to_string(err),
4156            ),
4157            Self::PreferredFormatCall(err) => write!(
4158                formatter,
4159                "[{}] gpu.getPreferredCanvasFormat() threw: {}",
4160                self.code(),
4161                js_error_to_string(err),
4162            ),
4163            Self::PreferredFormatType(value) => write!(
4164                formatter,
4165                "[{}] getPreferredCanvasFormat returned non-string: {}",
4166                self.code(),
4167                js_error_to_string(value),
4168            ),
4169            Self::ConfigureLookup(err) => write!(
4170                formatter,
4171                "[{}] Reflect::get(context, configure) failed: {}",
4172                self.code(),
4173                js_error_to_string(err),
4174            ),
4175            Self::QueueLookup(err) => write!(
4176                formatter,
4177                "[{}] Reflect::get(device, queue) failed: {}",
4178                self.code(),
4179                js_error_to_string(err),
4180            ),
4181        }
4182    }
4183}
4184
4185/// Implements the standard `std::error::Error` trait for `WebGpuInitError`.
4186///
4187/// The `source()` method delegates to the underlying JS error's `toString()`
4188/// representation when present, otherwise returns `None`. The engine never
4189/// logs or prints anything; this impl exists solely so the error composes
4190/// with `Result`-based APIs and `?` operator chains.
4191impl Error for WebGpuInitError {}
4192
4193/// Inherent implementation of [`PendingErrorCell`].
4194impl PendingErrorCell {
4195    /// Construct a new, empty pending-error slot.
4196    ///
4197    /// The inner `UnsafeCell<Option<JsValue>>` starts as `None`; the
4198    /// WebGPU `pop_error_sync` microtask is the only thing that ever
4199    /// writes to it, and `take_last_error` is the only reader.
4200    pub fn new() -> Self {
4201        Self(UnsafeCell::new(None))
4202    }
4203
4204    /// Hand out a raw pointer to the inner cell for the
4205    /// `spawn_local` closure to write through.
4206    ///
4207    /// # Safety
4208    ///
4209    /// The returned pointer is only valid for the lifetime of `&self`,
4210    /// and only safe to write to on the WASM main thread. The caller
4211    /// must guarantee that no other code is reading the same
4212    /// `PendingErrorCell` concurrently — this is enforced by the
4213    /// single-threaded scheduler: the spawned future is drained
4214    /// before the next render tick's `take_last_error` runs.
4215    ///
4216    /// # Returns
4217    ///
4218    /// - `*mut Option<JsValue>` - Raw pointer to the inner storage.
4219    pub(crate) fn as_ptr(&self) -> *mut Option<JsValue> {
4220        self.0.get()
4221    }
4222}
4223
4224/// Default-construction for [`PendingErrorCell`].
4225impl Default for PendingErrorCell {
4226    /// Constructs a default [`PendingErrorCell`] value.
4227    fn default() -> Self {
4228        Self::new()
4229    }
4230}
4231
4232// SAFETY: see the doc comment on `struct.rs::PendingErrorCell`.
4233//
4234// `PendingErrorCell` wraps `UnsafeCell`, which is `!Sync` by design.
4235// We hand-implement `Sync` because:
4236//
4237// - The renderer is compiled for `wasm32` and runs on the WASM
4238//   single-threaded scheduler; there is no other thread to race
4239//   against.
4240// - The owning pointer is held inside an `Rc<PendingErrorCell>`, and
4241//   `Rc` is itself `!Send`/`!Sync`, so the value cannot escape the
4242//   current thread even if the type were `Sync`.
4243// - The `pop_error_sync` future and `take_last_error` never overlap
4244//   in wall-clock time: the future is a microtask that resolves
4245//   before the next render tick drains the slot.
4246//
4247// If `euv-engine` is ever built for a multi-threaded target
4248// (native, `wasm-bindgen-rayon`, `wasm32-atomics`), this `unsafe impl`
4249// becomes unsound and must be removed — at that point the renderer
4250// will need a real `Mutex` or `RwLock` around the slot.
4251
4252unsafe impl Sync for PendingErrorCell {}