oxiui_render_wgpu/gpu/device.rs
1//! Headless wgpu device acquisition and the offscreen colour target.
2//!
3//! [`GpuContext::headless`] performs the full no-window initialisation chain:
4//! `Instance` → `request_adapter` → `request_device`/`queue` → an offscreen
5//! colour texture. Every step that can fail returns a [`UiError`]; in
6//! particular, a missing adapter (no GPU available) yields
7//! [`UiError::Unsupported`] so callers — most importantly the headless tests —
8//! can *gracefully skip* rather than panic on machines without a usable GPU.
9//!
10//! The offscreen target uses [`wgpu::TextureFormat::Rgba8Unorm`] (the *linear*,
11//! non-sRGB variant) so that a solid colour written by the fragment shader is
12//! copied back byte-for-byte. An sRGB target would apply the OETF on store and
13//! distort the asserted pixel values.
14
15use oxiui_core::UiError;
16
17/// The non-sRGB offscreen colour format. Chosen so solid-colour readback is
18/// byte-exact (sRGB encoding would skew the stored values).
19pub const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
20
21/// An initialised headless GPU context: device, queue, and an offscreen colour
22/// texture (plus its view) sized to the requested surface.
23///
24/// No window or swap-chain surface is involved — rendering goes to the
25/// offscreen texture, which can then be read back to CPU memory with
26/// [`crate::gpu::renderer`]'s readback path.
27pub struct GpuContext {
28 /// The logical GPU device.
29 pub device: wgpu::Device,
30 /// The command queue for the device.
31 pub queue: wgpu::Queue,
32 /// The offscreen colour texture (`RENDER_ATTACHMENT | COPY_SRC`).
33 /// This is always the resolve/readback target (sample_count=1).
34 pub color_texture: wgpu::Texture,
35 /// A default view over [`color_texture`](GpuContext::color_texture).
36 /// When MSAA is active this is the *resolve* target; when no MSAA it is
37 /// the direct render target.
38 pub color_view: wgpu::TextureView,
39 /// Target width in physical pixels.
40 pub width: u32,
41 /// Target height in physical pixels.
42 pub height: u32,
43 /// The effective MSAA sample count (1 = no MSAA, 4 or 8 = MSAA).
44 pub sample_count: u32,
45 /// The MSAA multisample texture view, present only when `sample_count > 1`.
46 pub msaa_view: Option<wgpu::TextureView>,
47}
48
49impl GpuContext {
50 /// Initialise a headless GPU context with an offscreen target of
51 /// `width × height` pixels and the specified MSAA sample count.
52 ///
53 /// The `requested` sample count is validated against adapter capabilities.
54 /// If the adapter does not support the requested count, `sample_count=1`
55 /// (no MSAA) is used instead. Only 4 and 8 are recognised as valid MSAA
56 /// counts; anything else silently falls back to 1.
57 ///
58 /// # Errors
59 ///
60 /// * [`UiError::Unsupported`] — no GPU adapter is available, or the
61 /// requested target dimensions are zero.
62 /// * [`UiError::Backend`] — the adapter was found but the device request
63 /// failed.
64 pub fn headless_with_sample_count(
65 width: u32,
66 height: u32,
67 requested: u32,
68 ) -> Result<Self, UiError> {
69 if width == 0 || height == 0 {
70 return Err(UiError::Unsupported(
71 "headless target dimensions must be non-zero".to_string(),
72 ));
73 }
74
75 // `Instance::default()` enables `Backends::all()` with no display handle
76 // — exactly what a headless (no-window) context needs.
77 let instance = wgpu::Instance::default();
78
79 // Block on the async adapter request. A `None`/`Err` here means no GPU
80 // is usable on this host → surface as Unsupported so tests can skip.
81 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
82 power_preference: wgpu::PowerPreference::default(),
83 force_fallback_adapter: false,
84 compatible_surface: None,
85 }))
86 .map_err(|e| UiError::Unsupported(format!("no GPU adapter available: {e}")))?;
87
88 // Validate requested MSAA count against adapter texture format capabilities.
89 let effective_count = match requested {
90 4 => {
91 let flags = adapter.get_texture_format_features(TARGET_FORMAT).flags;
92 if flags.contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X4) {
93 4
94 } else {
95 1
96 }
97 }
98 8 => {
99 let flags = adapter.get_texture_format_features(TARGET_FORMAT).flags;
100 if flags.contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X8) {
101 8
102 } else {
103 1
104 }
105 }
106 _ => 1,
107 };
108
109 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
110 label: Some("oxiui-render-wgpu headless device"),
111 required_features: wgpu::Features::empty(),
112 required_limits: wgpu::Limits::downlevel_defaults(),
113 memory_hints: wgpu::MemoryHints::Performance,
114 experimental_features: wgpu::ExperimentalFeatures::disabled(),
115 trace: wgpu::Trace::Off,
116 }))
117 .map_err(|e| UiError::Backend(format!("GPU device request failed: {e}")))?;
118
119 // The offscreen colour target is always sample_count=1 so that
120 // `copy_texture_to_buffer` (readback) works. When MSAA is active it
121 // acts as the *resolve* target.
122 let color_texture = device.create_texture(&wgpu::TextureDescriptor {
123 label: Some("oxiui-render-wgpu offscreen target"),
124 size: wgpu::Extent3d {
125 width,
126 height,
127 depth_or_array_layers: 1,
128 },
129 mip_level_count: 1,
130 sample_count: 1,
131 dimension: wgpu::TextureDimension::D2,
132 format: TARGET_FORMAT,
133 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
134 view_formats: &[],
135 });
136
137 let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default());
138
139 // Allocate MSAA multisample texture when effective_count > 1.
140 // MSAA textures must NOT have COPY_SRC — they cannot be read back
141 // directly; they are resolved into the colour_texture above.
142 let msaa_view = if effective_count > 1 {
143 let msaa_texture = device.create_texture(&wgpu::TextureDescriptor {
144 label: Some("oxiui-render-wgpu msaa color"),
145 size: wgpu::Extent3d {
146 width,
147 height,
148 depth_or_array_layers: 1,
149 },
150 mip_level_count: 1,
151 sample_count: effective_count,
152 dimension: wgpu::TextureDimension::D2,
153 format: TARGET_FORMAT,
154 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
155 view_formats: &[],
156 });
157 Some(msaa_texture.create_view(&wgpu::TextureViewDescriptor::default()))
158 } else {
159 None
160 };
161
162 Ok(Self {
163 device,
164 queue,
165 color_texture,
166 color_view,
167 width,
168 height,
169 sample_count: effective_count,
170 msaa_view,
171 })
172 }
173
174 /// Initialise a headless GPU context with an offscreen target of
175 /// `width × height` pixels without MSAA (sample_count=1).
176 ///
177 /// This delegates to [`headless_with_sample_count`] with `requested=1`,
178 /// preserving the exact same code path as before MSAA support was added.
179 ///
180 /// # Errors
181 ///
182 /// * [`UiError::Unsupported`] — no GPU adapter is available (the caller
183 /// should treat this as "skip", not a hard failure), or the requested
184 /// target dimensions are zero.
185 /// * [`UiError::Backend`] — the adapter was found but the device request
186 /// failed.
187 ///
188 /// [`headless_with_sample_count`]: GpuContext::headless_with_sample_count
189 pub fn headless(width: u32, height: u32) -> Result<Self, UiError> {
190 Self::headless_with_sample_count(width, height, 1)
191 }
192
193 /// Returns the colour attachment view and optional resolve target.
194 ///
195 /// Under MSAA (sample_count > 1): render into `msaa_view`, resolve into
196 /// `color_view`.
197 /// Under no MSAA (sample_count == 1): render directly into `color_view`,
198 /// no resolve.
199 pub fn color_attachment(&self) -> (&wgpu::TextureView, Option<&wgpu::TextureView>) {
200 match &self.msaa_view {
201 Some(msaa) => (msaa, Some(&self.color_view)),
202 None => (&self.color_view, None),
203 }
204 }
205
206 /// Returns the effective MSAA sample count (1 = no MSAA, 4 or 8 = MSAA).
207 pub fn sample_count(&self) -> u32 {
208 self.sample_count
209 }
210
211 /// Resize the offscreen colour target to `new_width × new_height` pixels.
212 ///
213 /// Recreates only the colour texture and its view (and the MSAA texture if
214 /// active). The device, queue, and sample count are preserved.
215 ///
216 /// # Errors
217 ///
218 /// Returns [`UiError::Unsupported`] if either dimension is zero.
219 pub fn resize(&mut self, new_width: u32, new_height: u32) -> Result<(), UiError> {
220 if new_width == 0 || new_height == 0 {
221 return Err(UiError::Unsupported(
222 "GpuContext resize dimensions must be non-zero".to_string(),
223 ));
224 }
225
226 // Recreate the single-sample colour target.
227 let color_texture = self.device.create_texture(&wgpu::TextureDescriptor {
228 label: Some("oxiui-render-wgpu offscreen target (resized)"),
229 size: wgpu::Extent3d {
230 width: new_width,
231 height: new_height,
232 depth_or_array_layers: 1,
233 },
234 mip_level_count: 1,
235 sample_count: 1,
236 dimension: wgpu::TextureDimension::D2,
237 format: TARGET_FORMAT,
238 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
239 view_formats: &[],
240 });
241 let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default());
242
243 // Recreate the MSAA texture if necessary.
244 let msaa_view = if self.sample_count > 1 {
245 let msaa_texture = self.device.create_texture(&wgpu::TextureDescriptor {
246 label: Some("oxiui-render-wgpu msaa color (resized)"),
247 size: wgpu::Extent3d {
248 width: new_width,
249 height: new_height,
250 depth_or_array_layers: 1,
251 },
252 mip_level_count: 1,
253 sample_count: self.sample_count,
254 dimension: wgpu::TextureDimension::D2,
255 format: TARGET_FORMAT,
256 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
257 view_formats: &[],
258 });
259 Some(msaa_texture.create_view(&wgpu::TextureViewDescriptor::default()))
260 } else {
261 None
262 };
263
264 self.color_texture = color_texture;
265 self.color_view = color_view;
266 self.msaa_view = msaa_view;
267 self.width = new_width;
268 self.height = new_height;
269
270 Ok(())
271 }
272}