Skip to main content

ff_render/nodes/
glow.rs

1//! Three-pass GPU bloom / glow render node.
2//!
3//! Pass 0 extracts pixels at or above a luma `threshold` (others become black),
4//! pass 1 Gaussian-blurs those highlights (reusing [`GaussianBlurNode`]), and
5//! pass 2 adds the blurred highlights back onto the original frame weighted by
6//! `intensity`. A CPU fallback ([`RenderNodeCpu`]) mirrors the same three steps.
7
8use super::RenderNodeCpu;
9use super::blur::separable_blur_f32;
10
11#[cfg(feature = "wgpu")]
12use super::GaussianBlurNode;
13#[cfg(feature = "wgpu")]
14use super::blur::{create_uniform, fullscreen_pipeline, run_fullscreen, texture_entry};
15
16// GlowNode
17
18/// Three-pass GPU bloom / glow effect.
19///
20/// Pass 0: extract pixels whose luma is `>= threshold` (others become black).
21/// Pass 1: Gaussian-blur the extracted highlights (sigma = `radius`).
22/// Pass 2: additive blend of original + blurred highlights weighted by `intensity`.
23pub struct GlowNode {
24    /// Luminance threshold, clamped to `[0.0, 1.0]`; pixels below it are
25    /// suppressed before the glow blur.
26    pub threshold: f32,
27    /// Gaussian blur sigma in pixels for the glow spread (shares the blur node's
28    /// `[0.5, 20.0]` effective range).
29    pub radius: f32,
30    /// Additive blend weight of the glow layer (`0.0` = no glow / identity).
31    pub intensity: f32,
32    /// The blur node reused for pass 1 (its pipeline caches independently).
33    #[cfg(feature = "wgpu")]
34    blur: GaussianBlurNode,
35    #[cfg(feature = "wgpu")]
36    pipeline: std::sync::OnceLock<GlowPipeline>,
37}
38
39impl GlowNode {
40    /// Creates a glow node with the given threshold, blur radius, and intensity.
41    #[must_use]
42    pub fn new(threshold: f32, radius: f32, intensity: f32) -> Self {
43        Self {
44            threshold,
45            radius,
46            intensity,
47            #[cfg(feature = "wgpu")]
48            blur: GaussianBlurNode::new(radius),
49            #[cfg(feature = "wgpu")]
50            pipeline: std::sync::OnceLock::new(),
51        }
52    }
53}
54
55impl Default for GlowNode {
56    /// Identity node (no glow).
57    fn default() -> Self {
58        Self::new(0.8, 10.0, 0.0)
59    }
60}
61
62// CPU path
63
64impl RenderNodeCpu for GlowNode {
65    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
66    fn process_cpu(&self, rgba: &mut [u8], w: u32, h: u32) {
67        let threshold = self.threshold.clamp(0.0, 1.0);
68
69        // Pass 0: extract highlights into an opaque black scratch buffer.
70        let mut highlights = vec![0u8; rgba.len()];
71        for (dst, px) in highlights
72            .as_chunks_mut::<4>()
73            .0
74            .iter_mut()
75            .zip(rgba.as_chunks::<4>().0)
76        {
77            let luma =
78                (0.299 * f32::from(px[0]) + 0.587 * f32::from(px[1]) + 0.114 * f32::from(px[2]))
79                    / 255.0;
80            if luma >= threshold {
81                dst[0..3].copy_from_slice(&px[0..3]);
82            }
83            dst[3] = 255;
84        }
85
86        // Pass 1: Gaussian-blur the highlights (shared kernel with the blur node).
87        let Some(blurred) = separable_blur_f32(&highlights, w, h, self.radius) else {
88            return;
89        };
90
91        // Pass 2: additive blend of the original with the blurred highlights.
92        for (px, glow) in rgba
93            .as_chunks_mut::<4>()
94            .0
95            .iter_mut()
96            .zip(blurred.as_chunks::<4>().0)
97        {
98            for c in 0..3 {
99                let base = f32::from(px[c]) / 255.0;
100                let out = (base + glow[c] * self.intensity).clamp(0.0, 1.0);
101                px[c] = (out * 255.0 + 0.5) as u8;
102            }
103            // alpha unchanged
104        }
105    }
106}
107
108// GPU path
109
110#[cfg(feature = "wgpu")]
111struct GlowPipeline {
112    extract: EffectPipeline,
113    blend: EffectPipeline,
114}
115
116#[cfg(feature = "wgpu")]
117struct EffectPipeline {
118    render_pipeline: wgpu::RenderPipeline,
119    bind_group_layout: wgpu::BindGroupLayout,
120    uniform_buf: wgpu::Buffer,
121}
122
123#[cfg(feature = "wgpu")]
124impl GlowNode {
125    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &GlowPipeline {
126        self.pipeline.get_or_init(|| GlowPipeline {
127            extract: create_extract_pipeline(ctx, self.threshold.clamp(0.0, 1.0)),
128            blend: create_blend_pipeline(ctx, self.intensity),
129        })
130    }
131}
132
133#[cfg(feature = "wgpu")]
134impl super::RenderNode for GlowNode {
135    fn pass_count(&self) -> usize {
136        3
137    }
138
139    fn process(
140        &self,
141        inputs: &[&wgpu::Texture],
142        outputs: &[&wgpu::Texture],
143        ctx: &crate::context::RenderContext,
144    ) {
145        let Some(input) = inputs.first() else {
146            log::warn!("GlowNode::process called with no inputs");
147            return;
148        };
149        if outputs.len() < 3 {
150            log::warn!("GlowNode::process needs 3 output targets");
151            return;
152        }
153        let pd = self.get_or_create_pipeline(ctx);
154
155        // Pass 0: extract highlights (input -> outputs[0]).
156        encode_uniform_texture_pass(ctx, &pd.extract, &[input], outputs[0], "Glow extract");
157
158        // Pass 1: blur the highlights. Delegating to GaussianBlurNode runs the two
159        // separable passes as outputs[0] -> outputs[1] -> outputs[0], leaving the
160        // blurred highlights in outputs[0]. The extract in outputs[0] is consumed by
161        // the horizontal pass before the vertical pass overwrites it.
162        self.blur
163            .process(&[outputs[0]], &[outputs[1], outputs[0]], ctx);
164
165        // Pass 2: additive blend of the original with the blurred highlights
166        // (input + outputs[0] -> outputs[2], the final target).
167        encode_uniform_texture_pass(
168            ctx,
169            &pd.blend,
170            &[input, outputs[0]],
171            outputs[2],
172            "Glow blend",
173        );
174    }
175}
176
177/// Builds the single-pass highlight-extraction pipeline (texture + `threshold`).
178#[cfg(feature = "wgpu")]
179fn create_extract_pipeline(ctx: &crate::context::RenderContext, threshold: f32) -> EffectPipeline {
180    let device = &ctx.device;
181    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
182        label: Some("Glow extract shader"),
183        source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/glow_extract.wgsl").into()),
184    });
185    let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
186        label: Some("Glow extract BGL"),
187        entries: &[texture_entry(0), uniform_entry(1)],
188    });
189    let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "Glow extract");
190    let uniform_buf = create_uniform(device, "Glow extract uniforms", 16);
191    ctx.queue
192        .write_buffer(&uniform_buf, 0, &pack_scalar(threshold));
193    EffectPipeline {
194        render_pipeline,
195        bind_group_layout: bgl,
196        uniform_buf,
197    }
198}
199
200/// Builds the two-input additive-blend pipeline (original + blurred + `intensity`).
201#[cfg(feature = "wgpu")]
202fn create_blend_pipeline(ctx: &crate::context::RenderContext, intensity: f32) -> EffectPipeline {
203    let device = &ctx.device;
204    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
205        label: Some("Glow blend shader"),
206        source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/glow_blend.wgsl").into()),
207    });
208    let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
209        label: Some("Glow blend BGL"),
210        entries: &[texture_entry(0), texture_entry(1), uniform_entry(2)],
211    });
212    let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "Glow blend");
213    let uniform_buf = create_uniform(device, "Glow blend uniforms", 16);
214    ctx.queue
215        .write_buffer(&uniform_buf, 0, &pack_scalar(intensity));
216    EffectPipeline {
217        render_pipeline,
218        bind_group_layout: bgl,
219        uniform_buf,
220    }
221}
222
223#[cfg(feature = "wgpu")]
224fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
225    wgpu::BindGroupLayoutEntry {
226        binding,
227        visibility: wgpu::ShaderStages::FRAGMENT,
228        ty: wgpu::BindingType::Buffer {
229            ty: wgpu::BufferBindingType::Uniform,
230            has_dynamic_offset: false,
231            min_binding_size: None,
232        },
233        count: None,
234    }
235}
236
237/// Packs one f32 into a 16-byte uniform (the shader pads it to a vec4).
238#[cfg(feature = "wgpu")]
239fn pack_scalar(v: f32) -> [u8; 16] {
240    let mut b = [0u8; 16];
241    b[0..4].copy_from_slice(&v.to_le_bytes());
242    b
243}
244
245/// Encodes one fullscreen pass binding `textures` at bindings `0..n` followed by
246/// the pipeline's uniform at binding `n`, writing into `output`.
247// `textures.len()` is 1 or 2 here, so the binding-index casts cannot truncate.
248#[cfg(feature = "wgpu")]
249#[allow(clippy::cast_possible_truncation)]
250fn encode_uniform_texture_pass(
251    ctx: &crate::context::RenderContext,
252    pd: &EffectPipeline,
253    textures: &[&wgpu::Texture],
254    output: &wgpu::Texture,
255    label: &str,
256) {
257    let views: Vec<wgpu::TextureView> = textures
258        .iter()
259        .map(|t| t.create_view(&wgpu::TextureViewDescriptor::default()))
260        .collect();
261    let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
262
263    let mut entries: Vec<wgpu::BindGroupEntry> = views
264        .iter()
265        .enumerate()
266        .map(|(i, view)| wgpu::BindGroupEntry {
267            binding: i as u32,
268            resource: wgpu::BindingResource::TextureView(view),
269        })
270        .collect();
271    entries.push(wgpu::BindGroupEntry {
272        binding: views.len() as u32,
273        resource: pd.uniform_buf.as_entire_binding(),
274    });
275
276    let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
277        label: Some(label),
278        layout: &pd.bind_group_layout,
279        entries: &entries,
280    });
281    run_fullscreen(ctx, &pd.render_pipeline, &bind_group, &output_view, label);
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    /// A `w × h` opaque black frame with a filled `[x0, x1) × [y0, y1)` white
289    /// rectangle (used as the glow source).
290    fn white_rect(
291        w: usize,
292        h: usize,
293        x0: usize,
294        x1: usize,
295        y0: usize,
296        y1: usize,
297        v: u8,
298    ) -> Vec<u8> {
299        let mut buf = vec![0u8; w * h * 4];
300        for (i, px) in buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
301            let x = i % w;
302            let y = i / w;
303            px[3] = 255;
304            if x >= x0 && x < x1 && y >= y0 && y < y1 {
305                px[0] = v;
306                px[1] = v;
307                px[2] = v;
308            }
309        }
310        buf
311    }
312
313    #[test]
314    fn glow_should_produce_halo_beyond_edge() {
315        // 16x16 white rectangle centred in a 48x48 black frame; right edge at x=32.
316        let (w, h) = (48usize, 48usize);
317        let mut frame = white_rect(w, h, 16, 32, 16, 32, 255);
318        GlowNode::new(0.8, 10.0, 1.0).process_cpu(&mut frame, w as u32, h as u32);
319        // A pixel 5 px beyond the right edge (x=37, y=24) must have gained glow.
320        let p = (24 * w + 37) * 4;
321        assert!(
322            frame[p] > 0,
323            "glow halo must extend >= 5 px beyond the edge; got {}",
324            frame[p]
325        );
326    }
327
328    #[test]
329    fn glow_intensity_zero_should_be_noop() {
330        let (w, h) = (32usize, 32usize);
331        let original = white_rect(w, h, 8, 24, 8, 24, 255);
332        let mut frame = original.clone();
333        GlowNode::new(0.8, 10.0, 0.0).process_cpu(&mut frame, w as u32, h as u32);
334        for (a, b) in frame.iter().zip(original.iter()) {
335            assert!(
336                (i32::from(*a) - i32::from(*b)).abs() <= 1,
337                "intensity 0 must be a no-op (within rounding); got {a} vs {b}"
338            );
339        }
340    }
341
342    #[test]
343    fn glow_high_threshold_should_suppress() {
344        // A bright but sub-white rectangle (luma ~0.78): with threshold 1.1 clamped
345        // to 1.0, no pixel qualifies, so the frame is unchanged.
346        let (w, h) = (32usize, 32usize);
347        let original = white_rect(w, h, 8, 24, 8, 24, 200);
348        let mut frame = original.clone();
349        GlowNode::new(1.1, 10.0, 1.0).process_cpu(&mut frame, w as u32, h as u32);
350        for (a, b) in frame.iter().zip(original.iter()) {
351            assert!(
352                (i32::from(*a) - i32::from(*b)).abs() <= 1,
353                "threshold above the brightest luma must suppress all glow; got {a} vs {b}"
354            );
355        }
356    }
357}
358
359#[cfg(all(test, feature = "wgpu"))]
360mod gpu_tests {
361    use super::*;
362    use crate::context::RenderContext;
363    use crate::graph::RenderGraph;
364    use std::sync::Arc;
365
366    fn ctx() -> Option<Arc<RenderContext>> {
367        match futures::executor::block_on(RenderContext::init()) {
368            Ok(ctx) => Some(Arc::new(ctx)),
369            Err(_) => None,
370        }
371    }
372
373    fn white_rect(
374        w: usize,
375        h: usize,
376        x0: usize,
377        x1: usize,
378        y0: usize,
379        y1: usize,
380        v: u8,
381    ) -> Vec<u8> {
382        let mut buf = vec![0u8; w * h * 4];
383        for (i, px) in buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
384            let x = i % w;
385            let y = i / w;
386            px[3] = 255;
387            if x >= x0 && x < x1 && y >= y0 && y < y1 {
388                px[0] = v;
389                px[1] = v;
390                px[2] = v;
391            }
392        }
393        buf
394    }
395
396    #[test]
397    fn glow_gpu_should_produce_halo_beyond_edge() {
398        let Some(ctx) = ctx() else {
399            return;
400        };
401        let (w, h) = (48u32, 48u32);
402        let frame = white_rect(48, 48, 16, 32, 16, 32, 255);
403        let gpu = RenderGraph::new(Arc::clone(&ctx))
404            .push(GlowNode::new(0.8, 10.0, 1.0))
405            .process_gpu(&frame, w, h)
406            .expect("gpu glow");
407        let p = (24 * 48 + 37) * 4;
408        assert!(
409            gpu[p] > 0,
410            "GPU glow halo must extend >= 5 px beyond the edge; got {}",
411            gpu[p]
412        );
413    }
414
415    #[test]
416    fn glow_gpu_intensity_zero_should_preserve_input() {
417        let Some(ctx) = ctx() else {
418            return;
419        };
420        let (w, h) = (32u32, 32u32);
421        let frame = white_rect(32, 32, 8, 24, 8, 24, 255);
422        let gpu = RenderGraph::new(Arc::clone(&ctx))
423            .push(GlowNode::new(0.8, 10.0, 0.0))
424            .process_gpu(&frame, w, h)
425            .expect("gpu glow");
426        // Sample the rectangle centre and a background pixel: both must be unchanged.
427        for &idx in &[(16 * 32 + 16) * 4, (2 * 32 + 2) * 4] {
428            assert!(
429                (i32::from(gpu[idx]) - i32::from(frame[idx])).abs() <= 1,
430                "intensity 0 must preserve the input on the GPU path"
431            );
432        }
433    }
434}