Skip to main content

alpha2_render/
readback.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2#[cfg(not(target_arch = "wasm32"))]
3use std::sync::mpsc;
4
5use crate::canary::{CANARY_BYTES, Canary};
6use crate::{
7    ReadbackFailure, RenderError, RenderSize, RenderedTexture, Renderer,
8    pipeline::{CachedPipeline, CachedYuvReadbackPipeline},
9    pool::{ReadbackBufferLease, TextureLease},
10    uniform::{UniformArenaLease, f32_bytes},
11};
12use alpha2_gpu::GpuContext;
13use alpha2_media::{ChromaLocation, ColorMatrix, PixelLayout, YuvEncoding};
14use alpha2_shaders::{READBACK_PACK_WGSL, RGB_TO_YUV420_WGSL};
15
16const RGBA8_BYTES_PER_PIXEL: u32 = 4;
17const RGBA16F_BYTES_PER_PIXEL: u32 = 8;
18const YUV420_PLANE_COUNT: u64 = 3;
19
20/// D3D12 texture-to-buffer copies must start on this; wgpu's DX12 workaround for an unaligned
21/// start pads rows and overwrites whatever follows.
22const PLANE_PLACEMENT_ALIGNMENT: u64 = 512;
23
24/// Where each plane of one I420 readback sits in its staging buffer.
25///
26/// Each plane starts on [`PLANE_PLACEMENT_ALIGNMENT`] so DX12's padded-copy workaround cannot
27/// overwrite the next plane.
28#[derive(Copy, Clone, Debug, Eq, PartialEq)]
29pub(crate) struct Yuv420StagingLayout {
30    luma_pitch: u32,
31    chroma_pitch: u32,
32    u_offset: u64,
33    v_offset: u64,
34    bytes: u64,
35}
36
37impl Yuv420StagingLayout {
38    pub(crate) fn new(size: RenderSize) -> Result<Self, RenderError> {
39        let luma_pitch = aligned_copy_row(size.width)?;
40        let chroma_pitch = aligned_copy_row(size.width / 2)?;
41        let region = |pitch: u32, rows: u32| u64::from(pitch).checked_mul(u64::from(rows));
42        let place = |end: u64| end.checked_next_multiple_of(PLANE_PLACEMENT_ALIGNMENT);
43        let chroma_bytes = region(chroma_pitch, size.height / 2)
44            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
45        let u_offset = region(luma_pitch, size.height)
46            .and_then(place)
47            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
48        let v_offset = u_offset
49            .checked_add(chroma_bytes)
50            .and_then(place)
51            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
52        let bytes = v_offset
53            .checked_add(chroma_bytes)
54            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
55        Ok(Self {
56            luma_pitch,
57            chroma_pitch,
58            u_offset,
59            v_offset,
60            bytes,
61        })
62    }
63
64    /// `(offset, pitch, width, rows)` in Y, U, V order.
65    fn planes(self, size: RenderSize) -> [(u64, u32, u32, u32); 3] {
66        let (chroma_width, chroma_rows) = (size.width / 2, size.height / 2);
67        [
68            (0, self.luma_pitch, size.width, size.height),
69            (self.u_offset, self.chroma_pitch, chroma_width, chroma_rows),
70            (self.v_offset, self.chroma_pitch, chroma_width, chroma_rows),
71        ]
72    }
73}
74
75/// Staging bytes of one planar YUV420 readback of `size`, including row padding, plane
76/// alignment and the 16-byte canary. A budget must reserve this per in-flight readback.
77///
78/// # Errors
79/// Returns [`RenderError::TextureReadback`] when the layout overflows.
80pub fn yuv420p_staging_bytes(size: RenderSize) -> Result<u64, RenderError> {
81    Yuv420StagingLayout::new(size).map(|layout| layout.bytes + CANARY_BYTES)
82}
83
84fn plane_rows(
85    mapped: &[u8],
86    (offset, pitch, width, rows): (u64, u32, u32, u32),
87) -> Result<std::iter::Take<std::slice::ChunksExact<'_, u8>>, RenderError> {
88    let offset = usize::try_from(offset)
89        .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
90    let pitch = usize::try_from(pitch)
91        .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
92    let width = usize::try_from(width)
93        .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
94    let rows =
95        usize::try_from(rows).map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
96    let region = pitch
97        .checked_mul(rows)
98        .and_then(|length| length.checked_add(offset))
99        .and_then(|end| mapped.get(offset..end))
100        .filter(|_| width <= pitch)
101        .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
102    Ok(region.chunks_exact(pitch).take(rows))
103}
104
105pub(crate) struct EncodedYuv420pReadback {
106    buffer: ReadbackBufferLease,
107    size: RenderSize,
108    layout: Yuv420StagingLayout,
109    staging_bytes: u64,
110    canary: Canary,
111    y_texture: TextureLease,
112    u_texture: TextureLease,
113    v_texture: TextureLease,
114    uniforms: UniformArenaLease,
115}
116
117impl EncodedYuv420pReadback {
118    pub(crate) fn flush_uniforms(&self, queue: &wgpu::Queue) {
119        self.uniforms.flush(queue);
120    }
121}
122
123impl Renderer {
124    /// Uploads tightly packed premultiplied sRGB RGBA8 pixels into a pooled upload texture.
125    ///
126    /// # Errors
127    /// [`RenderError::InvalidImageData`] when `pixels` is not four bytes per pixel.
128    pub fn upload_rgba8(
129        &self,
130        size: RenderSize,
131        pixels: &[u8],
132    ) -> Result<crate::UploadTexture, RenderError> {
133        self.write_rgba8(
134            size,
135            crate::SourcePlane {
136                bytes: pixels,
137                stride: size
138                    .width
139                    .checked_mul(RGBA8_BYTES_PER_PIXEL)
140                    .ok_or(RenderError::InvalidImageData)?,
141            },
142        )
143    }
144
145    /// Uploads tightly packed premultiplied linear RGBA half floats (BT.709, `1.0` = SDR white) for
146    /// [`Renderer::prepare_linear_presented`].
147    ///
148    /// # Errors
149    /// [`RenderError::InvalidImageData`] when `pixels` is not four halves per pixel.
150    pub fn upload_linear_rgba16f(
151        &self,
152        size: RenderSize,
153        pixels: &[u16],
154    ) -> Result<crate::UploadTexture, RenderError> {
155        // Avoids a frame-sized copy per frame on little-endian targets.
156        let bytes: std::borrow::Cow<'_, [u8]> = if cfg!(target_endian = "little") {
157            std::borrow::Cow::Borrowed(bytemuck::cast_slice(pixels))
158        } else {
159            std::borrow::Cow::Owned(pixels.iter().flat_map(|half| half.to_le_bytes()).collect())
160        };
161        self.write_linear_rgba16f(
162            size,
163            crate::SourcePlane {
164                bytes: &bytes,
165                stride: size
166                    .width
167                    .checked_mul(RGBA16F_BYTES_PER_PIXEL)
168                    .ok_or(RenderError::InvalidImageData)?,
169            },
170        )
171    }
172
173    /// Writes strided little-endian RGBA half-float rows into a pooled upload texture.
174    ///
175    /// # Errors
176    /// [`RenderError::InvalidImageData`] for a plane shorter than `size`.
177    pub fn write_linear_rgba16f(
178        &self,
179        size: RenderSize,
180        plane: crate::SourcePlane<'_>,
181    ) -> Result<crate::UploadTexture, RenderError> {
182        self.validate_size(size)?;
183        let texture = self.upload_textures.acquire(
184            self.gpu.device(),
185            size,
186            wgpu::TextureFormat::Rgba16Float,
187            "alpha2 linear RGBA upload texture",
188        );
189        self.write_plane(&texture, size, plane, RGBA16F_BYTES_PER_PIXEL)?;
190        Ok(texture)
191    }
192
193    pub(crate) fn write_rgba8(
194        &self,
195        size: RenderSize,
196        plane: crate::SourcePlane<'_>,
197    ) -> Result<crate::UploadTexture, RenderError> {
198        let texture = self.acquire_rgba8_upload_texture(size)?;
199        self.write_plane(&texture, size, plane, RGBA8_BYTES_PER_PIXEL)?;
200        Ok(texture)
201    }
202
203    fn write_plane(
204        &self,
205        texture: &crate::UploadTexture,
206        size: RenderSize,
207        plane: crate::SourcePlane<'_>,
208        bytes_per_pixel: u32,
209    ) -> Result<(), RenderError> {
210        let row_bytes = size
211            .width
212            .checked_mul(bytes_per_pixel)
213            .ok_or(RenderError::InvalidImageData)?;
214        let required = usize::try_from(plane.stride)
215            .ok()
216            .zip(usize::try_from(size.height).ok())
217            .and_then(|(stride, rows)| stride.checked_mul(rows));
218        if plane.stride < row_bytes || required != Some(plane.bytes.len()) {
219            return Err(RenderError::InvalidImageData);
220        }
221        self.gpu.queue().write_texture(
222            wgpu::TexelCopyTextureInfo {
223                texture: texture.texture(),
224                mip_level: 0,
225                origin: wgpu::Origin3d::ZERO,
226                aspect: wgpu::TextureAspect::All,
227            },
228            plane.bytes,
229            wgpu::TexelCopyBufferLayout {
230                offset: 0,
231                bytes_per_row: Some(plane.stride),
232                rows_per_image: Some(size.height),
233            },
234            wgpu::Extent3d {
235                width: size.width,
236                height: size.height,
237                depth_or_array_layers: 1,
238            },
239        );
240        Ok(())
241    }
242
243    /// Copies one RGBA8 render result into tightly packed premultiplied host memory.
244    ///
245    /// Blocks on the GPU, so native and test only; callers that cannot block use
246    /// [`Renderer::begin_rgba8_readback`]. Half-float intermediates are quantized to RGBA8 on the
247    /// GPU first.
248    ///
249    /// # Errors
250    /// [`RenderError::TextureReadback`] for another target format or a failed mapping.
251    #[cfg(not(target_arch = "wasm32"))]
252    pub fn read_rgba8(&self, target: &RenderedTexture) -> Result<Vec<u8>, RenderError> {
253        self.begin_rgba8_readback(target)?.wait()
254    }
255
256    /// Submits a readback copy and returns an unmapped handle.
257    ///
258    /// The copy is queued on return, so mapping can resolve on an event loop via
259    /// [`Rgba8Readback::map_async`]. Used for stills, thumbnails and goldens; exports use YUV or
260    /// NV12 readbacks instead.
261    ///
262    /// # Errors
263    /// [`RenderError::TextureReadback`] for an unsupported format or unrepresentable row layout.
264    pub fn begin_rgba8_readback(
265        &self,
266        target: &RenderedTexture,
267    ) -> Result<Rgba8Readback, RenderError> {
268        self.ensure_gpu_runs_work()?;
269        self.validate_size(target.size)?;
270        if !matches!(
271            target.format,
272            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba16Float
273        ) {
274            return Err(RenderError::TextureReadback(
275                ReadbackFailure::UnsupportedTarget,
276            ));
277        }
278
279        let unpadded_bytes_per_row = target
280            .size
281            .width
282            .checked_mul(RGBA8_BYTES_PER_PIXEL)
283            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
284        let alignment = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
285        let padded_bytes_per_row = unpadded_bytes_per_row
286            .checked_add(alignment - 1)
287            .map(|value| value / alignment)
288            .and_then(|rows| rows.checked_mul(alignment))
289            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
290        let image_bytes = u64::from(padded_bytes_per_row)
291            .checked_mul(u64::from(target.size.height))
292            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
293        // Rows, then the canary.
294        let buffer_size = image_bytes
295            .checked_add(CANARY_BYTES)
296            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
297        let mut encoder = self.command_encoder("alpha2 RGBA8 readback encoder");
298        let buffer =
299            self.readback_buffers
300                .acquire(self.gpu.device(), buffer_size, "alpha2 RGBA8 readback");
301        let packed_texture = self.encode_readback_pack(target, &mut encoder);
302        let copy_source = packed_texture
303            .as_ref()
304            .map_or(&target.texture, TextureLease::texture);
305        encoder.copy_texture_to_buffer(
306            wgpu::TexelCopyTextureInfo {
307                texture: copy_source,
308                mip_level: 0,
309                origin: wgpu::Origin3d::ZERO,
310                aspect: wgpu::TextureAspect::All,
311            },
312            wgpu::TexelCopyBufferInfo {
313                buffer: buffer.buffer(),
314                layout: wgpu::TexelCopyBufferLayout {
315                    offset: 0,
316                    bytes_per_row: Some(padded_bytes_per_row),
317                    rows_per_image: Some(target.size.height),
318                },
319            },
320            wgpu::Extent3d {
321                width: target.size.width,
322                height: target.size.height,
323                depth_or_array_layers: 1,
324            },
325        );
326        // Same encoder, after the copy, so it lands only if the copy ran.
327        let canary = self
328            .canaries
329            .encode(&self.gpu, &mut encoder, buffer.buffer(), image_bytes)?;
330        let command_buffer = encoder.finish();
331        #[cfg(not(target_arch = "wasm32"))]
332        let submission = self.submit([command_buffer])?;
333        #[cfg(target_arch = "wasm32")]
334        self.submit([command_buffer])?;
335        let render_passes = u64::from(packed_texture.is_some());
336        let copy_bytes =
337            u64::from(unpadded_bytes_per_row).saturating_mul(u64::from(target.size.height));
338        crate::diagnostics::record_readback_work(
339            &self.diagnostics,
340            render_passes,
341            1,
342            copy_bytes,
343            true,
344        );
345        Ok(Rgba8Readback {
346            buffer,
347            gpu: self.gpu.clone(),
348            size: target.size,
349            padded_bytes_per_row,
350            unpadded_bytes_per_row,
351            #[cfg(not(target_arch = "wasm32"))]
352            submission,
353            #[cfg(not(target_arch = "wasm32"))]
354            timeout: self.gpu_wait_timeout,
355            staging_bytes: buffer_size,
356            image_bytes,
357            canary,
358            mapping_requested: AtomicBool::new(false),
359        })
360    }
361
362    /// Converts a render result to 8-bit planar YUV420 on the GPU and submits an async readback.
363    ///
364    /// The size must be even. Chroma is filtered for the siting `encoding.chroma` names
365    /// ([`alpha2_media::VideoCodec::chroma_location`]).
366    ///
367    /// # Errors
368    /// [`RenderError::TextureReadback`] for unsupported formats, odd dimensions or unrepresentable
369    /// layouts.
370    pub fn begin_yuv420p_readback(
371        &self,
372        target: &RenderedTexture,
373        encoding: YuvEncoding,
374    ) -> Result<Yuv420pReadback, RenderError> {
375        let mut command_encoder = self.command_encoder("alpha2 YUV420P readback encoder");
376        let readback_work = self.encode_yuv420p_readback(target, encoding, &mut command_encoder)?;
377        readback_work.flush_uniforms(self.gpu.queue());
378        #[cfg(not(target_arch = "wasm32"))]
379        let submission = self.submit([command_encoder.finish()])?;
380        #[cfg(target_arch = "wasm32")]
381        self.submit([command_encoder.finish()])?;
382        Ok(self.complete_yuv420p_readback(
383            readback_work,
384            #[cfg(not(target_arch = "wasm32"))]
385            submission,
386            true,
387        ))
388    }
389
390    /// Reads a render result back as stored and packs it into `layout` on the CPU
391    /// ([`Yuv420pReadback::finish_into_planes`]), for outputs other than 8-bit 4:2:0.
392    ///
393    /// 10-bit output requires the `rgba16float` intermediate.
394    ///
395    /// # Errors
396    /// [`RenderError::TextureReadback`] for an unsupported format, a 10-bit layout over an 8-bit
397    /// target, or an unrepresentable row layout.
398    pub fn begin_layout_readback(
399        &self,
400        target: &RenderedTexture,
401        encoding: YuvEncoding,
402        layout: PixelLayout,
403    ) -> Result<Yuv420pReadback, RenderError> {
404        self.ensure_gpu_runs_work()?;
405        self.validate_size(target.size)?;
406        let texel_bytes = match target.format {
407            wgpu::TextureFormat::Rgba16Float => 8,
408            wgpu::TextureFormat::Rgba8Unorm
409                if layout.bit_depth == alpha2_media::BitDepth::Eight =>
410            {
411                4
412            }
413            _ => {
414                return Err(RenderError::TextureReadback(
415                    ReadbackFailure::UnsupportedTarget,
416                ));
417            }
418        };
419        let row_bytes = target
420            .size
421            .width
422            .checked_mul(texel_bytes)
423            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
424        let pitch = aligned_copy_row(row_bytes)?;
425        let image_bytes = u64::from(pitch)
426            .checked_mul(u64::from(target.size.height))
427            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
428        let buffer_size = image_bytes
429            .checked_add(CANARY_BYTES)
430            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
431        let mut encoder = self.command_encoder("alpha2 texel readback encoder");
432        let buffer =
433            self.readback_buffers
434                .acquire(self.gpu.device(), buffer_size, "alpha2 texel readback");
435        encoder.copy_texture_to_buffer(
436            wgpu::TexelCopyTextureInfo {
437                texture: &target.texture,
438                mip_level: 0,
439                origin: wgpu::Origin3d::ZERO,
440                aspect: wgpu::TextureAspect::All,
441            },
442            wgpu::TexelCopyBufferInfo {
443                buffer: buffer.buffer(),
444                layout: wgpu::TexelCopyBufferLayout {
445                    offset: 0,
446                    bytes_per_row: Some(pitch),
447                    rows_per_image: Some(target.size.height),
448                },
449            },
450            wgpu::Extent3d {
451                width: target.size.width,
452                height: target.size.height,
453                depth_or_array_layers: 1,
454            },
455        );
456        let canary = self
457            .canaries
458            .encode(&self.gpu, &mut encoder, buffer.buffer(), image_bytes)?;
459        #[cfg(not(target_arch = "wasm32"))]
460        let submission = self.submit([encoder.finish()])?;
461        #[cfg(target_arch = "wasm32")]
462        self.submit([encoder.finish()])?;
463        crate::diagnostics::record_readback_work(
464            &self.diagnostics,
465            0,
466            1,
467            u64::from(row_bytes).saturating_mul(u64::from(target.size.height)),
468            true,
469        );
470        Ok(Yuv420pReadback {
471            buffer,
472            gpu: self.gpu.clone(),
473            size: target.size,
474            layout: Yuv420StagingLayout {
475                luma_pitch: 0,
476                chroma_pitch: 0,
477                u_offset: 0,
478                v_offset: 0,
479                bytes: 0,
480            },
481            texels: Some(Box::new(TexelStaging {
482                format: target.format,
483                pitch,
484                bytes: image_bytes,
485                layout,
486                encoding,
487            })),
488            #[cfg(not(target_arch = "wasm32"))]
489            submission,
490            #[cfg(not(target_arch = "wasm32"))]
491            timeout: self.gpu_wait_timeout,
492            staging_bytes: buffer_size,
493            canary,
494            mapping_requested: AtomicBool::new(false),
495        })
496    }
497
498    pub(crate) fn encode_yuv420p_readback(
499        &self,
500        target: &RenderedTexture,
501        encoding: YuvEncoding,
502        encoder: &mut wgpu::CommandEncoder,
503    ) -> Result<EncodedYuv420pReadback, RenderError> {
504        self.ensure_gpu_runs_work()?;
505        self.validate_size(target.size)?;
506        if !matches!(
507            target.format,
508            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba16Float
509        ) || !target.size.width.is_multiple_of(2)
510            || !target.size.height.is_multiple_of(2)
511        {
512            return Err(RenderError::TextureReadback(
513                ReadbackFailure::UnsupportedTarget,
514            ));
515        }
516        let layout = Yuv420StagingLayout::new(target.size)?;
517        let uv_rows = target.size.height / 2;
518        let chroma_width = target.size.width / 2;
519        let buffer_size = layout.bytes + CANARY_BYTES;
520        let buffer = self.readback_buffers.acquire(
521            self.gpu.device(),
522            buffer_size,
523            "alpha2 YUV420P readback",
524        );
525        let y_texture = self.textures.acquire(
526            self.gpu.device(),
527            target.size,
528            wgpu::TextureFormat::R8Unorm,
529            "alpha2 GPU Y readback texture",
530        );
531        let uv_size = RenderSize {
532            width: chroma_width,
533            height: uv_rows,
534        };
535        let u_texture = self.textures.acquire(
536            self.gpu.device(),
537            uv_size,
538            wgpu::TextureFormat::R8Unorm,
539            "alpha2 GPU U readback texture",
540        );
541        let v_texture = self.textures.acquire(
542            self.gpu.device(),
543            uv_size,
544            wgpu::TextureFormat::R8Unorm,
545            "alpha2 GPU V readback texture",
546        );
547        let uniform_bytes = f32_bytes(yuv_uniform(encoding));
548        let mut uniforms = self.uniform_buffers.acquire(
549            self.gpu.device(),
550            u64::try_from(uniform_bytes.len()).unwrap_or(u64::MAX),
551        );
552        let uniform = uniforms.write(&uniform_bytes);
553        self.encode_yuv420_pack(
554            target,
555            [y_texture.view(), u_texture.view(), v_texture.view()],
556            encoder,
557            &uniforms,
558            uniform,
559        );
560        encode_plane_copy(
561            encoder,
562            y_texture.texture(),
563            buffer.buffer(),
564            0,
565            layout.luma_pitch,
566            target.size,
567        );
568        encode_plane_copy(
569            encoder,
570            u_texture.texture(),
571            buffer.buffer(),
572            layout.u_offset,
573            layout.chroma_pitch,
574            uv_size,
575        );
576        encode_plane_copy(
577            encoder,
578            v_texture.texture(),
579            buffer.buffer(),
580            layout.v_offset,
581            layout.chroma_pitch,
582            uv_size,
583        );
584        // Same encoder, after the plane copies. Not counted as a copy in diagnostics.
585        let canary = self
586            .canaries
587            .encode(&self.gpu, encoder, buffer.buffer(), layout.bytes)?;
588        Ok(EncodedYuv420pReadback {
589            buffer,
590            size: target.size,
591            layout,
592            staging_bytes: buffer_size,
593            canary,
594            y_texture,
595            u_texture,
596            v_texture,
597            uniforms,
598        })
599    }
600
601    pub(crate) fn complete_yuv420p_readback(
602        &self,
603        encoded: EncodedYuv420pReadback,
604        #[cfg(not(target_arch = "wasm32"))] submission: wgpu::SubmissionIndex,
605        standalone_submission: bool,
606    ) -> Yuv420pReadback {
607        let EncodedYuv420pReadback {
608            buffer,
609            size,
610            layout,
611            staging_bytes,
612            canary,
613            y_texture,
614            u_texture,
615            v_texture,
616            uniforms,
617        } = encoded;
618        uniforms.recycle();
619        drop((y_texture, u_texture, v_texture));
620        let texture_bytes = yuv420_tight_bytes(size).expect("validated YUV420 dimensions");
621        crate::diagnostics::record_readback_work(
622            &self.diagnostics,
623            2,
624            3,
625            texture_bytes,
626            standalone_submission,
627        );
628        Yuv420pReadback {
629            buffer,
630            gpu: self.gpu.clone(),
631            size,
632            layout,
633            texels: None,
634            #[cfg(not(target_arch = "wasm32"))]
635            submission,
636            #[cfg(not(target_arch = "wasm32"))]
637            timeout: self.gpu_wait_timeout,
638            staging_bytes,
639            canary,
640            mapping_requested: AtomicBool::new(false),
641        }
642    }
643
644    /// Packs one rendered frame into caller-owned NV12 plane textures.
645    ///
646    /// Same conversion as [`Self::begin_yuv420p_readback`] without the readback. wgpu allows only
647    /// `TEXTURE_BINDING` on `TextureFormat::NV12`, so the native layer copies these `R8Unorm` and
648    /// `Rg8Unorm` targets into the encoder's image afterwards.
649    ///
650    /// # Errors
651    /// [`RenderError::TextureReadback`] for an unaccepted target format or size,
652    /// [`RenderError::InvalidPlaneTarget`] for mismatched plane textures, and
653    /// [`RenderError::GpuWorkDropped`] once the device was found dropping work.
654    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
655    pub fn encode_nv12_pack(
656        &self,
657        target: &RenderedTexture,
658        planes: Nv12PlaneTargets<'_>,
659        encoding: YuvEncoding,
660        encoder: &mut wgpu::CommandEncoder,
661    ) -> Result<EncodedNv12Pack, RenderError> {
662        // A pack has no canary of its own; an earlier failed readback canary applies.
663        self.ensure_gpu_runs_work()?;
664        let encode = self.open_encode();
665        self.validate_size(target.size)?;
666        if !matches!(
667            target.format,
668            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba16Float
669        ) || !target.size.width.is_multiple_of(2)
670            || !target.size.height.is_multiple_of(2)
671        {
672            return Err(RenderError::TextureReadback(
673                ReadbackFailure::UnsupportedTarget,
674            ));
675        }
676        let chroma_size = RenderSize {
677            width: target.size.width / 2,
678            height: target.size.height / 2,
679        };
680        // Planes may belong to a hardware encoder, so check them here rather than hit a validation
681        // error.
682        let attachment = |texture: &wgpu::Texture| {
683            texture
684                .usage()
685                .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
686                && texture.mip_level_count() == 1
687                && texture.sample_count() == 1
688                && texture.depth_or_array_layers() == 1
689        };
690        if planes.luma.format() != wgpu::TextureFormat::R8Unorm
691            || planes.chroma.format() != wgpu::TextureFormat::Rg8Unorm
692            || planes.luma.width() != target.size.width
693            || planes.luma.height() != target.size.height
694            || planes.chroma.width() != chroma_size.width
695            || planes.chroma.height() != chroma_size.height
696            || !attachment(planes.luma)
697            || !attachment(planes.chroma)
698        {
699            return Err(RenderError::InvalidPlaneTarget);
700        }
701        let uniform_bytes = f32_bytes(yuv_uniform(encoding));
702        let mut uniforms = self.uniform_buffers.acquire(
703            self.gpu.device(),
704            u64::try_from(uniform_bytes.len()).unwrap_or(u64::MAX),
705        );
706        let uniform = uniforms.write(&uniform_bytes);
707        let cached = self.yuv_readback_pipeline();
708        let bind_group = self.yuv_pack_bind_group(target, cached, &uniforms, uniform);
709        let luma_view = crate::pool::default_view(planes.luma);
710        encode_yuv_render_pass(
711            encoder,
712            "alpha2 GPU NV12 Y pack pass",
713            &cached.y_pipeline,
714            &bind_group,
715            &luma_view,
716        );
717        let chroma_view = crate::pool::default_view(planes.chroma);
718        encode_yuv_render_pass(
719            encoder,
720            "alpha2 GPU NV12 UV pack pass",
721            &cached.uv_nv12_pipeline,
722            &bind_group,
723            &chroma_view,
724        );
725        // `write` fills only the host copy; the shader reads stale data without the flush.
726        uniforms.flush(self.gpu.queue());
727        // Keep the uniforms until the caller submits `encoder`, or the next call could overwrite
728        // them.
729        Ok(EncodedNv12Pack {
730            uniforms: Some(uniforms),
731            _encode: encode,
732        })
733    }
734
735    fn yuv_pack_bind_group(
736        &self,
737        target: &RenderedTexture,
738        cached: &CachedYuvReadbackPipeline,
739        uniforms: &crate::uniform::UniformArenaLease,
740        uniform: crate::uniform::UniformSlice,
741    ) -> wgpu::BindGroup {
742        self.gpu
743            .device()
744            .create_bind_group(&wgpu::BindGroupDescriptor {
745                label: Some("alpha2 RGB to YUV bind group"),
746                layout: &cached.bind_group_layout,
747                entries: &[
748                    wgpu::BindGroupEntry {
749                        binding: 0,
750                        resource: wgpu::BindingResource::TextureView(target.view()),
751                    },
752                    wgpu::BindGroupEntry {
753                        binding: 1,
754                        resource: uniforms.binding(uniform),
755                    },
756                ],
757            })
758    }
759
760    fn encode_yuv420_pack(
761        &self,
762        target: &RenderedTexture,
763        plane_views: [&wgpu::TextureView; 3],
764        encoder: &mut wgpu::CommandEncoder,
765        uniforms: &crate::uniform::UniformArenaLease,
766        uniform: crate::uniform::UniformSlice,
767    ) {
768        let cached = self.yuv_readback_pipeline();
769        let bind_group = self.yuv_pack_bind_group(target, cached, uniforms, uniform);
770        let [y_view, u_view, v_view] = plane_views;
771        encode_yuv_render_pass(
772            encoder,
773            "alpha2 GPU Y pack pass",
774            &cached.y_pipeline,
775            &bind_group,
776            y_view,
777        );
778        encode_yuv_render_pass_pair(
779            encoder,
780            "alpha2 GPU UV pack pass",
781            &cached.uv_pipeline,
782            &bind_group,
783            [u_view, v_view],
784        );
785    }
786
787    fn yuv_readback_pipeline(&self) -> &CachedYuvReadbackPipeline {
788        self.pipelines.yuv_readback.get_or_init(|| {
789            let bind_group_layout =
790                self.gpu
791                    .device()
792                    .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
793                        label: Some("alpha2 RGB to YUV layout"),
794                        entries: &[
795                            wgpu::BindGroupLayoutEntry {
796                                binding: 0,
797                                visibility: wgpu::ShaderStages::FRAGMENT,
798                                ty: wgpu::BindingType::Texture {
799                                    sample_type: wgpu::TextureSampleType::Float {
800                                        filterable: false,
801                                    },
802                                    view_dimension: wgpu::TextureViewDimension::D2,
803                                    multisampled: false,
804                                },
805                                count: None,
806                            },
807                            wgpu::BindGroupLayoutEntry {
808                                binding: 1,
809                                visibility: wgpu::ShaderStages::FRAGMENT,
810                                ty: wgpu::BindingType::Buffer {
811                                    ty: wgpu::BufferBindingType::Uniform,
812                                    has_dynamic_offset: false,
813                                    min_binding_size: None,
814                                },
815                                count: None,
816                            },
817                        ],
818                    });
819            let shader = self
820                .gpu
821                .device()
822                .create_shader_module(wgpu::ShaderModuleDescriptor {
823                    label: Some("alpha2 RGB to YUV shader"),
824                    source: wgpu::ShaderSource::Wgsl(RGB_TO_YUV420_WGSL.into()),
825                });
826            let layout =
827                self.gpu
828                    .device()
829                    .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
830                        label: Some("alpha2 RGB to YUV pipeline layout"),
831                        bind_group_layouts: &[Some(&bind_group_layout)],
832                        immediate_size: 0,
833                    });
834            CachedYuvReadbackPipeline {
835                y_pipeline: yuv_render_pipeline(
836                    self.gpu.device(),
837                    &layout,
838                    &shader,
839                    "y_fs",
840                    &[wgpu::TextureFormat::R8Unorm],
841                ),
842                uv_pipeline: yuv_render_pipeline(
843                    self.gpu.device(),
844                    &layout,
845                    &shader,
846                    "uv_fs",
847                    &[wgpu::TextureFormat::R8Unorm, wgpu::TextureFormat::R8Unorm],
848                ),
849                uv_nv12_pipeline: yuv_render_pipeline(
850                    self.gpu.device(),
851                    &layout,
852                    &shader,
853                    "uv_nv12_fs",
854                    &[wgpu::TextureFormat::Rg8Unorm],
855                ),
856                bind_group_layout,
857            }
858        })
859    }
860
861    fn encode_readback_pack(
862        &self,
863        target: &RenderedTexture,
864        encoder: &mut wgpu::CommandEncoder,
865    ) -> Option<TextureLease> {
866        if target.format != wgpu::TextureFormat::Rgba16Float {
867            return None;
868        }
869        let texture = self.textures.acquire(
870            self.gpu.device(),
871            target.size,
872            wgpu::TextureFormat::Rgba8Unorm,
873            "alpha2 GPU-packed RGBA8 readback texture",
874        );
875        let cached = self.readback_pack_pipeline();
876        let bind_group = self
877            .gpu
878            .device()
879            .create_bind_group(&wgpu::BindGroupDescriptor {
880                label: Some("alpha2 readback pack bind group"),
881                layout: &cached.bind_group_layout,
882                entries: &[wgpu::BindGroupEntry {
883                    binding: 0,
884                    resource: wgpu::BindingResource::TextureView(target.view()),
885                }],
886            });
887        {
888            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
889                label: Some("alpha2 GPU RGBA8 readback pack pass"),
890                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
891                    view: texture.view(),
892                    depth_slice: None,
893                    resolve_target: None,
894                    ops: wgpu::Operations {
895                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
896                        store: wgpu::StoreOp::Store,
897                    },
898                })],
899                depth_stencil_attachment: None,
900                timestamp_writes: None,
901                occlusion_query_set: None,
902                multiview_mask: None,
903            });
904            pass.set_pipeline(&cached.pipeline);
905            pass.set_bind_group(0, &bind_group, &[]);
906            pass.draw(0..3, 0..1);
907        }
908        Some(texture)
909    }
910
911    fn readback_pack_pipeline(&self) -> &CachedPipeline {
912        self.pipelines.readback_pack.get_or_init(|| {
913            let bind_group_layout =
914                self.gpu
915                    .device()
916                    .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
917                        label: Some("alpha2 readback pack layout"),
918                        entries: &[wgpu::BindGroupLayoutEntry {
919                            binding: 0,
920                            visibility: wgpu::ShaderStages::FRAGMENT,
921                            ty: wgpu::BindingType::Texture {
922                                sample_type: wgpu::TextureSampleType::Float { filterable: false },
923                                view_dimension: wgpu::TextureViewDimension::D2,
924                                multisampled: false,
925                            },
926                            count: None,
927                        }],
928                    });
929            let shader = self
930                .gpu
931                .device()
932                .create_shader_module(wgpu::ShaderModuleDescriptor {
933                    label: Some("alpha2 readback pack shader"),
934                    source: wgpu::ShaderSource::Wgsl(READBACK_PACK_WGSL.into()),
935                });
936            let layout =
937                self.gpu
938                    .device()
939                    .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
940                        label: Some("alpha2 readback pack pipeline layout"),
941                        bind_group_layouts: &[Some(&bind_group_layout)],
942                        immediate_size: 0,
943                    });
944            let pipeline =
945                self.gpu
946                    .device()
947                    .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
948                        label: Some("alpha2 readback pack pipeline"),
949                        layout: Some(&layout),
950                        vertex: wgpu::VertexState {
951                            module: &shader,
952                            entry_point: Some("vs"),
953                            buffers: &[],
954                            compilation_options: wgpu::PipelineCompilationOptions::default(),
955                        },
956                        fragment: Some(wgpu::FragmentState {
957                            module: &shader,
958                            entry_point: Some("fs"),
959                            targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
960                            compilation_options: wgpu::PipelineCompilationOptions::default(),
961                        }),
962                        primitive: wgpu::PrimitiveState::default(),
963                        depth_stencil: None,
964                        multisample: wgpu::MultisampleState::default(),
965                        multiview_mask: None,
966                        cache: None,
967                    });
968            CachedPipeline {
969                bind_group_layout,
970                pipeline,
971            }
972        })
973    }
974}
975
976/// A submitted texture readback whose buffer has not been mapped yet.
977///
978/// Owns its staging buffer, so it can move into an async browser task.
979#[derive(Debug)]
980pub struct Rgba8Readback {
981    buffer: ReadbackBufferLease,
982    gpu: GpuContext,
983    size: RenderSize,
984    padded_bytes_per_row: u32,
985    unpadded_bytes_per_row: u32,
986    #[cfg(not(target_arch = "wasm32"))]
987    submission: wgpu::SubmissionIndex,
988    #[cfg(not(target_arch = "wasm32"))]
989    timeout: std::time::Duration,
990    staging_bytes: u64,
991    image_bytes: u64,
992    canary: Canary,
993    mapping_requested: AtomicBool,
994}
995
996impl Rgba8Readback {
997    /// Test-only: expect a different canary, simulating a stale staging buffer.
998    #[cfg(test)]
999    pub(crate) fn expecting(mut self, canary: Canary) -> Self {
1000        self.canary = canary;
1001        self
1002    }
1003
1004    /// The pixel dimensions of the result.
1005    #[must_use]
1006    pub const fn size(&self) -> RenderSize {
1007        self.size
1008    }
1009
1010    /// Bytes held by the staging buffer.
1011    #[must_use]
1012    pub const fn staging_bytes(&self) -> u64 {
1013        self.staging_bytes
1014    }
1015
1016    /// Blocks a native caller until this readback is mapped, without draining the whole queue.
1017    ///
1018    /// # Errors
1019    /// [`RenderError::TextureReadback`] when mapping or device polling fails.
1020    #[cfg(not(target_arch = "wasm32"))]
1021    pub fn wait_ready(&self) -> Result<(), RenderError> {
1022        let (sender, receiver) = mpsc::channel();
1023        self.map_async(move |mapped| {
1024            let _ = sender.send(mapped);
1025        });
1026        self.gpu
1027            .device()
1028            .poll(wgpu::PollType::Wait {
1029                submission_index: Some(self.submission.clone()),
1030                timeout: Some(self.timeout),
1031            })
1032            .map_err(crate::error::poll_error)?;
1033        receiver
1034            .recv()
1035            .map_err(|_| RenderError::TextureReadback(ReadbackFailure::CallbackDropped))?
1036    }
1037
1038    /// Waits for this readback and returns tightly packed RGBA8 pixels.
1039    ///
1040    /// # Errors
1041    /// [`RenderError::TextureReadback`] when mapping, polling or row packing fails.
1042    #[cfg(not(target_arch = "wasm32"))]
1043    pub fn wait(self) -> Result<Vec<u8>, RenderError> {
1044        self.wait_ready()?;
1045        self.finish()
1046    }
1047
1048    /// Requests buffer mapping and reports readiness exactly once.
1049    ///
1050    /// A refused or repeated request reports a failure, which callers must treat as a failed frame.
1051    pub fn map_async(&self, on_ready: impl FnOnce(Result<(), RenderError>) + Send + 'static) {
1052        map_staging(&self.gpu, &self.buffer, &self.mapping_requested, on_ready);
1053    }
1054
1055    /// Polls backends that need it to resolve a mapping; browser `WebGPU` ignores this.
1056    pub fn poll(&self) {
1057        let _ = self.gpu.device().poll(wgpu::PollType::Poll);
1058    }
1059
1060    /// Packs the mapped rows into tightly packed premultiplied RGBA8.
1061    ///
1062    /// # Errors
1063    /// [`RenderError::TextureReadback`] when the buffer is not mapped, and
1064    /// [`RenderError::GpuWorkDropped`] when the GPU did not run the copy; the buffer is then not
1065    /// recycled and the renderer refuses further work.
1066    pub fn finish(self) -> Result<Vec<u8>, RenderError> {
1067        let Ok(view) = self.buffer.buffer().slice(..).get_mapped_range() else {
1068            // Unmapping an unmapped buffer is a validation error. Dropping the lease destroys the
1069            // buffer and ends any pending mapping.
1070            return Err(RenderError::TextureReadback(ReadbackFailure::NotMapped));
1071        };
1072        let image_bytes = usize::try_from(self.image_bytes)
1073            .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
1074        let checked = view
1075            .get(image_bytes..)
1076            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))
1077            .and_then(|found| self.canary.check(found));
1078        if let Err(error) = checked {
1079            drop(view);
1080            self.buffer.buffer().unmap();
1081            return Err(error);
1082        }
1083        let mapped = &view[..image_bytes];
1084        let padded_row = usize::try_from(self.padded_bytes_per_row)
1085            .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
1086        let unpadded_row = usize::try_from(self.unpadded_bytes_per_row)
1087            .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
1088        let packed = if padded_row == unpadded_row {
1089            mapped.to_vec()
1090        } else {
1091            let capacity = unpadded_row
1092                .checked_mul(
1093                    usize::try_from(self.size.height)
1094                        .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?,
1095                )
1096                .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
1097            let mut packed = Vec::with_capacity(capacity);
1098            for row in mapped.chunks_exact(padded_row) {
1099                packed.extend_from_slice(&row[..unpadded_row]);
1100            }
1101            packed
1102        };
1103        drop(view);
1104        self.buffer.buffer().unmap();
1105        self.buffer.recycle();
1106        Ok(packed)
1107    }
1108}
1109
1110/// GPU state an NV12 pack recorded into a caller's encoder still needs.
1111///
1112/// Drop it only after that encoder is submitted; dropping earlier lets a later renderer call
1113/// overwrite the pack's uniforms.
1114#[must_use = "hold the pack until its encoder is submitted"]
1115#[derive(Debug)]
1116#[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
1117pub struct EncodedNv12Pack {
1118    uniforms: Option<crate::uniform::UniformArenaLease>,
1119    /// Defers destruction of released pool resources until the pack is dropped.
1120    _encode: crate::retire::EncodeScope,
1121}
1122
1123impl Drop for EncodedNv12Pack {
1124    fn drop(&mut self) {
1125        if let Some(uniforms) = self.uniforms.take() {
1126            uniforms.recycle();
1127        }
1128    }
1129}
1130
1131/// Caller-owned NV12 plane textures for a readback-free pack.
1132///
1133/// `luma` is `R8Unorm` at the output size; `chroma` is `Rg8Unorm` at half size with U in red and
1134/// V in green.
1135#[derive(Clone, Copy, Debug)]
1136#[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
1137pub struct Nv12PlaneTargets<'a> {
1138    /// Full-size single-channel luma target.
1139    pub luma: &'a wgpu::Texture,
1140    /// Half-size two-channel interleaved chroma target.
1141    pub chroma: &'a wgpu::Texture,
1142}
1143
1144/// A caller-owned destination for one packed plane.
1145#[derive(Debug)]
1146pub struct PlaneTarget<'a> {
1147    pub(crate) bytes: &'a mut [u8],
1148    pub(crate) stride: usize,
1149}
1150
1151impl<'a> PlaneTarget<'a> {
1152    /// Describes plane memory whose rows start `stride` bytes apart.
1153    #[must_use]
1154    pub fn new(bytes: &'a mut [u8], stride: usize) -> Self {
1155        Self { bytes, stride }
1156    }
1157
1158    /// The plane memory.
1159    #[must_use]
1160    pub fn bytes(&self) -> &[u8] {
1161        self.bytes
1162    }
1163
1164    /// Reborrows the plane memory.
1165    pub fn reborrow(&mut self) -> PlaneTarget<'_> {
1166        PlaneTarget {
1167            bytes: self.bytes,
1168            stride: self.stride,
1169        }
1170    }
1171
1172    pub(crate) fn fits(&self, width: usize, height: usize) -> Result<(), RenderError> {
1173        if self.stride < width {
1174            return Err(RenderError::TextureReadback(
1175                ReadbackFailure::DestinationTooSmall,
1176            ));
1177        }
1178        // The last row needs only its visible width, as `FFmpeg` sizes a frame's final plane.
1179        let needed = self
1180            .stride
1181            .checked_mul(height.saturating_sub(1))
1182            .and_then(|rows| rows.checked_add(width))
1183            .ok_or(RenderError::TextureReadback(
1184                ReadbackFailure::DestinationTooSmall,
1185            ))?;
1186        if self.bytes.len() < needed {
1187            return Err(RenderError::TextureReadback(
1188                ReadbackFailure::DestinationTooSmall,
1189            ));
1190        }
1191        Ok(())
1192    }
1193
1194    fn row(&mut self, index: usize, width: usize) -> Result<&mut [u8], RenderError> {
1195        let start = index
1196            .checked_mul(self.stride)
1197            .ok_or(RenderError::TextureReadback(
1198                ReadbackFailure::DestinationTooSmall,
1199            ))?;
1200        let end = start
1201            .checked_add(width)
1202            .ok_or(RenderError::TextureReadback(
1203                ReadbackFailure::DestinationTooSmall,
1204            ))?;
1205        self.bytes
1206            .get_mut(start..end)
1207            .ok_or(RenderError::TextureReadback(
1208                ReadbackFailure::DestinationTooSmall,
1209            ))
1210    }
1211}
1212
1213/// Destinations for the three planes of one packed frame.
1214#[derive(Debug)]
1215pub struct Yuv420pTargets<'a> {
1216    /// Full-resolution luma.
1217    pub y: PlaneTarget<'a>,
1218    /// Half-resolution blue-difference chroma.
1219    pub u: PlaneTarget<'a>,
1220    /// Half-resolution red-difference chroma.
1221    pub v: PlaneTarget<'a>,
1222}
1223
1224/// Tightly packed planar YUV420 data produced by a GPU readback.
1225#[derive(Clone, Debug, Eq, PartialEq)]
1226pub struct Yuv420pPlanes {
1227    /// Full-resolution luma samples.
1228    pub y: Vec<u8>,
1229    /// Half-width, half-height blue-difference chroma samples.
1230    pub u: Vec<u8>,
1231    /// Half-width, half-height red-difference chroma samples.
1232    pub v: Vec<u8>,
1233}
1234
1235/// A submitted GPU RGB-to-YUV420 conversion whose buffer is not mapped yet.
1236#[derive(Debug)]
1237pub struct Yuv420pReadback {
1238    buffer: ReadbackBufferLease,
1239    gpu: GpuContext,
1240    size: RenderSize,
1241    layout: Yuv420StagingLayout,
1242    /// Set for a texel readback packed on the CPU ([`Renderer::begin_layout_readback`]); `layout`
1243    /// is unused then.
1244    texels: Option<Box<TexelStaging>>,
1245    #[cfg(not(target_arch = "wasm32"))]
1246    submission: wgpu::SubmissionIndex,
1247    #[cfg(not(target_arch = "wasm32"))]
1248    timeout: std::time::Duration,
1249    staging_bytes: u64,
1250    canary: Canary,
1251    mapping_requested: AtomicBool,
1252}
1253
1254impl Yuv420pReadback {
1255    /// The luma-plane dimensions.
1256    #[must_use]
1257    pub const fn size(&self) -> RenderSize {
1258        self.size
1259    }
1260
1261    /// Bytes held by the staging buffer.
1262    #[must_use]
1263    pub const fn staging_bytes(&self) -> u64 {
1264        self.staging_bytes
1265    }
1266
1267    /// Blocks a native caller until this readback is mapped.
1268    ///
1269    /// # Errors
1270    /// Returns [`RenderError::TextureReadback`] when mapping or polling fails.
1271    #[cfg(not(target_arch = "wasm32"))]
1272    pub fn wait_ready(&self) -> Result<(), RenderError> {
1273        let (sender, receiver) = mpsc::channel();
1274        self.map_async(move |mapped| {
1275            let _ = sender.send(mapped);
1276        });
1277        self.wait_for_submission()?;
1278        receiver
1279            .recv()
1280            .map_err(|_| RenderError::TextureReadback(ReadbackFailure::CallbackDropped))?
1281    }
1282
1283    /// Blocks until this readback's submission completes; may follow an earlier
1284    /// [`Self::map_async`].
1285    ///
1286    /// # Errors
1287    /// [`RenderError::GpuTimeout`] past the renderer's wait timeout, or
1288    /// [`RenderError::TextureReadback`] when device polling fails.
1289    #[cfg(not(target_arch = "wasm32"))]
1290    pub fn wait_for_submission(&self) -> Result<(), RenderError> {
1291        self.gpu
1292            .device()
1293            .poll(wgpu::PollType::Wait {
1294                submission_index: Some(self.submission.clone()),
1295                timeout: Some(self.timeout),
1296            })
1297            .map_err(crate::error::poll_error)?;
1298        Ok(())
1299    }
1300
1301    /// Waits for this readback and returns tightly packed planes.
1302    ///
1303    /// # Errors
1304    /// [`RenderError::TextureReadback`] when mapping, polling or row packing fails.
1305    #[cfg(not(target_arch = "wasm32"))]
1306    pub fn wait(self) -> Result<Yuv420pPlanes, RenderError> {
1307        self.wait_ready()?;
1308        self.finish()
1309    }
1310
1311    /// Requests buffer mapping and reports readiness exactly once.
1312    pub fn map_async(&self, on_ready: impl FnOnce(Result<(), RenderError>) + Send + 'static) {
1313        map_staging(&self.gpu, &self.buffer, &self.mapping_requested, on_ready);
1314    }
1315
1316    /// Polls backends that need it to resolve a mapping.
1317    pub fn poll(&self) {
1318        let _ = self.gpu.device().poll(wgpu::PollType::Poll);
1319    }
1320
1321    /// Packs mapped planes into caller-owned, possibly strided, plane buffers, avoiding the
1322    /// allocation and copy of [`Yuv420pReadback::finish`].
1323    ///
1324    /// # Errors [`RenderError::TextureReadback`] when the buffer is not mapped, the layout is
1325    /// unrepresentable or a target is too small, and [`RenderError::GpuWorkDropped`] when the GPU
1326    /// did not run the copy (the buffer is not recycled and the renderer refuses further work).
1327    pub fn finish_into(self, targets: &mut Yuv420pTargets<'_>) -> Result<(), RenderError> {
1328        if self.texels.is_some() {
1329            return Err(RenderError::TextureReadback(ReadbackFailure::Layout));
1330        }
1331        let Ok(mapped) = self.buffer.buffer().slice(..).get_mapped_range() else {
1332            // Not mapped, so nothing to unmap.
1333            return Err(RenderError::TextureReadback(ReadbackFailure::NotMapped));
1334        };
1335        let result = (|| {
1336            self.check_canary(&mapped)?;
1337            let planes = self.layout.planes(self.size);
1338            let destinations = [&mut targets.y, &mut targets.u, &mut targets.v];
1339            for (plane, target) in planes.into_iter().zip(destinations) {
1340                let (_, _, width, rows) = plane;
1341                let width = usize::try_from(width)
1342                    .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
1343                let rows = usize::try_from(rows)
1344                    .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
1345                target.fits(width, rows)?;
1346                for (index, row) in plane_rows(&mapped, plane)?.enumerate() {
1347                    target.row(index, width)?.copy_from_slice(&row[..width]);
1348                }
1349            }
1350            Ok(())
1351        })();
1352        drop(mapped);
1353        self.buffer.buffer().unmap();
1354        if result.is_ok() {
1355            self.buffer.recycle();
1356        }
1357        result
1358    }
1359
1360    /// Packs mapped planes into three tightly packed planes.
1361    ///
1362    /// # Errors
1363    /// As [`Self::finish_into`].
1364    pub fn finish(self) -> Result<Yuv420pPlanes, RenderError> {
1365        let planes = self.unpack(|[y, u, v]| Yuv420pPlanes { y, u, v })?;
1366        Ok(planes)
1367    }
1368
1369    /// Plane offsets and strides in the bytes [`Self::finish_mapped`] hands over.
1370    #[must_use]
1371    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
1372    pub fn plane_layout(&self) -> [StagingPlane; 3] {
1373        self.layout
1374            .planes(self.size)
1375            .map(|(offset, stride, _, _)| StagingPlane { offset, stride })
1376    }
1377
1378    /// Hands the mapped staging buffer to `read`, then unmaps it.
1379    ///
1380    /// Planes are at the offsets and strides [`Self::plane_layout`] reports, rows padded to the
1381    /// copy alignment. A strided consumer such as a `WebCodecs` `VideoFrame` can copy straight from
1382    /// the mapping; in a browser that copy can stay in JavaScript (`BufferView::as_uint8array`).
1383    ///
1384    /// # Errors
1385    /// As [`Self::finish_into`]; `read` is not called then.
1386    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
1387    pub fn finish_mapped<T>(
1388        self,
1389        read: impl FnOnce(&wgpu::BufferView) -> T,
1390    ) -> Result<T, RenderError> {
1391        let Ok(canary) = self
1392            .buffer
1393            .buffer()
1394            .slice(self.image_bytes()..)
1395            .get_mapped_range()
1396        else {
1397            // Not mapped, so nothing to unmap.
1398            return Err(RenderError::TextureReadback(ReadbackFailure::NotMapped));
1399        };
1400        let checked = self.canary.check(&canary);
1401        drop(canary);
1402        if let Err(error) = checked {
1403            self.buffer.buffer().unmap();
1404            return Err(error);
1405        }
1406        let Ok(mapped) = self
1407            .buffer
1408            .buffer()
1409            .slice(..self.image_bytes())
1410            .get_mapped_range()
1411        else {
1412            self.buffer.buffer().unmap();
1413            return Err(RenderError::TextureReadback(ReadbackFailure::NotMapped));
1414        };
1415        let result = read(&mapped);
1416        drop(mapped);
1417        self.buffer.buffer().unmap();
1418        self.buffer.recycle();
1419        Ok(result)
1420    }
1421
1422    /// Copies the three planes out tightly packed and hands them to `pack`.
1423    fn unpack<T>(self, pack: impl FnOnce([Vec<u8>; 3]) -> T) -> Result<T, RenderError> {
1424        if self.texels.is_some() {
1425            return Err(RenderError::TextureReadback(ReadbackFailure::Layout));
1426        }
1427        let Ok(mapped) = self.buffer.buffer().slice(..).get_mapped_range() else {
1428            // Not mapped, so nothing to unmap.
1429            return Err(RenderError::TextureReadback(ReadbackFailure::NotMapped));
1430        };
1431        if let Err(error) = self.check_canary(&mapped) {
1432            drop(mapped);
1433            self.buffer.buffer().unmap();
1434            return Err(error);
1435        }
1436        let planes = self.layout.planes(self.size).map(|plane| {
1437            let (_, _, width, rows) = plane;
1438            let width = usize::try_from(width)
1439                .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
1440            let rows = usize::try_from(rows)
1441                .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
1442            plane_rows(&mapped, plane).map(|source| {
1443                let mut tight = Vec::with_capacity(width * rows);
1444                for row in source {
1445                    tight.extend_from_slice(&row[..width]);
1446                }
1447                tight
1448            })
1449        });
1450        drop(mapped);
1451        self.buffer.buffer().unmap();
1452        let [y, u, v] = planes;
1453        let planes = [y?, u?, v?];
1454        self.buffer.recycle();
1455        Ok(pack(planes))
1456    }
1457
1458    /// Bytes before the canary.
1459    fn image_bytes(&self) -> u64 {
1460        self.texels
1461            .as_ref()
1462            .map_or(self.layout.bytes, |texels| texels.bytes)
1463    }
1464
1465    /// The layout a texel readback packs into, `None` for a GPU-packed 8-bit 4:2:0 readback.
1466    #[must_use]
1467    pub fn pixel_layout(&self) -> Option<PixelLayout> {
1468        self.texels.as_ref().map(|texels| texels.layout)
1469    }
1470
1471    /// Packs the mapped frame into one target per plane of its layout, in storage order
1472    /// ([`PixelLayout::planes`]).
1473    ///
1474    /// # Errors
1475    /// As [`Self::finish_into`], and [`RenderError::TextureReadback`] for a wrong target count.
1476    pub fn finish_into_planes(self, targets: &mut [PlaneTarget<'_>]) -> Result<(), RenderError> {
1477        let Some(texels) = self.texels.as_deref().cloned() else {
1478            let [y, u, v] = targets else {
1479                return Err(RenderError::TextureReadback(ReadbackFailure::Layout));
1480            };
1481            let mut planes = Yuv420pTargets {
1482                y: PlaneTarget::new(y.bytes, y.stride),
1483                u: PlaneTarget::new(u.bytes, u.stride),
1484                v: PlaneTarget::new(v.bytes, v.stride),
1485            };
1486            return self.finish_into(&mut planes);
1487        };
1488        let Ok(mapped) = self.buffer.buffer().slice(..).get_mapped_range() else {
1489            // Not mapped, so nothing to unmap.
1490            return Err(RenderError::TextureReadback(ReadbackFailure::NotMapped));
1491        };
1492        let result = (|| {
1493            self.check_canary(&mapped)?;
1494            let (width, height) = (self.size.width as usize, self.size.height as usize);
1495            let pitch = texels.pitch as usize;
1496            let rows = mapped
1497                .get(..pitch * height)
1498                .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
1499            let row = |y: usize, out: &mut [[f32; 4]]| {
1500                let texels_row = &rows[y * pitch..];
1501                decode_texels(texels.format, texels_row, &mut out[..width]);
1502            };
1503            let picture = crate::layout_pack::Picture {
1504                width,
1505                height,
1506                row: &row,
1507            };
1508            crate::layout_pack::pack(&picture, texels.encoding, texels.layout, targets)
1509        })();
1510        drop(mapped);
1511        self.buffer.buffer().unmap();
1512        if result.is_ok() {
1513            self.buffer.recycle();
1514        }
1515        result
1516    }
1517
1518    /// Checks that the mapped buffer ends in this readback's canary; a failing buffer is not
1519    /// recycled.
1520    fn check_canary(&self, mapped: &[u8]) -> Result<(), RenderError> {
1521        let start = usize::try_from(self.image_bytes())
1522            .map_err(|_| RenderError::TextureReadback(ReadbackFailure::Layout))?;
1523        let found = mapped
1524            .get(start..)
1525            .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))?;
1526        self.canary.check(found)
1527    }
1528}
1529
1530fn map_staging(
1531    gpu: &GpuContext,
1532    buffer: &ReadbackBufferLease,
1533    requested: &AtomicBool,
1534    on_ready: impl FnOnce(Result<(), RenderError>) + Send + 'static,
1535) {
1536    if requested.swap(true, Ordering::AcqRel) {
1537        on_ready(Err(RenderError::TextureReadback(
1538            ReadbackFailure::DuplicateMapRequest,
1539        )));
1540        return;
1541    }
1542    gpu.map_async(
1543        &buffer.buffer().slice(..),
1544        wgpu::MapMode::Read,
1545        move |result| {
1546            // `BufferAsyncError` names no cause; device diagnostics may explain it.
1547            on_ready(result.map_err(|_| RenderError::TextureReadback(ReadbackFailure::MapFailed)));
1548        },
1549    );
1550}
1551
1552#[derive(Clone, Debug)]
1553struct TexelStaging {
1554    format: wgpu::TextureFormat,
1555    pitch: u32,
1556    bytes: u64,
1557    layout: PixelLayout,
1558    encoding: YuvEncoding,
1559}
1560
1561fn decode_texels(format: wgpu::TextureFormat, row: &[u8], out: &mut [[f32; 4]]) {
1562    match format {
1563        wgpu::TextureFormat::Rgba16Float => {
1564            for (texel, bytes) in out.iter_mut().zip(row.as_chunks::<8>().0) {
1565                *texel = std::array::from_fn(|channel| {
1566                    crate::cpu::half_value(u16::from_le_bytes([
1567                        bytes[channel * 2],
1568                        bytes[channel * 2 + 1],
1569                    ]))
1570                });
1571            }
1572        }
1573        _ => {
1574            for (texel, bytes) in out.iter_mut().zip(row.as_chunks::<4>().0) {
1575                *texel = std::array::from_fn(|channel| crate::cpu::unorm8_value(bytes[channel]));
1576            }
1577        }
1578    }
1579}
1580
1581/// Where one plane sits in a mapped readback staging buffer.
1582#[derive(Copy, Clone, Debug, Eq, PartialEq)]
1583pub struct StagingPlane {
1584    /// Byte offset of the plane's first row.
1585    pub offset: u64,
1586    /// Bytes from the start of one row to the start of the next.
1587    pub stride: u32,
1588}
1589
1590fn aligned_copy_row(width: u32) -> Result<u32, RenderError> {
1591    let alignment = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
1592    width
1593        .checked_add(alignment - 1)
1594        .map(|value| value / alignment)
1595        .and_then(|rows| rows.checked_mul(alignment))
1596        .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))
1597}
1598
1599fn yuv420_tight_bytes(size: RenderSize) -> Result<u64, RenderError> {
1600    u64::from(size.width)
1601        .checked_mul(u64::from(size.height))
1602        .and_then(|luma| luma.checked_mul(YUV420_PLANE_COUNT))
1603        .map(|three_planes| three_planes / 2)
1604        .ok_or(RenderError::TextureReadback(ReadbackFailure::Layout))
1605}
1606
1607/// Pack-shader uniforms for `encoding`: luma weights, range levels and chroma co-siting axes,
1608/// from the same definitions import decodes with so a round trip is the identity.
1609pub(crate) fn yuv_uniform(encoding: YuvEncoding) -> [f32; 12] {
1610    let YuvEncoding { color, chroma } = encoding;
1611    // Components that are already RGB have no pack: they take BT.709's.
1612    let (kr, kb) = color
1613        .matrix
1614        .luma_weights()
1615        .or(ColorMatrix::Bt709.luma_weights())
1616        .expect("BT.709 is a YCbCr matrix");
1617    let levels = color.range.levels(8);
1618    let (cosited_x, cosited_y) = match chroma {
1619        ChromaLocation::Left => (true, false),
1620        ChromaLocation::TopLeft => (true, true),
1621        ChromaLocation::Unspecified | ChromaLocation::Center => (false, false),
1622    };
1623    [
1624        kr,
1625        1.0 - kr - kb,
1626        kb,
1627        0.0,
1628        levels.luma_black / levels.maximum,
1629        levels.luma_span / levels.maximum,
1630        levels.chroma_center / levels.maximum,
1631        levels.chroma_span / levels.maximum,
1632        f32::from(u8::from(cosited_x)),
1633        f32::from(u8::from(cosited_y)),
1634        0.0,
1635        0.0,
1636    ]
1637}
1638
1639fn yuv_render_pipeline(
1640    device: &wgpu::Device,
1641    layout: &wgpu::PipelineLayout,
1642    shader: &wgpu::ShaderModule,
1643    fragment_entry: &'static str,
1644    target_formats: &[wgpu::TextureFormat],
1645) -> wgpu::RenderPipeline {
1646    let targets = target_formats
1647        .iter()
1648        .copied()
1649        .map(|format| Some(format.into()))
1650        .collect::<Vec<_>>();
1651    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1652        label: Some("alpha2 RGB to YUV pipeline"),
1653        layout: Some(layout),
1654        vertex: wgpu::VertexState {
1655            module: shader,
1656            entry_point: Some("vs"),
1657            buffers: &[],
1658            compilation_options: wgpu::PipelineCompilationOptions::default(),
1659        },
1660        fragment: Some(wgpu::FragmentState {
1661            module: shader,
1662            entry_point: Some(fragment_entry),
1663            targets: &targets,
1664            compilation_options: wgpu::PipelineCompilationOptions::default(),
1665        }),
1666        primitive: wgpu::PrimitiveState::default(),
1667        depth_stencil: None,
1668        multisample: wgpu::MultisampleState::default(),
1669        multiview_mask: None,
1670        cache: None,
1671    })
1672}
1673
1674fn encode_yuv_render_pass(
1675    encoder: &mut wgpu::CommandEncoder,
1676    label: &'static str,
1677    pipeline: &wgpu::RenderPipeline,
1678    bind_group: &wgpu::BindGroup,
1679    target: &wgpu::TextureView,
1680) {
1681    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1682        label: Some(label),
1683        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1684            view: target,
1685            depth_slice: None,
1686            resolve_target: None,
1687            ops: wgpu::Operations {
1688                load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1689                store: wgpu::StoreOp::Store,
1690            },
1691        })],
1692        depth_stencil_attachment: None,
1693        timestamp_writes: None,
1694        occlusion_query_set: None,
1695        multiview_mask: None,
1696    });
1697    pass.set_pipeline(pipeline);
1698    pass.set_bind_group(0, bind_group, &[]);
1699    pass.draw(0..3, 0..1);
1700}
1701
1702fn encode_yuv_render_pass_pair(
1703    encoder: &mut wgpu::CommandEncoder,
1704    label: &'static str,
1705    pipeline: &wgpu::RenderPipeline,
1706    bind_group: &wgpu::BindGroup,
1707    targets: [&wgpu::TextureView; 2],
1708) {
1709    let attachments = targets.map(|target| {
1710        Some(wgpu::RenderPassColorAttachment {
1711            view: target,
1712            depth_slice: None,
1713            resolve_target: None,
1714            ops: wgpu::Operations {
1715                load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1716                store: wgpu::StoreOp::Store,
1717            },
1718        })
1719    });
1720    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1721        label: Some(label),
1722        color_attachments: &attachments,
1723        depth_stencil_attachment: None,
1724        timestamp_writes: None,
1725        occlusion_query_set: None,
1726        multiview_mask: None,
1727    });
1728    pass.set_pipeline(pipeline);
1729    pass.set_bind_group(0, bind_group, &[]);
1730    pass.draw(0..3, 0..1);
1731}
1732
1733fn encode_plane_copy(
1734    encoder: &mut wgpu::CommandEncoder,
1735    texture: &wgpu::Texture,
1736    buffer: &wgpu::Buffer,
1737    offset: u64,
1738    bytes_per_row: u32,
1739    size: RenderSize,
1740) {
1741    encoder.copy_texture_to_buffer(
1742        wgpu::TexelCopyTextureInfo {
1743            texture,
1744            mip_level: 0,
1745            origin: wgpu::Origin3d::ZERO,
1746            aspect: wgpu::TextureAspect::All,
1747        },
1748        wgpu::TexelCopyBufferInfo {
1749            buffer,
1750            layout: wgpu::TexelCopyBufferLayout {
1751                offset,
1752                bytes_per_row: Some(bytes_per_row),
1753                rows_per_image: Some(size.height),
1754            },
1755        },
1756        wgpu::Extent3d {
1757            width: size.width,
1758            height: size.height,
1759            depth_or_array_layers: 1,
1760        },
1761    );
1762}
1763
1764#[cfg(test)]
1765pub(crate) fn read_rgba8(renderer: &Renderer, target: &RenderedTexture) -> Vec<u8> {
1766    renderer
1767        .read_rgba8(target)
1768        .expect("test texture readback succeeds")
1769}
1770
1771#[cfg(test)]
1772#[path = "readback_tests.rs"]
1773mod layout_tests;