proof_engine/render/postfx/
bloom.rs1#[derive(Clone, Debug)]
25pub struct BloomParams {
26 pub enabled: bool,
28 pub threshold: f32,
30 pub intensity: f32,
32 pub radius: f32,
34 pub levels: u8,
36 pub knee: f32,
38 pub use_emission: bool,
40 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 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
95pub 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
111pub fn linear_gaussian_kernel(sigma: f32, taps: usize) -> (Vec<f32>, Vec<f32>) {
115 let full = gaussian_kernel(sigma, taps);
116 let half = taps + 1; let mut offsets = Vec::with_capacity(half);
119 let mut weights = Vec::with_capacity(half);
120
121 offsets.push(0.0);
123 weights.push(full[taps]);
124
125 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
144const LUM_R: f32 = 0.2126;
148const LUM_G: f32 = 0.7152;
149const LUM_B: f32 = 0.0722;
150
151#[inline]
153pub fn luminance(r: f32, g: f32, b: f32) -> f32 {
154 LUM_R * r + LUM_G * g + LUM_B * b
155}
156
157pub 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) }
170
171pub 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#[derive(Debug, Clone)]
183pub struct BloomPyramidLevel {
184 pub width: u32,
186 pub height: u32,
188 pub sigma: f32,
190 pub weight: f32,
192}
193
194pub 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); 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 let weight = 1.0 / (i as f32 + 1.0);
210 levels.push(BloomPyramidLevel { width: w, height: h, sigma, weight });
211 }
212 levels
213}
214
215pub 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
223pub 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
267pub 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
301pub 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
327pub 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
367pub 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
404pub 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 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 let mut tmp = vec![0.0f32; n];
429 cpu_blur_h(&extracted, &mut tmp, width, height, params.radius);
430 cpu_blur_v(&tmp, &mut blurred, width, height, params.radius);
432
433 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#[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, ¶ms);
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}