Skip to main content

proof_engine/render/postfx/
bloom_pipeline.rs

1//! Proper Bloom Pipeline — multi-level downsample/upsample bloom with brightness threshold.
2//!
3//! Only intentionally bright things glow. UI text (emission 0.0) never blooms.
4//! Damage numbers (emission 1.5), crits (emission 2.5), spells (emission 2.0+),
5//! boss auras (emission 1.0) all bloom at appropriate intensities.
6//!
7//! # Pipeline
8//!
9//! ```text
10//! scene_emission_tex (from glyph fragment shader, attachment 1)
11//!   └─ Threshold extract (luminance > threshold) ─▶ bright_tex (full res)
12//!       ├─ Downsample ½  ─▶ mip[0] ── H blur ── V blur ─▶ blurred[0]
13//!       ├─ Downsample ¼  ─▶ mip[1] ── H blur ── V blur ─▶ blurred[1]
14//!       ├─ Downsample ⅛  ─▶ mip[2] ── H blur ── V blur ─▶ blurred[2]
15//!       ├─ Downsample 1/16 ─▶ mip[3] ── H blur ── V blur ─▶ blurred[3]
16//!       └─ Downsample 1/32 ─▶ mip[4] ── H blur ── V blur ─▶ blurred[4]
17//!   Upsample chain: blurred[4] + blurred[3] + ... + blurred[0] ─▶ bloom_result
18//!   Composite: scene_color + bloom_result × intensity ─▶ output
19//! ```
20
21use super::bloom::{BloomParams, BloomPyramidLevel, compute_pyramid, normalise_pyramid_weights};
22
23// ── Emission presets ────────────────────────────────────────────────────────
24
25/// Standard emission values for different game element categories.
26/// These control which elements participate in bloom and how strongly.
27pub struct EmissionPresets;
28
29impl EmissionPresets {
30    // ── UI / Text ───────────────────────────────────────────────────────
31    /// UI text, labels, panels — never blooms.
32    pub const UI_TEXT: f32 = 0.0;
33    /// Menu highlight — very subtle.
34    pub const UI_HIGHLIGHT: f32 = 0.1;
35
36    // ── Environment ─────────────────────────────────────────────────────
37    /// Standard world glyphs — no bloom.
38    pub const WORLD_GLYPH: f32 = 0.0;
39    /// Chaos field background — subtle ambient glow.
40    pub const CHAOS_FIELD: f32 = 0.35;
41    /// Chaos field intense regions.
42    pub const CHAOS_FIELD_INTENSE: f32 = 0.5;
43    /// Torch/light source.
44    pub const LIGHT_SOURCE: f32 = 0.8;
45
46    // ── Combat ──────────────────────────────────────────────────────────
47    /// Normal damage numbers.
48    pub const DAMAGE_NUMBER: f32 = 1.5;
49    /// Critical hit numbers — strong glow.
50    pub const CRIT_NUMBER: f32 = 2.5;
51    /// Healing numbers.
52    pub const HEAL_NUMBER: f32 = 1.2;
53    /// Miss/block text.
54    pub const MISS_TEXT: f32 = 0.3;
55
56    // ── Spells & Effects ────────────────────────────────────────────────
57    /// Spell cast effect.
58    pub const SPELL_EFFECT: f32 = 2.0;
59    /// Spell impact flash.
60    pub const SPELL_IMPACT: f32 = 3.0;
61    /// Buff/debuff aura.
62    pub const BUFF_AURA: f32 = 1.0;
63
64    // ── Bosses ──────────────────────────────────────────────────────────
65    /// Boss idle aura.
66    pub const BOSS_AURA: f32 = 1.0;
67    /// Boss attack flash.
68    pub const BOSS_ATTACK: f32 = 2.0;
69    /// Boss phase transition.
70    pub const BOSS_PHASE_CHANGE: f32 = 3.5;
71
72    // ── Special ─────────────────────────────────────────────────────────
73    /// Shrine glow.
74    pub const SHRINE: f32 = 1.5;
75    /// Portal glow.
76    pub const PORTAL: f32 = 2.0;
77    /// Corruption visual.
78    pub const CORRUPTION: f32 = 0.8;
79    /// Loot drop sparkle.
80    pub const LOOT_SPARKLE: f32 = 1.8;
81}
82
83// ── Theme-based bloom profiles ──────────────────────────────────────────────
84
85/// Bloom intensity profiles per game theme.
86pub struct ThemeBloom;
87
88impl ThemeBloom {
89    /// VOID PROTOCOL: subtle, restrained bloom.
90    pub fn void_protocol() -> BloomParams {
91        BloomParams {
92            enabled: true,
93            threshold: 0.8,
94            intensity: 0.4,
95            radius: 3.0,
96            levels: 3,
97            knee: 0.1,
98            use_emission: true,
99            emission_weight: 1.0,
100        }
101    }
102
103    /// SOLAR FORGE: dramatic, bright bloom.
104    pub fn solar_forge() -> BloomParams {
105        BloomParams {
106            enabled: true,
107            threshold: 0.5,
108            intensity: 2.0,
109            radius: 6.0,
110            levels: 5,
111            knee: 0.15,
112            use_emission: true,
113            emission_weight: 2.0,
114        }
115    }
116
117    /// NEON GRID: cyberpunk-style vivid bloom.
118    pub fn neon_grid() -> BloomParams {
119        BloomParams {
120            enabled: true,
121            threshold: 0.6,
122            intensity: 1.5,
123            radius: 4.0,
124            levels: 4,
125            knee: 0.1,
126            use_emission: true,
127            emission_weight: 1.8,
128        }
129    }
130
131    /// CORRUPTION: everything slightly brighter as corruption rises.
132    pub fn corruption(level: f32) -> BloomParams {
133        let corruption_boost = (level / 1000.0).clamp(0.0, 1.0);
134        BloomParams {
135            enabled: true,
136            threshold: 0.7 - corruption_boost * 0.3,
137            intensity: 0.6 + corruption_boost * 1.5,
138            radius: 4.0 + corruption_boost * 3.0,
139            levels: 4,
140            knee: 0.1 + corruption_boost * 0.1,
141            use_emission: true,
142            emission_weight: 1.2 + corruption_boost * 0.8,
143        }
144    }
145
146    /// Boss fight: high contrast, focused bloom.
147    pub fn boss_fight() -> BloomParams {
148        BloomParams {
149            enabled: true,
150            threshold: 0.6,
151            intensity: 1.8,
152            radius: 5.0,
153            levels: 4,
154            knee: 0.12,
155            use_emission: true,
156            emission_weight: 1.5,
157        }
158    }
159
160    /// Death: bloom fades out.
161    pub fn death(progress: f32) -> BloomParams {
162        BloomParams {
163            enabled: true,
164            threshold: 0.5 + progress * 0.4,
165            intensity: 1.0 * (1.0 - progress),
166            radius: 4.0,
167            levels: 3,
168            knee: 0.1,
169            use_emission: true,
170            emission_weight: 1.0 * (1.0 - progress * 0.5),
171        }
172    }
173
174    /// Shrine: warm, soft bloom.
175    pub fn shrine() -> BloomParams {
176        BloomParams {
177            enabled: true,
178            threshold: 0.5,
179            intensity: 1.2,
180            radius: 6.0,
181            levels: 5,
182            knee: 0.2,
183            use_emission: true,
184            emission_weight: 1.5,
185        }
186    }
187}
188
189// ── Multi-level bloom GPU pipeline descriptor ───────────────────────────────
190
191/// Describes the full multi-level bloom pipeline state for one frame.
192///
193/// This is a CPU-side descriptor used to configure the GPU passes.
194/// The actual GL rendering is done by PostFxPipeline using these parameters.
195#[derive(Debug, Clone)]
196pub struct BloomPipelineState {
197    /// Active bloom parameters for this frame.
198    pub params: BloomParams,
199    /// Computed pyramid levels with sizes and weights.
200    pub levels: Vec<BloomPyramidLevel>,
201    /// Base resolution.
202    pub base_width: u32,
203    pub base_height: u32,
204    /// Total number of draw calls this pipeline will issue.
205    pub draw_call_count: u32,
206}
207
208impl BloomPipelineState {
209    /// Compute the pipeline state for a given frame.
210    pub fn compute(params: &BloomParams, width: u32, height: u32) -> Self {
211        let mut levels = compute_pyramid(width, height, params);
212        normalise_pyramid_weights(&mut levels);
213
214        // Each level: 1 downsample + 2 blur passes (H+V) + 1 upsample = 4 draws
215        // Plus: 1 threshold extract + 1 final composite = 2 more
216        let draw_call_count = if params.enabled {
217            1 + levels.len() as u32 * 4 + 1
218        } else {
219            0
220        };
221
222        Self {
223            params: params.clone(),
224            levels,
225            base_width: width,
226            base_height: height,
227            draw_call_count,
228        }
229    }
230
231    /// Whether bloom is active.
232    pub fn is_active(&self) -> bool { self.params.enabled && !self.levels.is_empty() }
233
234    /// Number of mip levels.
235    pub fn level_count(&self) -> usize { self.levels.len() }
236}
237
238// ── GLSL shader sources for multi-level bloom ───────────────────────────────
239
240/// Brightness threshold extraction shader.
241///
242/// Extracts pixels above a luminance threshold from the emission texture.
243/// Uses a soft knee for smooth falloff.
244pub const BLOOM_THRESHOLD_FRAG: &str = r#"
245#version 330 core
246
247in  vec2 f_uv;
248out vec4 frag_color;
249
250uniform sampler2D u_emission;
251uniform float     u_threshold;
252uniform float     u_knee;
253uniform float     u_emission_weight;
254
255const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);
256
257void main() {
258    vec3 emiss = texture(u_emission, f_uv).rgb * u_emission_weight;
259    float lum  = dot(emiss, LUMA);
260
261    // Soft threshold with knee
262    float lo = u_threshold - u_knee;
263    float hi = u_threshold + u_knee;
264    float weight;
265    if (lum <= lo) weight = 0.0;
266    else if (lum >= hi) weight = 1.0;
267    else {
268        float t = (lum - lo) / (2.0 * u_knee + 0.0001);
269        weight = t * t * (3.0 - 2.0 * t);
270    }
271
272    frag_color = vec4(emiss * weight, 1.0);
273}
274"#;
275
276/// Downsample shader (box filter 2x2).
277/// Takes a texture and renders it at half resolution.
278pub const BLOOM_DOWNSAMPLE_FRAG: &str = r#"
279#version 330 core
280
281in  vec2 f_uv;
282out vec4 frag_color;
283
284uniform sampler2D u_texture;
285uniform vec2      u_texel_size;
286
287void main() {
288    // 4-tap box downsample (tent filter)
289    vec3 a = texture(u_texture, f_uv + u_texel_size * vec2(-0.5, -0.5)).rgb;
290    vec3 b = texture(u_texture, f_uv + u_texel_size * vec2( 0.5, -0.5)).rgb;
291    vec3 c = texture(u_texture, f_uv + u_texel_size * vec2(-0.5,  0.5)).rgb;
292    vec3 d = texture(u_texture, f_uv + u_texel_size * vec2( 0.5,  0.5)).rgb;
293    frag_color = vec4((a + b + c + d) * 0.25, 1.0);
294}
295"#;
296
297/// Upsample shader (bilinear + additive blend).
298/// Combines the current mip level with the upsampled lower mip.
299pub const BLOOM_UPSAMPLE_FRAG: &str = r#"
300#version 330 core
301
302in  vec2 f_uv;
303out vec4 frag_color;
304
305uniform sampler2D u_lower_mip;   // smaller, more blurred
306uniform sampler2D u_current_mip; // current resolution
307uniform float     u_weight;      // blend weight for the lower mip
308
309void main() {
310    vec3 lower   = texture(u_lower_mip, f_uv).rgb;
311    vec3 current = texture(u_current_mip, f_uv).rgb;
312    frag_color = vec4(current + lower * u_weight, 1.0);
313}
314"#;
315
316/// Separable Gaussian blur with variable sigma.
317/// Direction (1,0) for horizontal, (0,1) for vertical.
318pub const BLOOM_BLUR_FRAG: &str = r#"
319#version 330 core
320
321in  vec2 f_uv;
322out vec4 frag_color;
323
324uniform sampler2D u_texture;
325uniform vec2      u_texel_size;
326uniform vec2      u_direction;
327uniform float     u_sigma;
328
329// 9-tap kernel (bilinear optimized to 5 taps)
330const int  N_TAPS = 5;
331const float OFFSETS[5] = float[](0.0, 1.3846, 3.2308, 5.0769, 6.9231);
332const float WEIGHTS[5] = float[](0.2270, 0.3162, 0.0703, 0.0162, 0.0054);
333
334void main() {
335    vec3 result = texture(u_texture, f_uv).rgb * WEIGHTS[0];
336    float scale = u_sigma / 1.5;
337
338    for (int i = 1; i < N_TAPS; ++i) {
339        vec2 off = u_direction * u_texel_size * OFFSETS[i] * scale;
340        result += texture(u_texture, f_uv + off).rgb * WEIGHTS[i];
341        result += texture(u_texture, f_uv - off).rgb * WEIGHTS[i];
342    }
343    frag_color = vec4(result, 1.0);
344}
345"#;
346
347// ── Bloom pipeline statistics ───────────────────────────────────────────────
348
349/// Per-frame bloom statistics.
350#[derive(Debug, Clone, Default)]
351pub struct BloomStats {
352    pub enabled: bool,
353    pub levels: u8,
354    pub threshold: f32,
355    pub intensity: f32,
356    pub draw_calls: u32,
357    pub brightest_pixel_lum: f32,
358}
359
360// ── Tests ───────────────────────────────────────────────────────────────────
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn emission_presets_hierarchy() {
368        assert_eq!(EmissionPresets::UI_TEXT, 0.0);
369        assert!(EmissionPresets::CHAOS_FIELD < EmissionPresets::DAMAGE_NUMBER);
370        assert!(EmissionPresets::DAMAGE_NUMBER < EmissionPresets::CRIT_NUMBER);
371        assert!(EmissionPresets::BOSS_AURA < EmissionPresets::BOSS_PHASE_CHANGE);
372    }
373
374    #[test]
375    fn theme_bloom_void_protocol_is_subtle() {
376        let params = ThemeBloom::void_protocol();
377        assert!(params.intensity < 1.0);
378        assert!(params.threshold > 0.7);
379    }
380
381    #[test]
382    fn theme_bloom_solar_forge_is_dramatic() {
383        let params = ThemeBloom::solar_forge();
384        assert!(params.intensity > 1.5);
385        assert!(params.levels >= 4);
386    }
387
388    #[test]
389    fn corruption_bloom_scales() {
390        let low = ThemeBloom::corruption(0.0);
391        let high = ThemeBloom::corruption(1000.0);
392        assert!(high.intensity > low.intensity);
393        assert!(high.threshold < low.threshold);
394    }
395
396    #[test]
397    fn pipeline_state_computes_levels() {
398        let params = BloomParams::default();
399        let state = BloomPipelineState::compute(&params, 1280, 720);
400        assert_eq!(state.levels.len(), params.levels as usize);
401        assert!(state.draw_call_count > 0);
402    }
403
404    #[test]
405    fn pipeline_state_disabled() {
406        let params = BloomParams::disabled();
407        let state = BloomPipelineState::compute(&params, 1280, 720);
408        assert!(!state.is_active());
409        assert_eq!(state.draw_call_count, 0);
410    }
411
412    #[test]
413    fn death_bloom_fades() {
414        let start = ThemeBloom::death(0.0);
415        let end = ThemeBloom::death(1.0);
416        assert!(end.intensity < start.intensity);
417    }
418}