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