Skip to main content

oxihuman_viewer/
post_process.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Post-processing effect pipeline descriptors (pure data, no GPU calls).
5
6// ── Types ──────────────────────────────────────────────────────────────────
7
8/// Screen-space bloom effect configuration.
9#[allow(dead_code)]
10#[derive(Debug, Clone)]
11pub struct BloomConfig {
12    pub enabled: bool,
13    /// Luminance threshold above which bloom is applied.
14    pub threshold: f32,
15    /// Bloom intensity multiplier.
16    pub intensity: f32,
17    /// Blur radius factor.
18    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/// Screen-Space Ambient Occlusion configuration.
33#[allow(dead_code)]
34#[derive(Debug, Clone)]
35pub struct SsaoConfig {
36    pub enabled: bool,
37    /// World-space hemisphere radius for occlusion sampling.
38    pub radius: f32,
39    /// Depth bias to avoid self-shadowing artifacts.
40    pub bias: f32,
41    /// Occlusion exponent (higher = darker shadows).
42    pub power: f32,
43    /// Number of SSAO sample kernel taps.
44    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/// Supported tone-mapping operators.
60#[allow(dead_code)]
61#[derive(Debug, Clone, PartialEq)]
62pub enum ToneMapMethod {
63    Linear,
64    Reinhard,
65    AcesFilm,
66    Filmic,
67    Uncharted2,
68}
69
70/// Tone-mapping and gamma configuration.
71#[allow(dead_code)]
72#[derive(Debug, Clone)]
73pub struct ToneMappingConfig {
74    pub method: ToneMapMethod,
75    /// Linear exposure multiplier applied before tone mapping.
76    pub exposure: f32,
77    /// Output gamma (sRGB default is 2.2).
78    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/// Fast-approximate anti-aliasing configuration.
92#[allow(dead_code)]
93#[derive(Debug, Clone)]
94pub struct FxaaConfig {
95    pub enabled: bool,
96    /// Sub-pixel quality dithering (0.0 = off, 0.75 = default).
97    pub quality_subpix: f32,
98    /// Minimum local contrast required to trigger FXAA (lower = more AA).
99    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/// Complete post-processing pipeline descriptor.
113#[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    /// Screen-edge darkening strength: 0 = none, 1 = full black edges.
121    pub vignette_strength: f32,
122    /// Lateral chromatic aberration offset (0 = none).
123    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    /// High-quality preset: SSAO + bloom + FXAA enabled, ACES tone mapping.
141    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    /// Performance preset: minimal effects, no SSAO, FXAA only, Reinhard tone mapping.
172    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    /// Cinematic preset: ACES + bloom + vignette + chromatic aberration.
198    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    /// Serialize to a compact JSON string.
229    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// ── Tone-mapping functions ─────────────────────────────────────────────────
261
262/// Reinhard tone operator: maps [0, ∞) → [0, 1).
263#[inline]
264pub fn tone_map_reinhard(x: f32) -> f32 {
265    x / (1.0 + x)
266}
267
268/// Approximate ACES filmic tone mapping (Narkowicz 2015).
269///
270/// Numerically stable for very bright inputs.
271#[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/// Linear tone mapping with exposure and gamma correction.
282///
283/// `pow(x * exposure, 1 / gamma)`.  Returns 0 for negative inputs.
284#[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/// Rec. 709 / sRGB luminance: `0.2126 R + 0.7152 G + 0.0722 B`.
294#[inline]
295pub fn luminance(r: f32, g: f32, b: f32) -> f32 {
296    0.2126 * r + 0.7152 * g + 0.0722 * b
297}
298
299/// Apply the configured tone-mapping operator per-channel and return the result.
300///
301/// The output may still exceed 1.0 for `Linear` with high exposure; callers
302/// should clamp if writing to an 8-bit target.
303pub 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            // Simple filmic S-curve approximation
336            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            // Uncharted 2 "Hable" filmic curve
349            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// ── Tests ──────────────────────────────────────────────────────────────────
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    // ── tone_map_reinhard ────────────────────────────────────────────────
380
381    #[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    // ── tone_map_aces_approx ─────────────────────────────────────────────
400
401    #[test]
402    fn tone_map_aces_stable_for_bright() {
403        // Should not panic or overflow for very large inputs
404        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        // At exposure=1, ACES(1.0) should be in a reasonable range
420        assert!(v > 0.7 && v <= 1.0, "ACES(1.0) should be ~0.8+, got {v}");
421    }
422
423    // ── luminance ────────────────────────────────────────────────────────
424
425    #[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    // ── apply_tone_map ───────────────────────────────────────────────────
458
459    #[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    // ── PostProcessPipeline presets ──────────────────────────────────────
482
483    #[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    // ── Default values ────────────────────────────────────────────────────
522
523    #[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    // ── to_json ───────────────────────────────────────────────────────────
547
548    #[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}