Skip to main content

wisp/texture/
render_texture.rs

1//! `RenderTexture` — texture configured as a render target with a pixel-readback path.
2//!
3//! Used by:
4//! - the export pipeline to render a scene to disk-bound bytes (M0.21);
5//! - filter passes (M0.16) that ping-pong between two render targets;
6//! - integration tests that need to verify rendered output by inspecting pixels.
7
8use std::sync::Arc;
9
10use crate::application::Application;
11use crate::texture::Texture;
12
13/// GPU texture configured for rendering into and reading back.
14///
15/// Usage flags: `RENDER_ATTACHMENT | COPY_SRC | TEXTURE_BINDING`.
16#[derive(Clone)]
17pub struct RenderTexture {
18    inner: Arc<RenderTextureInner>,
19}
20
21struct RenderTextureInner {
22    texture: wgpu::Texture,
23    view: wgpu::TextureView,
24    sampler: wgpu::Sampler,
25    width: u32,
26    height: u32,
27    format: wgpu::TextureFormat,
28}
29
30impl RenderTexture {
31    /// Allocate a new `RenderTexture` of the given dimensions in `Rgba8UnormSrgb`.
32    ///
33    /// Use [`RenderTexture::with_format`] for other formats.
34    #[must_use]
35    pub fn new(app: &Application, width: u32, height: u32) -> Self {
36        Self::with_format(app, width, height, wgpu::TextureFormat::Rgba8UnormSrgb)
37    }
38
39    /// Allocate with an explicit color format.
40    #[must_use]
41    pub fn with_format(
42        app: &Application,
43        width: u32,
44        height: u32,
45        format: wgpu::TextureFormat,
46    ) -> Self {
47        let device = app.device();
48        let texture = device.create_texture(&wgpu::TextureDescriptor {
49            label: Some("wisp::RenderTexture"),
50            size: wgpu::Extent3d {
51                width,
52                height,
53                depth_or_array_layers: 1,
54            },
55            mip_level_count: 1,
56            sample_count: 1,
57            dimension: wgpu::TextureDimension::D2,
58            format,
59            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
60                | wgpu::TextureUsages::COPY_SRC
61                | wgpu::TextureUsages::TEXTURE_BINDING,
62            view_formats: &[],
63        });
64        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
65        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
66            label: Some("wisp::RenderTexture sampler"),
67            mag_filter: wgpu::FilterMode::Linear,
68            min_filter: wgpu::FilterMode::Linear,
69            ..Default::default()
70        });
71        Self {
72            inner: Arc::new(RenderTextureInner {
73                texture,
74                view,
75                sampler,
76                width,
77                height,
78                format,
79            }),
80        }
81    }
82
83    /// `wgpu::TextureView` suitable for use as a render-pass color attachment.
84    #[must_use]
85    pub fn view(&self) -> &wgpu::TextureView {
86        &self.inner.view
87    }
88
89    /// Sampler for downstream passes that read from this render target.
90    #[must_use]
91    pub fn sampler(&self) -> &wgpu::Sampler {
92        &self.inner.sampler
93    }
94
95    /// Color format.
96    #[must_use]
97    pub fn format(&self) -> wgpu::TextureFormat {
98        self.inner.format
99    }
100
101    /// Width in pixels.
102    #[must_use]
103    pub fn width(&self) -> u32 {
104        self.inner.width
105    }
106
107    /// Height in pixels.
108    #[must_use]
109    pub fn height(&self) -> u32 {
110        self.inner.height
111    }
112
113    /// Wrap this render target as a sampled [`Texture`] for use in
114    /// the sprite pipeline.
115    ///
116    /// Shares the underlying wgpu resources — no GPU copy. The render
117    /// texture's `TEXTURE_BINDING` usage flag is already set, so
118    /// sampling is well-defined.
119    ///
120    /// Useful for compositing text/mask/filter outputs into a scene:
121    /// render into a `RenderTexture`, then `as_texture()` + attach to
122    /// a [`crate::scene::Sprite`].
123    #[must_use]
124    pub fn as_texture(&self) -> Texture {
125        Texture::from_render_texture_parts(
126            self.inner.texture.clone(),
127            self.inner.view.clone(),
128            self.inner.sampler.clone(),
129            self.inner.width,
130            self.inner.height,
131        )
132    }
133
134    /// Read back the rendered pixels as a tightly-packed RGBA8 buffer.
135    ///
136    /// For `Rgba8UnormSrgb` / `Bgra8UnormSrgb` formats the returned buffer is
137    /// `width * height * 4` bytes; row-padding from
138    /// `COPY_BYTES_PER_ROW_ALIGNMENT` is stripped before return.
139    ///
140    /// Blocks the current thread on `device.poll(Maintain::Wait)`.
141    #[must_use]
142    pub fn read_pixels(&self, app: &Application) -> Vec<u8> {
143        let device = app.device();
144        let queue = app.queue();
145        let unpadded_bytes_per_row = self.inner.width * 4;
146        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
147        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
148        let buffer_size = u64::from(padded_bytes_per_row) * u64::from(self.inner.height);
149
150        let staging = device.create_buffer(&wgpu::BufferDescriptor {
151            label: Some("wisp::RenderTexture readback"),
152            size: buffer_size,
153            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
154            mapped_at_creation: false,
155        });
156
157        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
158            label: Some("wisp::RenderTexture::read_pixels encoder"),
159        });
160        encoder.copy_texture_to_buffer(
161            wgpu::TexelCopyTextureInfo {
162                texture: &self.inner.texture,
163                mip_level: 0,
164                origin: wgpu::Origin3d::ZERO,
165                aspect: wgpu::TextureAspect::All,
166            },
167            wgpu::TexelCopyBufferInfo {
168                buffer: &staging,
169                layout: wgpu::TexelCopyBufferLayout {
170                    offset: 0,
171                    bytes_per_row: Some(padded_bytes_per_row),
172                    rows_per_image: Some(self.inner.height),
173                },
174            },
175            wgpu::Extent3d {
176                width: self.inner.width,
177                height: self.inner.height,
178                depth_or_array_layers: 1,
179            },
180        );
181        queue.submit(std::iter::once(encoder.finish()));
182
183        let (sender, receiver) = std::sync::mpsc::channel();
184        staging
185            .slice(..)
186            .map_async(wgpu::MapMode::Read, move |result| {
187                let _ = sender.send(result);
188            });
189        device.poll(wgpu::Maintain::Wait);
190        receiver
191            .recv()
192            .expect("readback channel dropped")
193            .expect("readback map failed");
194
195        let mapped = staging.slice(..).get_mapped_range();
196        let unpadded_size = (unpadded_bytes_per_row as usize) * (self.inner.height as usize);
197        let mut output = Vec::with_capacity(unpadded_size);
198        for row in 0..self.inner.height {
199            let start = (row * padded_bytes_per_row) as usize;
200            let end = start + unpadded_bytes_per_row as usize;
201            output.extend_from_slice(&mapped[start..end]);
202        }
203        drop(mapped);
204        staging.unmap();
205        output
206    }
207}
208
209impl std::fmt::Debug for RenderTexture {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.debug_struct("RenderTexture")
212            .field("width", &self.inner.width)
213            .field("height", &self.inner.height)
214            .field("format", &self.inner.format)
215            .finish()
216    }
217}