Skip to main content

repose_render_wgpu/
offscreen.rs

1//! Unified offscreen helper (native/android/wasm).
2
3use anyhow::Result;
4use wgpu::{PollType, TextureFormat};
5
6use crate::WgpuSceneRenderer;
7use repose_core::Scene;
8#[cfg(not(target_arch = "wasm32"))]
9use std::sync::{Mutex, OnceLock};
10
11#[cfg(not(target_arch = "wasm32"))]
12static SHARED_DEVICE: OnceLock<Mutex<Option<(wgpu::Device, wgpu::Queue)>>> = OnceLock::new();
13
14#[cfg(not(target_arch = "wasm32"))]
15fn shared_slot() -> &'static Mutex<Option<(wgpu::Device, wgpu::Queue)>> {
16    SHARED_DEVICE.get_or_init(|| Mutex::new(None))
17}
18
19#[cfg(target_arch = "wasm32")]
20thread_local! {
21    static SHARED_DEVICE_WASM: std::cell::RefCell<Option<(wgpu::Device, wgpu::Queue)>> = std::cell::RefCell::new(None);
22}
23
24/// Publish live Device/Queue for shared offscreen.
25///
26/// WASM: `thread_local`, so a worker thread never sees the main-thread
27/// device. Do NOT send the `Device`/`Queue` to a worker and render there..
28pub fn set_shared_device(device: wgpu::Device, queue: wgpu::Queue) {
29    #[cfg(not(target_arch = "wasm32"))]
30    {
31        if let Ok(mut g) = shared_slot().lock() {
32            *g = Some((device, queue));
33        }
34    }
35    #[cfg(target_arch = "wasm32")]
36    {
37        SHARED_DEVICE_WASM.with(|c| *c.borrow_mut() = Some((device, queue)));
38    }
39}
40pub fn shared_device() -> Option<(wgpu::Device, wgpu::Queue)> {
41    #[cfg(not(target_arch = "wasm32"))]
42    {
43        shared_slot().lock().ok().and_then(|g| g.clone())
44    }
45    #[cfg(target_arch = "wasm32")]
46    {
47        SHARED_DEVICE_WASM.with(|c| c.borrow().clone())
48    }
49}
50pub fn clear_shared_device() {
51    #[cfg(not(target_arch = "wasm32"))]
52    {
53        if let Ok(mut g) = shared_slot().lock() {
54            *g = None;
55        }
56    }
57    #[cfg(target_arch = "wasm32")]
58    {
59        SHARED_DEVICE_WASM.with(|c| *c.borrow_mut() = None);
60    }
61}
62
63/// Headless scene renderer: renders a [`Scene`] into CPU-readable RGBA8.
64///
65/// # Contracts
66///
67/// - Output is `width * height * 4` bytes, row-major, **premultiplied-alpha
68///   sRGB** (`Rgba8UnormSrgb`): opaque content reads back exact sRGB bytes;
69///   translucent pixels carry `rgb * a` (divide by `a` for straight alpha).
70/// - `clear = None` fills with `scene.clear_color`; `Some(c)` overrides it
71///   with linear-space `[r, g, b, a]` doubles.
72/// - All sizes clamp to a minimum of 1 (no zero-sized textures/buffers).
73/// - `render_rgba` blocks until the GPU work completes and the pixels are
74///   mapped; it is safe to call every frame (no per-frame allocations
75///   beyond the returned `Vec`).
76/// - Without a usable GPU adapter, [`new`](Self::new)/[`new_blocking`](Self::new_blocking)
77///   fail with an error (never a panic, never silent fallback).
78pub struct OffscreenRenderer {
79    renderer: WgpuSceneRenderer,
80    texture: wgpu::Texture,
81    view: wgpu::TextureView,
82    readback: wgpu::Buffer,
83    width: u32,
84    height: u32,
85    padded_bytes_per_row: u32,
86}
87
88impl OffscreenRenderer {
89    /// Create a renderer for `width`x`height` frames with `msaa` samples
90    /// (clamped to what the adapter supports via `pick_surface_msaa`).
91    /// Dimensions clamp to ≥ 1. Errors when no GPU adapter is available.
92    pub async fn new(width: u32, height: u32, msaa: u32) -> Result<Self> {
93        let width = width.max(1);
94        let height = height.max(1);
95        let instance = if cfg!(target_arch = "wasm32") {
96            let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
97            desc.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
98            wgpu::util::new_instance_with_webgpu_detection(desc).await
99        } else {
100            wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle())
101        };
102        let adapter = instance
103            .request_adapter(&wgpu::RequestAdapterOptions {
104                power_preference: wgpu::PowerPreference::HighPerformance,
105                compatible_surface: None,
106                ..Default::default()
107            })
108            .await?;
109        let format = TextureFormat::Rgba8UnormSrgb;
110        let (device, queue) = adapter
111            .request_device(&wgpu::DeviceDescriptor {
112                label: Some("repose-offscreen"),
113                required_features: wgpu::Features::empty(),
114                required_limits: wgpu::Limits::default(),
115                memory_hints: wgpu::MemoryHints::Performance,
116                experimental_features: wgpu::ExperimentalFeatures::default(),
117                trace: wgpu::Trace::Off,
118            })
119            .await?;
120        let renderer =
121            WgpuSceneRenderer::try_from_device_with_adapter(&adapter, device, queue, format, msaa)?;
122        Self::from_renderer(renderer, width, height)
123    }
124
125    /// Blocking [`new`](Self::new). Must not run on the wasm main thread
126    /// without block support (debug-asserted); use `render_rgba_async` there.
127    #[cfg(not(target_arch = "wasm32"))]
128    pub fn new_blocking(width: u32, height: u32, msaa: u32) -> Result<Self> {
129        pollster::block_on(Self::new(width, height, msaa))
130    }
131
132    #[cfg(target_arch = "wasm32")]
133    pub fn new_blocking(_width: u32, _height: u32, _msaa: u32) -> Result<Self> {
134        anyhow::bail!(
135            "use OffscreenRenderer::new(...).await on wasm32; a blocking adapter request cannot make progress on the browser main thread"
136        )
137    }
138
139    /// Shared-device: reuse Device/Queue, no Adapter. Dimensions clamp to
140    /// ≥ 1; `msaa` must be a wgpu-supported sample count (no adapter-based
141    /// picking without one, prefer
142    /// [`from_device_with_adapter`](Self::from_device_with_adapter)
143    /// when an adapter is handy).
144    pub fn from_device(
145        device: wgpu::Device,
146        queue: wgpu::Queue,
147        width: u32,
148        height: u32,
149        msaa: u32,
150    ) -> Result<Self> {
151        let width = width.max(1);
152        let height = height.max(1);
153        let format = TextureFormat::Rgba8UnormSrgb;
154        let renderer = WgpuSceneRenderer::from_device(device, queue, format, msaa)?;
155        Self::from_renderer(renderer, width, height)
156    }
157
158    /// Shared-device with adapter-based MSAA picking. Dimensions clamp to
159    /// ≥ 1.
160    pub fn from_device_with_adapter(
161        device: wgpu::Device,
162        queue: wgpu::Queue,
163        adapter: &wgpu::Adapter,
164        width: u32,
165        height: u32,
166        msaa: u32,
167    ) -> Result<Self> {
168        let width = width.max(1);
169        let height = height.max(1);
170        let format = TextureFormat::Rgba8UnormSrgb;
171        let renderer =
172            WgpuSceneRenderer::try_from_device_with_adapter(adapter, device, queue, format, msaa)?;
173        Self::from_renderer(renderer, width, height)
174    }
175
176    /// Wrap an existing scene renderer with a fresh target + readback
177    /// buffer. Dimensions clamp to ≥ 1 and error above the device limit.
178    ///
179    /// The readback target is `Rgba8UnormSrgb`, so the renderer must have
180    /// been built for that format: its pipelines target `output_format`, and
181    /// a mismatch (BGRA, HDR) is a render-pass validation error rather than
182    /// a slow path.
183    pub fn from_renderer(mut renderer: WgpuSceneRenderer, width: u32, height: u32) -> Result<Self> {
184        anyhow::ensure!(
185            renderer.output_format == TextureFormat::Rgba8UnormSrgb,
186            "offscreen readback needs an Rgba8UnormSrgb renderer, got {:?}",
187            renderer.output_format
188        );
189        let max = renderer.device.limits().max_texture_dimension_2d;
190        anyhow::ensure!(
191            width.max(1) <= max && height.max(1) <= max,
192            "requested offscreen size {width}x{height} exceeds the device texture limit {max}"
193        );
194        let width = width.max(1);
195        let height = height.max(1);
196        renderer.resize(width, height);
197        let texture = renderer.device.create_texture(&wgpu::TextureDescriptor {
198            label: Some("repose-offscreen-tex"),
199            size: wgpu::Extent3d {
200                width,
201                height,
202                depth_or_array_layers: 1,
203            },
204            mip_level_count: 1,
205            sample_count: 1,
206            dimension: wgpu::TextureDimension::D2,
207            format: TextureFormat::Rgba8UnormSrgb,
208            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
209                | wgpu::TextureUsages::TEXTURE_BINDING
210                | wgpu::TextureUsages::COPY_SRC,
211            view_formats: &[],
212        });
213        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
214        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
215        let unpadded = width
216            .checked_mul(4)
217            .ok_or_else(|| anyhow::anyhow!("offscreen row size overflow"))?;
218        let padded = unpadded
219            .checked_add(align - 1)
220            .ok_or_else(|| anyhow::anyhow!("offscreen row alignment overflow"))?
221            / align
222            * align;
223        let buf_size = u64::from(padded)
224            .checked_mul(u64::from(height))
225            .ok_or_else(|| anyhow::anyhow!("offscreen readback size overflow"))?;
226        if buf_size > renderer.device.limits().max_buffer_size {
227            anyhow::bail!("offscreen readback exceeds device buffer limit");
228        }
229        let readback = renderer.device.create_buffer(&wgpu::BufferDescriptor {
230            label: Some("repose-offscreen-readback"),
231            size: buf_size,
232            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
233            mapped_at_creation: false,
234        });
235        Ok(Self {
236            renderer,
237            texture,
238            view,
239            readback,
240            width,
241            height,
242            padded_bytes_per_row: padded,
243        })
244    }
245
246    fn encode_rgba(&mut self, scene: &Scene, clear: Option<[f64; 4]>) -> wgpu::CommandBuffer {
247        let mut encoder =
248            self.renderer
249                .device
250                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
251                    label: Some("repose-offscreen-encoder"),
252                });
253        self.renderer.render_scene_to_encoder_with_texture(
254            scene,
255            &mut encoder,
256            &self.view,
257            Some(&self.texture),
258            clear,
259        );
260        encoder.copy_texture_to_buffer(
261            wgpu::TexelCopyTextureInfo {
262                texture: &self.texture,
263                mip_level: 0,
264                origin: wgpu::Origin3d::ZERO,
265                aspect: wgpu::TextureAspect::All,
266            },
267            wgpu::TexelCopyBufferInfo {
268                buffer: &self.readback,
269                layout: wgpu::TexelCopyBufferLayout {
270                    offset: 0,
271                    bytes_per_row: Some(self.padded_bytes_per_row),
272                    rows_per_image: Some(self.height),
273                },
274            },
275            wgpu::Extent3d {
276                width: self.width,
277                height: self.height,
278                depth_or_array_layers: 1,
279            },
280        );
281        encoder.finish()
282    }
283
284    /// Render `scene` and read back `width * height * 4` premultiplied sRGB
285    /// bytes (see the [struct contracts](Self)). `clear = None` uses
286    /// `scene.clear_color`; an empty scene therefore reads back the clear
287    /// color exactly. Blocks until mapping completes.
288    pub fn render_rgba(&mut self, scene: &Scene, clear: Option<[f64; 4]>) -> Result<Vec<u8>> {
289        // Blocking map on the wasm main thread (Window) can never complete..
290        #[cfg(all(target_family = "wasm", target_os = "unknown"))]
291        debug_assert!(
292            web_workers::web::has_block_support(),
293            "render_rgba (blocking) called on wasm main thread; use render_rgba_async"
294        );
295        self.renderer.begin_frame()?;
296        let result = (|| -> Result<Vec<u8>> {
297            let cmd = self.encode_rgba(scene, clear);
298            self.renderer.queue.submit(Some(cmd));
299            let slice = self.readback.slice(..);
300            let (tx, rx) = web_workers::sync::mpsc::channel();
301            slice.map_async(wgpu::MapMode::Read, move |r| {
302                let _ = tx.send_sync(r);
303            });
304            self.renderer.device.poll(PollType::wait_indefinitely())?;
305            rx.recv_sync()??;
306            let mapped = slice.get_mapped_range()?;
307            let result = strip_padding(&mapped, self.width, self.height, self.padded_bytes_per_row);
308            drop(mapped);
309            self.readback.unmap();
310            result
311        })();
312        self.renderer.end_frame();
313        result
314    }
315
316    #[cfg(target_arch = "wasm32")]
317    pub async fn render_rgba_async(
318        &mut self,
319        scene: &Scene,
320        clear: Option<[f64; 4]>,
321    ) -> Result<Vec<u8>> {
322        self.renderer.begin_frame()?;
323        let result = async {
324            let cmd = self.encode_rgba(scene, clear);
325            self.renderer.queue.submit(Some(cmd));
326            let slice = self.readback.slice(..);
327            let (tx, rx) = web_workers::sync::mpsc::channel();
328            slice.map_async(wgpu::MapMode::Read, {
329                let tx = tx.clone();
330                move |r| {
331                    let _ = tx.send_sync(r);
332                }
333            });
334            let start = web_workers::sync::Instant::now();
335            loop {
336                let _ = self.renderer.device.poll(PollType::Poll);
337                if let Ok(r) = rx.try_recv() {
338                    r?;
339                    break;
340                }
341                if start.elapsed().as_secs() > 30 {
342                    anyhow::bail!(
343                        "offscreen readback timed out after 30s (map callback never fired)"
344                    );
345                }
346                web_workers::web::yield_now_async(web_workers::web::YieldTime::UserVisible).await;
347            }
348            let mapped = slice.get_mapped_range()?;
349            let result =
350                strip_padding(&*mapped, self.width, self.height, self.padded_bytes_per_row);
351            drop(mapped);
352            self.readback.unmap();
353            result
354        }
355        .await;
356        self.renderer.end_frame();
357        result
358    }
359
360    pub fn renderer_mut(&mut self) -> &mut WgpuSceneRenderer {
361        &mut self.renderer
362    }
363    pub fn renderer(&self) -> &WgpuSceneRenderer {
364        &self.renderer
365    }
366
367    /// Current target width in pixels.
368    pub fn width(&self) -> u32 {
369        self.width
370    }
371
372    /// Current target height in pixels.
373    pub fn height(&self) -> u32 {
374        self.height
375    }
376
377    /// Resize the target + readback buffer. A no-op when the size already
378    /// matches (no reallocation). Dimensions clamp to ≥ 1 and error above
379    /// the device limit, so `render_rgba` always returns
380    /// `width * height * 4` bytes at the requested size.
381    pub fn ensure_size(&mut self, width: u32, height: u32) -> Result<()> {
382        let max = self.renderer.device.limits().max_texture_dimension_2d;
383        anyhow::ensure!(
384            width.max(1) <= max && height.max(1) <= max,
385            "requested offscreen size {width}x{height} exceeds the device texture limit {max}"
386        );
387        let width = width.max(1);
388        let height = height.max(1);
389        if self.width == width && self.height == height {
390            return Ok(());
391        }
392        self.renderer.resize(width, height);
393        let texture = self
394            .renderer
395            .device
396            .create_texture(&wgpu::TextureDescriptor {
397                label: Some("repose-offscreen-tex"),
398                size: wgpu::Extent3d {
399                    width,
400                    height,
401                    depth_or_array_layers: 1,
402                },
403                mip_level_count: 1,
404                sample_count: 1,
405                dimension: wgpu::TextureDimension::D2,
406                format: TextureFormat::Rgba8UnormSrgb,
407                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
408                    | wgpu::TextureUsages::TEXTURE_BINDING
409                    | wgpu::TextureUsages::COPY_SRC,
410                view_formats: &[],
411            });
412        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
413        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
414        let unpadded = width
415            .checked_mul(4)
416            .ok_or_else(|| anyhow::anyhow!("offscreen row size overflow"))?;
417        let padded = unpadded
418            .checked_add(align - 1)
419            .ok_or_else(|| anyhow::anyhow!("offscreen row alignment overflow"))?
420            / align
421            * align;
422        let size = u64::from(padded)
423            .checked_mul(u64::from(height))
424            .ok_or_else(|| anyhow::anyhow!("offscreen readback size overflow"))?;
425        if size > self.renderer.device.limits().max_buffer_size {
426            anyhow::bail!("offscreen readback exceeds device buffer limit");
427        }
428        let readback = self.renderer.device.create_buffer(&wgpu::BufferDescriptor {
429            label: Some("repose-offscreen-readback"),
430            size,
431            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
432            mapped_at_creation: false,
433        });
434        self.texture = texture;
435        self.view = view;
436        self.readback = readback;
437        self.width = width;
438        self.height = height;
439        self.padded_bytes_per_row = padded;
440        Ok(())
441    }
442
443    pub async fn render_rgba_unified(
444        &mut self,
445        scene: &Scene,
446        clear: Option<[f64; 4]>,
447    ) -> Result<Vec<u8>> {
448        #[cfg(all(target_family = "wasm", target_os = "unknown"))]
449        {
450            if web_workers::web::has_block_support() {
451                return self.render_rgba(scene, clear);
452            } else {
453                return self.render_rgba_async(scene, clear).await;
454            }
455        }
456        #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
457        {
458            self.render_rgba(scene, clear)
459        }
460    }
461}
462
463fn strip_padding(mapped: &[u8], width: u32, height: u32, padded: u32) -> Result<Vec<u8>> {
464    let unpadded = usize::try_from(
465        width
466            .checked_mul(4)
467            .ok_or_else(|| anyhow::anyhow!("offscreen row size overflow"))?,
468    )
469    .map_err(|_| anyhow::anyhow!("offscreen row size exceeds addressable memory"))?;
470    let output_size = width
471        .checked_mul(height)
472        .and_then(|value| value.checked_mul(4))
473        .ok_or_else(|| anyhow::anyhow!("offscreen output size overflow"))?;
474    let capacity = usize::try_from(output_size)
475        .map_err(|_| anyhow::anyhow!("offscreen output exceeds addressable memory"))?;
476    let padded = usize::try_from(padded)
477        .map_err(|_| anyhow::anyhow!("offscreen padded row exceeds addressable memory"))?;
478    let mut out = Vec::with_capacity(capacity);
479    for y in 0..height {
480        let start = usize::try_from(y)
481            .ok()
482            .and_then(|row| row.checked_mul(padded))
483            .ok_or_else(|| anyhow::anyhow!("offscreen row offset overflow"))?;
484        let end = start
485            .checked_add(unpadded)
486            .ok_or_else(|| anyhow::anyhow!("offscreen row end overflow"))?;
487        let row = mapped
488            .get(start..end)
489            .ok_or_else(|| anyhow::anyhow!("offscreen readback row is truncated"))?;
490        out.extend_from_slice(row);
491    }
492    Ok(out)
493}
494
495pub fn map_buffer_blocking(slice: &wgpu::BufferSlice<'_>, device: &wgpu::Device) -> Result<()> {
496    let (tx, rx) = web_workers::sync::mpsc::channel();
497    slice.map_async(wgpu::MapMode::Read, move |r| {
498        let _ = tx.send_sync(r);
499    });
500    device.poll(PollType::wait_indefinitely())?;
501    rx.recv_sync()??;
502    Ok(())
503}
504
505#[cfg(target_arch = "wasm32")]
506pub async fn map_buffer_async(slice: &wgpu::BufferSlice<'_>, device: &wgpu::Device) -> Result<()> {
507    let (tx, rx) = web_workers::sync::mpsc::channel();
508    slice.map_async(wgpu::MapMode::Read, {
509        let tx = tx.clone();
510        move |r| {
511            let _ = tx.send_sync(r);
512        }
513    });
514    let start = web_workers::sync::Instant::now();
515    loop {
516        let _ = device.poll(PollType::Poll);
517        if let Ok(r) = rx.try_recv() {
518            r?;
519            break;
520        }
521        if start.elapsed().as_secs() > 30 {
522            anyhow::bail!("map_buffer timed out after 30s (map callback never fired)");
523        }
524        web_workers::web::yield_now_async(web_workers::web::YieldTime::UserVisible).await;
525    }
526    Ok(())
527}
528
529pub async fn map_buffer_unified(
530    slice: &wgpu::BufferSlice<'_>,
531    device: &wgpu::Device,
532) -> Result<()> {
533    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
534    {
535        if web_workers::web::has_block_support() {
536            return map_buffer_blocking(slice, device);
537        } else {
538            return map_buffer_async(slice, device).await;
539        }
540    }
541    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
542    {
543        map_buffer_blocking(slice, device)
544    }
545}