onedrop_renderer/
final_blend.rs

1//! Final blend pipeline: composites primary + secondary chain outputs into
2//! the renderer's display texture.
3//!
4//! When no transition is active (`secondary == None`), the pass behaves as
5//! a passthrough of `primary.final_texture`. When a transition is in
6//! flight, it samples both chains' `final_texture` and lerps them by
7//! `progress`:
8//!
9//! ```text
10//! display.rgb = mix(secondary.rgb, primary.rgb, progress)
11//! ```
12//!
13//! `progress` is the [`Transition::progress`](crate::transition::Transition)
14//! eased value in `[0, 1]`. At `progress = 0` the display shows the old
15//! preset (the secondary chain, fading out); at `progress = 1` the new one
16//! (primary, fading in). The eased curve is fed straight from
17//! [`onedrop_engine::transition`].
18//!
19//! Cost when no transition is in flight: one fullscreen triangle on a
20//! single sampled texture (~0.1 ms at 1080p on a modern GPU). The fallback
21//! 1×1 secondary view keeps the bind group layout uniform across both
22//! states so we never rebuild the pipeline mid-frame.
23
24use crate::config::RenderConfig;
25use crate::error::Result;
26use bytemuck::{Pod, Zeroable};
27use std::sync::Arc;
28use wgpu::util::DeviceExt;
29
30/// Per-pass uniforms: `progress` is the eased transition value in `[0, 1]`;
31/// `has_secondary` is `1` when a secondary chain is active, `0` otherwise
32/// — keeps the shader from sampling the fallback texture for nothing.
33#[repr(C)]
34#[derive(Debug, Clone, Copy, Pod, Zeroable)]
35pub struct BlendUniforms {
36    pub progress: f32,
37    pub has_secondary: u32,
38    _pad: [f32; 2],
39}
40
41impl Default for BlendUniforms {
42    fn default() -> Self {
43        Self {
44            progress: 0.0,
45            has_secondary: 0,
46            _pad: [0.0; 2],
47        }
48    }
49}
50
51/// Owns the blend pipeline, its bind group, the per-frame uniform buffer,
52/// and the display target it writes into. Created once at renderer init
53/// and never reallocated; only the bind group is rebuilt when a secondary
54/// chain is added or removed.
55pub struct FinalBlendPipeline {
56    pipeline: wgpu::RenderPipeline,
57    bind_group_layout: wgpu::BindGroupLayout,
58    sampler: wgpu::Sampler,
59    uniform_buffer: wgpu::Buffer,
60    bind_group: wgpu::BindGroup,
61    /// 1×1 fallback bound into the secondary slot when no secondary chain
62    /// is active. The shader skips it via `has_secondary == 0` but wgpu
63    /// still requires a valid view at every binding.
64    fallback_view: wgpu::TextureView,
65    /// Display target this pass writes into. The renderer exposes it via
66    /// `render_texture()` so existing callers see the post-blend image.
67    display_texture: wgpu::Texture,
68    display_texture_view: wgpu::TextureView,
69}
70
71impl FinalBlendPipeline {
72    /// Build the blend pipeline. `primary_view` is the initial primary
73    /// chain's `final_texture_view`; the bind group is configured for
74    /// passthrough (secondary = fallback, progress = 0) at construction.
75    pub fn new(
76        device: &Arc<wgpu::Device>,
77        config: &RenderConfig,
78        primary_view: &wgpu::TextureView,
79    ) -> Result<Self> {
80        let display_texture = device.create_texture(&wgpu::TextureDescriptor {
81            label: Some("Display Texture"),
82            size: wgpu::Extent3d {
83                width: config.width,
84                height: config.height,
85                depth_or_array_layers: 1,
86            },
87            mip_level_count: 1,
88            sample_count: 1,
89            dimension: wgpu::TextureDimension::D2,
90            format: config.texture_format.to_wgpu(),
91            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
92                | wgpu::TextureUsages::TEXTURE_BINDING
93                | wgpu::TextureUsages::COPY_SRC,
94            view_formats: &[],
95        });
96        let display_texture_view =
97            display_texture.create_view(&wgpu::TextureViewDescriptor::default());
98
99        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
100            label: Some("Final Blend Sampler"),
101            address_mode_u: wgpu::AddressMode::ClampToEdge,
102            address_mode_v: wgpu::AddressMode::ClampToEdge,
103            address_mode_w: wgpu::AddressMode::ClampToEdge,
104            mag_filter: wgpu::FilterMode::Linear,
105            min_filter: wgpu::FilterMode::Linear,
106            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
107            ..Default::default()
108        });
109
110        // 1×1 black fallback for the secondary slot when no transition is
111        // active. The shader's `has_secondary == 0` branch skips sampling
112        // it; this purely satisfies the bind group's "all bindings valid"
113        // requirement.
114        let fallback_texture = device.create_texture(&wgpu::TextureDescriptor {
115            label: Some("Final Blend Fallback (1x1)"),
116            size: wgpu::Extent3d {
117                width: 1,
118                height: 1,
119                depth_or_array_layers: 1,
120            },
121            mip_level_count: 1,
122            sample_count: 1,
123            dimension: wgpu::TextureDimension::D2,
124            format: config.texture_format.to_wgpu(),
125            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
126            view_formats: &[],
127        });
128        let fallback_view = fallback_texture.create_view(&wgpu::TextureViewDescriptor::default());
129
130        let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
131            label: Some("Final Blend Uniforms"),
132            contents: bytemuck::bytes_of(&BlendUniforms::default()),
133            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
134        });
135
136        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
137            label: Some("Final Blend BGL"),
138            entries: &[
139                wgpu::BindGroupLayoutEntry {
140                    binding: 0,
141                    visibility: wgpu::ShaderStages::FRAGMENT,
142                    ty: wgpu::BindingType::Buffer {
143                        ty: wgpu::BufferBindingType::Uniform,
144                        has_dynamic_offset: false,
145                        min_binding_size: None,
146                    },
147                    count: None,
148                },
149                wgpu::BindGroupLayoutEntry {
150                    binding: 1,
151                    visibility: wgpu::ShaderStages::FRAGMENT,
152                    ty: wgpu::BindingType::Texture {
153                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
154                        view_dimension: wgpu::TextureViewDimension::D2,
155                        multisampled: false,
156                    },
157                    count: None,
158                },
159                wgpu::BindGroupLayoutEntry {
160                    binding: 2,
161                    visibility: wgpu::ShaderStages::FRAGMENT,
162                    ty: wgpu::BindingType::Texture {
163                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
164                        view_dimension: wgpu::TextureViewDimension::D2,
165                        multisampled: false,
166                    },
167                    count: None,
168                },
169                wgpu::BindGroupLayoutEntry {
170                    binding: 3,
171                    visibility: wgpu::ShaderStages::FRAGMENT,
172                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
173                    count: None,
174                },
175            ],
176        });
177
178        let bind_group = make_bind_group(
179            device,
180            &bind_group_layout,
181            &uniform_buffer,
182            primary_view,
183            &fallback_view,
184            &sampler,
185        );
186
187        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
188            label: Some("Final Blend Shader"),
189            source: wgpu::ShaderSource::Wgsl(SHADER.into()),
190        });
191
192        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
193            label: Some("Final Blend Pipeline Layout"),
194            bind_group_layouts: &[Some(&bind_group_layout)],
195            immediate_size: 0,
196        });
197
198        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
199            label: Some("Final Blend Pipeline"),
200            layout: Some(&pipeline_layout),
201            vertex: wgpu::VertexState {
202                module: &shader,
203                entry_point: Some("vs_main"),
204                buffers: &[],
205                compilation_options: Default::default(),
206            },
207            fragment: Some(wgpu::FragmentState {
208                module: &shader,
209                entry_point: Some("fs_main"),
210                targets: &[Some(wgpu::ColorTargetState {
211                    format: config.texture_format.to_wgpu(),
212                    blend: None,
213                    write_mask: wgpu::ColorWrites::ALL,
214                })],
215                compilation_options: Default::default(),
216            }),
217            primitive: wgpu::PrimitiveState {
218                topology: wgpu::PrimitiveTopology::TriangleList,
219                ..Default::default()
220            },
221            depth_stencil: None,
222            multisample: wgpu::MultisampleState::default(),
223            multiview_mask: None,
224            cache: None,
225        });
226
227        Ok(Self {
228            pipeline,
229            bind_group_layout,
230            sampler,
231            uniform_buffer,
232            bind_group,
233            fallback_view,
234            display_texture,
235            display_texture_view,
236        })
237    }
238
239    /// The final display texture written by `record_pass`. Callers expose
240    /// it as the renderer's "presentable" output.
241    /// Borrow the display texture view — needed by overlay passes
242    /// (text, future HUD) that draw on top of the blended output.
243    pub fn display_texture_view(&self) -> &wgpu::TextureView {
244        &self.display_texture_view
245    }
246
247    pub fn display_texture(&self) -> &wgpu::Texture {
248        &self.display_texture
249    }
250
251    /// Refresh the bind group with the current primary chain's
252    /// `final_texture_view` and, optionally, a secondary chain's view. Call
253    /// this whenever either chain's textures get re-allocated (resize,
254    /// transition start/end).
255    pub fn set_inputs(
256        &mut self,
257        device: &Arc<wgpu::Device>,
258        primary_view: &wgpu::TextureView,
259        secondary_view: Option<&wgpu::TextureView>,
260    ) {
261        let secondary = secondary_view.unwrap_or(&self.fallback_view);
262        self.bind_group = make_bind_group(
263            device,
264            &self.bind_group_layout,
265            &self.uniform_buffer,
266            primary_view,
267            secondary,
268            &self.sampler,
269        );
270    }
271
272    /// Update the eased progress (`0` = full secondary, `1` = full primary)
273    /// and the `has_secondary` flag in one upload.
274    pub fn update_progress(&self, queue: &wgpu::Queue, progress: f32, has_secondary: bool) {
275        let u = BlendUniforms {
276            progress: progress.clamp(0.0, 1.0),
277            has_secondary: if has_secondary { 1 } else { 0 },
278            _pad: [0.0; 2],
279        };
280        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&u));
281    }
282
283    /// Rebuild the display target at a new resolution. Must be followed by
284    /// a `set_inputs` call to rebind the (also-resized) primary view.
285    pub fn resize(&mut self, device: &Arc<wgpu::Device>, config: &RenderConfig) {
286        self.display_texture = device.create_texture(&wgpu::TextureDescriptor {
287            label: Some("Display Texture"),
288            size: wgpu::Extent3d {
289                width: config.width,
290                height: config.height,
291                depth_or_array_layers: 1,
292            },
293            mip_level_count: 1,
294            sample_count: 1,
295            dimension: wgpu::TextureDimension::D2,
296            format: config.texture_format.to_wgpu(),
297            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
298                | wgpu::TextureUsages::TEXTURE_BINDING
299                | wgpu::TextureUsages::COPY_SRC,
300            view_formats: &[],
301        });
302        self.display_texture_view = self
303            .display_texture
304            .create_view(&wgpu::TextureViewDescriptor::default());
305    }
306
307    /// Record the blend pass into the supplied encoder. Output goes to the
308    /// display texture; caller is responsible for the submit.
309    pub fn record_pass(&self, encoder: &mut wgpu::CommandEncoder) {
310        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
311            label: Some("Final Blend Pass"),
312            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
313                view: &self.display_texture_view,
314                depth_slice: None,
315                resolve_target: None,
316                ops: wgpu::Operations {
317                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
318                    store: wgpu::StoreOp::Store,
319                },
320            })],
321            depth_stencil_attachment: None,
322            timestamp_writes: None,
323            occlusion_query_set: None,
324            multiview_mask: None,
325        });
326        pass.set_pipeline(&self.pipeline);
327        pass.set_bind_group(0, &self.bind_group, &[]);
328        pass.draw(0..3, 0..1);
329    }
330}
331
332fn make_bind_group(
333    device: &wgpu::Device,
334    layout: &wgpu::BindGroupLayout,
335    uniforms: &wgpu::Buffer,
336    primary_view: &wgpu::TextureView,
337    secondary_view: &wgpu::TextureView,
338    sampler: &wgpu::Sampler,
339) -> wgpu::BindGroup {
340    device.create_bind_group(&wgpu::BindGroupDescriptor {
341        label: Some("Final Blend BG"),
342        layout,
343        entries: &[
344            wgpu::BindGroupEntry {
345                binding: 0,
346                resource: uniforms.as_entire_binding(),
347            },
348            wgpu::BindGroupEntry {
349                binding: 1,
350                resource: wgpu::BindingResource::TextureView(primary_view),
351            },
352            wgpu::BindGroupEntry {
353                binding: 2,
354                resource: wgpu::BindingResource::TextureView(secondary_view),
355            },
356            wgpu::BindGroupEntry {
357                binding: 3,
358                resource: wgpu::BindingResource::Sampler(sampler),
359            },
360        ],
361    })
362}
363
364const SHADER: &str = r#"
365struct Uniforms {
366    progress: f32,
367    has_secondary: u32,
368    pad0: f32,
369    pad1: f32,
370};
371
372@group(0) @binding(0) var<uniform> u: Uniforms;
373@group(0) @binding(1) var primary_tex: texture_2d<f32>;
374@group(0) @binding(2) var secondary_tex: texture_2d<f32>;
375@group(0) @binding(3) var samp: sampler;
376
377struct VsOut {
378    @builtin(position) position: vec4<f32>,
379    @location(0) uv: vec2<f32>,
380};
381
382@vertex
383fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
384    let x = f32(idx & 1u) * 4.0 - 1.0;
385    let y = f32((idx >> 1u) & 1u) * 4.0 - 1.0;
386    var out: VsOut;
387    out.position = vec4<f32>(x, y, 0.0, 1.0);
388    out.uv = vec2<f32>((x + 1.0) * 0.5, 1.0 - (y + 1.0) * 0.5);
389    return out;
390}
391
392@fragment
393fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
394    let primary = textureSample(primary_tex, samp, in.uv);
395    if (u.has_secondary == 0u) {
396        return primary;
397    }
398    let secondary = textureSample(secondary_tex, samp, in.uv);
399    return mix(secondary, primary, u.progress);
400}
401"#;