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 msaa = crate::pick_surface_msaa(&adapter, format, msaa);
111        let (device, queue) = adapter
112            .request_device(&wgpu::DeviceDescriptor {
113                label: Some("repose-offscreen"),
114                required_features: wgpu::Features::empty(),
115                required_limits: wgpu::Limits::default(),
116                memory_hints: wgpu::MemoryHints::Performance,
117                experimental_features: wgpu::ExperimentalFeatures::default(),
118                trace: wgpu::Trace::Off,
119            })
120            .await?;
121        let renderer = WgpuSceneRenderer::from_device(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    pub fn new_blocking(width: u32, height: u32, msaa: u32) -> Result<Self> {
128        pollster::block_on(Self::new(width, height, msaa))
129    }
130
131    /// Shared-device: reuse Device/Queue, no Adapter. Dimensions clamp to
132    /// ≥ 1; `msaa` clamps to ≥ 1 (no adapter-based picking without one —
133    /// prefer [`from_device_with_adapter`](Self::from_device_with_adapter)
134    /// when an adapter is handy).
135    pub fn from_device(
136        device: wgpu::Device,
137        queue: wgpu::Queue,
138        width: u32,
139        height: u32,
140        msaa: u32,
141    ) -> Result<Self> {
142        let width = width.max(1);
143        let height = height.max(1);
144        let format = TextureFormat::Rgba8UnormSrgb;
145        let renderer = WgpuSceneRenderer::from_device(device, queue, format, msaa.max(1));
146        Self::from_renderer(renderer, width, height)
147    }
148
149    /// Shared-device with adapter-based MSAA picking. Dimensions clamp to
150    /// ≥ 1.
151    pub fn from_device_with_adapter(
152        device: wgpu::Device,
153        queue: wgpu::Queue,
154        adapter: &wgpu::Adapter,
155        width: u32,
156        height: u32,
157        msaa: u32,
158    ) -> Result<Self> {
159        let width = width.max(1);
160        let height = height.max(1);
161        let format = TextureFormat::Rgba8UnormSrgb;
162        let msaa = crate::pick_surface_msaa(adapter, format, msaa);
163        let renderer = WgpuSceneRenderer::from_device(device, queue, format, msaa);
164        Self::from_renderer(renderer, width, height)
165    }
166
167    /// Wrap an existing scene renderer with a fresh target + readback
168    /// buffer. Dimensions clamp to ≥ 1.
169    pub fn from_renderer(mut renderer: WgpuSceneRenderer, width: u32, height: u32) -> Result<Self> {
170        let width = width.max(1);
171        let height = height.max(1);
172        renderer.resize(width, height);
173        let texture = renderer.device.create_texture(&wgpu::TextureDescriptor {
174            label: Some("repose-offscreen-tex"),
175            size: wgpu::Extent3d {
176                width,
177                height,
178                depth_or_array_layers: 1,
179            },
180            mip_level_count: 1,
181            sample_count: 1,
182            dimension: wgpu::TextureDimension::D2,
183            format: TextureFormat::Rgba8UnormSrgb,
184            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
185            view_formats: &[],
186        });
187        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
188        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
189        let unpadded = width * 4;
190        let padded = unpadded.div_ceil(align) * align;
191        let buf_size = (padded * height) as u64;
192        let readback = renderer.device.create_buffer(&wgpu::BufferDescriptor {
193            label: Some("repose-offscreen-readback"),
194            size: buf_size,
195            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
196            mapped_at_creation: false,
197        });
198        Ok(Self {
199            renderer,
200            texture,
201            view,
202            readback,
203            width,
204            height,
205            padded_bytes_per_row: padded,
206        })
207    }
208
209    fn encode_rgba(&mut self, scene: &Scene, clear: Option<[f64; 4]>) -> wgpu::CommandBuffer {
210        let mut encoder =
211            self.renderer
212                .device
213                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
214                    label: Some("repose-offscreen-encoder"),
215                });
216        self.renderer
217            .render_scene_to_encoder(scene, &mut encoder, &self.view, clear);
218        encoder.copy_texture_to_buffer(
219            wgpu::TexelCopyTextureInfo {
220                texture: &self.texture,
221                mip_level: 0,
222                origin: wgpu::Origin3d::ZERO,
223                aspect: wgpu::TextureAspect::All,
224            },
225            wgpu::TexelCopyBufferInfo {
226                buffer: &self.readback,
227                layout: wgpu::TexelCopyBufferLayout {
228                    offset: 0,
229                    bytes_per_row: Some(self.padded_bytes_per_row),
230                    rows_per_image: Some(self.height),
231                },
232            },
233            wgpu::Extent3d {
234                width: self.width,
235                height: self.height,
236                depth_or_array_layers: 1,
237            },
238        );
239        encoder.finish()
240    }
241
242    /// Render `scene` and read back `width * height * 4` premultiplied sRGB
243    /// bytes (see the [struct contracts](Self)). `clear = None` uses
244    /// `scene.clear_color`; an empty scene therefore reads back the clear
245    /// color exactly. Blocks until mapping completes.
246    pub fn render_rgba(&mut self, scene: &Scene, clear: Option<[f64; 4]>) -> Result<Vec<u8>> {
247        // Blocking map on the wasm main thread (Window) can never complete..
248        #[cfg(all(target_family = "wasm", target_os = "unknown"))]
249        debug_assert!(
250            web_workers::web::has_block_support(),
251            "render_rgba (blocking) called on wasm main thread; use render_rgba_async"
252        );
253        let cmd = self.encode_rgba(scene, clear);
254        self.renderer.queue.submit(Some(cmd));
255        let slice = self.readback.slice(..);
256        let (tx, rx) = web_workers::sync::mpsc::channel();
257        slice.map_async(wgpu::MapMode::Read, move |r| {
258            let _ = tx.send_sync(r);
259        });
260        self.renderer.device.poll(PollType::wait_indefinitely())?;
261        rx.recv_sync()??;
262        let mapped = slice.get_mapped_range()?;
263        let out = strip_padding(&mapped, self.width, self.height, self.padded_bytes_per_row);
264        drop(mapped);
265        self.readback.unmap();
266        Ok(out)
267    }
268
269    #[cfg(target_arch = "wasm32")]
270    pub async fn render_rgba_async(
271        &mut self,
272        scene: &Scene,
273        clear: Option<[f64; 4]>,
274    ) -> Result<Vec<u8>> {
275        let cmd = self.encode_rgba(scene, clear);
276        self.renderer.queue.submit(Some(cmd));
277        let slice = self.readback.slice(..);
278        let (tx, rx) = web_workers::sync::mpsc::channel();
279        slice.map_async(wgpu::MapMode::Read, {
280            let tx = tx.clone();
281            move |r| {
282                let _ = tx.send_sync(r);
283            }
284        });
285        let start = web_workers::sync::Instant::now();
286        loop {
287            let _ = self.renderer.device.poll(PollType::Poll);
288            if let Ok(r) = rx.try_recv() {
289                r?;
290                break;
291            }
292            if start.elapsed().as_secs() > 30 {
293                anyhow::bail!("offscreen readback timed out after 30s (map callback never fired)");
294            }
295            web_workers::web::yield_now_async(web_workers::web::YieldTime::UserVisible).await;
296        }
297        let mapped = slice.get_mapped_range()?;
298        let out = strip_padding(&*mapped, self.width, self.height, self.padded_bytes_per_row);
299        drop(mapped);
300        self.readback.unmap();
301        Ok(out)
302    }
303
304    pub fn renderer_mut(&mut self) -> &mut WgpuSceneRenderer {
305        &mut self.renderer
306    }
307    pub fn renderer(&self) -> &WgpuSceneRenderer {
308        &self.renderer
309    }
310
311    /// Current target width in pixels.
312    pub fn width(&self) -> u32 {
313        self.width
314    }
315
316    /// Current target height in pixels.
317    pub fn height(&self) -> u32 {
318        self.height
319    }
320
321    /// Resize the target + readback buffer. A no-op when the size already
322    /// matches (no reallocation). Dimensions clamp to ≥ 1. After this,
323    /// `render_rgba` returns `width * height * 4` bytes at the new size.
324    pub fn ensure_size(&mut self, width: u32, height: u32) -> Result<()> {
325        let width = width.max(1);
326        let height = height.max(1);
327        if self.width == width && self.height == height {
328            return Ok(());
329        }
330        self.renderer.resize(width, height);
331        let texture = self
332            .renderer
333            .device
334            .create_texture(&wgpu::TextureDescriptor {
335                label: Some("repose-offscreen-tex"),
336                size: wgpu::Extent3d {
337                    width,
338                    height,
339                    depth_or_array_layers: 1,
340                },
341                mip_level_count: 1,
342                sample_count: 1,
343                dimension: wgpu::TextureDimension::D2,
344                format: TextureFormat::Rgba8UnormSrgb,
345                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
346                view_formats: &[],
347            });
348        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
349        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
350        let padded = (width * 4).div_ceil(align) * align;
351        let readback = self.renderer.device.create_buffer(&wgpu::BufferDescriptor {
352            label: Some("repose-offscreen-readback"),
353            size: (padded * height) as u64,
354            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
355            mapped_at_creation: false,
356        });
357        self.texture = texture;
358        self.view = view;
359        self.readback = readback;
360        self.width = width;
361        self.height = height;
362        self.padded_bytes_per_row = padded;
363        Ok(())
364    }
365
366    pub async fn render_rgba_unified(
367        &mut self,
368        scene: &Scene,
369        clear: Option<[f64; 4]>,
370    ) -> Result<Vec<u8>> {
371        #[cfg(all(target_family = "wasm", target_os = "unknown"))]
372        {
373            if web_workers::web::has_block_support() {
374                return self.render_rgba(scene, clear);
375            } else {
376                return self.render_rgba_async(scene, clear).await;
377            }
378        }
379        #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
380        {
381            self.render_rgba(scene, clear)
382        }
383    }
384}
385
386fn strip_padding(mapped: &[u8], width: u32, height: u32, padded: u32) -> Vec<u8> {
387    let unpadded = width * 4;
388    let mut out = Vec::with_capacity((width * height * 4) as usize);
389    for y in 0..height {
390        let start = (y * padded) as usize;
391        let end = start + unpadded as usize;
392        out.extend_from_slice(&mapped[start..end]);
393    }
394    out
395}
396
397pub fn map_buffer_blocking(slice: &wgpu::BufferSlice<'_>, device: &wgpu::Device) -> Result<()> {
398    let (tx, rx) = web_workers::sync::mpsc::channel();
399    slice.map_async(wgpu::MapMode::Read, move |r| {
400        let _ = tx.send_sync(r);
401    });
402    device.poll(PollType::wait_indefinitely())?;
403    rx.recv_sync()??;
404    Ok(())
405}
406
407#[cfg(target_arch = "wasm32")]
408pub async fn map_buffer_async(slice: &wgpu::BufferSlice<'_>, device: &wgpu::Device) -> Result<()> {
409    let (tx, rx) = web_workers::sync::mpsc::channel();
410    slice.map_async(wgpu::MapMode::Read, {
411        let tx = tx.clone();
412        move |r| {
413            let _ = tx.send_sync(r);
414        }
415    });
416    let start = web_workers::sync::Instant::now();
417    loop {
418        let _ = device.poll(PollType::Poll);
419        if let Ok(r) = rx.try_recv() {
420            r?;
421            break;
422        }
423        if start.elapsed().as_secs() > 30 {
424            anyhow::bail!("map_buffer timed out after 30s (map callback never fired)");
425        }
426        web_workers::web::yield_now_async(web_workers::web::YieldTime::UserVisible).await;
427    }
428    Ok(())
429}
430
431pub async fn map_buffer_unified(
432    slice: &wgpu::BufferSlice<'_>,
433    device: &wgpu::Device,
434) -> Result<()> {
435    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
436    {
437        if web_workers::web::has_block_support() {
438            return map_buffer_blocking(slice, device);
439        } else {
440            return map_buffer_async(slice, device).await;
441        }
442    }
443    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
444    {
445        map_buffer_blocking(slice, device)
446    }
447}