1use 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
24pub 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
63pub 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 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 #[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 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 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 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 pub fn render_rgba(&mut self, scene: &Scene, clear: Option<[f64; 4]>) -> Result<Vec<u8>> {
289 #[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 pub fn width(&self) -> u32 {
369 self.width
370 }
371
372 pub fn height(&self) -> u32 {
374 self.height
375 }
376
377 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}