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}