1#[allow(dead_code)]
10#[derive(Debug, Clone)]
11pub struct BloomConfig {
12 pub enabled: bool,
13 pub threshold: f32,
15 pub intensity: f32,
17 pub radius: f32,
19}
20
21impl Default for BloomConfig {
22 fn default() -> Self {
23 BloomConfig {
24 enabled: false,
25 threshold: 1.0,
26 intensity: 0.3,
27 radius: 1.0,
28 }
29 }
30}
31
32#[allow(dead_code)]
34#[derive(Debug, Clone)]
35pub struct SsaoConfig {
36 pub enabled: bool,
37 pub radius: f32,
39 pub bias: f32,
41 pub power: f32,
43 pub sample_count: u32,
45}
46
47impl Default for SsaoConfig {
48 fn default() -> Self {
49 SsaoConfig {
50 enabled: false,
51 radius: 0.5,
52 bias: 0.025,
53 power: 1.0,
54 sample_count: 16,
55 }
56 }
57}
58
59#[allow(dead_code)]
61#[derive(Debug, Clone, PartialEq)]
62pub enum ToneMapMethod {
63 Linear,
64 Reinhard,
65 AcesFilm,
66 Filmic,
67 Uncharted2,
68}
69
70#[allow(dead_code)]
72#[derive(Debug, Clone)]
73pub struct ToneMappingConfig {
74 pub method: ToneMapMethod,
75 pub exposure: f32,
77 pub gamma: f32,
79}
80
81impl Default for ToneMappingConfig {
82 fn default() -> Self {
83 ToneMappingConfig {
84 method: ToneMapMethod::Reinhard,
85 exposure: 1.0,
86 gamma: 2.2,
87 }
88 }
89}
90
91#[allow(dead_code)]
93#[derive(Debug, Clone)]
94pub struct FxaaConfig {
95 pub enabled: bool,
96 pub quality_subpix: f32,
98 pub quality_edge_threshold: f32,
100}
101
102impl Default for FxaaConfig {
103 fn default() -> Self {
104 FxaaConfig {
105 enabled: false,
106 quality_subpix: 0.75,
107 quality_edge_threshold: 0.166,
108 }
109 }
110}
111
112#[allow(dead_code)]
114#[derive(Debug, Clone)]
115pub struct PostProcessPipeline {
116 pub bloom: BloomConfig,
117 pub ssao: SsaoConfig,
118 pub tone_mapping: ToneMappingConfig,
119 pub fxaa: FxaaConfig,
120 pub vignette_strength: f32,
122 pub chromatic_aberration: f32,
124}
125
126impl Default for PostProcessPipeline {
127 fn default() -> Self {
128 PostProcessPipeline {
129 bloom: BloomConfig::default(),
130 ssao: SsaoConfig::default(),
131 tone_mapping: ToneMappingConfig::default(),
132 fxaa: FxaaConfig::default(),
133 vignette_strength: 0.0,
134 chromatic_aberration: 0.0,
135 }
136 }
137}
138
139impl PostProcessPipeline {
140 pub fn high_quality() -> Self {
142 PostProcessPipeline {
143 bloom: BloomConfig {
144 enabled: true,
145 threshold: 0.9,
146 intensity: 0.4,
147 radius: 1.2,
148 },
149 ssao: SsaoConfig {
150 enabled: true,
151 radius: 0.4,
152 bias: 0.02,
153 power: 1.5,
154 sample_count: 32,
155 },
156 tone_mapping: ToneMappingConfig {
157 method: ToneMapMethod::AcesFilm,
158 exposure: 1.0,
159 gamma: 2.2,
160 },
161 fxaa: FxaaConfig {
162 enabled: true,
163 quality_subpix: 0.75,
164 quality_edge_threshold: 0.125,
165 },
166 vignette_strength: 0.0,
167 chromatic_aberration: 0.0,
168 }
169 }
170
171 pub fn performance() -> Self {
173 PostProcessPipeline {
174 bloom: BloomConfig {
175 enabled: false,
176 ..BloomConfig::default()
177 },
178 ssao: SsaoConfig {
179 enabled: false,
180 ..SsaoConfig::default()
181 },
182 tone_mapping: ToneMappingConfig {
183 method: ToneMapMethod::Reinhard,
184 exposure: 1.0,
185 gamma: 2.2,
186 },
187 fxaa: FxaaConfig {
188 enabled: true,
189 quality_subpix: 0.5,
190 quality_edge_threshold: 0.25,
191 },
192 vignette_strength: 0.0,
193 chromatic_aberration: 0.0,
194 }
195 }
196
197 pub fn cinematic() -> Self {
199 PostProcessPipeline {
200 bloom: BloomConfig {
201 enabled: true,
202 threshold: 0.8,
203 intensity: 0.6,
204 radius: 1.5,
205 },
206 ssao: SsaoConfig {
207 enabled: true,
208 radius: 0.5,
209 bias: 0.025,
210 power: 2.0,
211 sample_count: 64,
212 },
213 tone_mapping: ToneMappingConfig {
214 method: ToneMapMethod::AcesFilm,
215 exposure: 1.2,
216 gamma: 2.2,
217 },
218 fxaa: FxaaConfig {
219 enabled: true,
220 quality_subpix: 0.75,
221 quality_edge_threshold: 0.125,
222 },
223 vignette_strength: 0.35,
224 chromatic_aberration: 0.003,
225 }
226 }
227
228 pub fn to_json(&self) -> String {
230 let method_str = match self.tone_mapping.method {
231 ToneMapMethod::Linear => "Linear",
232 ToneMapMethod::Reinhard => "Reinhard",
233 ToneMapMethod::AcesFilm => "AcesFilm",
234 ToneMapMethod::Filmic => "Filmic",
235 ToneMapMethod::Uncharted2 => "Uncharted2",
236 };
237 format!(
238 r#"{{"bloom":{{"enabled":{},"threshold":{:.4},"intensity":{:.4},"radius":{:.4}}},"ssao":{{"enabled":{},"radius":{:.4},"bias":{:.4},"power":{:.4},"sample_count":{}}},"tone_mapping":{{"method":"{}","exposure":{:.4},"gamma":{:.4}}},"fxaa":{{"enabled":{},"quality_subpix":{:.4},"quality_edge_threshold":{:.4}}},"vignette_strength":{:.4},"chromatic_aberration":{:.6}}}"#,
239 self.bloom.enabled,
240 self.bloom.threshold,
241 self.bloom.intensity,
242 self.bloom.radius,
243 self.ssao.enabled,
244 self.ssao.radius,
245 self.ssao.bias,
246 self.ssao.power,
247 self.ssao.sample_count,
248 method_str,
249 self.tone_mapping.exposure,
250 self.tone_mapping.gamma,
251 self.fxaa.enabled,
252 self.fxaa.quality_subpix,
253 self.fxaa.quality_edge_threshold,
254 self.vignette_strength,
255 self.chromatic_aberration,
256 )
257 }
258}
259
260#[inline]
264pub fn tone_map_reinhard(x: f32) -> f32 {
265 x / (1.0 + x)
266}
267
268#[inline]
272pub fn tone_map_aces_approx(x: f32) -> f32 {
273 let a = 2.51_f32;
274 let b = 0.03_f32;
275 let c = 2.43_f32;
276 let d = 0.59_f32;
277 let e = 0.14_f32;
278 ((x * (a * x + b)) / (x * (c * x + d) + e)).clamp(0.0, 1.0)
279}
280
281#[inline]
285pub fn tone_map_linear(x: f32, exposure: f32, gamma: f32) -> f32 {
286 let v = (x * exposure).max(0.0);
287 if gamma <= 0.0 {
288 return v;
289 }
290 v.powf(1.0 / gamma)
291}
292
293#[inline]
295pub fn luminance(r: f32, g: f32, b: f32) -> f32 {
296 0.2126 * r + 0.7152 * g + 0.0722 * b
297}
298
299pub fn apply_tone_map(color: [f32; 3], cfg: &ToneMappingConfig) -> [f32; 3] {
304 match cfg.method {
305 ToneMapMethod::Linear => [
306 tone_map_linear(color[0], cfg.exposure, cfg.gamma),
307 tone_map_linear(color[1], cfg.exposure, cfg.gamma),
308 tone_map_linear(color[2], cfg.exposure, cfg.gamma),
309 ],
310 ToneMapMethod::Reinhard => {
311 let ec = [
312 color[0] * cfg.exposure,
313 color[1] * cfg.exposure,
314 color[2] * cfg.exposure,
315 ];
316 [
317 tone_map_reinhard(ec[0]).max(0.0),
318 tone_map_reinhard(ec[1]).max(0.0),
319 tone_map_reinhard(ec[2]).max(0.0),
320 ]
321 }
322 ToneMapMethod::AcesFilm => {
323 let ec = [
324 color[0] * cfg.exposure,
325 color[1] * cfg.exposure,
326 color[2] * cfg.exposure,
327 ];
328 [
329 tone_map_aces_approx(ec[0]),
330 tone_map_aces_approx(ec[1]),
331 tone_map_aces_approx(ec[2]),
332 ]
333 }
334 ToneMapMethod::Filmic => {
335 let ec = [
337 color[0] * cfg.exposure,
338 color[1] * cfg.exposure,
339 color[2] * cfg.exposure,
340 ];
341 [
342 tone_map_reinhard(ec[0] * 1.6).max(0.0),
343 tone_map_reinhard(ec[1] * 1.6).max(0.0),
344 tone_map_reinhard(ec[2] * 1.6).max(0.0),
345 ]
346 }
347 ToneMapMethod::Uncharted2 => {
348 fn hable(x: f32) -> f32 {
350 let a = 0.15_f32;
351 let b = 0.50_f32;
352 let c = 0.10_f32;
353 let d = 0.20_f32;
354 let e = 0.02_f32;
355 let f = 0.30_f32;
356 (x * (a * x + c * b) + d * e) / (x * (a * x + b) + d * f) - e / f
357 }
358 let white = hable(11.2);
359 let ec = [
360 color[0] * cfg.exposure * 2.0,
361 color[1] * cfg.exposure * 2.0,
362 color[2] * cfg.exposure * 2.0,
363 ];
364 [
365 (hable(ec[0]) / white).clamp(0.0, 1.0),
366 (hable(ec[1]) / white).clamp(0.0, 1.0),
367 (hable(ec[2]) / white).clamp(0.0, 1.0),
368 ]
369 }
370 }
371}
372
373#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
382 fn tone_map_reinhard_at_one() {
383 let v = tone_map_reinhard(1.0);
384 assert!((v - 0.5).abs() < 1e-6, "reinhard(1) should be 0.5, got {v}");
385 }
386
387 #[test]
388 fn tone_map_reinhard_monotone() {
389 assert!(tone_map_reinhard(2.0) > tone_map_reinhard(1.0));
390 assert!(tone_map_reinhard(10.0) > tone_map_reinhard(2.0));
391 }
392
393 #[test]
394 fn tone_map_reinhard_approaches_one() {
395 let v = tone_map_reinhard(1_000_000.0);
396 assert!(v < 1.0 + 1e-4 && v > 0.999);
397 }
398
399 #[test]
402 fn tone_map_aces_stable_for_bright() {
403 let v = tone_map_aces_approx(1_000.0);
405 assert!(
406 (0.0..=1.0).contains(&v),
407 "ACES should clamp to [0,1], got {v}"
408 );
409 }
410
411 #[test]
412 fn tone_map_aces_zero_is_zero() {
413 assert!(tone_map_aces_approx(0.0).abs() < 1e-4);
414 }
415
416 #[test]
417 fn tone_map_aces_one_is_reasonable() {
418 let v = tone_map_aces_approx(1.0);
419 assert!(v > 0.7 && v <= 1.0, "ACES(1.0) should be ~0.8+, got {v}");
421 }
422
423 #[test]
426 fn luminance_white_is_one() {
427 let l = luminance(1.0, 1.0, 1.0);
428 assert!(
429 (l - 1.0).abs() < 1e-5,
430 "luminance(1,1,1) should be 1.0, got {l}"
431 );
432 }
433
434 #[test]
435 fn luminance_black_is_zero() {
436 assert!(luminance(0.0, 0.0, 0.0).abs() < 1e-6);
437 }
438
439 #[test]
440 fn luminance_formula() {
441 let l = luminance(1.0, 0.0, 0.0);
442 assert!(
443 (l - 0.2126).abs() < 1e-4,
444 "expected 0.2126 for pure red, got {l}"
445 );
446 }
447
448 #[test]
449 fn luminance_green_heaviest() {
450 let lr = luminance(1.0, 0.0, 0.0);
451 let lg = luminance(0.0, 1.0, 0.0);
452 let lb = luminance(0.0, 0.0, 1.0);
453 assert!(lg > lr, "green should dominate luminance");
454 assert!(lg > lb, "green should dominate luminance over blue");
455 }
456
457 #[test]
460 fn apply_tone_map_non_negative_output() {
461 let cfg = ToneMappingConfig::default();
462 let out = apply_tone_map([0.5, 1.0, 2.0], &cfg);
463 for ch in out {
464 assert!(ch >= 0.0, "output channel must be non-negative, got {ch}");
465 }
466 }
467
468 #[test]
469 fn apply_tone_map_aces_clamps() {
470 let cfg = ToneMappingConfig {
471 method: ToneMapMethod::AcesFilm,
472 exposure: 1.0,
473 gamma: 2.2,
474 };
475 let out = apply_tone_map([1000.0, 1000.0, 1000.0], &cfg);
476 for ch in out {
477 assert!(ch <= 1.0 + 1e-4, "ACES output must be ≤ 1.0, got {ch}");
478 }
479 }
480
481 #[test]
484 fn high_quality_ssao_enabled() {
485 assert!(PostProcessPipeline::high_quality().ssao.enabled);
486 }
487
488 #[test]
489 fn high_quality_fxaa_enabled() {
490 assert!(PostProcessPipeline::high_quality().fxaa.enabled);
491 }
492
493 #[test]
494 fn high_quality_bloom_enabled() {
495 assert!(PostProcessPipeline::high_quality().bloom.enabled);
496 }
497
498 #[test]
499 fn performance_ssao_disabled() {
500 assert!(!PostProcessPipeline::performance().ssao.enabled);
501 }
502
503 #[test]
504 fn performance_bloom_disabled() {
505 assert!(!PostProcessPipeline::performance().bloom.enabled);
506 }
507
508 #[test]
509 fn cinematic_vignette_positive() {
510 assert!(
511 PostProcessPipeline::cinematic().vignette_strength > 0.0,
512 "cinematic preset should have vignette"
513 );
514 }
515
516 #[test]
517 fn cinematic_chromatic_aberration_nonzero() {
518 assert!(PostProcessPipeline::cinematic().chromatic_aberration > 0.0);
519 }
520
521 #[test]
524 fn default_bloom_threshold_is_one() {
525 let cfg = BloomConfig::default();
526 assert!((cfg.threshold - 1.0).abs() < 1e-6);
527 }
528
529 #[test]
530 fn all_presets_have_valid_gamma() {
531 let presets = [
532 PostProcessPipeline::default(),
533 PostProcessPipeline::high_quality(),
534 PostProcessPipeline::performance(),
535 PostProcessPipeline::cinematic(),
536 ];
537 for p in &presets {
538 assert!(
539 p.tone_mapping.gamma > 0.0,
540 "gamma must be positive, got {}",
541 p.tone_mapping.gamma
542 );
543 }
544 }
545
546 #[test]
549 fn to_json_non_empty() {
550 let json = PostProcessPipeline::default().to_json();
551 assert!(!json.is_empty());
552 }
553
554 #[test]
555 fn to_json_contains_bloom_key() {
556 let json = PostProcessPipeline::high_quality().to_json();
557 assert!(json.contains("\"bloom\""), "JSON should contain 'bloom'");
558 }
559
560 #[test]
561 fn to_json_contains_tone_mapping() {
562 let json = PostProcessPipeline::cinematic().to_json();
563 assert!(
564 json.contains("AcesFilm"),
565 "cinematic JSON should mention AcesFilm"
566 );
567 }
568}