Skip to main content

proof_engine/render/
hdr.rs

1//! HDR Render Target — 16-bit float framebuffer with tone mapping and exposure control.
2//!
3//! Changes the scene framebuffer from RGBA8 to RGBA16F, allowing color values above 1.0.
4//! Crits and spell effects can be 10x brighter than normal without clipping, creating
5//! dramatic contrast when tone mapped back to SDR for display.
6//!
7//! # Tone mapping operators
8//!
9//! - **Reinhard**: Simple, preserves colors well. `c / (1 + c)`
10//! - **ACES**: Academy Color Encoding System filmic curve. Industry standard.
11//! - **Uncharted2**: John Hable's filmic operator from Uncharted 2.
12//!
13//! # Exposure control
14//!
15//! - **Auto-exposure**: Adapts based on scene average luminance (eye adaptation).
16//! - **Manual**: Fixed exposure value for specific moments (death, boss intro).
17
18// ── Tone map operator ───────────────────────────────────────────────────────
19
20/// Available tone mapping operators.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ToneMapOperator {
23    /// No tone mapping (clamp to [0,1]).
24    None,
25    /// Reinhard: simple, preserves color.
26    Reinhard,
27    /// Extended Reinhard with white point.
28    ReinhardExtended,
29    /// ACES filmic curve (industry standard).
30    Aces,
31    /// Uncharted 2 / John Hable filmic.
32    Uncharted2,
33    /// Neutral filmic (softer than ACES).
34    NeutralFilmic,
35}
36
37impl Default for ToneMapOperator {
38    fn default() -> Self { Self::Aces }
39}
40
41impl ToneMapOperator {
42    /// Apply tone mapping to a linear HDR color.
43    pub fn apply(&self, color: glam::Vec3, exposure: f32) -> glam::Vec3 {
44        let c = color * exposure;
45        match self {
46            Self::None => clamp01(c),
47            Self::Reinhard => reinhard(c),
48            Self::ReinhardExtended => reinhard_extended(c, 4.0),
49            Self::Aces => aces_filmic(c),
50            Self::Uncharted2 => uncharted2(c),
51            Self::NeutralFilmic => neutral_filmic(c),
52        }
53    }
54
55    /// GLSL function name for this operator (used in shader source generation).
56    pub fn glsl_func_name(&self) -> &'static str {
57        match self {
58            Self::None => "tonemap_none",
59            Self::Reinhard => "tonemap_reinhard",
60            Self::ReinhardExtended => "tonemap_reinhard_ext",
61            Self::Aces => "tonemap_aces",
62            Self::Uncharted2 => "tonemap_uncharted2",
63            Self::NeutralFilmic => "tonemap_neutral",
64        }
65    }
66}
67
68// ── Tone mapping functions ──────────────────────────────────────────────────
69
70fn clamp01(c: glam::Vec3) -> glam::Vec3 {
71    glam::Vec3::new(c.x.clamp(0.0, 1.0), c.y.clamp(0.0, 1.0), c.z.clamp(0.0, 1.0))
72}
73
74/// Reinhard: `c / (1 + c)`. Simple, good color preservation.
75fn reinhard(c: glam::Vec3) -> glam::Vec3 {
76    glam::Vec3::new(
77        c.x / (1.0 + c.x),
78        c.y / (1.0 + c.y),
79        c.z / (1.0 + c.z),
80    )
81}
82
83/// Extended Reinhard with adjustable white point.
84fn reinhard_extended(c: glam::Vec3, white_point: f32) -> glam::Vec3 {
85    let wp2 = white_point * white_point;
86    glam::Vec3::new(
87        c.x * (1.0 + c.x / wp2) / (1.0 + c.x),
88        c.y * (1.0 + c.y / wp2) / (1.0 + c.y),
89        c.z * (1.0 + c.z / wp2) / (1.0 + c.z),
90    )
91}
92
93/// ACES filmic tone mapping (approximation by Krzysztof Narkowicz).
94fn aces_filmic(c: glam::Vec3) -> glam::Vec3 {
95    let a = 2.51;
96    let b = 0.03;
97    let cc = 2.43;
98    let d = 0.59;
99    let e = 0.14;
100    let f = |x: f32| -> f32 {
101        ((x * (a * x + b)) / (x * (cc * x + d) + e)).clamp(0.0, 1.0)
102    };
103    glam::Vec3::new(f(c.x), f(c.y), f(c.z))
104}
105
106/// Uncharted 2 filmic curve (John Hable).
107fn uncharted2(c: glam::Vec3) -> glam::Vec3 {
108    let f = |x: f32| -> f32 {
109        let a = 0.15;
110        let b = 0.50;
111        let cc = 0.10;
112        let d = 0.20;
113        let e = 0.02;
114        let ff = 0.30;
115        ((x * (a * x + cc * b) + d * e) / (x * (a * x + b) + d * ff)) - e / ff
116    };
117    let white_scale = 1.0 / f(11.2);
118    glam::Vec3::new(
119        f(c.x) * white_scale,
120        f(c.y) * white_scale,
121        f(c.z) * white_scale,
122    )
123}
124
125/// Neutral filmic (softer than ACES, less saturation shift).
126fn neutral_filmic(c: glam::Vec3) -> glam::Vec3 {
127    let f = |x: f32| -> f32 {
128        let a = x.max(0.0);
129        (a * (a * 6.2 + 0.5)) / (a * (a * 6.2 + 1.7) + 0.06)
130    };
131    glam::Vec3::new(f(c.x), f(c.y), f(c.z))
132}
133
134// ── HDR parameters ──────────────────────────────────────────────────────────
135
136/// HDR rendering parameters.
137#[derive(Debug, Clone)]
138pub struct HdrParams {
139    /// Whether to use RGBA16F framebuffer.
140    pub enabled: bool,
141    /// Tone mapping operator.
142    pub tone_map: ToneMapOperator,
143    /// Exposure mode.
144    pub exposure_mode: ExposureMode,
145    /// Manual exposure value (used when mode = Manual).
146    pub manual_exposure: f32,
147    /// Auto-exposure adaptation speed (seconds to adapt 63%).
148    pub adaptation_speed: f32,
149    /// Minimum auto-exposure EV.
150    pub min_ev: f32,
151    /// Maximum auto-exposure EV.
152    pub max_ev: f32,
153    /// Exposure compensation (added to auto-exposure result).
154    pub compensation: f32,
155}
156
157impl Default for HdrParams {
158    fn default() -> Self {
159        Self {
160            enabled: true,
161            tone_map: ToneMapOperator::Aces,
162            exposure_mode: ExposureMode::Auto,
163            manual_exposure: 1.0,
164            adaptation_speed: 1.5,
165            min_ev: -2.0,
166            max_ev: 4.0,
167            compensation: 0.0,
168        }
169    }
170}
171
172impl HdrParams {
173    pub fn disabled() -> Self {
174        Self { enabled: false, ..Default::default() }
175    }
176
177    pub fn manual(exposure: f32) -> Self {
178        Self {
179            exposure_mode: ExposureMode::Manual,
180            manual_exposure: exposure,
181            ..Default::default()
182        }
183    }
184}
185
186/// Exposure control mode.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum ExposureMode {
189    /// Automatic eye-adaptation based on scene luminance.
190    Auto,
191    /// Fixed exposure value.
192    Manual,
193    /// Auto with locked range (doesn't adapt above/below bounds).
194    AutoClamped,
195}
196
197// ── Auto-exposure calculator ────────────────────────────────────────────────
198
199/// Tracks scene luminance and computes auto-exposure.
200pub struct AutoExposure {
201    /// Current adapted luminance.
202    current_lum: f32,
203    /// Current exposure value.
204    pub exposure: f32,
205    /// Smoothed average luminance (for display).
206    pub avg_luminance: f32,
207}
208
209impl AutoExposure {
210    pub fn new() -> Self {
211        Self {
212            current_lum: 0.5,
213            exposure: 1.0,
214            avg_luminance: 0.5,
215        }
216    }
217
218    /// Update with the scene's average luminance this frame.
219    ///
220    /// `avg_lum`: average scene luminance (log-average preferred).
221    /// `dt`: frame delta time.
222    /// `params`: HDR parameters.
223    pub fn update(&mut self, avg_lum: f32, dt: f32, params: &HdrParams) {
224        match params.exposure_mode {
225            ExposureMode::Manual => {
226                self.exposure = params.manual_exposure;
227                self.avg_luminance = avg_lum;
228            }
229            ExposureMode::Auto | ExposureMode::AutoClamped => {
230                // Exponential moving average adaptation
231                let speed = 1.0 - (-dt / params.adaptation_speed.max(0.01)).exp();
232                self.current_lum += (avg_lum.max(0.001) - self.current_lum) * speed;
233
234                // Convert luminance to EV and apply bounds
235                let ev = (self.current_lum / 0.18).log2();
236                let clamped_ev = ev.clamp(params.min_ev, params.max_ev);
237
238                // Convert EV back to exposure multiplier
239                self.exposure = 1.0 / (2.0_f32.powf(clamped_ev) * 1.2);
240                self.exposure *= 2.0_f32.powf(params.compensation);
241
242                self.avg_luminance = self.current_lum;
243            }
244        }
245    }
246
247    /// Reset to default state.
248    pub fn reset(&mut self) {
249        self.current_lum = 0.5;
250        self.exposure = 1.0;
251    }
252}
253
254impl Default for AutoExposure {
255    fn default() -> Self { Self::new() }
256}
257
258// ── Game-specific exposure presets ──────────────────────────────────────────
259
260/// Exposure presets for specific game moments.
261pub struct ExposurePresets;
262
263impl ExposurePresets {
264    /// Bright scene (shrine, victory, surface).
265    pub fn bright_scene() -> HdrParams {
266        HdrParams {
267            exposure_mode: ExposureMode::Auto,
268            min_ev: 0.0,
269            max_ev: 3.0,
270            compensation: -0.5,
271            ..Default::default()
272        }
273    }
274
275    /// Dark scene (deep floors, boss rooms).
276    pub fn dark_scene() -> HdrParams {
277        HdrParams {
278            exposure_mode: ExposureMode::Auto,
279            min_ev: -2.0,
280            max_ev: 1.0,
281            compensation: 0.5,
282            ..Default::default()
283        }
284    }
285
286    /// Death sequence: slowly reduce exposure to black.
287    pub fn death_sequence(progress: f32) -> HdrParams {
288        HdrParams {
289            exposure_mode: ExposureMode::Manual,
290            manual_exposure: (1.0 - progress * 0.95).max(0.05),
291            ..Default::default()
292        }
293    }
294
295    /// Boss entrance: brief flash then settle.
296    pub fn boss_entrance() -> HdrParams {
297        HdrParams {
298            exposure_mode: ExposureMode::Manual,
299            manual_exposure: 2.0,
300            ..Default::default()
301        }
302    }
303
304    /// Victory celebration: bright, warm.
305    pub fn victory() -> HdrParams {
306        HdrParams {
307            exposure_mode: ExposureMode::Manual,
308            manual_exposure: 1.3,
309            ..Default::default()
310        }
311    }
312
313    /// Normal gameplay.
314    pub fn normal() -> HdrParams {
315        HdrParams::default()
316    }
317
318    /// Shrine: slightly brighter, serene.
319    pub fn shrine() -> HdrParams {
320        HdrParams {
321            exposure_mode: ExposureMode::Auto,
322            compensation: 0.3,
323            ..Default::default()
324        }
325    }
326}
327
328// ── GLSL shader sources ─────────────────────────────────────────────────────
329
330/// HDR tone mapping + exposure fragment shader.
331/// Applied as the final post-processing pass before display.
332pub const HDR_TONEMAP_FRAG: &str = r#"
333#version 330 core
334
335in  vec2 f_uv;
336out vec4 frag_color;
337
338uniform sampler2D u_hdr_scene;
339uniform float     u_exposure;
340uniform int       u_tonemap_op;  // 0=none, 1=reinhard, 2=aces, 3=uncharted2
341
342// Reinhard
343vec3 tonemap_reinhard(vec3 c) {
344    return c / (vec3(1.0) + c);
345}
346
347// ACES (Narkowicz approximation)
348vec3 tonemap_aces(vec3 c) {
349    float a = 2.51;
350    float b = 0.03;
351    float cc = 2.43;
352    float d = 0.59;
353    float e = 0.14;
354    return clamp((c * (a * c + b)) / (c * (cc * c + d) + e), 0.0, 1.0);
355}
356
357// Uncharted 2 (Hable)
358vec3 uc2_curve(vec3 x) {
359    float A = 0.15; float B = 0.50; float C = 0.10;
360    float D = 0.20; float E = 0.02; float F = 0.30;
361    return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
362}
363
364vec3 tonemap_uncharted2(vec3 c) {
365    float W = 11.2;
366    return uc2_curve(c) / uc2_curve(vec3(W));
367}
368
369void main() {
370    vec3 hdr = texture(u_hdr_scene, f_uv).rgb;
371
372    // Apply exposure
373    vec3 exposed = hdr * u_exposure;
374
375    // Tone map
376    vec3 mapped;
377    if (u_tonemap_op == 0)      mapped = clamp(exposed, 0.0, 1.0);
378    else if (u_tonemap_op == 1) mapped = tonemap_reinhard(exposed);
379    else if (u_tonemap_op == 2) mapped = tonemap_aces(exposed);
380    else if (u_tonemap_op == 3) mapped = tonemap_uncharted2(exposed);
381    else                        mapped = tonemap_aces(exposed);
382
383    // Gamma correction (linear → sRGB)
384    mapped = pow(mapped, vec3(1.0 / 2.2));
385
386    frag_color = vec4(mapped, 1.0);
387}
388"#;
389
390/// Luminance computation shader for auto-exposure.
391/// Computes log-average luminance of the scene.
392pub const LUMINANCE_FRAG: &str = r#"
393#version 330 core
394
395in  vec2 f_uv;
396out vec4 frag_color;
397
398uniform sampler2D u_scene;
399
400void main() {
401    vec3 color = texture(u_scene, f_uv).rgb;
402    float lum = dot(color, vec3(0.2126, 0.7152, 0.0722));
403    // Output log luminance for averaging
404    float logLum = log(max(lum, 0.0001));
405    frag_color = vec4(logLum, lum, 0.0, 1.0);
406}
407"#;
408
409// ── HDR statistics ──────────────────────────────────────────────────────────
410
411/// Per-frame HDR rendering statistics.
412#[derive(Debug, Clone, Default)]
413pub struct HdrStats {
414    pub enabled: bool,
415    pub tone_map: &'static str,
416    pub exposure: f32,
417    pub avg_luminance: f32,
418    pub exposure_mode: &'static str,
419}
420
421impl HdrStats {
422    pub fn from_state(params: &HdrParams, auto_exp: &AutoExposure) -> Self {
423        Self {
424            enabled: params.enabled,
425            tone_map: match params.tone_map {
426                ToneMapOperator::None => "None",
427                ToneMapOperator::Reinhard => "Reinhard",
428                ToneMapOperator::ReinhardExtended => "Reinhard Ext",
429                ToneMapOperator::Aces => "ACES",
430                ToneMapOperator::Uncharted2 => "Uncharted2",
431                ToneMapOperator::NeutralFilmic => "Neutral",
432            },
433            exposure: auto_exp.exposure,
434            avg_luminance: auto_exp.avg_luminance,
435            exposure_mode: match params.exposure_mode {
436                ExposureMode::Auto => "Auto",
437                ExposureMode::Manual => "Manual",
438                ExposureMode::AutoClamped => "Auto (Clamped)",
439            },
440        }
441    }
442}
443
444// ── Tests ───────────────────────────────────────────────────────────────────
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use glam::Vec3;
450
451    #[test]
452    fn reinhard_preserves_zero() {
453        let result = ToneMapOperator::Reinhard.apply(Vec3::ZERO, 1.0);
454        assert!(result.length() < 1e-6);
455    }
456
457    #[test]
458    fn reinhard_maps_bright_below_one() {
459        let result = ToneMapOperator::Reinhard.apply(Vec3::new(10.0, 10.0, 10.0), 1.0);
460        assert!(result.x < 1.0);
461        assert!(result.x > 0.9);
462    }
463
464    #[test]
465    fn aces_maps_to_unit_range() {
466        let result = ToneMapOperator::Aces.apply(Vec3::new(5.0, 3.0, 1.0), 1.0);
467        assert!(result.x >= 0.0 && result.x <= 1.0);
468        assert!(result.y >= 0.0 && result.y <= 1.0);
469        assert!(result.z >= 0.0 && result.z <= 1.0);
470    }
471
472    #[test]
473    fn uncharted2_maps_to_unit_range() {
474        let result = ToneMapOperator::Uncharted2.apply(Vec3::new(5.0, 3.0, 1.0), 1.0);
475        assert!(result.x >= 0.0 && result.x <= 1.0);
476        assert!(result.y >= 0.0 && result.y <= 1.0);
477    }
478
479    #[test]
480    fn exposure_scales_output() {
481        let low = ToneMapOperator::Aces.apply(Vec3::ONE, 0.5);
482        let high = ToneMapOperator::Aces.apply(Vec3::ONE, 2.0);
483        assert!(high.x > low.x);
484    }
485
486    #[test]
487    fn auto_exposure_adapts() {
488        let params = HdrParams::default();
489        let mut ae = AutoExposure::new();
490
491        // Bright scene
492        for _ in 0..60 {
493            ae.update(2.0, 0.016, &params);
494        }
495        let bright_exp = ae.exposure;
496
497        // Dark scene
498        ae.reset();
499        for _ in 0..60 {
500            ae.update(0.01, 0.016, &params);
501        }
502        let dark_exp = ae.exposure;
503
504        // Dark scene should have higher exposure (brighten)
505        assert!(dark_exp > bright_exp, "dark={dark_exp} should > bright={bright_exp}");
506    }
507
508    #[test]
509    fn manual_exposure_fixed() {
510        let params = HdrParams::manual(2.5);
511        let mut ae = AutoExposure::new();
512        ae.update(0.5, 0.016, &params);
513        assert_eq!(ae.exposure, 2.5);
514    }
515
516    #[test]
517    fn death_exposure_dims() {
518        let start = ExposurePresets::death_sequence(0.0);
519        let end = ExposurePresets::death_sequence(1.0);
520        assert!(end.manual_exposure < start.manual_exposure);
521        assert!(end.manual_exposure > 0.0);
522    }
523
524    #[test]
525    fn none_tonemap_clamps() {
526        let result = ToneMapOperator::None.apply(Vec3::new(2.0, -0.5, 0.5), 1.0);
527        assert_eq!(result.x, 1.0);
528        assert_eq!(result.y, 0.0);
529        assert_eq!(result.z, 0.5);
530    }
531}