Skip to main content

alpha2_render/
composite.rs

1use crate::{
2    BlendMode, Color, GraphError, GraphLayer, RenderError, RenderGraph, RenderSize,
3    RenderedTexture, Renderer, SourceView,
4    pipeline::CachedCompositePipeline,
5    uniform::{BATCH_UNIFORM_FLOATS, UniformArenaLease, f32_bytes, float_uniform_bytes},
6};
7use alpha2_shaders::COMPOSITE_WGSL;
8
9/// Most layers per composite pass (`BATCH_LAYERS` in composite.wesl). With the backdrop this
10/// stays within the 16 sampled textures per stage of a WebGL2-class device.
11pub(crate) const COMPOSITE_BATCH_LAYERS: usize = 8;
12
13/// Placement and opacity of one composited layer.
14#[derive(Copy, Clone, Debug, PartialEq)]
15pub struct LayerTransform {
16    /// Normalized output translation, as `[x, y]`.
17    pub position: [f32; 2],
18    /// Normalized source anchor, as `[x, y]`.
19    pub anchor: [f32; 2],
20    /// Per-axis scale, as `[x, y]`.
21    pub scale: [f32; 2],
22    /// Clockwise rotation in degrees.
23    pub rotation_degrees: f32,
24    /// Opacity from zero to one.
25    pub opacity: f32,
26}
27
28/// One decoded, premultiplied sRGB layer and its presentation controls.
29#[derive(Copy, Clone, Debug)]
30#[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
31pub struct CompositeLayer<'a> {
32    /// Source texture sampled by this layer.
33    pub texture: &'a wgpu::TextureView,
34    /// Layer transform and opacity.
35    pub visual: LayerTransform,
36    /// Artistic blend operation evaluated before source-over coverage.
37    pub blend_mode: BlendMode,
38}
39
40#[derive(Clone, Copy)]
41pub(crate) struct CompositeTarget<'a> {
42    /// Composited over by the first layer; transparent when `None`.
43    pub(crate) background: Option<Color>,
44    pub(crate) blend_space: crate::BlendSpace,
45    pub(crate) output: &'a wgpu::Texture,
46    pub(crate) output_view: &'a wgpu::TextureView,
47}
48
49impl Renderer {
50    /// Composites layers in slice order onto a transparent RGBA target.
51    ///
52    /// Inputs and output are premultiplied sRGB; blending uses the renderer's
53    /// [`crate::BlendSpace`].
54    ///
55    /// # Errors
56    /// Returns [`RenderError::InvalidVisual`] for non-finite or out-of-range layer values. A
57    /// zero-scale layer is valid and draws nothing.
58    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
59    pub fn render_layers(
60        &self,
61        size: RenderSize,
62        layers: &[CompositeLayer<'_>],
63    ) -> Result<RenderedTexture, RenderError> {
64        let mut graph = RenderGraph::new(size);
65        let mut graph_layers = Vec::with_capacity(layers.len());
66        for layer in layers {
67            graph_layers.push(GraphLayer {
68                source: graph.add_source(SourceView::from_wgpu(layer.texture)),
69                visual: layer.visual,
70                blend_mode: layer.blend_mode,
71            });
72        }
73        let output = graph
74            .add_composite(graph_layers)
75            .map_err(GraphError::into_render_error)?;
76        graph
77            .set_output(output)
78            .map_err(GraphError::into_render_error)?;
79        self.execute_graph(&graph)
80            .map_err(GraphError::into_render_error)
81    }
82
83    /// Composites `layers` over `background` into `output`, returning the passes encoded.
84    ///
85    /// Layers that miss the target or have zero opacity are skipped. Small layers batch up to
86    /// [`Self::composite_batch_layers`] per pass and always draw with the loop pipeline, so a lone
87    /// small layer and a batch run identical code. Large layers get their own one-layer pass.
88    /// The first pass clears to the background; a later batch covering half the target or more
89    /// ping-pongs full-frame between two textures, while a smaller one draws in place, scissored,
90    /// over a copy of its region.
91    #[allow(clippy::too_many_lines)]
92    pub(crate) fn encode_layers_pass(
93        &self,
94        encoder: &mut wgpu::CommandEncoder,
95        size: RenderSize,
96        layers: &[CompositeLayer<'_>],
97        target: CompositeTarget<'_>,
98        uniforms: &mut UniformArenaLease,
99    ) -> Result<u64, RenderError> {
100        let CompositeTarget {
101            background,
102            blend_space,
103            output,
104            output_view,
105        } = target;
106        for layer in layers {
107            validate_visual(layer.visual)?;
108        }
109        let format = self.intermediate_format();
110        let drawn = self.drawn_layers(layers.iter().map(|layer| layer.visual), size);
111        let drawn = drawn
112            .iter()
113            .map(|(index, bounds, tier)| (&layers[*index], *bounds, *tier))
114            .collect::<Vec<_>>();
115        let background = stored_background(background, format);
116        if drawn.is_empty() {
117            // What a first layer writes where it does not reach, so an empty canvas matches.
118            clear_target(encoder, output_view, background);
119            return Ok(1);
120        }
121
122        let cached = self.composite_pipeline();
123        let preparation = BatchPreparationContext {
124            device: self.gpu.device(),
125            layout: &cached.bind_group_layout,
126            sampler: &cached.sampler,
127            no_backdrop: &self.empty_input,
128            blend_space,
129            max_filter_samples: self.quality_controls().max_filter_samples,
130            output_size: size,
131            background,
132        };
133        let batches = composite_batches(&drawn, self.composite_batch_layers())
134            .into_iter()
135            .map(|(range, bounds)| (&drawn[range], bounds))
136            .collect::<Vec<_>>();
137        let bounded = |bounds: &PixelRect| under_half(*bounds, size);
138        let ((first, first_bounds), rest) = batches.split_first().expect("a layer is drawn");
139        let first_program = self.batch_program(first);
140        let first = prepare_batch(&preparation, None, first, uniforms);
141        // The clear writes the stored background, so a small first batch can draw only inside its
142        // bounds.
143        let first_pass = PassTarget {
144            load: wgpu::LoadOp::Clear(stored_color(background)),
145            scissor: bounded(first_bounds).then_some(*first_bounds),
146        };
147        if rest.is_empty() {
148            self.draw_batch(
149                encoder,
150                cached,
151                first_program,
152                &first,
153                output_view,
154                first_pass,
155            );
156            return Ok(1);
157        }
158
159        let scratch = self.textures.acquire(
160            self.gpu.device(),
161            size,
162            format,
163            "alpha2 layer composition scratch",
164        );
165        let textures = [output, scratch.texture()];
166        let views = [output_view, scratch.view()];
167        // Each full-frame batch flips the target; start so the last one lands in `output`.
168        let mut current = rest.iter().filter(|(_, bounds)| !bounded(bounds)).count() % 2;
169        self.draw_batch(
170            encoder,
171            cached,
172            first_program,
173            &first,
174            views[current],
175            first_pass,
176        );
177        for (batch, bounds) in rest {
178            let other = 1 - current;
179            let program = self.batch_program(batch);
180            if bounded(bounds) {
181                // A pass cannot read the texture it writes, so copy the backdrop region aside
182                // first.
183                copy_region(encoder, textures[current], textures[other], *bounds);
184                let prepared = prepare_batch(&preparation, Some(views[other]), batch, uniforms);
185                self.draw_batch(
186                    encoder,
187                    cached,
188                    program,
189                    &prepared,
190                    views[current],
191                    PassTarget {
192                        load: wgpu::LoadOp::Load,
193                        scissor: Some(*bounds),
194                    },
195                );
196            } else {
197                let prepared = prepare_batch(&preparation, Some(views[current]), batch, uniforms);
198                self.draw_batch(
199                    encoder,
200                    cached,
201                    program,
202                    &prepared,
203                    views[other],
204                    PassTarget::WHOLE,
205                );
206                current = other;
207            }
208        }
209        debug_assert_eq!(current, 0);
210        Ok(u64::try_from(batches.len()).unwrap_or(u64::MAX))
211    }
212
213    /// The program a batch draws with; a multi-layer batch always uses the loop.
214    fn batch_program(
215        &self,
216        batch: &[(&CompositeLayer<'_>, PixelRect, LayerTier)],
217    ) -> (CompositeProgram, LayerTier, usize) {
218        let tier = batch[0].2;
219        debug_assert!(batch.iter().all(|(_, _, other)| *other == tier));
220        let program = self.composite_program(tier);
221        debug_assert!(batch.len() == 1 || program == CompositeProgram::Loop);
222        (program, tier, batch.len())
223    }
224
225    fn draw_batch(
226        &self,
227        encoder: &mut wgpu::CommandEncoder,
228        cached: &CachedCompositePipeline,
229        (program, tier, layers): (CompositeProgram, LayerTier, usize),
230        bind_group: &wgpu::BindGroup,
231        target: &wgpu::TextureView,
232        pass: PassTarget,
233    ) {
234        let pipeline = self.composite_program_pipeline(cached, program);
235        #[cfg(test)]
236        {
237            let mut log = self
238                .composite_pipelines
239                .lock()
240                .unwrap_or_else(std::sync::PoisonError::into_inner);
241            log.passes += 1;
242            log.drawn
243                .insert((program, pipeline.clone(), tier, layers > 1));
244        }
245        #[cfg(not(test))]
246        let _ = (tier, layers);
247        encode_batch(encoder, pipeline, bind_group, target, pass);
248    }
249
250    /// Whether small layers are batched and drawn with the loop pipeline: on a GPU, and in tests
251    /// that set `composite_batch_limit`.
252    pub(crate) fn batches_small_layers(&self) -> bool {
253        #[cfg(test)]
254        {
255            if self
256                .composite_batch_limit
257                .load(std::sync::atomic::Ordering::Relaxed)
258                > 0
259            {
260                return true;
261            }
262        }
263        !self.gpu.capabilities().software_rasterizer
264    }
265
266    /// The program for a layer of `tier`: the loop for small layers when batching, otherwise the
267    /// one-layer program.
268    ///
269    /// Small layers always use the loop, alone or batched, because Metal, D3D12 and GL compilers
270    /// contract two separate programs differently and the stored bytes differ in the last bit.
271    /// Software rasterizers execute every branch, so they never batch.
272    pub(crate) fn composite_program(&self, tier: LayerTier) -> CompositeProgram {
273        if tier == LayerTier::Small && self.batches_small_layers() {
274            CompositeProgram::Loop
275        } else {
276            CompositeProgram::OneLayer
277        }
278    }
279
280    /// Most layers per composite pass: [`COMPOSITE_BATCH_LAYERS`] on a GPU, one on a software
281    /// rasterizer, or `composite_batch_limit` in tests.
282    ///
283    /// A GPU runs a layer's step only on quads overlapping it, so batching saves passes, barriers
284    /// and backdrop copies. A software rasterizer pays for the whole batch area per layer.
285    pub(crate) fn composite_batch_layers(&self) -> usize {
286        #[cfg(test)]
287        {
288            let limit = self
289                .composite_batch_limit
290                .load(std::sync::atomic::Ordering::Relaxed);
291            if limit > 0 {
292                return limit.min(COMPOSITE_BATCH_LAYERS);
293            }
294        }
295        if self.batches_small_layers() {
296            COMPOSITE_BATCH_LAYERS
297        } else {
298            1
299        }
300    }
301
302    /// Shader, layout and sampler shared by both composite pipelines, created on first use.
303    pub(crate) fn composite_pipeline(&self) -> &CachedCompositePipeline {
304        self.pipelines.composite.get_or_init(|| {
305            let device = self.gpu.device();
306            let bind_group_layout = batch_layout(device);
307            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
308                label: Some("alpha2 layer composite shader"),
309                source: wgpu::ShaderSource::Wgsl(COMPOSITE_WGSL.into()),
310            });
311            let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
312                label: Some("alpha2 layer sampler"),
313                address_mode_u: wgpu::AddressMode::ClampToEdge,
314                address_mode_v: wgpu::AddressMode::ClampToEdge,
315                mag_filter: wgpu::FilterMode::Linear,
316                min_filter: wgpu::FilterMode::Linear,
317                ..Default::default()
318            });
319            CachedCompositePipeline {
320                bind_group_layout,
321                sampler,
322                shader,
323                programs: Default::default(),
324            }
325        })
326    }
327
328    pub(crate) fn composite_program_pipeline<'a>(
329        &self,
330        cached: &'a CachedCompositePipeline,
331        program: CompositeProgram,
332    ) -> &'a wgpu::RenderPipeline {
333        let (slot, entry, label) = match program {
334            CompositeProgram::Loop => (0, "fs", "alpha2 small layer composite pipeline"),
335            CompositeProgram::OneLayer => (1, "fs_layer", "alpha2 large layer composite pipeline"),
336        };
337        cached.programs[slot].get_or_init(|| {
338            #[cfg(test)]
339            {
340                self.composite_pipelines
341                    .lock()
342                    .unwrap_or_else(std::sync::PoisonError::into_inner)
343                    .created
344                    .push(program);
345            }
346            crate::diagnostics::timed_pipeline_creation(
347                &self.diagnostics,
348                || label.to_owned(),
349                || {
350                    composite_pipeline(
351                        self.gpu.device(),
352                        &cached.bind_group_layout,
353                        &cached.shader,
354                        self.intermediate_format(),
355                        entry,
356                        label,
357                    )
358                },
359            )
360        })
361    }
362
363    /// Drawn layers by index with their reachable target pixels and tier; skips layers that miss
364    /// or have zero opacity.
365    #[cfg_attr(not(test), allow(clippy::unused_self))]
366    pub(crate) fn drawn_layers(
367        &self,
368        visuals: impl Iterator<Item = LayerTransform>,
369        size: RenderSize,
370    ) -> Vec<(usize, PixelRect, LayerTier)> {
371        #[cfg(test)]
372        let full_frame = self
373            .full_frame_layers
374            .load(std::sync::atomic::Ordering::Relaxed);
375        #[cfg(not(test))]
376        let full_frame = false;
377        visuals
378            .enumerate()
379            .filter_map(|(index, visual)| {
380                if is_degenerate(visual) || is_invisible(visual) {
381                    return None;
382                }
383                let bounds = layer_bounds(visual, size);
384                // Drawn full-frame in tests, a layer keeps its bounded tier so the pipeline
385                // matches.
386                let tier = LayerTier::of(bounds.unwrap_or(PixelRect::EMPTY), size);
387                if full_frame {
388                    Some((index, PixelRect::whole(size), tier))
389                } else {
390                    bounds.map(|bounds| (index, bounds, tier))
391                }
392            })
393            .collect()
394    }
395
396    /// Passes (and batch uniforms) a composite of these layers encodes, not counting a clear.
397    pub(crate) fn composite_batch_count(
398        &self,
399        visuals: impl Iterator<Item = LayerTransform>,
400        size: RenderSize,
401    ) -> usize {
402        let drawn = self.drawn_layers(visuals, size);
403        composite_batches(&drawn, self.composite_batch_layers()).len()
404    }
405
406    pub(crate) fn warm_composite(&self, layers: usize) {
407        if layers == 0 {
408            return;
409        }
410        let cached = self.composite_pipeline();
411        self.composite_program_pipeline(cached, CompositeProgram::OneLayer);
412        if self.batches_small_layers() {
413            self.composite_program_pipeline(cached, CompositeProgram::Loop);
414        }
415    }
416}
417
418/// A drawn layer's size class. It alone decides both batching ([`composite_batches`]) and the
419/// program ([`Renderer::composite_program`]), so the two cannot disagree.
420#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
421pub(crate) enum LayerTier {
422    /// Bounds under half the target: may share a pass.
423    Small,
424    /// Bounds of half the target or more: always a pass of its own.
425    Large,
426}
427
428impl LayerTier {
429    pub(crate) fn of(bounds: PixelRect, size: RenderSize) -> Self {
430        if under_half(bounds, size) {
431            Self::Small
432        } else {
433            Self::Large
434        }
435    }
436}
437
438fn under_half(bounds: PixelRect, size: RenderSize) -> bool {
439    bounds.area() * 2 < PixelRect::whole(size).area()
440}
441
442/// Entry points of composite.wesl sharing `composite_layer`.
443#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
444pub(crate) enum CompositeProgram {
445    /// `fs`: the pass's layers in a loop, count in a uniform.
446    Loop,
447    /// `fs_layer`: one layer, no loop.
448    OneLayer,
449}
450
451/// Test-only record of composite pipelines created and drawn with.
452#[cfg(test)]
453#[derive(Debug, Default)]
454pub(crate) struct CompositePipelineLog {
455    pub(crate) created: Vec<CompositeProgram>,
456    pub(crate) passes: usize,
457    /// Distinct (program, pipeline, tier, multi-layer) combinations drawn.
458    pub(crate) drawn:
459        std::collections::HashSet<(CompositeProgram, wgpu::RenderPipeline, LayerTier, bool)>,
460}
461
462/// Groups drawn layers, in order, into passes, each with the union of its layers' bounds.
463///
464/// Only [`LayerTier::Small`] layers share a pass, up to `limit`. Batching saves a small layer's
465/// pass, barrier and backdrop copy; a large layer would make every batched step run over its
466/// area, so it gets its own pass.
467pub(crate) fn composite_batches<T>(
468    drawn: &[(T, PixelRect, LayerTier)],
469    limit: usize,
470) -> Vec<(std::ops::Range<usize>, PixelRect)> {
471    let mut batches: Vec<(std::ops::Range<usize>, PixelRect)> = Vec::new();
472    for (index, (_, bounds, tier)) in drawn.iter().enumerate() {
473        match batches.last_mut() {
474            Some((range, union))
475                if range.len() < limit
476                    && *tier == LayerTier::Small
477                    && drawn[range.start].2 == LayerTier::Small =>
478            {
479                range.end = index + 1;
480                *union = union.union(*bounds);
481            }
482            _ => batches.push((index..index + 1, *bounds)),
483        }
484    }
485    batches
486}
487
488/// How the intermediate stores a value; the shader rounds to match (`STORED_FORMAT`).
489#[derive(Copy, Clone, Debug)]
490enum StoredValue {
491    /// Kept as computed.
492    AsIs,
493    /// Rounded to a half float.
494    Half,
495    /// Rounded to an 8-bit unorm code.
496    Unorm8,
497}
498
499impl StoredValue {
500    fn of(format: wgpu::TextureFormat) -> Self {
501        match format {
502            wgpu::TextureFormat::Rgba16Float => Self::Half,
503            wgpu::TextureFormat::Rgba8Unorm => Self::Unorm8,
504            _ => Self::AsIs,
505        }
506    }
507
508    const fn shader_value(self) -> f64 {
509        match self {
510            Self::AsIs => 0.0,
511            Self::Half => 1.0,
512            Self::Unorm8 => 2.0,
513        }
514    }
515}
516
517struct BatchPreparationContext<'a> {
518    device: &'a wgpu::Device,
519    layout: &'a wgpu::BindGroupLayout,
520    sampler: &'a wgpu::Sampler,
521    /// Bound as the first pass's backdrop, which composites over `background` and reads nothing.
522    no_backdrop: &'a wgpu::TextureView,
523    blend_space: crate::BlendSpace,
524    max_filter_samples: u32,
525    output_size: RenderSize,
526    background: [f32; 4],
527}
528
529/// Writes a batch's `BatchParams` and binds its textures, filling unused slots with the first
530/// layer's. Without `backdrop` the batch composites over the background.
531fn prepare_batch(
532    context: &BatchPreparationContext<'_>,
533    backdrop: Option<&wgpu::TextureView>,
534    batch: &[(&CompositeLayer<'_>, PixelRect, LayerTier)],
535    uniforms: &mut UniformArenaLease,
536) -> wgpu::BindGroup {
537    debug_assert!(!batch.is_empty() && batch.len() <= COMPOSITE_BATCH_LAYERS);
538    let mut values = [0.0_f32; BATCH_UNIFORM_FLOATS];
539    #[allow(clippy::cast_precision_loss)]
540    let controls = [
541        context.blend_space.shader_value(),
542        context.max_filter_samples as f32,
543        batch.len() as f32,
544        if backdrop.is_some() { 0.0 } else { 1.0 },
545        context.output_size.width as f32,
546        context.output_size.height as f32,
547        0.0,
548        0.0,
549        context.background[0],
550        context.background[1],
551        context.background[2],
552        context.background[3],
553    ];
554    values[..controls.len()].copy_from_slice(&controls);
555    for ((layer, bounds, _), slot) in batch
556        .iter()
557        .zip(values[controls.len()..].as_chunks_mut::<16>().0)
558    {
559        let visual = layer.visual;
560        #[allow(clippy::cast_precision_loss)]
561        slot.copy_from_slice(&[
562            visual.position[0],
563            visual.position[1],
564            visual.anchor[0],
565            visual.anchor[1],
566            visual.scale[0],
567            visual.scale[1],
568            visual.rotation_degrees.to_radians(),
569            visual.opacity,
570            layer.blend_mode.shader_value(),
571            0.0,
572            0.0,
573            0.0,
574            bounds.x as f32,
575            bounds.y as f32,
576            (bounds.x + bounds.width) as f32,
577            (bounds.y + bounds.height) as f32,
578        ]);
579    }
580    let uniform = uniforms.write(&f32_bytes(values));
581    let texture = |slot: usize| {
582        let (layer, _, _) = batch.get(slot).unwrap_or(&batch[0]);
583        layer.texture
584    };
585    let mut entries = (0..COMPOSITE_BATCH_LAYERS)
586        .map(|slot| wgpu::BindGroupEntry {
587            binding: u32::try_from(slot).expect("few layer slots"),
588            resource: wgpu::BindingResource::TextureView(texture(slot)),
589        })
590        .collect::<Vec<_>>();
591    entries.push(wgpu::BindGroupEntry {
592        binding: SAMPLER_BINDING,
593        resource: wgpu::BindingResource::Sampler(context.sampler),
594    });
595    entries.push(wgpu::BindGroupEntry {
596        binding: BACKDROP_BINDING,
597        resource: wgpu::BindingResource::TextureView(backdrop.unwrap_or(context.no_backdrop)),
598    });
599    entries.push(wgpu::BindGroupEntry {
600        binding: UNIFORM_BINDING,
601        resource: uniforms.binding(uniform),
602    });
603    context
604        .device
605        .create_bind_group(&wgpu::BindGroupDescriptor {
606            label: Some("alpha2 layer batch bind group"),
607            layout: context.layout,
608            entries: &entries,
609        })
610}
611
612/// Clears `target` to `stored`, a value its format holds exactly, so the clear matches what a
613/// layer pass writes for the same background.
614fn clear_target(encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, stored: [f32; 4]) {
615    let color = stored_color(stored);
616    let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
617        label: Some("alpha2 layer clear pass"),
618        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
619            view: target,
620            depth_slice: None,
621            resolve_target: None,
622            ops: wgpu::Operations {
623                load: wgpu::LoadOp::Clear(color),
624                store: wgpu::StoreOp::Store,
625            },
626        })],
627        depth_stencil_attachment: None,
628        timestamp_writes: None,
629        occlusion_query_set: None,
630        multiview_mask: None,
631    });
632}
633
634#[derive(Copy, Clone)]
635struct PassTarget {
636    load: wgpu::LoadOp<wgpu::Color>,
637    /// Only these pixels are drawn; the whole target when `None`.
638    scissor: Option<PixelRect>,
639}
640
641impl PassTarget {
642    /// Writes every pixel, so previous contents do not matter.
643    const WHOLE: Self = Self {
644        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
645        scissor: None,
646    };
647}
648
649/// A stored value as a clear color, exact in the target format so no device rounds it.
650fn stored_color(stored: [f32; 4]) -> wgpu::Color {
651    let [r, g, b, a] = stored.map(f64::from);
652    wgpu::Color { r, g, b, a }
653}
654
655/// Draws one batch into `target`, full-frame or scissored, after loading or clearing it.
656fn encode_batch(
657    encoder: &mut wgpu::CommandEncoder,
658    pipeline: &wgpu::RenderPipeline,
659    bind_group: &wgpu::BindGroup,
660    target: &wgpu::TextureView,
661    PassTarget { load, scissor }: PassTarget,
662) {
663    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
664        label: Some("alpha2 layer composition pass"),
665        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
666            view: target,
667            depth_slice: None,
668            resolve_target: None,
669            ops: wgpu::Operations {
670                load,
671                store: wgpu::StoreOp::Store,
672            },
673        })],
674        depth_stencil_attachment: None,
675        timestamp_writes: None,
676        occlusion_query_set: None,
677        multiview_mask: None,
678    });
679    pass.set_pipeline(pipeline);
680    pass.set_bind_group(0, bind_group, &[]);
681    if let Some(scissor) = scissor {
682        pass.set_scissor_rect(scissor.x, scissor.y, scissor.width, scissor.height);
683    }
684    pass.draw(0..3, 0..1);
685}
686
687fn copy_region(
688    encoder: &mut wgpu::CommandEncoder,
689    source: &wgpu::Texture,
690    destination: &wgpu::Texture,
691    region: PixelRect,
692) {
693    let at = |texture| wgpu::TexelCopyTextureInfo {
694        texture,
695        mip_level: 0,
696        origin: wgpu::Origin3d {
697            x: region.x,
698            y: region.y,
699            z: 0,
700        },
701        aspect: wgpu::TextureAspect::All,
702    };
703    encoder.copy_texture_to_texture(
704        at(source),
705        at(destination),
706        wgpu::Extent3d {
707            width: region.width,
708            height: region.height,
709            depth_or_array_layers: 1,
710        },
711    );
712}
713
714/// A rectangle of target pixels.
715#[derive(Copy, Clone, Debug, Eq, PartialEq)]
716pub(crate) struct PixelRect {
717    pub(crate) x: u32,
718    pub(crate) y: u32,
719    pub(crate) width: u32,
720    pub(crate) height: u32,
721}
722
723impl PixelRect {
724    /// No pixels; the bounds of a layer that misses the target.
725    const EMPTY: Self = Self {
726        x: 0,
727        y: 0,
728        width: 0,
729        height: 0,
730    };
731
732    const fn whole(size: RenderSize) -> Self {
733        Self {
734            x: 0,
735            y: 0,
736            width: size.width,
737            height: size.height,
738        }
739    }
740
741    pub(crate) fn area(self) -> u64 {
742        u64::from(self.width) * u64::from(self.height)
743    }
744
745    fn union(self, other: Self) -> Self {
746        let x = self.x.min(other.x);
747        let y = self.y.min(other.y);
748        let right = (self.x + self.width).max(other.x + other.width);
749        let bottom = (self.y + self.height).max(other.y + other.height);
750        Self {
751            x,
752            y,
753            width: right - x,
754            height: bottom - y,
755        }
756    }
757}
758
759/// Pixels beyond a layer's edges its coverage can still reach. Antialiasing reaches about
760/// 1.5 pixels, so this is generous.
761const COVERAGE_MARGIN: f64 = 4.0;
762
763/// Target pixels a layer can change: its transformed rectangle widened by [`COVERAGE_MARGIN`]
764/// and clamped, or `None` if it misses. Elsewhere coverage is zero.
765///
766/// Mirrors the shader: source corner `(u, v)` lands at
767/// `position * size + R^T ((u, v) - anchor) * scale * size`, with `R` the shader's rotation.
768pub(crate) fn layer_bounds(visual: LayerTransform, size: RenderSize) -> Option<PixelRect> {
769    let (width, height) = (f64::from(size.width), f64::from(size.height));
770    let (turn_sin, turn_cos) = libm::sincos(f64::from(visual.rotation_degrees).to_radians());
771    let corner = |u: f64, v: f64| {
772        let x = (u - f64::from(visual.anchor[0])) * f64::from(visual.scale[0]) * width;
773        let y = (v - f64::from(visual.anchor[1])) * f64::from(visual.scale[1]) * height;
774        (
775            f64::from(visual.position[0]) * width + turn_cos * x - turn_sin * y,
776            f64::from(visual.position[1]) * height + turn_sin * x + turn_cos * y,
777        )
778    };
779    let corners = [
780        corner(0.0, 0.0),
781        corner(1.0, 0.0),
782        corner(0.0, 1.0),
783        corner(1.0, 1.0),
784    ];
785    let fold = |pick: fn(&(f64, f64)) -> f64, extreme: fn(f64, f64) -> f64, start: f64| {
786        corners.iter().map(pick).fold(start, extreme)
787    };
788    let left = (fold(|c| c.0, f64::min, f64::INFINITY) - COVERAGE_MARGIN)
789        .floor()
790        .max(0.0);
791    let top = (fold(|c| c.1, f64::min, f64::INFINITY) - COVERAGE_MARGIN)
792        .floor()
793        .max(0.0);
794    let right = (fold(|c| c.0, f64::max, f64::NEG_INFINITY) + COVERAGE_MARGIN)
795        .ceil()
796        .min(width);
797    let bottom = (fold(|c| c.1, f64::max, f64::NEG_INFINITY) + COVERAGE_MARGIN)
798        .ceil()
799        .min(height);
800    if !(right > left && bottom > top) {
801        return None;
802    }
803    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
804    Some(PixelRect {
805        x: left as u32,
806        y: top as u32,
807        width: (right - left) as u32,
808        height: (bottom - top) as u32,
809    })
810}
811
812/// The canvas background as the intermediate stores it (8-bit or half, round to nearest),
813/// transparent when absent.
814///
815/// Both the empty-canvas clear and the first layer pass use this value. Letting the device
816/// convert the original can round a tie differently (0.7 is 178.49999 in `f32`), so an empty
817/// frame would differ from its neighbour by one code.
818pub(crate) fn stored_background(
819    background: Option<Color>,
820    format: wgpu::TextureFormat,
821) -> [f32; 4] {
822    let Some(color) = background else {
823        return [0.0; 4];
824    };
825    // Clear values reach the device as `f32`.
826    #[allow(clippy::cast_possible_truncation)]
827    let channel = |value: f64| {
828        let value = value as f32;
829        match format {
830            wgpu::TextureFormat::Rgba8Unorm => (value.clamp(0.0, 1.0) * 255.0).round() / 255.0,
831            wgpu::TextureFormat::Rgba16Float => half::f16::from_f32(value).to_f32(),
832            _ => value,
833        }
834    };
835    [
836        channel(color.red),
837        channel(color.green),
838        channel(color.blue),
839        channel(color.alpha),
840    ]
841}
842
843const SAMPLER_BINDING: u32 = 8;
844const BACKDROP_BINDING: u32 = 9;
845const UNIFORM_BINDING: u32 = 10;
846
847/// Composite pass layout: a texture per layer slot, sampler, backdrop and batch uniform.
848fn batch_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
849    let mut entries = (0..COMPOSITE_BATCH_LAYERS)
850        .map(|slot| texture_entry(u32::try_from(slot).expect("few layer slots"), true))
851        .collect::<Vec<_>>();
852    entries.push(wgpu::BindGroupLayoutEntry {
853        binding: SAMPLER_BINDING,
854        visibility: wgpu::ShaderStages::FRAGMENT,
855        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
856        count: None,
857    });
858    entries.push(texture_entry(BACKDROP_BINDING, false));
859    entries.push(wgpu::BindGroupLayoutEntry {
860        binding: UNIFORM_BINDING,
861        visibility: wgpu::ShaderStages::FRAGMENT,
862        ty: batch_uniform_binding(),
863        count: None,
864    });
865    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
866        label: Some("alpha2 layer batch bind group layout"),
867        entries: &entries,
868    })
869}
870
871/// Sized to the graph's reservation so an oversized `BatchParams` fails pipeline creation
872/// instead of overrunning the uniform arena.
873fn batch_uniform_binding() -> wgpu::BindingType {
874    wgpu::BindingType::Buffer {
875        ty: wgpu::BufferBindingType::Uniform,
876        has_dynamic_offset: false,
877        min_binding_size: std::num::NonZeroU64::new(float_uniform_bytes(BATCH_UNIFORM_FLOATS)),
878    }
879}
880
881fn texture_entry(binding: u32, filterable: bool) -> wgpu::BindGroupLayoutEntry {
882    wgpu::BindGroupLayoutEntry {
883        binding,
884        visibility: wgpu::ShaderStages::FRAGMENT,
885        ty: wgpu::BindingType::Texture {
886            sample_type: wgpu::TextureSampleType::Float { filterable },
887            view_dimension: wgpu::TextureViewDimension::D2,
888            multisampled: false,
889        },
890        count: None,
891    }
892}
893
894fn composite_pipeline(
895    device: &wgpu::Device,
896    layout: &wgpu::BindGroupLayout,
897    shader: &wgpu::ShaderModule,
898    format: wgpu::TextureFormat,
899    fragment_entry: &str,
900    label: &str,
901) -> wgpu::RenderPipeline {
902    let label = Some(label);
903    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
904        label,
905        bind_group_layouts: &[Some(layout)],
906        immediate_size: 0,
907    });
908    let constants = [("STORED_FORMAT", StoredValue::of(format).shader_value())];
909    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
910        label,
911        layout: Some(&pipeline_layout),
912        vertex: wgpu::VertexState {
913            module: shader,
914            entry_point: Some("vs"),
915            buffers: &[],
916            compilation_options: wgpu::PipelineCompilationOptions::default(),
917        },
918        fragment: Some(wgpu::FragmentState {
919            module: shader,
920            entry_point: Some(fragment_entry),
921            targets: &[Some(format.into())],
922            compilation_options: wgpu::PipelineCompilationOptions {
923                constants: &constants,
924                ..wgpu::PipelineCompilationOptions::default()
925            },
926        }),
927        primitive: wgpu::PrimitiveState::default(),
928        depth_stencil: None,
929        multisample: wgpu::MultisampleState::default(),
930        multiview_mask: None,
931        cache: None,
932    })
933}
934
935pub(crate) fn validate_visual(visual: LayerTransform) -> Result<(), RenderError> {
936    let values = [
937        visual.position[0],
938        visual.position[1],
939        visual.anchor[0],
940        visual.anchor[1],
941        visual.scale[0],
942        visual.scale[1],
943        visual.rotation_degrees,
944        visual.opacity,
945    ];
946    if values.into_iter().any(|value| !value.is_finite()) || !(0.0..=1.0).contains(&visual.opacity)
947    {
948        return Err(RenderError::InvalidVisual);
949    }
950    Ok(())
951}
952
953/// Whether a layer collapses to a line or point and covers no pixel.
954///
955/// A scale passing through zero is a valid instant, not an error; the transform just has no
956/// inverse.
957pub(crate) fn is_degenerate(visual: LayerTransform) -> bool {
958    visual.scale[0].abs() <= f32::EPSILON || visual.scale[1].abs() <= f32::EPSILON
959}
960
961/// Whether a layer has zero opacity and so leaves every pixel unchanged.
962///
963/// Drawing it would only round the backdrop through the blend space.
964pub(crate) fn is_invisible(visual: LayerTransform) -> bool {
965    visual.opacity <= 0.0
966}