onedrop_renderer/
border.rs

1//! Outer + inner border pass.
2//!
3//! MD2 paints two rectangular rings around the frame between the wave /
4//! shape overlays and the blur pyramid. Outer ring thickness comes from
5//! `ob_size`, inner from `ib_size`; both are MD2-space fractions of the
6//! **shorter** screen dimension, so a horizontal strip on a 16:9 display
7//! is screen-symmetric (the thickness in x is divided by the aspect
8//! ratio).
9//!
10//! Implementation: one alpha-blended `TriangleList` pipeline, no vertex
11//! buffer. The vertex shader derives the 24-vertex frame from
12//! `@builtin(vertex_index)` and a per-draw uniform carrying the outer
13//! extent (e.g. `(1, 1)` for the outer ring), the inner extent (e.g.
14//! `(1 - t_x, 1 - t_y)`), and the ring colour. Two draws per frame at
15//! most; both are no-ops when the alpha is zero.
16//!
17//! Dispatch slot: after `CustomShapeRenderer`, before the blur pyramid —
18//! so the rings feed back next frame (a high-decay preset trails them
19//! inward, mirroring MD2's behaviour) and contribute to `GetBlur*`.
20
21use bytemuck::{Pod, Zeroable};
22use wgpu::util::DeviceExt;
23
24use crate::config::BorderParams;
25
26/// One ring's per-draw uniform: outer + inner clip-space extents and
27/// the ring's RGBA colour. The vertex shader treats the extents as
28/// `(±outer_x, ±outer_y)` for the outer quad corners and
29/// `(±inner_x, ±inner_y)` for the inner quad corners; the four frame
30/// strips (top/bottom/left/right) are built from those eight corners.
31#[repr(C)]
32#[derive(Debug, Clone, Copy, Pod, Zeroable, PartialEq)]
33pub struct BorderUniform {
34    /// `[outer_x, outer_y, inner_x, inner_y]` in clip space `[0, 1]`.
35    /// We mirror to `(±outer_x, ±outer_y)` inside the shader.
36    pub extents: [f32; 4],
37    /// `[r, g, b, a]`. Pre-multiplied isn't needed — the pipeline uses
38    /// standard alpha blending.
39    pub color: [f32; 4],
40}
41
42impl Default for BorderUniform {
43    fn default() -> Self {
44        Self {
45            extents: [1.0, 1.0, 1.0, 1.0],
46            color: [0.0, 0.0, 0.0, 0.0],
47        }
48    }
49}
50
51/// GPU pass for the two MD2 border rings.
52///
53/// Holds one shader, one alpha-blended pipeline, and two uniform buffers
54/// (outer + inner ring) wired through a shared bind-group layout. Each
55/// frame's [`Self::update`] writes both buffers; [`Self::render`]
56/// dispatches one draw per ring whose alpha is non-zero.
57pub struct BorderRenderer {
58    pipeline: wgpu::RenderPipeline,
59    bind_group_layout: wgpu::BindGroupLayout,
60    outer_uniform: wgpu::Buffer,
61    inner_uniform: wgpu::Buffer,
62    outer_bind_group: wgpu::BindGroup,
63    inner_bind_group: wgpu::BindGroup,
64    outer_visible: bool,
65    inner_visible: bool,
66}
67
68/// Number of vertices the WGSL shader expands per ring. 4 strips × 2
69/// triangles × 3 vertices = 24. Exposed for tests.
70pub const BORDER_VERTICES_PER_RING: u32 = 24;
71
72impl BorderRenderer {
73    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
74        let shader = crate::pipeline_helpers::load_wgsl(
75            device,
76            "Border Shader",
77            include_str!("../shaders/border.wgsl"),
78        );
79
80        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
81            label: Some("Border Bind Group Layout"),
82            entries: &[wgpu::BindGroupLayoutEntry {
83                binding: 0,
84                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
85                ty: wgpu::BindingType::Buffer {
86                    ty: wgpu::BufferBindingType::Uniform,
87                    has_dynamic_offset: false,
88                    min_binding_size: None,
89                },
90                count: None,
91            }],
92        });
93
94        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
95            label: Some("Border Pipeline Layout"),
96            bind_group_layouts: &[Some(&bind_group_layout)],
97            immediate_size: 0,
98        });
99
100        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
101            label: Some("Border Pipeline"),
102            layout: Some(&pipeline_layout),
103            vertex: wgpu::VertexState {
104                module: &shader,
105                entry_point: Some("vs_main"),
106                buffers: &[],
107                compilation_options: Default::default(),
108            },
109            fragment: Some(wgpu::FragmentState {
110                module: &shader,
111                entry_point: Some("fs_main"),
112                targets: &[Some(wgpu::ColorTargetState {
113                    format,
114                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
115                    write_mask: wgpu::ColorWrites::ALL,
116                })],
117                compilation_options: Default::default(),
118            }),
119            primitive: wgpu::PrimitiveState {
120                topology: wgpu::PrimitiveTopology::TriangleList,
121                ..Default::default()
122            },
123            depth_stencil: None,
124            multisample: wgpu::MultisampleState::default(),
125            multiview_mask: None,
126            cache: None,
127        });
128
129        let initial = BorderUniform::default();
130        let make_uniform = |label: &str| {
131            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
132                label: Some(label),
133                contents: bytemuck::bytes_of(&initial),
134                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
135            })
136        };
137        let outer_uniform = make_uniform("Border Outer Uniform");
138        let inner_uniform = make_uniform("Border Inner Uniform");
139
140        let make_bind = |label: &str, buffer: &wgpu::Buffer| {
141            device.create_bind_group(&wgpu::BindGroupDescriptor {
142                label: Some(label),
143                layout: &bind_group_layout,
144                entries: &[wgpu::BindGroupEntry {
145                    binding: 0,
146                    resource: buffer.as_entire_binding(),
147                }],
148            })
149        };
150        let outer_bind_group = make_bind("Border Outer Bind Group", &outer_uniform);
151        let inner_bind_group = make_bind("Border Inner Bind Group", &inner_uniform);
152
153        Self {
154            pipeline,
155            bind_group_layout,
156            outer_uniform,
157            inner_uniform,
158            outer_bind_group,
159            inner_bind_group,
160            outer_visible: false,
161            inner_visible: false,
162        }
163    }
164
165    /// Recompute the per-ring uniforms from a [`BorderParams`] snapshot
166    /// and the current output aspect ratio. Rings with alpha ≤ 0 are
167    /// flagged as invisible so [`Self::render`] skips their draw call.
168    pub fn update(&mut self, queue: &wgpu::Queue, params: BorderParams, aspect_ratio: f32) {
169        let (outer, outer_visible) = ring_uniform(
170            1.0,
171            1.0,
172            params.outer_size,
173            params.outer_color,
174            aspect_ratio,
175        );
176        // Inner ring sits just inboard of the outer one — its outer
177        // extent starts where the outer ring's inner extent stops.
178        let inner_outer_x = outer.extents[2];
179        let inner_outer_y = outer.extents[3];
180        let (inner, inner_visible) = ring_uniform(
181            inner_outer_x,
182            inner_outer_y,
183            params.inner_size,
184            params.inner_color,
185            aspect_ratio,
186        );
187
188        queue.write_buffer(&self.outer_uniform, 0, bytemuck::bytes_of(&outer));
189        queue.write_buffer(&self.inner_uniform, 0, bytemuck::bytes_of(&inner));
190        self.outer_visible = outer_visible;
191        self.inner_visible = inner_visible;
192    }
193
194    /// Render both visible rings into `view`. The pass loads existing
195    /// contents (no clear) so it composites on top of the warp / wave /
196    /// shape output.
197    pub fn render(&self, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
198        if !self.outer_visible && !self.inner_visible {
199            return;
200        }
201        let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
202            label: Some("Border Pass"),
203            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
204                view,
205                depth_slice: None,
206                resolve_target: None,
207                ops: wgpu::Operations {
208                    load: wgpu::LoadOp::Load,
209                    store: wgpu::StoreOp::Store,
210                },
211            })],
212            depth_stencil_attachment: None,
213            timestamp_writes: None,
214            occlusion_query_set: None,
215            multiview_mask: None,
216        });
217        rp.set_pipeline(&self.pipeline);
218        if self.outer_visible {
219            rp.set_bind_group(0, &self.outer_bind_group, &[]);
220            rp.draw(0..BORDER_VERTICES_PER_RING, 0..1);
221        }
222        if self.inner_visible {
223            rp.set_bind_group(0, &self.inner_bind_group, &[]);
224            rp.draw(0..BORDER_VERTICES_PER_RING, 0..1);
225        }
226    }
227
228    /// Number of draw calls the next [`Self::render`] will issue.
229    /// Exposed for tests asserting that the pass becomes a no-op when
230    /// both rings are transparent.
231    pub fn batch_count(&self) -> usize {
232        usize::from(self.outer_visible) + usize::from(self.inner_visible)
233    }
234
235    /// Read-only access to the bind-group layout. Currently used only
236    /// in unit tests that build their own pipelines.
237    pub fn bind_group_layout(&self) -> &wgpu::BindGroupLayout {
238        &self.bind_group_layout
239    }
240}
241
242/// Build a `BorderUniform` for one ring given its outer extent
243/// `(out_x, out_y)` in clip space, the MD2 `size` (a fraction of the
244/// shorter screen axis), the RGBA colour, and the framebuffer aspect
245/// ratio `width / height`.
246///
247/// Returned `(uniform, visible)` — `visible` is `false` when alpha is
248/// ≤ 0 or when the requested thickness is so small it would render zero
249/// pixels.
250fn ring_uniform(
251    out_x: f32,
252    out_y: f32,
253    size: f32,
254    color: [f32; 4],
255    aspect_ratio: f32,
256) -> (BorderUniform, bool) {
257    let alpha = color[3];
258    let s = size.clamp(0.0, 0.5);
259    if alpha <= 0.0 || s <= 0.0 {
260        return (
261            BorderUniform {
262                extents: [out_x, out_y, out_x, out_y],
263                color,
264            },
265            false,
266        );
267    }
268
269    // MD2 sizes are fractions of the shorter screen dimension. Apply
270    // them in clip space (range 2.0): `t_y = 2.0 * size`. To keep the
271    // ring screen-symmetric on non-square aspect ratios we divide the
272    // x thickness by the aspect ratio (width / height ≥ 1 for typical
273    // landscape outputs; the same formula works for portrait — the
274    // ring just gets thicker in y).
275    let aspect_safe = aspect_ratio.max(1e-3);
276    let t_y = 2.0 * s;
277    let t_x = if aspect_safe >= 1.0 {
278        t_y / aspect_safe
279    } else {
280        t_y
281    };
282    let t_y = if aspect_safe < 1.0 {
283        t_y * aspect_safe
284    } else {
285        t_y
286    };
287    let in_x = (out_x - t_x).max(0.0);
288    let in_y = (out_y - t_y).max(0.0);
289
290    (
291        BorderUniform {
292            extents: [out_x, out_y, in_x, in_y],
293            color,
294        },
295        true,
296    )
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn zero_alpha_is_invisible() {
305        let (_, visible) = ring_uniform(1.0, 1.0, 0.05, [1.0, 0.0, 0.0, 0.0], 1.0);
306        assert!(!visible);
307    }
308
309    #[test]
310    fn zero_size_is_invisible() {
311        let (_, visible) = ring_uniform(1.0, 1.0, 0.0, [1.0, 0.0, 0.0, 1.0], 1.0);
312        assert!(!visible);
313    }
314
315    #[test]
316    fn aspect_correction_keeps_ring_symmetric() {
317        // Square aspect: x and y thickness equal.
318        let (sq, vis_sq) = ring_uniform(1.0, 1.0, 0.05, [1.0; 4], 1.0);
319        assert!(vis_sq);
320        let dx_sq = 1.0 - sq.extents[2];
321        let dy_sq = 1.0 - sq.extents[3];
322        assert!((dx_sq - dy_sq).abs() < 1e-5);
323
324        // 16:9: shorter dim is y, so x gets thinner in clip space.
325        let aspect = 16.0_f32 / 9.0;
326        let (wide, vis_w) = ring_uniform(1.0, 1.0, 0.05, [1.0; 4], aspect);
327        assert!(vis_w);
328        let dx = 1.0 - wide.extents[2];
329        let dy = 1.0 - wide.extents[3];
330        // dy should be the same as the square case (anchored on shorter
331        // dim), dx should be scaled down by aspect.
332        assert!((dy - dy_sq).abs() < 1e-5);
333        assert!((dx - dy / aspect).abs() < 1e-5);
334    }
335
336    #[test]
337    fn portrait_aspect_swaps_axis() {
338        // 9:16 portrait: shorter dim is x, so y gets the divisor.
339        let aspect = 9.0_f32 / 16.0;
340        let (port, vis) = ring_uniform(1.0, 1.0, 0.05, [1.0; 4], aspect);
341        assert!(vis);
342        let dx = 1.0 - port.extents[2];
343        let dy = 1.0 - port.extents[3];
344        // x thickness should match the square case; y should scale down.
345        assert!((dx - 2.0 * 0.05).abs() < 1e-5);
346        assert!((dy - dx * aspect).abs() < 1e-5);
347    }
348
349    #[test]
350    fn inner_extent_clamps_at_zero() {
351        // Asking for a thickness larger than the half-extent should
352        // clamp to 0 instead of producing a negative inner edge.
353        let (uni, _vis) = ring_uniform(1.0, 1.0, 0.5, [1.0; 4], 1.0);
354        assert!(uni.extents[2] >= 0.0);
355        assert!(uni.extents[3] >= 0.0);
356    }
357}