Skip to main content

oxiui_render_wgpu/gpu/
render_target.rs

1//! Off-screen render targets for cached widget subtrees and compositing.
2//!
3//! [`RenderTarget`] owns an offscreen `Rgba8Unorm` texture that can be used as
4//! a `RENDER_ATTACHMENT`, and whose contents can be read back to CPU memory or
5//! sampled as a texture in subsequent render passes.
6//!
7//! # Usage
8//!
9//! 1. Create a `RenderTarget` with [`RenderTarget::new`].
10//! 2. Render into it by using [`RenderTarget::color_view`] as the colour
11//!    attachment in a render pass.
12//! 3. Optionally call [`RenderTarget::readback_rgba`] for CPU pixel access.
13//! 4. Use [`RenderTarget::texture_view`] + a sampler to composite the target
14//!    into a parent pass (e.g. via the textured pipeline).
15//!
16//! Render targets support optional MSAA: pass `sample_count > 1` to obtain a
17//! multisample render texture that resolves into the single-sample backing
18//! texture on each render pass.  The backing texture (`color_texture`) is
19//! always sample_count=1 and `COPY_SRC`, making readback straightforward.
20
21use oxiui_core::UiError;
22
23use crate::gpu::device::TARGET_FORMAT;
24
25// ── RenderTarget ──────────────────────────────────────────────────────────────
26
27/// An off-screen GPU render target.
28///
29/// Wraps a `Rgba8Unorm` texture (single-sample, `RENDER_ATTACHMENT | COPY_SRC |
30/// TEXTURE_BINDING`) and an optional MSAA resolve texture.
31pub struct RenderTarget {
32    /// The single-sample backing texture (always `COPY_SRC` for readback).
33    pub color_texture: wgpu::Texture,
34    /// Default view over `color_texture`.
35    pub color_view: wgpu::TextureView,
36    /// Optional MSAA multisample texture view.  Present when `sample_count > 1`.
37    pub msaa_view: Option<wgpu::TextureView>,
38    /// Target width in physical pixels.
39    pub width: u32,
40    /// Target height in physical pixels.
41    pub height: u32,
42    /// Effective MSAA sample count (1 = no MSAA, 4 or 8 = MSAA).
43    pub sample_count: u32,
44    /// Whether the target content is considered dirty (needs re-render).
45    dirty: bool,
46}
47
48impl RenderTarget {
49    /// Create a new off-screen render target of `width × height` pixels with
50    /// the given MSAA `sample_count` (1 = no MSAA).
51    ///
52    /// If `sample_count > 1` but MSAA is not supported for `TARGET_FORMAT`
53    /// by the adapter, `sample_count` falls back to 1 silently.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`UiError::Unsupported`] if `width` or `height` is zero.
58    pub fn new(
59        device: &wgpu::Device,
60        width: u32,
61        height: u32,
62        sample_count: u32,
63    ) -> Result<Self, UiError> {
64        if width == 0 || height == 0 {
65            return Err(UiError::Unsupported(
66                "RenderTarget dimensions must be non-zero".to_string(),
67            ));
68        }
69        let sc = sample_count.max(1);
70
71        // Single-sample backing texture (COPY_SRC for readback, TEXTURE_BINDING
72        // for compositing).
73        let color_texture = device.create_texture(&wgpu::TextureDescriptor {
74            label: Some("oxiui-render-wgpu render-target backing"),
75            size: wgpu::Extent3d {
76                width,
77                height,
78                depth_or_array_layers: 1,
79            },
80            mip_level_count: 1,
81            sample_count: 1,
82            dimension: wgpu::TextureDimension::D2,
83            format: TARGET_FORMAT,
84            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
85                | wgpu::TextureUsages::COPY_SRC
86                | wgpu::TextureUsages::TEXTURE_BINDING,
87            view_formats: &[],
88        });
89        let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default());
90
91        // Optional MSAA texture — no COPY_SRC (can't read back a multisampled
92        // texture directly; the resolve into `color_texture` is the readback path).
93        let msaa_view = if sc > 1 {
94            let msaa_texture = device.create_texture(&wgpu::TextureDescriptor {
95                label: Some("oxiui-render-wgpu render-target msaa"),
96                size: wgpu::Extent3d {
97                    width,
98                    height,
99                    depth_or_array_layers: 1,
100                },
101                mip_level_count: 1,
102                sample_count: sc,
103                dimension: wgpu::TextureDimension::D2,
104                format: TARGET_FORMAT,
105                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
106                view_formats: &[],
107            });
108            Some(msaa_texture.create_view(&wgpu::TextureViewDescriptor::default()))
109        } else {
110            None
111        };
112
113        Ok(Self {
114            color_texture,
115            color_view,
116            msaa_view,
117            width,
118            height,
119            sample_count: sc,
120            dirty: true,
121        })
122    }
123
124    /// Create a new render target with no MSAA (sample_count=1).
125    ///
126    /// Convenience wrapper around [`RenderTarget::new`].
127    pub fn new_simple(device: &wgpu::Device, width: u32, height: u32) -> Result<Self, UiError> {
128        Self::new(device, width, height, 1)
129    }
130
131    /// Returns the colour attachment view and optional resolve target.
132    ///
133    /// Under MSAA: `(msaa_view, Some(&color_view))` — render into the MSAA
134    /// surface, resolve into the backing texture.
135    /// Under no MSAA: `(&color_view, None)` — render directly into the backing
136    /// texture.
137    pub fn color_attachment(&self) -> (&wgpu::TextureView, Option<&wgpu::TextureView>) {
138        match &self.msaa_view {
139            Some(msaa) => (msaa, Some(&self.color_view)),
140            None => (&self.color_view, None),
141        }
142    }
143
144    /// A view over the resolved (single-sample) backing texture.
145    ///
146    /// Use this view for compositing the render target into a parent pass or
147    /// for sampling in a shader.
148    pub fn texture_view(&self) -> &wgpu::TextureView {
149        &self.color_view
150    }
151
152    /// Mark the render target as dirty (needing re-render).
153    pub fn mark_dirty(&mut self) {
154        self.dirty = true;
155    }
156
157    /// Mark the render target as clean (up-to-date).
158    pub fn mark_clean(&mut self) {
159        self.dirty = false;
160    }
161
162    /// Return `true` if the content is stale and needs re-rendering.
163    pub fn is_dirty(&self) -> bool {
164        self.dirty
165    }
166
167    /// Read the render target's pixel contents back to CPU memory as a tightly
168    /// packed `width * height * 4` RGBA byte vector.
169    ///
170    /// Row padding (from `COPY_BYTES_PER_ROW_ALIGNMENT`) is stripped.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`UiError::Render`] if the GPU poll or buffer mapping fails.
175    pub fn readback_rgba(
176        &self,
177        device: &wgpu::Device,
178        queue: &wgpu::Queue,
179    ) -> Result<Vec<u8>, UiError> {
180        let unpadded_bytes_per_row = self.width * 4;
181        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
182        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
183        let buffer_size = (padded_bytes_per_row * self.height) as wgpu::BufferAddress;
184
185        let readback = device.create_buffer(&wgpu::BufferDescriptor {
186            label: Some("oxiui-render-wgpu render-target readback"),
187            size: buffer_size,
188            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
189            mapped_at_creation: false,
190        });
191
192        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
193            label: Some("oxiui-render-wgpu render-target readback encoder"),
194        });
195
196        encoder.copy_texture_to_buffer(
197            wgpu::TexelCopyTextureInfo {
198                texture: &self.color_texture,
199                mip_level: 0,
200                origin: wgpu::Origin3d::ZERO,
201                aspect: wgpu::TextureAspect::All,
202            },
203            wgpu::TexelCopyBufferInfo {
204                buffer: &readback,
205                layout: wgpu::TexelCopyBufferLayout {
206                    offset: 0,
207                    bytes_per_row: Some(padded_bytes_per_row),
208                    rows_per_image: Some(self.height),
209                },
210            },
211            wgpu::Extent3d {
212                width: self.width,
213                height: self.height,
214                depth_or_array_layers: 1,
215            },
216        );
217
218        queue.submit(Some(encoder.finish()));
219
220        let slice = readback.slice(..);
221        slice.map_async(wgpu::MapMode::Read, |_| {});
222        device
223            .poll(wgpu::PollType::wait_indefinitely())
224            .map_err(|e| UiError::Render(format!("RenderTarget GPU poll failed: {e:?}")))?;
225
226        let data = slice.get_mapped_range();
227        let mut out = Vec::with_capacity((unpadded_bytes_per_row * self.height) as usize);
228        for row in 0..self.height {
229            let start = (row * padded_bytes_per_row) as usize;
230            let end = start + unpadded_bytes_per_row as usize;
231            out.extend_from_slice(&data[start..end]);
232        }
233        drop(data);
234        readback.unmap();
235        Ok(out)
236    }
237
238    /// Resize the render target to new dimensions.
239    ///
240    /// All existing texture resources are dropped and recreated.  Any
241    /// previously-held views or samplers that refer to the old textures become
242    /// invalid.  The dirty flag is set to `true`.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`UiError::Unsupported`] if `new_width` or `new_height` is zero.
247    pub fn resize(
248        &mut self,
249        device: &wgpu::Device,
250        new_width: u32,
251        new_height: u32,
252    ) -> Result<(), UiError> {
253        *self = Self::new(device, new_width, new_height, self.sample_count)?;
254        Ok(())
255    }
256}
257
258// ── Tests ─────────────────────────────────────────────────────────────────────
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use oxiui_core::UiError;
264
265    fn try_device() -> Option<(wgpu::Device, wgpu::Queue)> {
266        let instance = wgpu::Instance::default();
267        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
268            power_preference: wgpu::PowerPreference::default(),
269            force_fallback_adapter: false,
270            compatible_surface: None,
271        }))
272        .ok()?;
273        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
274            label: Some("test device"),
275            required_features: wgpu::Features::empty(),
276            required_limits: wgpu::Limits::downlevel_defaults(),
277            memory_hints: wgpu::MemoryHints::Performance,
278            experimental_features: wgpu::ExperimentalFeatures::disabled(),
279            trace: wgpu::Trace::Off,
280        }))
281        .ok()
282    }
283
284    #[test]
285    fn render_target_zero_dimensions_fail() {
286        let Some((device, _queue)) = try_device() else {
287            return;
288        };
289        assert!(matches!(
290            RenderTarget::new(&device, 0, 64, 1),
291            Err(UiError::Unsupported(_))
292        ));
293        assert!(matches!(
294            RenderTarget::new(&device, 64, 0, 1),
295            Err(UiError::Unsupported(_))
296        ));
297    }
298
299    #[test]
300    fn render_target_creates_and_is_dirty() {
301        let Some((device, _queue)) = try_device() else {
302            return;
303        };
304        let rt = RenderTarget::new_simple(&device, 64, 32).expect("create render target");
305        assert_eq!(rt.width, 64);
306        assert_eq!(rt.height, 32);
307        assert_eq!(rt.sample_count, 1);
308        assert!(rt.is_dirty(), "fresh target must be dirty");
309    }
310
311    #[test]
312    fn render_target_dirty_flag_management() {
313        let Some((device, _queue)) = try_device() else {
314            return;
315        };
316        let mut rt = RenderTarget::new_simple(&device, 32, 32).expect("create");
317        assert!(rt.is_dirty());
318        rt.mark_clean();
319        assert!(!rt.is_dirty());
320        rt.mark_dirty();
321        assert!(rt.is_dirty());
322    }
323
324    #[test]
325    fn render_target_resize_resets_dirty() {
326        let Some((device, _queue)) = try_device() else {
327            return;
328        };
329        let mut rt = RenderTarget::new_simple(&device, 32, 32).expect("create");
330        rt.mark_clean();
331        assert!(!rt.is_dirty());
332        rt.resize(&device, 64, 64).expect("resize");
333        assert_eq!(rt.width, 64);
334        assert_eq!(rt.height, 64);
335        // After resize the target is fresh (dirty=true).
336        assert!(rt.is_dirty(), "resized target must be dirty");
337    }
338
339    #[test]
340    fn render_target_readback_all_transparent() {
341        // A freshly created render target (never drawn into) should read back
342        // as transparent black because the GPU memory may be zeroed.
343        // We only assert the buffer is the right size — actual pixel values
344        // depend on GPU memory initialization.
345        let Some((device, queue)) = try_device() else {
346            return;
347        };
348        let rt = RenderTarget::new_simple(&device, 16, 16).expect("create");
349        let buf = rt.readback_rgba(&device, &queue).expect("readback");
350        assert_eq!(
351            buf.len(),
352            (16 * 16 * 4) as usize,
353            "readback buffer must be tightly packed"
354        );
355    }
356}