Skip to main content

flashraw_gpu/
context.rs

1//! Adapter/device management: lazy, cached, async initialisation (with a blocking
2//! wrapper on native), adapter classification, pipeline cache and small helpers
3//! shared by the kernels.
4
5use crate::kernels;
6use crate::oneshot;
7use flashraw_core::{Error, Result};
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex, OnceLock};
11
12/// Coarse adapter class used by backend selection.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum AdapterClass {
15    Discrete,
16    /// Integrated / unified-memory GPU (e.g. Apple Silicon).
17    Integrated,
18    Virtual,
19    /// Software rasteriser (llvmpipe, lavapipe, WARP, SwiftShader). Never auto-selected.
20    Software,
21    /// The platform does not say (common on WebGPU, where adapter info is redacted).
22    Unknown,
23}
24
25impl AdapterClass {
26    pub fn name(self) -> &'static str {
27        match self {
28            AdapterClass::Discrete => "discrete",
29            AdapterClass::Integrated => "integrated",
30            AdapterClass::Virtual => "virtual",
31            AdapterClass::Software => "software",
32            AdapterClass::Unknown => "unknown",
33        }
34    }
35}
36
37/// Description of the adapter in use.
38#[derive(Debug, Clone, PartialEq)]
39pub struct GpuAdapterInfo {
40    pub name: String,
41    pub backend: String,
42    pub driver: String,
43    pub class: AdapterClass,
44    /// GPU timestamp queries are available (kernel times are measured on the GPU).
45    pub timestamps: bool,
46    pub max_buffer_size: u64,
47    pub max_storage_binding: u64,
48}
49
50impl std::fmt::Display for GpuAdapterInfo {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "{} ({}, {})", self.name, self.backend, self.class.name())
53    }
54}
55
56/// Kernel entry points.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub(crate) enum Kernel {
59    Unpack,
60    Normalize,
61    Develop,
62    DevelopHalf,
63    /// Edge-aware demosaic passes (bind group 1, see `shaders/edge.wgsl`), in dispatch order.
64    EdgeMosaic,
65    EdgeG0,
66    EdgeRgb,
67    EdgeBayerD1,
68    EdgeBayerV2,
69    EdgeXtransD1,
70    /// AHD demosaic passes (same bind group, after `EdgeMosaic`, before `EdgeDevelop`).
71    AhdGreen,
72    AhdLab,
73    AhdHom,
74    AhdBlend,
75    EdgeDevelop,
76}
77
78impl Kernel {
79    fn is_edge(self) -> bool {
80        matches!(
81            self,
82            Kernel::EdgeMosaic
83                | Kernel::EdgeG0
84                | Kernel::EdgeRgb
85                | Kernel::EdgeBayerD1
86                | Kernel::EdgeBayerV2
87                | Kernel::EdgeXtransD1
88                | Kernel::AhdGreen
89                | Kernel::AhdLab
90                | Kernel::AhdHom
91                | Kernel::AhdBlend
92                | Kernel::EdgeDevelop
93        )
94    }
95}
96
97/// A specialised pipeline: kernel + override constants.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99pub(crate) struct PipelineKey {
100    pub kernel: Kernel,
101    pub input_mode: u32,
102    pub order: u32,
103    pub use_lut: bool,
104    /// Develop only.
105    pub layout: u32,
106    pub out_mode: u32,
107}
108
109/// A device plus everything FlashRaw needs to run its kernels on it.
110///
111/// Create one with [`GpuContext::new`] (default adapter), or wrap an application's
112/// existing device with [`GpuContext::from_device`] so that FlashRaw outputs live on
113/// the same device as the rest of the app (required for zero-copy interop).
114pub struct GpuContext {
115    pub(crate) adapter: Option<wgpu::Adapter>,
116    pub(crate) device: wgpu::Device,
117    pub(crate) queue: wgpu::Queue,
118    pub(crate) info: GpuAdapterInfo,
119    pub(crate) layout: wgpu::BindGroupLayout,
120    pipeline_layout: wgpu::PipelineLayout,
121    /// Bind group 1 of the edge-aware kernels: parameters, filter tables, scratch planes.
122    pub(crate) edge_layout: wgpu::BindGroupLayout,
123    edge_pipeline_layout: wgpu::PipelineLayout,
124    /// Filter tables of the edge-aware demosaic per CFA period (uploaded once).
125    edge_tables: Mutex<HashMap<u32, Arc<wgpu::Buffer>>>,
126    pub(crate) tex_layout: wgpu::BindGroupLayout,
127    tex_pipeline: OnceLock<wgpu::ComputePipeline>,
128    modules: Mutex<HashMap<Kernel, wgpu::ShaderModule>>,
129    pipelines: Mutex<HashMap<PipelineKey, wgpu::ComputePipeline>>,
130    pub(crate) srgb: Vec<u32>,
131    pub(crate) last_error: Arc<Mutex<Option<String>>>,
132    pub(crate) calibration: OnceLock<crate::policy::GpuCalibration>,
133    /// Storage buffers are host-mappable (unified memory): no staging copies.
134    pub(crate) uma: bool,
135    /// Recycled buffers (large allocations are expensive: page mapping + zero-fill).
136    pool: Mutex<Vec<wgpu::Buffer>>,
137    /// Caps below the adapter limits (0 = none), see [`GpuContext::override_limits`].
138    binding_cap: AtomicU64,
139    buffer_cap: AtomicU64,
140}
141
142/// Upper bound of memory kept in the buffer pool.
143const POOL_MAX_BYTES: u64 = 1 << 30;
144
145impl std::fmt::Debug for GpuContext {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.debug_struct("GpuContext").field("adapter", &self.info).finish()
148    }
149}
150
151fn classify(t: wgpu::DeviceType) -> AdapterClass {
152    match t {
153        wgpu::DeviceType::DiscreteGpu => AdapterClass::Discrete,
154        wgpu::DeviceType::IntegratedGpu => AdapterClass::Integrated,
155        wgpu::DeviceType::VirtualGpu => AdapterClass::Virtual,
156        wgpu::DeviceType::Cpu => AdapterClass::Software,
157        wgpu::DeviceType::Other => AdapterClass::Unknown,
158    }
159}
160
161fn is_software_name(name: &str) -> bool {
162    let n = name.to_ascii_lowercase();
163    ["llvmpipe", "lavapipe", "softpipe", "swiftshader", "microsoft basic render", "warp"].iter().any(|s| n.contains(s))
164}
165
166fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
167    wgpu::BindGroupLayoutEntry {
168        binding,
169        visibility: wgpu::ShaderStages::COMPUTE,
170        ty: wgpu::BindingType::Buffer {
171            ty: wgpu::BufferBindingType::Storage { read_only },
172            has_dynamic_offset: false,
173            min_binding_size: None,
174        },
175        count: None,
176    }
177}
178
179fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
180    wgpu::BindGroupLayoutEntry {
181        binding,
182        visibility: wgpu::ShaderStages::COMPUTE,
183        ty: wgpu::BindingType::Buffer {
184            ty: wgpu::BufferBindingType::Uniform,
185            has_dynamic_offset: false,
186            min_binding_size: None,
187        },
188        count: None,
189    }
190}
191
192impl GpuContext {
193    /// Request the default high-performance adapter and create a device.
194    pub async fn new() -> Result<Self> {
195        if disabled_by_env() {
196            return Err(Error::GpuUnavailable("disabled by `FLASHRAW_GPU`".into()));
197        }
198        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
199        let adapter = instance
200            .request_adapter(&wgpu::RequestAdapterOptions {
201                power_preference: wgpu::PowerPreference::HighPerformance,
202                force_fallback_adapter: false,
203                compatible_surface: None,
204                apply_limit_buckets: false,
205            })
206            .await
207            .map_err(|e| Error::GpuUnavailable(format!("no GPU adapter: {e}")))?;
208        let mut features = wgpu::Features::empty();
209        if adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
210            features |= wgpu::Features::TIMESTAMP_QUERY;
211        }
212        // Unified memory (Apple Silicon, most integrated GPUs): map storage buffers
213        // directly instead of staging through extra copies.
214        let integrated = adapter.get_info().device_type == wgpu::DeviceType::IntegratedGpu;
215        if integrated && adapter.features().contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS) {
216            features |= wgpu::Features::MAPPABLE_PRIMARY_BUFFERS;
217        }
218        // Large frames (60 MP RGBA8 is 240 MB) need more than the WebGPU default limits.
219        let limits = adapter.limits();
220        let (device, queue) = adapter
221            .request_device(&wgpu::DeviceDescriptor {
222                label: Some("flashraw"),
223                required_features: features,
224                required_limits: limits,
225                experimental_features: wgpu::ExperimentalFeatures::default(),
226                memory_hints: wgpu::MemoryHints::Performance,
227                trace: wgpu::Trace::Off,
228            })
229            .await
230            .map_err(|e| Error::GpuUnavailable(format!("device request failed: {e}")))?;
231        let info = adapter.get_info();
232        Ok(Self::build(Some(adapter), device, queue, &info))
233    }
234
235    /// Use an application-owned device (e.g. the one a wgpu/WebGPU renderer already
236    /// uses) so that GPU-resident outputs can be consumed without copies.
237    /// `TIMESTAMP_QUERY` is used when the device was created with it.
238    pub fn from_device(device: wgpu::Device, queue: wgpu::Queue, info: &wgpu::AdapterInfo) -> Self {
239        Self::build(None, device, queue, info)
240    }
241
242    fn build(adapter: Option<wgpu::Adapter>, device: wgpu::Device, queue: wgpu::Queue, ai: &wgpu::AdapterInfo) -> Self {
243        let mut class = classify(ai.device_type);
244        if is_software_name(&ai.name) {
245            class = AdapterClass::Software;
246        }
247        let limits = device.limits();
248        let uma = device.features().contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS);
249        let info = GpuAdapterInfo {
250            name: if ai.name.is_empty() { "WebGPU adapter".into() } else { ai.name.clone() },
251            backend: format!("{:?}", ai.backend),
252            driver: [ai.driver.as_str(), ai.driver_info.as_str()]
253                .iter()
254                .filter(|s| !s.is_empty())
255                .cloned()
256                .collect::<Vec<_>>()
257                .join(" "),
258            class,
259            timestamps: device.features().contains(wgpu::Features::TIMESTAMP_QUERY),
260            max_buffer_size: limits.max_buffer_size,
261            max_storage_binding: limits.max_storage_buffer_binding_size,
262        };
263        let last_error: Arc<Mutex<Option<String>>> = Arc::default();
264        {
265            // Library code must not panic on device errors (wgpu's default handler does):
266            // record them; operations check error scopes and this slot.
267            let slot = last_error.clone();
268            device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| {
269                if let Ok(mut s) = slot.lock() {
270                    *s = Some(e.to_string());
271                }
272            }));
273        }
274        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
275            label: Some("flashraw.kernels"),
276            entries: &[
277                uniform_entry(0),
278                storage_entry(1, true),
279                storage_entry(2, true),
280                storage_entry(3, true),
281                storage_entry(4, false),
282            ],
283        });
284        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
285            label: Some("flashraw.kernels"),
286            bind_group_layouts: &[Some(&layout)],
287            immediate_size: 0,
288        });
289        let edge_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
290            label: Some("flashraw.edge"),
291            entries: &[uniform_entry(0), storage_entry(1, true), storage_entry(2, false), storage_entry(3, true)],
292        });
293        let edge_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
294            label: Some("flashraw.edge"),
295            bind_group_layouts: &[Some(&layout), Some(&edge_layout)],
296            immediate_size: 0,
297        });
298        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
299            label: Some("flashraw.to_texture"),
300            entries: &[
301                uniform_entry(0),
302                storage_entry(1, true),
303                wgpu::BindGroupLayoutEntry {
304                    binding: 2,
305                    visibility: wgpu::ShaderStages::COMPUTE,
306                    ty: wgpu::BindingType::StorageTexture {
307                        access: wgpu::StorageTextureAccess::WriteOnly,
308                        format: wgpu::TextureFormat::Rgba8Unorm,
309                        view_dimension: wgpu::TextureViewDimension::D2,
310                    },
311                    count: None,
312                },
313            ],
314        });
315        GpuContext {
316            adapter,
317            device,
318            queue,
319            info,
320            layout,
321            pipeline_layout,
322            edge_layout,
323            edge_pipeline_layout,
324            edge_tables: Mutex::default(),
325            tex_layout,
326            tex_pipeline: OnceLock::new(),
327            modules: Mutex::default(),
328            pipelines: Mutex::default(),
329            srgb: kernels::srgb_table(),
330            last_error,
331            calibration: OnceLock::new(),
332            uma,
333            pool: Mutex::default(),
334            binding_cap: AtomicU64::new(0),
335            buffer_cap: AtomicU64::new(0),
336        }
337    }
338
339    /// A buffer of at least `size` bytes with exactly `usage`, recycled when possible.
340    /// Large sizes are rounded up so that frames of similar size share buffers.
341    pub(crate) fn acquire(&self, label: &str, size: u64, usage: wgpu::BufferUsages) -> wgpu::Buffer {
342        let size = size.max(4);
343        {
344            let mut pool = self.pool.lock().unwrap_or_else(|e| e.into_inner());
345            let best = pool
346                .iter()
347                .enumerate()
348                .filter(|(_, b)| b.usage() == usage && b.size() >= size && b.size() <= size + size / 4 + (16 << 20))
349                .min_by_key(|(_, b)| b.size())
350                .map(|(i, _)| i);
351            if let Some(i) = best {
352                return pool.swap_remove(i);
353            }
354        }
355        let granule: u64 = if size > (64 << 20) { 4 << 20 } else { 256 };
356        // Never round past the adapter's buffer size limit.
357        let rounded = (size.div_ceil(granule) * granule).min(self.info.max_buffer_size & !3).max(size);
358        self.device.create_buffer(&wgpu::BufferDescriptor {
359            label: Some(label),
360            size: rounded,
361            usage,
362            mapped_at_creation: false,
363        })
364    }
365
366    /// Return a buffer to the pool (the caller guarantees no one else uses it any more).
367    pub(crate) fn release(&self, b: wgpu::Buffer) {
368        if b.size() < (1 << 20) {
369            return; // small buffers are cheap to recreate
370        }
371        let mut pool = self.pool.lock().unwrap_or_else(|e| e.into_inner());
372        pool.push(b);
373        let mut total: u64 = pool.iter().map(|b| b.size()).sum();
374        while total > POOL_MAX_BYTES && !pool.is_empty() {
375            let old = pool.remove(0);
376            total -= old.size();
377            old.destroy();
378        }
379    }
380
381    /// Free all pooled buffers.
382    pub fn trim(&self) {
383        let mut pool = self.pool.lock().unwrap_or_else(|e| e.into_inner());
384        for b in pool.drain(..) {
385            b.destroy();
386        }
387    }
388
389    /// True when storage buffers are mapped directly (unified memory).
390    pub fn unified_memory(&self) -> bool {
391        self.uma
392    }
393
394    pub fn info(&self) -> &GpuAdapterInfo {
395        &self.info
396    }
397    pub fn device(&self) -> &wgpu::Device {
398        &self.device
399    }
400    pub fn queue(&self) -> &wgpu::Queue {
401        &self.queue
402    }
403    /// The adapter, when this context created its own device.
404    pub fn adapter(&self) -> Option<&wgpu::Adapter> {
405        self.adapter.as_ref()
406    }
407
408    fn module(&self, k: Kernel) -> wgpu::ShaderModule {
409        let mut m = self.modules.lock().unwrap_or_else(|e| e.into_inner());
410        m.entry(k)
411            .or_insert_with(|| {
412                let (label, src) = match k {
413                    Kernel::Unpack => ("flashraw.unpack", kernels::UNPACK_WGSL),
414                    Kernel::Normalize => ("flashraw.normalize", kernels::NORMALIZE_WGSL),
415                    Kernel::Develop | Kernel::DevelopHalf => ("flashraw.develop", kernels::DEVELOP_WGSL),
416                    k if k.is_edge() => ("flashraw.edge", kernels::EDGE_WGSL),
417                    _ => unreachable!("every kernel has a module"),
418                };
419                self.device.create_shader_module(wgpu::ShaderModuleDescriptor {
420                    label: Some(label),
421                    source: wgpu::ShaderSource::Wgsl(src.into()),
422                })
423            })
424            .clone()
425    }
426
427    /// A pipeline specialised with override constants (compiled once, cached).
428    pub(crate) fn pipeline(&self, key: PipelineKey) -> wgpu::ComputePipeline {
429        if let Some(p) = self.pipelines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
430            return p.clone();
431        }
432        // All edge-aware kernels share one module.
433        let module = self.module(if key.kernel.is_edge() { Kernel::EdgeMosaic } else { key.kernel });
434        let entry = match key.kernel {
435            Kernel::Unpack => "unpack",
436            Kernel::Normalize => "normalize",
437            Kernel::Develop => "develop",
438            Kernel::DevelopHalf => "develop_half",
439            Kernel::EdgeMosaic => "edge_mosaic",
440            Kernel::EdgeG0 => "edge_g0",
441            Kernel::EdgeRgb => "edge_rgb",
442            Kernel::EdgeBayerD1 => "edge_bayer_d1",
443            Kernel::EdgeBayerV2 => "edge_bayer_v2",
444            Kernel::EdgeXtransD1 => "edge_xtrans_d1",
445            Kernel::AhdGreen => "ahd_green",
446            Kernel::AhdLab => "ahd_lab",
447            Kernel::AhdHom => "ahd_hom",
448            Kernel::AhdBlend => "ahd_blend",
449            Kernel::EdgeDevelop => "develop_edge",
450        };
451        let mut constants: Vec<(&str, f64)> = vec![
452            ("INPUT_MODE", key.input_mode as f64),
453            ("ORDER", key.order as f64),
454            ("USE_LUT", if key.use_lut { 1.0 } else { 0.0 }),
455        ];
456        if matches!(key.kernel, Kernel::Develop | Kernel::DevelopHalf) || key.kernel.is_edge() {
457            constants.push(("LAYOUT", key.layout as f64));
458            constants.push(("OUT_MODE", key.out_mode as f64));
459        }
460        let p = self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
461            label: Some(entry),
462            layout: Some(if key.kernel.is_edge() { &self.edge_pipeline_layout } else { &self.pipeline_layout }),
463            module: &module,
464            entry_point: Some(entry),
465            compilation_options: wgpu::PipelineCompilationOptions {
466                constants: &constants,
467                // Every workgroup-memory element is written before it is read.
468                zero_initialize_workgroup_memory: false,
469            },
470            cache: None,
471        });
472        self.pipelines.lock().unwrap_or_else(|e| e.into_inner()).insert(key, p.clone());
473        p
474    }
475
476    /// The filter tables of the edge-aware demosaic for a CFA of `period` (2 Bayer, 6 X-Trans) as
477    /// `f32`: the canonical colours, then the `G0` filters, then the directional filters.
478    /// Uploaded on first use and kept.
479    pub(crate) fn edge_table(&self, period: u32, build: impl FnOnce() -> Vec<f32>) -> Arc<wgpu::Buffer> {
480        let mut m = self.edge_tables.lock().unwrap_or_else(|e| e.into_inner());
481        m.entry(period)
482            .or_insert_with(|| {
483                Arc::new(crate::run::buffer_init(
484                    &self.device,
485                    "flashraw.edge_tables",
486                    bytemuck::cast_slice(&build()),
487                    wgpu::BufferUsages::STORAGE,
488                ))
489            })
490            .clone()
491    }
492
493    pub(crate) fn tex_pipeline(&self) -> &wgpu::ComputePipeline {
494        self.tex_pipeline.get_or_init(|| {
495            let module = self.device.create_shader_module(wgpu::ShaderModuleDescriptor {
496                label: Some("flashraw.to_texture"),
497                source: wgpu::ShaderSource::Wgsl(kernels::TO_TEXTURE_WGSL.into()),
498            });
499            let layout = self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
500                label: Some("flashraw.to_texture"),
501                bind_group_layouts: &[Some(&self.tex_layout)],
502                immediate_size: 0,
503            });
504            self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
505                label: Some("to_texture"),
506                layout: Some(&layout),
507                module: &module,
508                entry_point: Some("to_texture"),
509                compilation_options: Default::default(),
510                cache: None,
511            })
512        })
513    }
514
515    /// Wait until all submitted work has completed. On native this blocks in
516    /// `Device::poll`; in the browser it yields to the event loop.
517    pub async fn wait_idle(&self) -> Result<()> {
518        let (tx, rx) = oneshot::channel();
519        self.queue.on_submitted_work_done(move || tx.send(()));
520        #[cfg(not(target_arch = "wasm32"))]
521        {
522            self.device
523                .poll(wgpu::PollType::wait_indefinitely())
524                .map_err(|e| Error::GpuUnavailable(format!("device poll failed: {e}")))?;
525            if rx.try_take().is_some() {
526                return Ok(());
527            }
528        }
529        rx.await.ok_or_else(|| Error::GpuUnavailable("GPU work did not complete (device lost?)".into()))
530    }
531
532    /// Map `buffer[..size]` for reading and copy it out with `f`.
533    pub(crate) async fn read_mapped<T>(
534        &self,
535        buffer: &wgpu::Buffer,
536        size: u64,
537        f: impl FnOnce(&[u8]) -> T,
538    ) -> Result<T> {
539        let (tx, rx) = oneshot::channel();
540        buffer.map_async(wgpu::MapMode::Read, ..size, move |r| tx.send(r));
541        #[cfg(not(target_arch = "wasm32"))]
542        self.device
543            .poll(wgpu::PollType::wait_indefinitely())
544            .map_err(|e| Error::GpuUnavailable(format!("device poll failed: {e}")))?;
545        let r = match rx.try_take() {
546            Some(r) => Some(r),
547            None => rx.await,
548        };
549        match r {
550            Some(Ok(())) => {}
551            Some(Err(e)) => return Err(Error::GpuUnavailable(format!("buffer mapping failed: {e}"))),
552            None => return Err(Error::GpuUnavailable("buffer mapping was cancelled".into())),
553        }
554        let out = {
555            let view = buffer
556                .get_mapped_range(..size)
557                .map_err(|e| Error::GpuUnavailable(format!("mapped range unavailable: {e}")))?;
558            f(&view)
559        };
560        buffer.unmap();
561        Ok(out)
562    }
563
564    /// Map `buffer[..size]` for writing and fill it with `data` (zero-padded).
565    ///
566    /// `after` is the last submission that used the buffer (`None`: idle). Only that
567    /// submission is waited for, so later work (e.g. the previous band's kernel) keeps
568    /// running while the host fills the buffer. Returns the time spent copying (ms),
569    /// excluding the wait for the buffer.
570    pub(crate) async fn write_mapped(
571        &self,
572        buffer: &wgpu::Buffer,
573        data: &[u8],
574        after: Option<wgpu::SubmissionIndex>,
575    ) -> Result<f64> {
576        let size = (data.len() as u64).div_ceil(4).max(1) * 4;
577        let (tx, rx) = oneshot::channel();
578        buffer.map_async(wgpu::MapMode::Write, ..size, move |r| tx.send(r));
579        #[cfg(not(target_arch = "wasm32"))]
580        {
581            let poll = match after {
582                Some(i) => wgpu::PollType::Wait { submission_index: Some(i), timeout: None },
583                None => wgpu::PollType::Poll,
584            };
585            self.device.poll(poll).map_err(|e| Error::GpuUnavailable(format!("device poll failed: {e}")))?;
586            if !rx.is_ready() {
587                self.device
588                    .poll(wgpu::PollType::wait_indefinitely())
589                    .map_err(|e| Error::GpuUnavailable(format!("device poll failed: {e}")))?;
590            }
591        }
592        #[cfg(target_arch = "wasm32")]
593        let _ = after;
594        let r = match rx.try_take() {
595            Some(r) => Some(r),
596            None => rx.await,
597        };
598        match r {
599            Some(Ok(())) => {}
600            Some(Err(e)) => return Err(Error::GpuUnavailable(format!("buffer mapping failed: {e}"))),
601            None => return Err(Error::GpuUnavailable("buffer mapping was cancelled".into())),
602        }
603        let t0 = flashraw_core::profile::now_ms();
604        {
605            let mut view = buffer
606                .get_mapped_range_mut(..size)
607                .map_err(|e| Error::GpuUnavailable(format!("mapped range unavailable: {e}")))?;
608            let bulk = data.len();
609            view.slice(..bulk).copy_from_slice(data);
610            if (bulk as u64) < size {
611                view.slice(bulk..).fill(0);
612            }
613        }
614        buffer.unmap();
615        Ok(flashraw_core::profile::now_ms() - t0)
616    }
617
618    /// Take the last uncaptured device error, if any.
619    pub(crate) fn take_error(&self) -> Option<String> {
620        self.last_error.lock().ok()?.take()
621    }
622
623    /// Cap the storage-binding size and/or the buffer size FlashRaw plans with, below
624    /// the adapter's limits (`None` restores the adapter limit). Frames are processed
625    /// in bands that fit these caps, so this bounds the size of every buffer FlashRaw
626    /// binds (useful to limit peak device memory, and to exercise tiling in tests).
627    /// Values are rounded down to a multiple of 256 bytes, with a 4 KB minimum.
628    pub fn override_limits(&self, max_binding: Option<u64>, max_buffer: Option<u64>) {
629        let v = |o: Option<u64>| o.map(|b| (b & !255).max(4096)).unwrap_or(0);
630        self.binding_cap.store(v(max_binding), Ordering::Relaxed);
631        self.buffer_cap.store(v(max_buffer), Ordering::Relaxed);
632    }
633
634    /// Largest storage binding FlashRaw uses (adapter limit, caps, 32-bit addressing).
635    pub fn max_binding_bytes(&self) -> u64 {
636        let mut b = self.info.max_storage_binding.min(self.info.max_buffer_size).min(u32::MAX as u64 & !255);
637        let cap = self.binding_cap.load(Ordering::Relaxed);
638        if cap > 0 {
639            b = b.min(cap);
640        }
641        b.min(self.max_buffer_bytes())
642    }
643
644    /// Largest single buffer FlashRaw allocates (adapter limit and cap).
645    pub fn max_buffer_bytes(&self) -> u64 {
646        let cap = self.buffer_cap.load(Ordering::Relaxed);
647        if cap > 0 { self.info.max_buffer_size.min(cap) } else { self.info.max_buffer_size }
648    }
649
650    /// Number of bands a frame with `input_bytes` of upload and `output_bytes` of
651    /// result needs on this device (an estimate for cost models; 1 = untiled).
652    pub fn estimate_bands(&self, input_bytes: u64, output_bytes: u64) -> u32 {
653        let b = self.max_binding_bytes().max(1);
654        let out = if output_bytes <= b { 1 } else { output_bytes.div_ceil(b) };
655        input_bytes.div_ceil(b).max(out).max(1).min(u32::MAX as u64) as u32
656    }
657}
658
659/// `FLASHRAW_GPU=0|off|false|none` disables the GPU backend.
660fn disabled_by_env() -> bool {
661    #[cfg(not(target_arch = "wasm32"))]
662    {
663        matches!(
664            std::env::var("FLASHRAW_GPU").map(|v| v.to_ascii_lowercase()).as_deref(),
665            Ok("0" | "off" | "false" | "none" | "cpu")
666        )
667    }
668    #[cfg(target_arch = "wasm32")]
669    {
670        false
671    }
672}
673
674// ---- Global context -------------------------------------------------------------
675
676#[derive(Default)]
677struct Slot {
678    ctx: Option<Arc<GpuContext>>,
679    error: Option<String>,
680    tried: bool,
681}
682
683#[cfg(not(target_arch = "wasm32"))]
684fn with_slot<T>(f: impl FnOnce(&mut Slot) -> T) -> T {
685    static SLOT: OnceLock<Mutex<Slot>> = OnceLock::new();
686    let mut g = SLOT.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
687    f(&mut g)
688}
689
690#[cfg(target_arch = "wasm32")]
691fn with_slot<T>(f: impl FnOnce(&mut Slot) -> T) -> T {
692    // wgpu's WebGPU objects are not Send/Sync; the browser main thread owns them.
693    thread_local! { static SLOT: std::cell::RefCell<Slot> = Default::default(); }
694    SLOT.with(|s| f(&mut s.borrow_mut()))
695}
696
697/// Initialise the global GPU context (idempotent). Required before any GPU work in
698/// the browser, where initialisation cannot block.
699pub async fn init() -> Result<Arc<GpuContext>> {
700    if let Some(r) = with_slot(|s| s.tried.then(|| s.ctx.clone().ok_or_else(|| s.error.clone().unwrap_or_default()))) {
701        return r.map_err(Error::GpuUnavailable);
702    }
703    let r = GpuContext::new().await.map(Arc::new);
704    with_slot(|s| {
705        if let Some(c) = &s.ctx {
706            return Ok(c.clone()); // initialised concurrently
707        }
708        s.tried = true;
709        match r {
710            Ok(c) => {
711                s.ctx = Some(c.clone());
712                Ok(c)
713            }
714            Err(e) => {
715                s.error = Some(e.to_string());
716                Err(e)
717            }
718        }
719    })
720}
721
722/// The global GPU context, initialising it on first use (native: blocking).
723/// In the browser this only returns a context after [`init`] has completed.
724pub fn context() -> Result<Arc<GpuContext>> {
725    #[cfg(not(target_arch = "wasm32"))]
726    {
727        if let Some(r) =
728            with_slot(|s| s.tried.then(|| s.ctx.clone().ok_or_else(|| s.error.clone().unwrap_or_default())))
729        {
730            return r.map_err(Error::GpuUnavailable);
731        }
732        pollster::block_on(init())
733    }
734    #[cfg(target_arch = "wasm32")]
735    {
736        with_slot(|s| s.ctx.clone())
737            .ok_or_else(|| Error::GpuUnavailable("call `flashraw_gpu::init().await` before using the GPU".into()))
738    }
739}
740
741/// The global context if it has already been initialised (never initialises).
742pub fn try_context() -> Option<Arc<GpuContext>> {
743    with_slot(|s| s.ctx.clone())
744}
745
746/// Install an application-provided context (see [`GpuContext::from_device`]) as the
747/// global one used by FlashRaw.
748pub fn set_context(ctx: Arc<GpuContext>) {
749    with_slot(|s| {
750        s.ctx = Some(ctx);
751        s.tried = true;
752        s.error = None;
753    })
754}
755
756/// Whether a GPU adapter is available (initialises the context on native).
757pub fn available() -> bool {
758    context().is_ok()
759}
760
761/// Adapter description of the global context.
762pub fn adapter_info() -> Option<GpuAdapterInfo> {
763    context().ok().map(|c| c.info.clone())
764}