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