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