Skip to main content

rlx_core/render/
image_layer.rs

1//! One shell-supplied picture drawn over the frame, behind the non-default
2//! `text` feature beside the text layer it composites with.
3//!
4//! The shell hands the renderer an RGBA8 image with
5//! [`Renderer::set_overlay_image`](super::Renderer::set_overlay_image), which is
6//! the **only** upload: the bytes go into a texture the layer keeps, and every
7//! later frame that wants the picture queues a rectangle with
8//! [`Renderer::queue_image`](super::Renderer::queue_image), the way it queues
9//! [`TextRun`](super::TextRun)s. The draw rides the pass that draws the text,
10//! before the text, so a label can sit on top of the picture.
11//!
12//! **A renderer that never sets an image holds no GPU object for this layer.**
13//! The pipeline, sampler, rectangle uniform, texture and bind group are all
14//! built on the first `set`, so the cost of the layer while unused is one
15//! `Option` test per frame. A frame that sets an image and queues nothing
16//! encodes no draw; a frame that only queues writes no texture and, while the
17//! rectangle is unchanged, writes nothing at all.
18//!
19//! **The texture is `Rgba8UnormSrgb`, the format a headless capture reads back
20//! from**, so bytes a capture wrote are sRGB-encoded exactly as this texture
21//! expects: sampling decodes them to linear light and the sRGB surface encodes
22//! them again on the way out, and a picture drawn at its own size reads back as
23//! the bytes it was set from. An `Rgba8Unorm` texture here would apply the
24//! surface's encode to bytes that already carry it, and the picture would come
25//! out washed pale.
26
27// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set). The
28// prepare and draw run every displayed frame while an image is queued; a panic
29// here is a visible crash.
30#![deny(
31    clippy::unwrap_used,
32    clippy::expect_used,
33    clippy::indexing_slicing,
34    clippy::panic,
35    clippy::unreachable
36)]
37
38use super::gpu;
39
40/// The picture's texture format. See the module docs: it has to be the format
41/// the capture path's bytes are encoded in, or the colour step runs twice.
42pub(crate) const IMAGE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
43
44/// An image the shell hands the renderer: `width * height` pixels of RGBA8,
45/// rows top first, no row padding — a
46/// [`CaptureImage`](super::CaptureImage)'s own layout. The bytes are copied into
47/// a texture; the borrow ends with the call.
48pub struct OverlayImage<'a> {
49    /// `width * height * 4` bytes.
50    pub rgba: &'a [u8],
51    /// Width in pixels.
52    pub width: u32,
53    /// Height in pixels.
54    pub height: u32,
55}
56
57/// Where the set image is drawn this frame, in device pixels from the target's
58/// top-left — the coordinate space [`TextRun`](super::TextRun) uses. The image
59/// is stretched to fill it; the caller chooses a rectangle of the image's own
60/// aspect if it wants none.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct ImageRect {
63    /// Left edge.
64    pub x: f32,
65    /// Top edge.
66    pub y: f32,
67    /// Width.
68    pub w: f32,
69    /// Height.
70    pub h: f32,
71}
72
73/// Why [`Renderer::set_overlay_image`](super::Renderer::set_overlay_image)
74/// refused an image. Nothing changes on a refusal: an image already set stays
75/// set.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum OverlayImageError {
78    /// A dimension is zero, or larger than the adapter's 2D texture limit.
79    Size {
80        /// The width asked for.
81        width: u32,
82        /// The height asked for.
83        height: u32,
84    },
85    /// The byte count is not `width * height * 4`.
86    Length {
87        /// `width * height * 4`.
88        expected: usize,
89        /// The bytes handed over.
90        got: usize,
91    },
92}
93
94impl std::fmt::Display for OverlayImageError {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Self::Size { width, height } => {
98                write!(f, "an overlay image cannot be {width}x{height}")
99            }
100            Self::Length { expected, got } => {
101                write!(f, "an overlay image needs {expected} bytes, got {got}")
102            }
103        }
104    }
105}
106
107impl std::error::Error for OverlayImageError {}
108
109/// The quad's shader. `rect` is `(x0, y0, x1, y1)` in NDC with `y0` the top
110/// edge, and `uv` runs `0..1` across the quad with `v = 0` at the top — which is
111/// also the texture's first row, so no flip is applied anywhere.
112const IMAGE_WGSL: &str = r#"
113@group(0) @binding(0) var src: texture_2d<f32>;
114@group(0) @binding(1) var samp: sampler;
115struct Rect { ndc: vec4<f32> };
116@group(0) @binding(2) var<uniform> rect: Rect;
117
118struct VsOut {
119    @builtin(position) pos: vec4<f32>,
120    @location(0) uv: vec2<f32>,
121};
122
123@vertex
124fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
125    var corners = array<vec2<f32>, 6>(
126        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
127        vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
128    );
129    let c = corners[vi];
130    var out: VsOut;
131    out.pos = vec4<f32>(
132        mix(rect.ndc.x, rect.ndc.z, c.x),
133        mix(rect.ndc.y, rect.ndc.w, c.y),
134        0.0,
135        1.0,
136    );
137    out.uv = c;
138    return out;
139}
140
141@fragment
142fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
143    return vec4<f32>(textureSample(src, samp, in.uv).rgb, 1.0);
144}
145"#;
146
147/// The parts that do not depend on the image: built on the first `set` and kept
148/// for the renderer's life.
149struct Pipe {
150    pipeline: wgpu::RenderPipeline,
151    layout: wgpu::BindGroupLayout,
152    sampler: wgpu::Sampler,
153    rect: wgpu::Buffer,
154}
155
156impl Pipe {
157    fn new(device: &wgpu::Device, target: wgpu::TextureFormat) -> Self {
158        // Texture, sampler, then a vertex-visible uniform: a shape no other
159        // layout in `core/src` has (ADR-0058), which the tonemap tests'
160        // enumeration holds.
161        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
162            label: Some("rlx-overlay-image-layout"),
163            entries: &[
164                gpu::texture(0, true),
165                gpu::sampler(1),
166                gpu::uniform(2, wgpu::ShaderStages::VERTEX),
167            ],
168        });
169        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
170            label: Some("rlx-overlay-image"),
171            source: wgpu::ShaderSource::Wgsl(IMAGE_WGSL.into()),
172        });
173        let pipeline = gpu::fullscreen_pipeline(
174            device,
175            &shader,
176            &[&layout],
177            target,
178            wgpu::BlendState::REPLACE,
179            "rlx-overlay-image",
180        );
181        Self {
182            pipeline,
183            layout,
184            sampler: device.create_sampler(&wgpu::SamplerDescriptor {
185                label: Some("rlx-overlay-image-sampler"),
186                // Linear, clamped: a picture scaled to a pane is resampled,
187                // and at its own size every sample lands on a texel centre, so
188                // the filter changes nothing there.
189                mag_filter: wgpu::FilterMode::Linear,
190                min_filter: wgpu::FilterMode::Linear,
191                ..Default::default()
192            }),
193            rect: gpu::uniform_buffer(
194                device,
195                "rlx-overlay-image-rect",
196                std::mem::size_of::<[f32; 4]>(),
197            ),
198        }
199    }
200}
201
202/// The uploaded picture: its texture, and the bind group built against it.
203struct Uploaded {
204    texture: wgpu::Texture,
205    group: wgpu::BindGroup,
206    size: (u32, u32),
207}
208
209/// What the layer has done since it was built — read by the tests that hold the
210/// "setting is the only upload" claim.
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
212pub(crate) struct ImageLayerCounts {
213    /// Textures created. Moves only when the image's dimensions change.
214    pub(crate) textures: u32,
215    /// `write_texture` calls. Moves only on a `set`.
216    pub(crate) uploads: u32,
217    /// Writes of the rectangle uniform. Moves only when the rectangle, in the
218    /// target's NDC, differs from the last one written.
219    pub(crate) rect_writes: u32,
220}
221
222/// The layer: nothing until the first image is set, then a pipeline and one
223/// texture. One instance per [`super::Renderer`].
224pub(crate) struct ImageLayer {
225    /// The format the draw writes — the surface's, like the text layer's.
226    target: wgpu::TextureFormat,
227    pipe: Option<Pipe>,
228    image: Option<Uploaded>,
229    /// This frame's rectangle, taken by [`prepare`](Self::prepare).
230    queued: Option<ImageRect>,
231    /// The NDC rectangle last written to the uniform, so an unchanged one is
232    /// not written again.
233    written: Option<[f32; 4]>,
234    /// Whether the last `prepare` left something for [`render`](Self::render).
235    ready: bool,
236    counts: ImageLayerCounts,
237}
238
239impl ImageLayer {
240    /// An empty layer drawing into `target`. Creates no GPU object.
241    pub(crate) fn new(target: wgpu::TextureFormat) -> Self {
242        Self {
243            target,
244            pipe: None,
245            image: None,
246            queued: None,
247            written: None,
248            ready: false,
249            counts: ImageLayerCounts::default(),
250        }
251    }
252
253    /// Whether any GPU object of this layer exists.
254    #[cfg(test)]
255    pub(crate) fn built(&self) -> bool {
256        self.pipe.is_some() || self.image.is_some()
257    }
258
259    #[cfg(test)]
260    pub(crate) fn counts(&self) -> ImageLayerCounts {
261        self.counts
262    }
263
264    /// The set image's size, or `None` when none is set.
265    pub(crate) fn size(&self) -> Option<(u32, u32)> {
266        self.image.as_ref().map(|image| image.size)
267    }
268
269    /// Set the picture, or clear it with `None`.
270    ///
271    /// The bytes are validated here, once, at the boundary: a refused image
272    /// changes nothing. A texture is created only when there is none or the
273    /// dimensions differ from the one held; otherwise the bytes are written into
274    /// the existing texture, and its bind group stays valid.
275    pub(crate) fn set(
276        &mut self,
277        device: &wgpu::Device,
278        queue: &wgpu::Queue,
279        image: Option<OverlayImage<'_>>,
280    ) -> Result<(), OverlayImageError> {
281        let Some(image) = image else {
282            self.image = None;
283            return Ok(());
284        };
285        let (width, height) = (image.width, image.height);
286        let limit = device.limits().max_texture_dimension_2d;
287        if width == 0 || height == 0 || width > limit || height > limit {
288            return Err(OverlayImageError::Size { width, height });
289        }
290        let expected = width as usize * height as usize * 4;
291        if image.rgba.len() != expected {
292            return Err(OverlayImageError::Length {
293                expected,
294                got: image.rgba.len(),
295            });
296        }
297
298        let target = self.target;
299        let pipe = self.pipe.get_or_insert_with(|| Pipe::new(device, target));
300        if self
301            .image
302            .as_ref()
303            .is_none_or(|held| held.size != (width, height))
304        {
305            let texture = device.create_texture(&wgpu::TextureDescriptor {
306                label: Some("rlx-overlay-image-texture"),
307                size: wgpu::Extent3d {
308                    width,
309                    height,
310                    depth_or_array_layers: 1,
311                },
312                mip_level_count: 1,
313                sample_count: 1,
314                dimension: wgpu::TextureDimension::D2,
315                format: IMAGE_FORMAT,
316                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
317                view_formats: &[],
318            });
319            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
320            let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
321                label: Some("rlx-overlay-image-group"),
322                layout: &pipe.layout,
323                entries: &[
324                    wgpu::BindGroupEntry {
325                        binding: 0,
326                        resource: wgpu::BindingResource::TextureView(&view),
327                    },
328                    wgpu::BindGroupEntry {
329                        binding: 1,
330                        resource: wgpu::BindingResource::Sampler(&pipe.sampler),
331                    },
332                    wgpu::BindGroupEntry {
333                        binding: 2,
334                        resource: pipe.rect.as_entire_binding(),
335                    },
336                ],
337            });
338            self.image = Some(Uploaded {
339                texture,
340                group,
341                size: (width, height),
342            });
343            self.counts.textures = self.counts.textures.saturating_add(1);
344        }
345        if let Some(held) = self.image.as_ref() {
346            queue.write_texture(
347                wgpu::TexelCopyTextureInfo {
348                    texture: &held.texture,
349                    mip_level: 0,
350                    origin: wgpu::Origin3d::ZERO,
351                    aspect: wgpu::TextureAspect::All,
352                },
353                image.rgba,
354                wgpu::TexelCopyBufferLayout {
355                    offset: 0,
356                    bytes_per_row: Some(width * 4),
357                    rows_per_image: Some(height),
358                },
359                wgpu::Extent3d {
360                    width,
361                    height,
362                    depth_or_array_layers: 1,
363                },
364            );
365            self.counts.uploads = self.counts.uploads.saturating_add(1);
366        }
367        Ok(())
368    }
369
370    /// Draw the set image into `rect` on the next frame. Replaces a rectangle
371    /// already queued; a frame consumes it, so a picture wanted on every frame
372    /// is queued on every frame, as text is.
373    pub(crate) fn queue(&mut self, rect: ImageRect) {
374        self.queued = Some(rect);
375    }
376
377    /// Take this frame's rectangle and, if there is an image to put in it,
378    /// write the rectangle for a `width`x`height` target. Returns whether
379    /// [`render`](Self::render) will draw.
380    ///
381    /// A rectangle with no area, or one that is not finite, draws nothing.
382    pub(crate) fn prepare(&mut self, queue: &wgpu::Queue, width: u32, height: u32) -> bool {
383        self.ready = false;
384        let Some(rect) = self.queued.take() else {
385            return false;
386        };
387        let (Some(pipe), Some(_)) = (self.pipe.as_ref(), self.image.as_ref()) else {
388            return false;
389        };
390        let finite = [rect.x, rect.y, rect.w, rect.h]
391            .iter()
392            .all(|v| v.is_finite());
393        if !finite || rect.w <= 0.0 || rect.h <= 0.0 {
394            return false;
395        }
396        let ndc = to_ndc(rect, width, height);
397        if self.written != Some(ndc) {
398            queue.write_buffer(&pipe.rect, 0, bytemuck::cast_slice(&ndc));
399            self.written = Some(ndc);
400            self.counts.rect_writes = self.counts.rect_writes.saturating_add(1);
401        }
402        self.ready = true;
403        true
404    }
405
406    /// Draw the prepared quad into `pass`. A no-op unless `prepare` said it
407    /// would draw.
408    pub(crate) fn render(&self, pass: &mut wgpu::RenderPass<'_>) {
409        if !self.ready {
410            return;
411        }
412        let (Some(pipe), Some(image)) = (self.pipe.as_ref(), self.image.as_ref()) else {
413            return;
414        };
415        pass.set_pipeline(&pipe.pipeline);
416        pass.set_bind_group(0, &image.group, &[]);
417        pass.draw(0..6, 0..1);
418    }
419}
420
421/// `rect`, in device pixels of a `width`x`height` target, as
422/// `(x0, y0, x1, y1)` in NDC with `y0` the top edge.
423fn to_ndc(rect: ImageRect, width: u32, height: u32) -> [f32; 4] {
424    let (w, h) = (width.max(1) as f32, height.max(1) as f32);
425    [
426        rect.x / w * 2.0 - 1.0,
427        1.0 - rect.y / h * 2.0,
428        (rect.x + rect.w) / w * 2.0 - 1.0,
429        1.0 - (rect.y + rect.h) / h * 2.0,
430    ]
431}