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}