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 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 pub fn new_blocking(width: u32, height: u32, msaa: u32) -> Result<Self> {
128 pollster::block_on(Self::new(width, height, msaa))
129 }
130
131 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 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 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 pub fn render_rgba(&mut self, scene: &Scene, clear: Option<[f64; 4]>) -> Result<Vec<u8>> {
254 #[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 pub fn width(&self) -> u32 {
320 self.width
321 }
322
323 pub fn height(&self) -> u32 {
325 self.height
326 }
327
328 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}