Skip to main content

frust_engine/gpu/
present.rs

1//! The present pass: the engine's premultiplied frame, converted for a
2//! swapchain that stores STRAIGHT alpha.
3//!
4//! Every engine pipeline blends and writes premultiplied alpha (see
5//! [`super::pipelines`]), which is exactly what an opaque swapchain — or a
6//! premultiplied-expecting translucent one, Android's `Inherit` — wants. The
7//! one composite alpha mode that disagrees is iOS's `PostMultiplied`: it reads
8//! the frame's `(C·a, a)` as `(C, a)`, so every partial-alpha pixel presents
9//! too dark. [`UnpremultiplyPass`] is what serves that surface — the frame is
10//! rendered into an intermediate of the host's own and one full-screen
11//! triangle writes `(rgb / max(a, ALPHA_FLOOR), a)` into the swapchain, in
12//! `unpremultiply.wgsl`.
13//!
14//! **A render pass, never a compute one.** The conversion is a fragment write
15//! into an ordinary `RENDER_ATTACHMENT` colour target: neither a compute stage
16//! nor a storage texture (E1/E2), both of which the engine refuses by design
17//! and neither of which a downlevel target offers. A swapchain configured for
18//! this tier carries `RENDER_ATTACHMENT` and nothing else, so a storage write
19//! into it is not merely disallowed by the rules — it is unavailable.
20//!
21//! **The host decides, the pass states the rule.** Which surfaces need it is
22//! [`UnpremultiplyPass::selected_by`]: an [`OutputAlpha::Straight`] *swapchain*
23//! does, an [`OutputAlpha::Premultiplied`] one does not. Note the split that
24//! implies for a host on this arm — the intermediate the engine renders into
25//! really does hold premultiplied pixels, so it is described to
26//! [`crate::EngineRenderer::encode`] as [`OutputAlpha::Premultiplied`] (which
27//! is what makes the frame's base colour clear in the same convention as
28//! everything drawn over it); `Straight` describes the *swapchain* this pass
29//! then writes, not the buffer it reads.
30//!
31//! **A plain copy, for a frame that has to read itself back.** A frame carrying
32//! a backdrop blur reads a region of its own target back into a page
33//! ([`crate::renderer::RegionCopyMode`]), which a swapchain — `RENDER_ATTACHMENT`
34//! only, never widened — cannot be read back from. [`CopyPresentPass`] serves
35//! that frame on an opaque or premultiplied surface: the host renders it into
36//! an intermediate of the swapchain's own format and this pass copies it
37//! across, one full-screen quad through the engine's own region-copy program
38//! (`copy.wgsl`), sharing the conversion pass's plumbing — a pass the caller
39//! records into its own encoder, clearing a fresh swapchain image, reading one
40//! sampled texture. It costs one full-screen fragment pass on exactly the
41//! frames that carry a backdrop; a straight-alpha surface already renders
42//! every frame into an intermediate and needs no second pass.
43//!
44//! **The caller owns the encoder.** [`UnpremultiplyPass::record`] records into
45//! a `wgpu::CommandEncoder` it is handed and never submits, the same
46//! single-submit contract `frust_gpu::encoder` states and
47//! [`crate::EngineRenderer::encode`] keeps: the frame's own passes and this one
48//! land in one command buffer, in order, against the texture about to be
49//! presented.
50
51use std::sync::Arc;
52
53use frust_gpu::lint::PipelineLayoutDesc;
54use frust_gpu::{PipelineCache, RenderPipelineDesc, ShaderId, ShaderLibrary};
55
56use super::pipelines::{FS_MAIN, TOPOLOGY, VS_MAIN, copy_vertex_layout};
57use super::shader_src::{COPY, COPY_NAME};
58use crate::OutputAlpha;
59
60/// The conversion program, loaded from the shader directory like every other
61/// engine module so `frust_gpu::lint::lint_wgsl_dir` sees every line the GPU
62/// compiles.
63///
64/// Not assembled in [`super::shader_src`] with the pipeline modules: this is
65/// the host's *present* pass rather than one of the frame's own, it needs no
66/// helper prelude, and it is compiled per surface by [`UnpremultiplyPass::new`]
67/// rather than registered in the renderer's shared library.
68pub const UNPREMULTIPLY: &str = include_str!("../../shaders/unpremultiply.wgsl");
69
70/// The name [`UNPREMULTIPLY`] is registered under in this pass's own shader
71/// library.
72pub const UNPREMULTIPLY_NAME: &str = "frust-engine unpremultiply";
73
74/// The floor the shader divides by, mirroring its own `ALPHA_FLOOR` constant.
75///
76/// Stated here so a host can document the guard it is buying without reading
77/// WGSL. A test below pins the two against each other, since a divisor guard
78/// that drifted would only show up as NaN pixels on a device.
79pub const ALPHA_FLOOR: f32 = 1e-4;
80
81/// Vertices the full-screen triangle draws. One primitive, no vertex buffer.
82const FULLSCREEN_VERTICES: u32 = 3;
83
84/// Bind groups the program declares: its source texture, and nothing else.
85const UNPREMULTIPLY_BIND_GROUPS: usize = 1;
86
87/// The straight-alpha present pass for one surface: a pipeline built for that
88/// surface's own format plus the bind-group layout its program derived.
89///
90/// Built once per surface configure (a rare event) — a pipeline object, a
91/// bind-group layout and nothing per frame but one bind group.
92#[derive(Debug)]
93pub struct UnpremultiplyPass {
94    pipeline: wgpu::RenderPipeline,
95    /// wgpu's default layout, derived from the program — the only layout
96    /// `frust_gpu::RenderPipelineDesc` builds with, so it is read back off the
97    /// pipeline rather than declared twice.
98    bind_group_layout: wgpu::BindGroupLayout,
99    format: wgpu::TextureFormat,
100}
101
102impl UnpremultiplyPass {
103    /// Whether a swapchain interpreting its alpha as `output` needs this pass.
104    ///
105    /// The whole routing rule, in one place: `Straight` does, `Premultiplied`
106    /// does not — the engine already writes the latter's convention, and a
107    /// conversion there would divide every partial-alpha pixel by its own alpha
108    /// for nothing.
109    #[must_use]
110    pub fn selected_by(output: OutputAlpha) -> bool {
111        matches!(output, OutputAlpha::Straight)
112    }
113
114    /// Builds the pass for a `format` target.
115    ///
116    /// `driver_cache` is the host's persisted `wgpu::PipelineCache` when it has
117    /// one — `None` on every backend but Vulkan, which is every backend this
118    /// arm actually runs on today (it exists for iOS/Metal), so the parameter
119    /// is about keeping the seam honest rather than about a cache hit.
120    ///
121    /// The pipeline is built through a `frust_gpu::PipelineCache` of this
122    /// pass's own, over a one-module library: the substrate's rule is that a
123    /// frame is never the first place a pipeline is compiled, and a surface
124    /// configure is not a frame. The cache is not kept — the compiled pipeline
125    /// is a reference-counted handle that outlives it, and there is exactly one
126    /// variant to ask for.
127    #[must_use]
128    pub fn new(
129        device: &wgpu::Device,
130        format: wgpu::TextureFormat,
131        driver_cache: Option<&wgpu::PipelineCache>,
132    ) -> Self {
133        let mut library = ShaderLibrary::new();
134        let shader = library.insert_wgsl(device, UNPREMULTIPLY_NAME, UNPREMULTIPLY);
135        let mut pipelines = PipelineCache::new(Arc::new(library), driver_cache.cloned());
136        let pipeline = pipelines
137            .get_or_create(device, &Self::desc(shader, format))
138            .clone();
139        let bind_group_layout = pipeline.get_bind_group_layout(0);
140        Self {
141            pipeline,
142            bind_group_layout,
143            format,
144        }
145    }
146
147    /// The target format this pass was built for — the surface's own, since it
148    /// writes the swapchain directly.
149    #[must_use]
150    pub fn format(&self) -> wgpu::TextureFormat {
151        self.format
152    }
153
154    /// Records the conversion into `encoder`: reads `source_view` (the
155    /// premultiplied intermediate the frame was rendered into) and writes
156    /// straight-alpha pixels into `target_view` (the acquired swapchain
157    /// texture). The caller submits `encoder`.
158    ///
159    /// No extent is taken: the triangle covers the whole destination whatever
160    /// its size, and the fragment stage reads the source at its own position,
161    /// so the two views only have to agree with each other — which the surface
162    /// that owns both guarantees by recreating them together.
163    ///
164    /// The attachment is CLEARED rather than loaded even though every pixel of
165    /// it is then written: the destination is a fresh swapchain image whose
166    /// prior contents mean nothing, and a load would cost a tile read per tile
167    /// on exactly the mobile GPUs this tier targets.
168    ///
169    /// The bind group is built per call rather than cached, mirroring
170    /// `frust-render`'s own premultiply pass: it names the source view, which a
171    /// resize replaces, so caching it would need an invalidation seam to buy one
172    /// allocation a frame.
173    ///
174    /// `timestamp_writes` is the pass's own `timestamp_writes`, verbatim — the
175    /// host asks its `frust_gpu::diag::TimestampRing` (via
176    /// `frust_engine::FrameTimestamps`) for one fresh pair charged to
177    /// [`crate::diag::EngineSpan::Blit`] and hands it straight through, `None`
178    /// on every arm that does not time this pass (an inert ring, a tier build
179    /// with no `perf-trace`, or the `EngineDirect` arm this pass never runs on
180    /// at all).
181    pub fn record(
182        &self,
183        device: &wgpu::Device,
184        encoder: &mut wgpu::CommandEncoder,
185        source_view: &wgpu::TextureView,
186        target_view: &wgpu::TextureView,
187        timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
188    ) {
189        let bind_group = source_bind_group(
190            device,
191            &self.bind_group_layout,
192            source_view,
193            "frust-engine unpremultiply bind group",
194        );
195        let mut pass = begin_present(
196            encoder,
197            target_view,
198            "frust-engine unpremultiply pass",
199            timestamp_writes,
200        );
201        pass.set_pipeline(&self.pipeline);
202        pass.set_bind_group(0, &bind_group, &[]);
203        pass.draw(0..FULLSCREEN_VERTICES, 0..1);
204    }
205
206    /// This pass's shape as the downlevel lint reads it, so the bind-group,
207    /// vertex-topology and sample-count ceilings
208    /// (`frust_gpu::lint::lint_pipeline_layout`) are checked against the real
209    /// description rather than restated in prose.
210    #[must_use]
211    pub fn layout_desc() -> PipelineLayoutDesc {
212        PipelineLayoutDesc {
213            bind_group_count: UNPREMULTIPLY_BIND_GROUPS,
214            // The program generates its own geometry, so there is no vertex
215            // buffer to declare and nothing to step over.
216            max_vertex_buffers: 0,
217            total_vertex_attributes: 0,
218            max_vertex_buffer_stride: 0,
219            sample_count: 1,
220            uniform_buffer_sizes: Vec::new(),
221        }
222    }
223
224    /// The pipeline description, which is `RenderPipelineDesc::new`'s minimal
225    /// variant unchanged — every one of its defaults is what this pass wants:
226    /// no vertex layouts (the program generates its own geometry), no blend
227    /// (the pass REPLACES every pixel of the destination — a blend would
228    /// composite the frame onto whatever the swapchain image last held), no
229    /// depth, one sample, and a triangle list, whose single primitive is the
230    /// full-screen triangle itself.
231    fn desc(shader: ShaderId, format: wgpu::TextureFormat) -> RenderPipelineDesc {
232        RenderPipelineDesc::new(shader, VS_MAIN, FS_MAIN, format)
233    }
234}
235
236/// Vertices the copy program's one quad draws (`copy.wgsl`'s `quad_corner`,
237/// a triangle strip).
238const COPY_QUAD_VERTICES: u32 = 4;
239
240/// One copy instance: four `u16` pairs ([`copy_vertex_layout`]).
241const COPY_INSTANCE_BYTES: u64 = 16;
242
243/// The plain present pass for one surface: the frame, rendered into an
244/// intermediate of the swapchain's own format, copied into the swapchain
245/// unconverted (see the module doc's *A plain copy*).
246///
247/// Built once per surface configure, like [`UnpremultiplyPass`]: a pipeline
248/// for the swapchain's format, the bind-group layout its program derived, and
249/// the one 16-byte instance the quad is drawn from.
250#[derive(Debug)]
251pub struct CopyPresentPass {
252    pipeline: wgpu::RenderPipeline,
253    bind_group_layout: wgpu::BindGroupLayout,
254    instance: wgpu::Buffer,
255    format: wgpu::TextureFormat,
256}
257
258impl CopyPresentPass {
259    /// Builds the pass for a `format` target, through a pipeline cache of its
260    /// own exactly as [`UnpremultiplyPass::new`] does — a surface configure is
261    /// not a frame, so the one pipeline is compiled here.
262    #[must_use]
263    pub fn new(
264        device: &wgpu::Device,
265        format: wgpu::TextureFormat,
266        driver_cache: Option<&wgpu::PipelineCache>,
267    ) -> Self {
268        let mut library = ShaderLibrary::new();
269        let shader = library.insert_wgsl(device, COPY_NAME, COPY);
270        let mut pipelines = PipelineCache::new(Arc::new(library), driver_cache.cloned());
271        let pipeline = pipelines
272            .get_or_create(device, &Self::desc(shader, format))
273            .clone();
274        let bind_group_layout = pipeline.get_bind_group_layout(0);
275        let instance = device.create_buffer(&wgpu::BufferDescriptor {
276            label: Some("frust-engine copy present instance"),
277            size: COPY_INSTANCE_BYTES,
278            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
279            mapped_at_creation: false,
280        });
281        Self {
282            pipeline,
283            bind_group_layout,
284            instance,
285            format,
286        }
287    }
288
289    /// The target format this pass was built for — the swapchain's own.
290    #[must_use]
291    pub fn format(&self) -> wgpu::TextureFormat {
292        self.format
293    }
294
295    /// Records the copy into `encoder`: reads `source_view` (the intermediate
296    /// the frame was rendered into, `width` x `height` like the swapchain) and
297    /// writes it unconverted over the whole of `target_view` (the acquired
298    /// swapchain texture). The caller submits `encoder`.
299    ///
300    /// The instance is written through `queue` and lands ahead of the command
301    /// buffer the caller submits, so a resize between frames is honoured by
302    /// the very next one. The attachment is cleared rather than loaded, for
303    /// [`UnpremultiplyPass::record`]'s reason. `timestamp_writes` is that
304    /// method's too.
305    #[expect(
306        clippy::too_many_arguments,
307        reason = "the device, queue and encoder the pass records with, its two views,                   their shared extent and the timestamp sink, each supplied by a                   different owner"
308    )]
309    pub fn record(
310        &self,
311        device: &wgpu::Device,
312        queue: &wgpu::Queue,
313        encoder: &mut wgpu::CommandEncoder,
314        source_view: &wgpu::TextureView,
315        target_view: &wgpu::TextureView,
316        size: (u32, u32),
317        timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
318    ) {
319        let width = u16::try_from(size.0).unwrap_or(u16::MAX);
320        let height = u16::try_from(size.1).unwrap_or(u16::MAX);
321        let extent = u32::from(width) | (u32::from(height) << 16);
322        // Destination origin, source origin, region size, destination size.
323        let instance: [u32; 4] = [0, 0, extent, extent];
324        queue.write_buffer(&self.instance, 0, bytemuck::cast_slice(&instance));
325        let bind_group = source_bind_group(
326            device,
327            &self.bind_group_layout,
328            source_view,
329            "frust-engine copy present bind group",
330        );
331        let mut pass = begin_present(
332            encoder,
333            target_view,
334            "frust-engine copy present pass",
335            timestamp_writes,
336        );
337        pass.set_pipeline(&self.pipeline);
338        pass.set_vertex_buffer(0, self.instance.slice(..));
339        pass.set_bind_group(0, &bind_group, &[]);
340        pass.draw(0..COPY_QUAD_VERTICES, 0..1);
341    }
342
343    /// This pass's shape as the downlevel lint reads it — one bind group, one
344    /// instance buffer of four `u32` attributes, no uniform.
345    #[must_use]
346    pub fn layout_desc() -> PipelineLayoutDesc {
347        let layout = copy_vertex_layout();
348        PipelineLayoutDesc {
349            bind_group_count: 1,
350            max_vertex_buffers: 1,
351            total_vertex_attributes: layout.attributes.len(),
352            max_vertex_buffer_stride: layout.array_stride as usize,
353            sample_count: 1,
354            uniform_buffer_sizes: Vec::new(),
355        }
356    }
357
358    /// The copy program drawn into the swapchain's format: its instance
359    /// layout, the engine's strip topology, no blend (the pass replaces every
360    /// pixel), no depth.
361    fn desc(shader: ShaderId, format: wgpu::TextureFormat) -> RenderPipelineDesc {
362        RenderPipelineDesc {
363            vertex_layouts: vec![copy_vertex_layout()],
364            topology: TOPOLOGY,
365            ..RenderPipelineDesc::new(shader, VS_MAIN, FS_MAIN, format)
366        }
367    }
368}
369
370/// The present passes' one bind group: their source texture, at binding 0.
371fn source_bind_group(
372    device: &wgpu::Device,
373    layout: &wgpu::BindGroupLayout,
374    source_view: &wgpu::TextureView,
375    label: &str,
376) -> wgpu::BindGroup {
377    device.create_bind_group(&wgpu::BindGroupDescriptor {
378        label: Some(label),
379        layout,
380        entries: &[wgpu::BindGroupEntry {
381            binding: 0,
382            resource: wgpu::BindingResource::TextureView(source_view),
383        }],
384    })
385}
386
387/// Begins a present pass over `target_view`, clearing it: the destination is a
388/// fresh swapchain image whose prior contents mean nothing.
389fn begin_present<'encoder>(
390    encoder: &'encoder mut wgpu::CommandEncoder,
391    target_view: &wgpu::TextureView,
392    label: &str,
393    timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
394) -> wgpu::RenderPass<'encoder> {
395    encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
396        label: Some(label),
397        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
398            view: target_view,
399            depth_slice: None,
400            resolve_target: None,
401            ops: wgpu::Operations {
402                load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
403                store: wgpu::StoreOp::Store,
404            },
405        })],
406        depth_stencil_attachment: None,
407        timestamp_writes,
408        occlusion_query_set: None,
409        multiview_mask: None,
410    })
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn the_copy_present_layout_passes_the_downlevel_lint() {
419        let violations = frust_gpu::lint_pipeline_layout(&CopyPresentPass::layout_desc());
420        assert!(
421            violations.is_empty(),
422            "the copy present pass violates a downlevel design rule: {violations:?}"
423        );
424    }
425
426    #[test]
427    fn the_copy_present_program_is_the_engines_own_region_copy() {
428        assert!(COPY.contains("fn vs_main"));
429        assert!(COPY.contains("fn fs_main"));
430        assert!(
431            !COPY.contains("@compute"),
432            "the copy is a render pass, never a compute one (E1)"
433        );
434        assert!(
435            !COPY.contains("texture_storage_"),
436            "a swapchain on this tier is RENDER_ATTACHMENT-only (E2)"
437        );
438    }
439
440    /// The pass is selected by the swapchain's own alpha convention, and by
441    /// nothing else.
442    #[test]
443    fn only_a_straight_alpha_target_selects_the_pass() {
444        assert!(UnpremultiplyPass::selected_by(OutputAlpha::Straight));
445        assert!(!UnpremultiplyPass::selected_by(OutputAlpha::Premultiplied));
446    }
447
448    /// The value the shader's own `ALPHA_FLOOR` declaration carries, read out
449    /// of the source the GPU compiles rather than restated here.
450    fn shader_alpha_floor() -> f32 {
451        let declaration = UNPREMULTIPLY
452            .lines()
453            .find(|line| line.trim_start().starts_with("const ALPHA_FLOOR"))
454            .expect("the shader declares an ALPHA_FLOOR constant");
455        declaration
456            .split('=')
457            .nth(1)
458            .expect("the declaration assigns a value")
459            .trim()
460            .trim_end_matches(';')
461            .parse()
462            .expect("the shader's alpha floor is a float literal")
463    }
464
465    /// The divisor guard is one decision spelled in two languages; a drift
466    /// between them would surface only as NaN pixels on a device.
467    #[test]
468    fn the_shader_and_rust_alpha_floors_agree() {
469        let floor = shader_alpha_floor();
470        assert_eq!(
471            floor, ALPHA_FLOOR,
472            "the shader's floor must stay in step with `ALPHA_FLOOR`"
473        );
474        assert!(
475            floor > 0.0,
476            "a floor of zero would leave `0 / 0` NaNs in the swapchain"
477        );
478        assert!(
479            floor < 1.0 / 255.0,
480            "the floor must sit below the smallest representable 8-bit alpha, so it never clamps \
481             a pixel that carries colour"
482        );
483    }
484
485    /// The program the host compiles is the file the WGSL lint scans, not an
486    /// inline string — the shader directory's own rule.
487    #[test]
488    fn the_program_is_loaded_from_the_shader_directory() {
489        assert!(UNPREMULTIPLY.contains("fn vs_main"));
490        assert!(UNPREMULTIPLY.contains("fn fs_main"));
491        assert!(
492            !UNPREMULTIPLY.contains("@compute"),
493            "the conversion is a render pass, never a compute one (E1)"
494        );
495        assert!(
496            !UNPREMULTIPLY.contains("texture_storage_"),
497            "a swapchain on this tier is RENDER_ATTACHMENT-only (E2)"
498        );
499    }
500
501    #[test]
502    fn the_pass_layout_passes_the_downlevel_lint() {
503        let violations = frust_gpu::lint_pipeline_layout(&UnpremultiplyPass::layout_desc());
504        assert!(
505            violations.is_empty(),
506            "the present pass violates a downlevel design rule: {violations:?}"
507        );
508        assert_eq!(
509            UnpremultiplyPass::layout_desc().bind_group_count,
510            UNPREMULTIPLY_BIND_GROUPS,
511            "one bind group: the source texture"
512        );
513    }
514}