Skip to main content

proof_engine/render/postfx/
bloom.rs

1//! Bloom post-processing pass.
2//!
3//! Implements a multi-level Gaussian bloom using a luminance threshold extract
4//! followed by a separable ping-pong blur pyramid. The result is additively
5//! blended onto the scene framebuffer.
6//!
7//! ## Pipeline
8//! ```text
9//! scene_color + scene_emission
10//!   ── extract bright pixels ──▶ bright_fbo
11//!   ── H blur (radius 1) ──────▶ blur_h[0]
12//!   ── V blur (radius 1) ──────▶ blur_v[0]
13//!   ── H blur (radius 2) ──────▶ blur_h[1]   ← pyramid level 1
14//!   ── V blur (radius 2) ──────▶ blur_v[1]
15//!   ─── additive composite ────▶ output
16//! ```
17//!
18//! Each pyramid level is half-resolution, giving a wider, softer halo.
19
20
21// ── Bloom parameters ──────────────────────────────────────────────────────────
22
23/// Configuration for the bloom pass.
24#[derive(Clone, Debug)]
25pub struct BloomParams {
26    /// Enable or disable bloom entirely.
27    pub enabled:   bool,
28    /// Minimum luminance to include in bloom (0=all, 1=only bright pixels).
29    pub threshold: f32,
30    /// Additive blend weight of the bloom result.
31    pub intensity: f32,
32    /// Blur kernel radius in pixels (higher = softer/larger).
33    pub radius:    f32,
34    /// Number of pyramid levels (1=single, 3=multi-scale wide bloom).
35    pub levels:    u8,
36    /// Knee: soft threshold falloff width (higher = smoother cutoff).
37    pub knee:      f32,
38    /// Whether to use emission texture as additional bloom input.
39    pub use_emission: bool,
40    /// Contribution weight of the emission texture in bloom.
41    pub emission_weight: f32,
42}
43
44impl Default for BloomParams {
45    fn default() -> Self {
46        Self {
47            enabled:          true,
48            threshold:        0.5,
49            intensity:        1.0,
50            radius:           4.0,
51            levels:           3,
52            knee:             0.1,
53            use_emission:     true,
54            emission_weight:  1.5,
55        }
56    }
57}
58
59impl BloomParams {
60    pub fn disabled() -> Self {
61        Self { enabled: false, ..Self::default() }
62    }
63
64    pub fn subtle() -> Self {
65        Self { threshold: 0.7, intensity: 0.4, radius: 2.0, levels: 2, ..Self::default() }
66    }
67
68    pub fn intense() -> Self {
69        Self { threshold: 0.3, intensity: 2.5, radius: 8.0, levels: 4, ..Self::default() }
70    }
71
72    pub fn retro_crt() -> Self {
73        Self {
74            threshold:   0.6,
75            intensity:   1.2,
76            radius:      3.0,
77            levels:      3,
78            knee:        0.05,
79            emission_weight: 2.0,
80            ..Self::default()
81        }
82    }
83
84    /// Validate and clamp parameters to safe ranges.
85    pub fn validate(&mut self) {
86        self.threshold       = self.threshold.clamp(0.0, 1.0);
87        self.intensity       = self.intensity.clamp(0.0, 10.0);
88        self.radius          = self.radius.clamp(0.5, 32.0);
89        self.levels          = self.levels.clamp(1, 6);
90        self.knee            = self.knee.clamp(0.0, 0.5);
91        self.emission_weight = self.emission_weight.clamp(0.0, 5.0);
92    }
93}
94
95// ── Gaussian kernel ───────────────────────────────────────────────────────────
96
97/// Compute a 1D Gaussian kernel of given `radius` (standard deviation).
98/// Returns weights summing to 1 for a kernel of `2*size+1` taps.
99pub fn gaussian_kernel(sigma: f32, size: usize) -> Vec<f32> {
100    let mut weights: Vec<f32> = (0..=(size as i32 * 2))
101        .map(|i| {
102            let x = (i - size as i32) as f32;
103            (-x * x / (2.0 * sigma * sigma)).exp()
104        })
105        .collect();
106    let sum: f32 = weights.iter().sum();
107    weights.iter_mut().for_each(|w| *w /= sum);
108    weights
109}
110
111/// Separable Gaussian weights optimised for bilinear texture fetches.
112/// Returns `(offsets, weights)` for a half-kernel (center + positive taps).
113/// Linear sampling combines two adjacent texels, halving the tap count.
114pub fn linear_gaussian_kernel(sigma: f32, taps: usize) -> (Vec<f32>, Vec<f32>) {
115    let full = gaussian_kernel(sigma, taps);
116    let half = taps + 1; // center + positive side
117
118    let mut offsets = Vec::with_capacity(half);
119    let mut weights = Vec::with_capacity(half);
120
121    // Center tap
122    offsets.push(0.0);
123    weights.push(full[taps]);
124
125    // Bilinear taps: each combines tap[k] and tap[k+1]
126    let mut k = taps + 1;
127    while k < full.len() - 1 {
128        let w0 = full[k];
129        let w1 = full[k + 1];
130        let w  = w0 + w1;
131        let o  = (k as f32 - taps as f32) + w1 / w;
132        offsets.push(o);
133        weights.push(w);
134        k += 2;
135    }
136    if k < full.len() {
137        offsets.push((k - taps) as f32);
138        weights.push(full[k]);
139    }
140
141    (offsets, weights)
142}
143
144// ── Luminance utilities ───────────────────────────────────────────────────────
145
146/// ITU-R BT.709 luminance coefficients.
147const LUM_R: f32 = 0.2126;
148const LUM_G: f32 = 0.7152;
149const LUM_B: f32 = 0.0722;
150
151/// Compute perceptual luminance from linear RGB.
152#[inline]
153pub fn luminance(r: f32, g: f32, b: f32) -> f32 {
154    LUM_R * r + LUM_G * g + LUM_B * b
155}
156
157/// Soft-threshold a luminance value with knee falloff.
158/// Pixels below `threshold - knee` contribute 0, above `threshold + knee` contribute fully.
159pub fn soft_threshold(lum: f32, threshold: f32, knee: f32) -> f32 {
160    if knee < 1e-5 {
161        return if lum > threshold { 1.0 } else { 0.0 };
162    }
163    let lo = threshold - knee;
164    let hi = threshold + knee;
165    if lum <= lo  { return 0.0; }
166    if lum >= hi  { return 1.0; }
167    let t = (lum - lo) / (2.0 * knee);
168    t * t * (3.0 - 2.0 * t) // smoothstep
169}
170
171/// Extract the bloom contribution from a pixel with a soft threshold.
172/// Returns `(r, g, b)` with the threshold applied.
173pub fn extract_bloom_pixel(r: f32, g: f32, b: f32, threshold: f32, knee: f32) -> (f32, f32, f32) {
174    let lum    = luminance(r, g, b);
175    let weight = soft_threshold(lum, threshold, knee);
176    (r * weight, g * weight, b * weight)
177}
178
179// ── Pyramid level descriptor ──────────────────────────────────────────────────
180
181/// Descriptor for one level of the bloom pyramid.
182#[derive(Debug, Clone)]
183pub struct BloomPyramidLevel {
184    /// Width of this level in pixels.
185    pub width:  u32,
186    /// Height of this level in pixels.
187    pub height: u32,
188    /// Blur sigma (standard deviation) for this level.
189    pub sigma:  f32,
190    /// Contribution weight when compositing all levels.
191    pub weight: f32,
192}
193
194/// Compute the pyramid levels for a given base resolution and params.
195pub fn compute_pyramid(
196    base_width:  u32,
197    base_height: u32,
198    params:      &BloomParams,
199) -> Vec<BloomPyramidLevel> {
200    let n = params.levels as usize;
201    let mut levels = Vec::with_capacity(n);
202
203    for i in 0..n {
204        let scale  = 1u32 << (i + 1); // level 0 = half-res, level 1 = quarter-res ...
205        let w      = (base_width  / scale).max(1);
206        let h      = (base_height / scale).max(1);
207        let sigma  = params.radius * (i as f32 * 0.5 + 1.0);
208        // Higher pyramid levels contribute less (exponential decay)
209        let weight = 1.0 / (i as f32 + 1.0);
210        levels.push(BloomPyramidLevel { width: w, height: h, sigma, weight });
211    }
212    levels
213}
214
215/// Normalise pyramid weights so they sum to 1.
216pub fn normalise_pyramid_weights(levels: &mut [BloomPyramidLevel]) {
217    let total: f32 = levels.iter().map(|l| l.weight).sum();
218    if total > 0.0 {
219        for l in levels.iter_mut() { l.weight /= total; }
220    }
221}
222
223// ── GLSL shader source fragments ──────────────────────────────────────────────
224
225/// GLSL fragment shader source for the bright-extract pass.
226/// Expects:
227///   `u_scene`:     sampler2D — full scene color
228///   `u_emission`:  sampler2D — emission texture (optional)
229///   `u_threshold`: float
230///   `u_knee`:      float
231///   `u_emission_weight`: float
232pub const EXTRACT_FRAG: &str = r#"
233#version 330 core
234
235in  vec2 v_uv;
236out vec4 frag_color;
237
238uniform sampler2D u_scene;
239uniform sampler2D u_emission;
240uniform float     u_threshold;
241uniform float     u_knee;
242uniform float     u_emission_weight;
243
244const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);
245
246float soft_threshold(float lum) {
247    float lo = u_threshold - u_knee;
248    float hi = u_threshold + u_knee;
249    if (lum <= lo) return 0.0;
250    if (lum >= hi) return 1.0;
251    float t = (lum - lo) / (2.0 * u_knee + 0.0001);
252    return t * t * (3.0 - 2.0 * t);
253}
254
255void main() {
256    vec3 scene = texture(u_scene, v_uv).rgb;
257    vec3 emiss = texture(u_emission, v_uv).rgb * u_emission_weight;
258    vec3 combined = scene + emiss;
259
260    float lum    = dot(combined, LUMA);
261    float weight = soft_threshold(lum);
262
263    frag_color = vec4(combined * weight, 1.0);
264}
265"#;
266
267/// GLSL fragment shader source for the separable Gaussian blur pass.
268/// Expects:
269///   `u_texture`:    sampler2D — input texture
270///   `u_texel_size`: vec2      — 1/resolution
271///   `u_direction`:  vec2      — (1,0) for H, (0,1) for V
272///   `u_sigma`:      float     — Gaussian sigma in pixels
273pub const BLUR_FRAG: &str = r#"
274#version 330 core
275
276in  vec2 v_uv;
277out vec4 frag_color;
278
279uniform sampler2D u_texture;
280uniform vec2      u_texel_size;
281uniform vec2      u_direction;
282uniform float     u_sigma;
283
284// Fixed 9-tap kernel weights + offsets (radius 4, precomputed for sigma≈1.5)
285// For variable sigma, you'd compute these on the CPU and upload as uniforms.
286const int  N_TAPS    = 5;
287const float OFFSETS[5] = float[](0.0, 1.3846153846, 3.2307692308, 5.0769230769, 6.9230769231);
288const float WEIGHTS[5] = float[](0.2270270270, 0.3162162162, 0.0702702703, 0.0162162162, 0.0054054054);
289
290void main() {
291    vec4 result = texture(u_texture, v_uv) * WEIGHTS[0];
292    for (int i = 1; i < N_TAPS; ++i) {
293        vec2 off = u_direction * u_texel_size * OFFSETS[i] * (u_sigma / 1.5);
294        result += texture(u_texture, v_uv + off) * WEIGHTS[i];
295        result += texture(u_texture, v_uv - off) * WEIGHTS[i];
296    }
297    frag_color = result;
298}
299"#;
300
301/// GLSL fragment shader source for the bloom composite pass.
302/// Expects:
303///   `u_scene`:     sampler2D — original scene
304///   `u_bloom`:     sampler2D — blurred bloom
305///   `u_intensity`: float     — additive blend weight
306///   `u_dirt`:      sampler2D — optional lens dirt mask
307///   `u_dirt_intensity`: float
308pub const COMPOSITE_FRAG: &str = r#"
309#version 330 core
310
311in  vec2 v_uv;
312out vec4 frag_color;
313
314uniform sampler2D u_scene;
315uniform sampler2D u_bloom;
316uniform float     u_intensity;
317
318void main() {
319    vec3 scene = texture(u_scene, v_uv).rgb;
320    vec3 bloom = texture(u_bloom, v_uv).rgb;
321    // Additive bloom blend
322    vec3 result = scene + bloom * u_intensity;
323    frag_color = vec4(result, 1.0);
324}
325"#;
326
327// ── CPU-side bloom simulation (for testing / software path) ───────────────────
328
329/// Simulate one horizontal Gaussian blur pass on a flat `width × height` RGBA buffer.
330/// `buffer` is `RGBA` interleaved (stride = width * 4).
331pub fn cpu_blur_h(src: &[f32], dst: &mut [f32], width: usize, height: usize, sigma: f32) {
332    let (offsets, weights) = linear_gaussian_kernel(sigma, (sigma * 3.0) as usize + 1);
333    for y in 0..height {
334        for x in 0..width {
335            let mut r = 0.0f32;
336            let mut g = 0.0f32;
337            let mut b = 0.0f32;
338            let mut a = 0.0f32;
339            for (i, &w) in weights.iter().enumerate() {
340                let offset = offsets[i];
341                let xi = (x as f32 + offset).round() as isize;
342                let xi = xi.clamp(0, width as isize - 1) as usize;
343                let idx = (y * width + xi) * 4;
344                r += src[idx    ] * w;
345                g += src[idx + 1] * w;
346                b += src[idx + 2] * w;
347                a += src[idx + 3] * w;
348                if i > 0 {
349                    let xim = (x as f32 - offset).round() as isize;
350                    let xim = xim.clamp(0, width as isize - 1) as usize;
351                    let idxm = (y * width + xim) * 4;
352                    r += src[idxm    ] * w;
353                    g += src[idxm + 1] * w;
354                    b += src[idxm + 2] * w;
355                    a += src[idxm + 3] * w;
356                }
357            }
358            let out = (y * width + x) * 4;
359            dst[out    ] = r;
360            dst[out + 1] = g;
361            dst[out + 2] = b;
362            dst[out + 3] = a;
363        }
364    }
365}
366
367/// Simulate one vertical Gaussian blur pass on a flat RGBA buffer.
368pub fn cpu_blur_v(src: &[f32], dst: &mut [f32], width: usize, height: usize, sigma: f32) {
369    let (offsets, weights) = linear_gaussian_kernel(sigma, (sigma * 3.0) as usize + 1);
370    for y in 0..height {
371        for x in 0..width {
372            let mut r = 0.0f32;
373            let mut g = 0.0f32;
374            let mut b = 0.0f32;
375            let mut a = 0.0f32;
376            for (i, &w) in weights.iter().enumerate() {
377                let offset = offsets[i];
378                let yi  = (y as f32 + offset).round() as isize;
379                let yi  = yi.clamp(0, height as isize - 1) as usize;
380                let idx = (yi * width + x) * 4;
381                r += src[idx    ] * w;
382                g += src[idx + 1] * w;
383                b += src[idx + 2] * w;
384                a += src[idx + 3] * w;
385                if i > 0 {
386                    let yim = (y as f32 - offset).round() as isize;
387                    let yim = yim.clamp(0, height as isize - 1) as usize;
388                    let idxm = (yim * width + x) * 4;
389                    r += src[idxm    ] * w;
390                    g += src[idxm + 1] * w;
391                    b += src[idxm + 2] * w;
392                    a += src[idxm + 3] * w;
393                }
394            }
395            let out = (y * width + x) * 4;
396            dst[out    ] = r;
397            dst[out + 1] = g;
398            dst[out + 2] = b;
399            dst[out + 3] = a;
400        }
401    }
402}
403
404/// Full CPU bloom simulation (extract → H blur → V blur → composite).
405/// Returns a new RGBA buffer with bloom composited onto the input.
406pub fn cpu_bloom(
407    input:  &[f32],
408    width:  usize,
409    height: usize,
410    params: &BloomParams,
411) -> Vec<f32> {
412    let n = width * height * 4;
413    let mut extracted = vec![0.0f32; n];
414    let mut blurred   = vec![0.0f32; n];
415
416    // Extract bright pixels
417    for i in 0..(width * height) {
418        let base  = i * 4;
419        let (r, g, b) = extract_bloom_pixel(input[base], input[base + 1], input[base + 2],
420                                            params.threshold, params.knee);
421        extracted[base    ] = r;
422        extracted[base + 1] = g;
423        extracted[base + 2] = b;
424        extracted[base + 3] = input[base + 3];
425    }
426
427    // H blur
428    let mut tmp = vec![0.0f32; n];
429    cpu_blur_h(&extracted, &mut tmp, width, height, params.radius);
430    // V blur
431    cpu_blur_v(&tmp, &mut blurred, width, height, params.radius);
432
433    // Composite: input + bloom * intensity
434    let mut output = input.to_vec();
435    for i in 0..(width * height) {
436        let base = i * 4;
437        output[base    ] = (output[base    ] + blurred[base    ] * params.intensity).min(1.0);
438        output[base + 1] = (output[base + 1] + blurred[base + 1] * params.intensity).min(1.0);
439        output[base + 2] = (output[base + 2] + blurred[base + 2] * params.intensity).min(1.0);
440    }
441    output
442}
443
444// ── Tests ─────────────────────────────────────────────────────────────────────
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn gaussian_kernel_sums_to_one() {
452        let k = gaussian_kernel(2.0, 4);
453        let sum: f32 = k.iter().sum();
454        assert!((sum - 1.0).abs() < 1e-5, "sum={sum}");
455    }
456
457    #[test]
458    fn soft_threshold_at_zero_knee() {
459        assert_eq!(soft_threshold(0.4, 0.5, 0.0), 0.0);
460        assert_eq!(soft_threshold(0.6, 0.5, 0.0), 1.0);
461    }
462
463    #[test]
464    fn soft_threshold_smooth_at_knee() {
465        let t = soft_threshold(0.5, 0.5, 0.1);
466        assert!(t > 0.0 && t < 1.0, "expected soft transition, got {t}");
467    }
468
469    #[test]
470    fn pyramid_has_correct_level_count() {
471        let params = BloomParams { levels: 3, ..Default::default() };
472        let levels = compute_pyramid(1280, 720, &params);
473        assert_eq!(levels.len(), 3);
474        assert_eq!(levels[0].width, 640);
475        assert_eq!(levels[1].width, 320);
476    }
477
478    #[test]
479    fn cpu_bloom_preserves_size() {
480        let w = 4usize; let h = 4usize;
481        let input: Vec<f32> = vec![0.5; w * h * 4];
482        let output = cpu_bloom(&input, w, h, &BloomParams::default());
483        assert_eq!(output.len(), input.len());
484    }
485}