Skip to main content

proof_engine/render/postfx/
color_grade.rs

1//! Color grading pass — full CPU/GPU color pipeline.
2//!
3//! Implements a professional-grade color grading system including:
4//! - Per-channel curves (lift/gamma/gain)
5//! - Saturation, contrast, brightness, hue rotation
6//! - Split toning (shadows/highlights)
7//! - Color look-up tables (3D LUT, 17³ = 4913 entries)
8//! - Cinematic film looks (ACES, Kodak, Fuji, Noir, etc.)
9//! - Vignette with user-controlled shape
10//! - Animated grade transitions via keyframes
11//! - Color grading presets for common game states
12
13use glam::{Vec3, Vec4};
14
15// ── ColorGradeParams ──────────────────────────────────────────────────────────
16
17/// Full color grading parameters for one frame.
18#[derive(Clone, Debug)]
19pub struct ColorGradeParams {
20    pub enabled: bool,
21
22    // ── Global adjustments ────────────────────────────────────────────────────
23    /// Overall tint multiplied onto the final image (RGB, 1.0 = neutral).
24    pub tint: Vec3,
25    /// Saturation multiplier (1.0 = normal, 0.0 = greyscale, >1 = oversaturated).
26    pub saturation: f32,
27    /// Contrast multiplier (1.0 = normal).
28    pub contrast: f32,
29    /// Brightness offset (0.0 = normal, range -1..1).
30    pub brightness: f32,
31    /// Hue rotation in degrees (0.0 = none).
32    pub hue_shift: f32,
33
34    // ── Lift/Gamma/Gain ───────────────────────────────────────────────────────
35    /// Shadow color offset (RGB, 0.0 = neutral). Applied in dark regions.
36    pub lift: Vec3,
37    /// Midtone gamma correction (RGB, 1.0 = neutral). Applied via power function.
38    pub gamma: Vec3,
39    /// Highlight multiplier (RGB, 1.0 = neutral).
40    pub gain: Vec3,
41
42    // ── Split toning ──────────────────────────────────────────────────────────
43    /// Shadow tint color (applied to dark areas).
44    pub shadow_tint: Vec3,
45    /// Highlight tint color (applied to bright areas).
46    pub highlight_tint: Vec3,
47    /// How much shadow tint to apply (0.0 = none, 1.0 = full).
48    pub shadow_tint_strength: f32,
49    /// How much highlight tint to apply.
50    pub highlight_tint_strength: f32,
51    /// Luminance threshold separating shadows from highlights.
52    pub split_midpoint: f32,
53
54    // ── Curves ────────────────────────────────────────────────────────────────
55    /// Per-channel luminance S-curve strength (0.0 = linear, 1.0 = full S).
56    pub curve_strength: f32,
57    /// Independent S-curve strengths for R, G, B channels.
58    pub channel_curves: Vec3,
59
60    // ── Vignette ──────────────────────────────────────────────────────────────
61    /// Vignette strength (0.0 = none, 1.0 = full black edges).
62    pub vignette: f32,
63    /// Vignette feather (0.0 = hard, 1.0 = smooth).
64    pub vignette_feather: f32,
65    /// Vignette roundness (1.0 = circle, 0.0 = rectangle).
66    pub vignette_roundness: f32,
67    /// Vignette color (default black).
68    pub vignette_color: Vec3,
69
70    // ── LUT ───────────────────────────────────────────────────────────────────
71    /// Optional 3D LUT. If present, applied after all other grading.
72    pub lut: Option<ColorLut>,
73    /// How much to blend the LUT result with the non-LUT result (0.0-1.0).
74    pub lut_strength: f32,
75
76    // ── Film look ─────────────────────────────────────────────────────────────
77    pub film_look: FilmLook,
78    /// Strength of the selected film look (0.0 = none, 1.0 = full).
79    pub film_look_strength: f32,
80}
81
82impl Default for ColorGradeParams {
83    fn default() -> Self {
84        Self {
85            enabled:                 true,
86            tint:                    Vec3::ONE,
87            saturation:              1.0,
88            contrast:                1.0,
89            brightness:              0.0,
90            hue_shift:               0.0,
91            lift:                    Vec3::ZERO,
92            gamma:                   Vec3::ONE,
93            gain:                    Vec3::ONE,
94            shadow_tint:             Vec3::ZERO,
95            highlight_tint:          Vec3::ZERO,
96            shadow_tint_strength:    0.0,
97            highlight_tint_strength: 0.0,
98            split_midpoint:          0.5,
99            curve_strength:          0.0,
100            channel_curves:          Vec3::ZERO,
101            vignette:                0.15,
102            vignette_feather:        0.5,
103            vignette_roundness:      0.8,
104            vignette_color:          Vec3::ZERO,
105            lut:                     None,
106            lut_strength:            1.0,
107            film_look:               FilmLook::None,
108            film_look_strength:      1.0,
109        }
110    }
111}
112
113impl ColorGradeParams {
114    // ── Presets ────────────────────────────────────────────────────────────────
115
116    /// Neutral — no grading applied.
117    pub fn neutral() -> Self { Self::default() }
118
119    /// Red-tinted grade for hit flash effects.
120    pub fn hit_flash(intensity: f32) -> Self {
121        Self {
122            tint:       Vec3::new(1.0 + intensity * 0.5, 0.8 - intensity * 0.2, 0.8 - intensity * 0.2),
123            saturation: 1.3,
124            contrast:   1.1,
125            vignette:   0.15 + intensity * 0.4,
126            shadow_tint: Vec3::new(0.4, 0.0, 0.0),
127            shadow_tint_strength: intensity * 0.3,
128            ..Default::default()
129        }
130    }
131
132    /// Desaturated grade for death/game over sequence.
133    pub fn death(progress: f32) -> Self {
134        Self {
135            saturation: 1.0 - progress * 0.85,
136            brightness: -progress * 0.3,
137            contrast:   1.0 + progress * 0.2,
138            vignette:   0.15 + progress * 0.7,
139            tint:       Vec3::new(0.8, 0.7, 0.7),
140            lift:       Vec3::splat(-progress * 0.05),
141            film_look:  FilmLook::Noir,
142            film_look_strength: progress * 0.6,
143            ..Default::default()
144        }
145    }
146
147    /// Warm golden grade for victory/level completion.
148    pub fn victory() -> Self {
149        Self {
150            tint:                    Vec3::new(1.15, 1.05, 0.85),
151            saturation:              1.3,
152            brightness:              0.08,
153            highlight_tint:          Vec3::new(1.0, 0.9, 0.5),
154            highlight_tint_strength: 0.4,
155            film_look:               FilmLook::Golden,
156            film_look_strength:      0.5,
157            ..Default::default()
158        }
159    }
160
161    /// Cold blue for low-health / danger state.
162    pub fn danger(severity: f32) -> Self {
163        Self {
164            tint:          Vec3::new(0.9 - severity * 0.2, 0.9, 1.1 + severity * 0.1),
165            saturation:    1.0 - severity * 0.3,
166            contrast:      1.0 + severity * 0.15,
167            vignette:      0.2 + severity * 0.5,
168            shadow_tint:   Vec3::new(0.0, 0.05, 0.2),
169            shadow_tint_strength: severity * 0.4,
170            ..Default::default()
171        }
172    }
173
174    /// Retro CRT / scanline aesthetic.
175    pub fn retro_crt() -> Self {
176        Self {
177            saturation:   1.4,
178            contrast:     1.2,
179            brightness:   -0.05,
180            film_look:    FilmLook::RetroTv,
181            film_look_strength: 0.8,
182            vignette:     0.3,
183            vignette_feather: 0.3,
184            tint:         Vec3::new(0.95, 1.05, 0.9),
185            ..Default::default()
186        }
187    }
188
189    /// Dreamy soft look (healing, sanctuary).
190    pub fn dream() -> Self {
191        Self {
192            saturation:              1.2,
193            contrast:                0.85,
194            brightness:              0.1,
195            highlight_tint:          Vec3::new(1.0, 0.95, 1.1),
196            highlight_tint_strength: 0.3,
197            shadow_tint:             Vec3::new(0.1, 0.1, 0.3),
198            shadow_tint_strength:    0.2,
199            curve_strength:          0.3,
200            film_look:               FilmLook::Soft,
201            film_look_strength:      0.6,
202            ..Default::default()
203        }
204    }
205
206    /// Poison/acid aesthetic.
207    pub fn poison(intensity: f32) -> Self {
208        Self {
209            tint:          Vec3::new(0.8, 1.1 + intensity * 0.3, 0.7),
210            saturation:    1.5,
211            contrast:      1.1,
212            shadow_tint:   Vec3::new(0.0, 0.3, 0.0),
213            shadow_tint_strength: intensity * 0.4,
214            vignette:      0.2 + intensity * 0.2,
215            vignette_color: Vec3::new(0.0, 0.3, 0.0),
216            ..Default::default()
217        }
218    }
219
220    /// Lerp between two color grades.
221    pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
222        let t = t.clamp(0.0, 1.0);
223        Self {
224            enabled:                 a.enabled || b.enabled,
225            tint:                    Vec3::lerp(a.tint, b.tint, t),
226            saturation:              lerp_f32(a.saturation, b.saturation, t),
227            contrast:                lerp_f32(a.contrast, b.contrast, t),
228            brightness:              lerp_f32(a.brightness, b.brightness, t),
229            hue_shift:               lerp_f32(a.hue_shift, b.hue_shift, t),
230            lift:                    Vec3::lerp(a.lift, b.lift, t),
231            gamma:                   Vec3::lerp(a.gamma, b.gamma, t),
232            gain:                    Vec3::lerp(a.gain, b.gain, t),
233            shadow_tint:             Vec3::lerp(a.shadow_tint, b.shadow_tint, t),
234            highlight_tint:          Vec3::lerp(a.highlight_tint, b.highlight_tint, t),
235            shadow_tint_strength:    lerp_f32(a.shadow_tint_strength, b.shadow_tint_strength, t),
236            highlight_tint_strength: lerp_f32(a.highlight_tint_strength, b.highlight_tint_strength, t),
237            split_midpoint:          lerp_f32(a.split_midpoint, b.split_midpoint, t),
238            curve_strength:          lerp_f32(a.curve_strength, b.curve_strength, t),
239            channel_curves:          Vec3::lerp(a.channel_curves, b.channel_curves, t),
240            vignette:                lerp_f32(a.vignette, b.vignette, t),
241            vignette_feather:        lerp_f32(a.vignette_feather, b.vignette_feather, t),
242            vignette_roundness:      lerp_f32(a.vignette_roundness, b.vignette_roundness, t),
243            vignette_color:          Vec3::lerp(a.vignette_color, b.vignette_color, t),
244            lut:                     if t < 0.5 { a.lut.clone() } else { b.lut.clone() },
245            lut_strength:            lerp_f32(a.lut_strength, b.lut_strength, t),
246            film_look:               if t < 0.5 { a.film_look } else { b.film_look },
247            film_look_strength:      lerp_f32(a.film_look_strength, b.film_look_strength, t),
248        }
249    }
250
251    // ── CPU pixel processing ───────────────────────────────────────────────────
252
253    /// Apply the full color grade to a single pixel (linear float RGB).
254    /// Returns a graded linear RGB value.
255    pub fn apply_to_pixel(&self, pixel: Vec3) -> Vec3 {
256        if !self.enabled { return pixel; }
257
258        let mut c = pixel;
259
260        // 1. Brightness
261        c += Vec3::splat(self.brightness);
262
263        // 2. Contrast (around 0.5)
264        c = (c - 0.5) * self.contrast + 0.5;
265
266        // 3. Lift / Gamma / Gain
267        c = c + self.lift;
268        c = Vec3::new(
269            pow_f32(c.x.max(0.0), 1.0 / self.gamma.x.max(0.001)),
270            pow_f32(c.y.max(0.0), 1.0 / self.gamma.y.max(0.001)),
271            pow_f32(c.z.max(0.0), 1.0 / self.gamma.z.max(0.001)),
272        );
273        c *= self.gain;
274
275        // 4. Tint
276        c *= self.tint;
277
278        // 5. Saturation (via luminance)
279        let luma = luminance(c);
280        c = Vec3::splat(luma) + (c - Vec3::splat(luma)) * self.saturation;
281
282        // 6. Hue rotation
283        if self.hue_shift.abs() > 0.001 {
284            c = rotate_hue(c, self.hue_shift);
285        }
286
287        // 7. S-curve
288        if self.curve_strength > 0.001 {
289            c = s_curve_v3(c, self.curve_strength);
290        }
291
292        // 8. Per-channel curves
293        if self.channel_curves.length_squared() > 0.001 {
294            c.x = apply_channel_curve(c.x, self.channel_curves.x);
295            c.y = apply_channel_curve(c.y, self.channel_curves.y);
296            c.z = apply_channel_curve(c.z, self.channel_curves.z);
297        }
298
299        // 9. Split toning
300        if self.shadow_tint_strength > 0.001 || self.highlight_tint_strength > 0.001 {
301            let luma2 = luminance(c);
302            let shadow_w    = (1.0 - luma2 / self.split_midpoint.max(0.001)).clamp(0.0, 1.0);
303            let highlight_w = (luma2 - self.split_midpoint).max(0.0)
304                            / (1.0 - self.split_midpoint).max(0.001);
305            let highlight_w = highlight_w.clamp(0.0, 1.0);
306            c = Vec3::lerp(c, c * (Vec3::ONE + self.shadow_tint),
307                           shadow_w * self.shadow_tint_strength);
308            c = Vec3::lerp(c, c * (Vec3::ONE + self.highlight_tint),
309                           highlight_w * self.highlight_tint_strength);
310        }
311
312        // 10. LUT
313        if let Some(ref lut) = self.lut {
314            let lut_out = lut.sample(c);
315            c = Vec3::lerp(c, lut_out, self.lut_strength);
316        }
317
318        // 11. Film look
319        if self.film_look_strength > 0.001 {
320            let film_out = self.film_look.apply(c);
321            c = Vec3::lerp(c, film_out, self.film_look_strength);
322        }
323
324        c.max(Vec3::ZERO)
325    }
326
327    /// Apply vignette at normalized UV position `(u, v) ∈ [0, 1]²`.
328    /// Returns a multiplier in `[0, 1]` to apply to the pixel.
329    pub fn vignette_at(&self, u: f32, v: f32) -> f32 {
330        if self.vignette < 0.001 { return 1.0; }
331        let dx = (u - 0.5) * 2.0;
332        let dy = (v - 0.5) * 2.0;
333        // Roundness blends between L∞ (rect) and L2 (circle)
334        let r = lerp_f32(
335            dx.abs().max(dy.abs()),
336            (dx * dx + dy * dy).sqrt(),
337            self.vignette_roundness,
338        );
339        let feather = self.vignette_feather.max(0.001);
340        let edge_dist = ((r - (1.0 - self.vignette)) / feather).clamp(0.0, 1.0);
341        1.0 - edge_dist * edge_dist * (3.0 - 2.0 * edge_dist) // smoothstep
342    }
343
344    /// Full pixel apply including vignette at UV.
345    pub fn apply_full(&self, pixel: Vec3, u: f32, v: f32) -> Vec3 {
346        let graded  = self.apply_to_pixel(pixel);
347        let vignette_m = self.vignette_at(u, v);
348        let dark    = Vec3::lerp(graded, self.vignette_color, 1.0 - vignette_m);
349        dark
350    }
351
352    /// Apply grading to an entire image buffer (width × height × 3 linear f32).
353    pub fn apply_to_image(&self, pixels: &mut [f32], width: usize, height: usize) {
354        let inv_w = 1.0 / width.max(1) as f32;
355        let inv_h = 1.0 / height.max(1) as f32;
356        for y in 0..height {
357            for x in 0..width {
358                let base = (y * width + x) * 3;
359                if base + 2 >= pixels.len() { break; }
360                let c = Vec3::new(pixels[base], pixels[base + 1], pixels[base + 2]);
361                let u = (x as f32 + 0.5) * inv_w;
362                let v = (y as f32 + 0.5) * inv_h;
363                let out = self.apply_full(c, u, v);
364                pixels[base    ] = out.x;
365                pixels[base + 1] = out.y;
366                pixels[base + 2] = out.z;
367            }
368        }
369    }
370}
371
372// ── FilmLook ──────────────────────────────────────────────────────────────────
373
374/// Cinematic film simulation presets.
375#[derive(Clone, Copy, Debug, PartialEq)]
376pub enum FilmLook {
377    None,
378    /// ACES (Academy Color Encoding System) filmic tone curve.
379    Aces,
380    /// Kodak-inspired warm emulsion look.
381    Kodak,
382    /// Fuji-inspired cooler, slightly desaturated look.
383    Fuji,
384    /// High contrast black-and-white noir.
385    Noir,
386    /// Warm golden summer look.
387    Golden,
388    /// Soft pastel dreamlike look.
389    Soft,
390    /// Retro CRT / VHS look with slight color bleeding.
391    RetroTv,
392    /// Faded film (lifted blacks, reduced contrast).
393    FadedFilm,
394    /// Teal-and-orange (Hollywood blockbuster style).
395    TealOrange,
396}
397
398impl FilmLook {
399    pub fn apply(self, c: Vec3) -> Vec3 {
400        match self {
401            FilmLook::None     => c,
402            FilmLook::Aces     => aces_filmic(c),
403            FilmLook::Kodak    => kodak_look(c),
404            FilmLook::Fuji     => fuji_look(c),
405            FilmLook::Noir     => noir_look(c),
406            FilmLook::Golden   => golden_look(c),
407            FilmLook::Soft     => soft_look(c),
408            FilmLook::RetroTv  => retro_tv_look(c),
409            FilmLook::FadedFilm => faded_film_look(c),
410            FilmLook::TealOrange => teal_orange_look(c),
411        }
412    }
413
414    pub fn name(self) -> &'static str {
415        match self {
416            FilmLook::None      => "None",
417            FilmLook::Aces      => "ACES",
418            FilmLook::Kodak     => "Kodak",
419            FilmLook::Fuji      => "Fuji",
420            FilmLook::Noir      => "Noir",
421            FilmLook::Golden    => "Golden",
422            FilmLook::Soft      => "Soft",
423            FilmLook::RetroTv   => "Retro TV",
424            FilmLook::FadedFilm => "Faded Film",
425            FilmLook::TealOrange => "Teal & Orange",
426        }
427    }
428}
429
430// ACES filmic tone mapping approximation (Hill 2016)
431fn aces_filmic(c: Vec3) -> Vec3 {
432    let a = 2.51_f32;
433    let b = 0.03_f32;
434    let cc = 2.43_f32;
435    let d = 0.59_f32;
436    let e = 0.14_f32;
437    let x = c;
438    ((x * (a * x + b)) / (x * (cc * x + d) + e)).clamp(Vec3::ZERO, Vec3::ONE)
439}
440
441fn kodak_look(c: Vec3) -> Vec3 {
442    // Warm, slightly lifted shadows, slightly rolled-off highlights
443    let lifted = c * 0.93 + Vec3::new(0.02, 0.015, 0.01);
444    let warm = lifted * Vec3::new(1.08, 1.02, 0.95);
445    soft_knee_compress(warm, 0.85, 0.1)
446}
447
448fn fuji_look(c: Vec3) -> Vec3 {
449    let cooler = c * Vec3::new(0.97, 1.0, 1.06);
450    let slight_desat = {
451        let luma = luminance(cooler);
452        Vec3::lerp(Vec3::splat(luma), cooler, 0.88)
453    };
454    soft_knee_compress(slight_desat, 0.9, 0.08)
455}
456
457fn noir_look(c: Vec3) -> Vec3 {
458    let luma = luminance(c);
459    // Full desaturation + S-curve for high contrast
460    let grey = Vec3::splat(luma);
461    let contrasted = s_curve_v3(grey, 0.7);
462    contrasted * Vec3::new(0.95, 0.92, 0.9) // slight warm tint in greys
463}
464
465fn golden_look(c: Vec3) -> Vec3 {
466    let warm = c * Vec3::new(1.15, 1.05, 0.82);
467    // Compress highlights
468    soft_knee_compress(warm, 0.88, 0.1)
469}
470
471fn soft_look(c: Vec3) -> Vec3 {
472    // Reduce contrast, add slight glow in highlights
473    let soft_c = (c - 0.5) * 0.8 + 0.5;
474    let bloom  = (c - Vec3::splat(0.7)).max(Vec3::ZERO) * 0.3;
475    soft_c + bloom
476}
477
478fn retro_tv_look(c: Vec3) -> Vec3 {
479    // Slight color bleeding: G channel bleeds into R, B fades
480    let r = c.x * 0.9 + c.y * 0.1;
481    let g = c.y;
482    let b = c.z * 0.85;
483    // Higher saturation
484    let bleed = Vec3::new(r, g, b);
485    let luma  = luminance(bleed);
486    Vec3::lerp(Vec3::splat(luma), bleed, 1.4)
487}
488
489fn faded_film_look(c: Vec3) -> Vec3 {
490    // Lift blacks, reduce whites
491    c * 0.85 + Vec3::splat(0.06)
492}
493
494fn teal_orange_look(c: Vec3) -> Vec3 {
495    let luma = luminance(c);
496    let shadow_w    = (1.0 - luma).clamp(0.0, 1.0).powi(2);
497    let highlight_w = luma.clamp(0.0, 1.0).powi(2);
498    // Shadows → teal
499    let teal_shadows = Vec3::lerp(c, Vec3::new(0.3, 0.7, 0.8), shadow_w * 0.35);
500    // Highlights → orange
501    Vec3::lerp(teal_shadows, Vec3::new(1.1, 0.7, 0.4), highlight_w * 0.35)
502}
503
504// ── 3D Color LUT ──────────────────────────────────────────────────────────────
505
506/// 3D color look-up table (17³ entries = 4913 triplets).
507#[derive(Clone, Debug)]
508pub struct ColorLut {
509    pub size: usize,
510    /// Flat array of RGB values: `[r, g, b, r, g, b, ...]` with `size³` entries.
511    pub entries: Vec<Vec3>,
512}
513
514impl ColorLut {
515    /// Create a neutral (identity) LUT of given size (typically 17 or 33).
516    pub fn identity(size: usize) -> Self {
517        let n = size * size * size;
518        let mut entries = Vec::with_capacity(n);
519        let inv = (size - 1) as f32;
520        for b in 0..size {
521            for g in 0..size {
522                for r in 0..size {
523                    entries.push(Vec3::new(r as f32 / inv, g as f32 / inv, b as f32 / inv));
524                }
525            }
526        }
527        Self { size, entries }
528    }
529
530    /// Build a LUT by applying a color grade to the identity LUT.
531    pub fn from_grade(grade: &ColorGradeParams, size: usize) -> Self {
532        let mut lut = Self::identity(size);
533        for e in &mut lut.entries {
534            *e = grade.apply_to_pixel(*e);
535        }
536        lut
537    }
538
539    /// Trilinear interpolation sample from the LUT.
540    pub fn sample(&self, color: Vec3) -> Vec3 {
541        let n = self.size;
542        let s = (n - 1) as f32;
543        let cr = (color.x * s).clamp(0.0, s);
544        let cg = (color.y * s).clamp(0.0, s);
545        let cb = (color.z * s).clamp(0.0, s);
546        let r0 = cr.floor() as usize;
547        let g0 = cg.floor() as usize;
548        let b0 = cb.floor() as usize;
549        let r1 = (r0 + 1).min(n - 1);
550        let g1 = (g0 + 1).min(n - 1);
551        let b1 = (b0 + 1).min(n - 1);
552        let tr = cr.fract();
553        let tg = cg.fract();
554        let tb = cb.fract();
555
556        let idx = |r: usize, g: usize, b: usize| b * n * n + g * n + r;
557
558        let c000 = self.entries[idx(r0, g0, b0)];
559        let c100 = self.entries[idx(r1, g0, b0)];
560        let c010 = self.entries[idx(r0, g1, b0)];
561        let c110 = self.entries[idx(r1, g1, b0)];
562        let c001 = self.entries[idx(r0, g0, b1)];
563        let c101 = self.entries[idx(r1, g0, b1)];
564        let c011 = self.entries[idx(r0, g1, b1)];
565        let c111 = self.entries[idx(r1, g1, b1)];
566
567        let c00 = Vec3::lerp(c000, c100, tr);
568        let c01 = Vec3::lerp(c001, c101, tr);
569        let c10 = Vec3::lerp(c010, c110, tr);
570        let c11 = Vec3::lerp(c011, c111, tr);
571        let c0  = Vec3::lerp(c00, c10, tg);
572        let c1  = Vec3::lerp(c01, c11, tg);
573        Vec3::lerp(c0, c1, tb)
574    }
575
576    /// Serialize LUT to .cube format string for external tools.
577    pub fn to_cube_string(&self, title: &str) -> String {
578        let mut out = format!("TITLE \"{}\"\nLUT_3D_SIZE {}\n", title, self.size);
579        for e in &self.entries {
580            out.push_str(&format!("{:.6} {:.6} {:.6}\n", e.x, e.y, e.z));
581        }
582        out
583    }
584}
585
586// ── ColorGradeKeyframe ────────────────────────────────────────────────────────
587
588/// An animated keyframe for a color grade transition.
589#[derive(Clone, Debug)]
590pub struct ColorGradeKeyframe {
591    pub time:   f32,
592    pub grade:  ColorGradeParams,
593    /// Easing: 0 = linear, 1 = smooth, 2 = ease-in, 3 = ease-out.
594    pub easing: u8,
595}
596
597/// Animated color grader — holds a sequence of keyframes and evaluates smoothly.
598#[derive(Clone, Debug)]
599pub struct AnimatedColorGrade {
600    pub keyframes: Vec<ColorGradeKeyframe>,
601}
602
603impl AnimatedColorGrade {
604    pub fn new() -> Self { Self { keyframes: Vec::new() } }
605
606    pub fn add(mut self, time: f32, grade: ColorGradeParams) -> Self {
607        self.keyframes.push(ColorGradeKeyframe { time, grade, easing: 1 });
608        self.keyframes.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap());
609        self
610    }
611
612    pub fn add_eased(mut self, time: f32, grade: ColorGradeParams, easing: u8) -> Self {
613        self.keyframes.push(ColorGradeKeyframe { time, grade, easing });
614        self.keyframes.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap());
615        self
616    }
617
618    /// Evaluate the grade at a given time.
619    pub fn evaluate(&self, t: f32) -> ColorGradeParams {
620        if self.keyframes.is_empty() { return ColorGradeParams::default(); }
621        if self.keyframes.len() == 1 { return self.keyframes[0].grade.clone(); }
622        if t <= self.keyframes[0].time { return self.keyframes[0].grade.clone(); }
623        let last = self.keyframes.last().unwrap();
624        if t >= last.time { return last.grade.clone(); }
625
626        // Binary search for surrounding keyframes
627        let i = self.keyframes.partition_point(|k| k.time <= t) - 1;
628        let k0 = &self.keyframes[i];
629        let k1 = &self.keyframes[i + 1];
630        let span = k1.time - k0.time;
631        let raw_t = if span < 1e-6 { 0.0 } else { (t - k0.time) / span };
632        let et = ease(raw_t, k0.easing);
633        ColorGradeParams::lerp(&k0.grade, &k1.grade, et)
634    }
635}
636
637fn ease(t: f32, mode: u8) -> f32 {
638    match mode {
639        0 => t,
640        1 => t * t * (3.0 - 2.0 * t), // smoothstep
641        2 => t * t,                      // ease-in
642        3 => t * (2.0 - t),              // ease-out
643        _ => t,
644    }
645}
646
647// ── Utility functions ─────────────────────────────────────────────────────────
648
649#[inline]
650fn luminance(c: Vec3) -> f32 {
651    c.x * 0.2126 + c.y * 0.7152 + c.z * 0.0722
652}
653
654#[inline]
655fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
656
657#[inline]
658fn pow_f32(base: f32, exp: f32) -> f32 {
659    if base <= 0.0 { 0.0 } else { base.powf(exp) }
660}
661
662fn rotate_hue(c: Vec3, degrees: f32) -> Vec3 {
663    let angle = degrees.to_radians();
664    let cos_a = angle.cos();
665    let sin_a = angle.sin();
666    // Rotate in RGB space using a hue rotation matrix
667    let sqrt3 = 3.0_f32.sqrt();
668    let r = c.x * (cos_a + (1.0 - cos_a) / 3.0) + c.y * ((1.0 - cos_a) / 3.0 - sin_a / sqrt3)
669          + c.z * ((1.0 - cos_a) / 3.0 + sin_a / sqrt3);
670    let g = c.x * ((1.0 - cos_a) / 3.0 + sin_a / sqrt3) + c.y * (cos_a + (1.0 - cos_a) / 3.0)
671          + c.z * ((1.0 - cos_a) / 3.0 - sin_a / sqrt3);
672    let b = c.x * ((1.0 - cos_a) / 3.0 - sin_a / sqrt3) + c.y * ((1.0 - cos_a) / 3.0 + sin_a / sqrt3)
673          + c.z * (cos_a + (1.0 - cos_a) / 3.0);
674    Vec3::new(r, g, b).max(Vec3::ZERO)
675}
676
677fn s_curve_v3(c: Vec3, strength: f32) -> Vec3 {
678    Vec3::new(
679        s_curve(c.x, strength),
680        s_curve(c.y, strength),
681        s_curve(c.z, strength),
682    )
683}
684
685fn s_curve(x: f32, strength: f32) -> f32 {
686    let x = x.clamp(0.0, 1.0);
687    let curved = x * x * (3.0 - 2.0 * x); // smoothstep
688    lerp_f32(x, curved, strength)
689}
690
691fn apply_channel_curve(x: f32, strength: f32) -> f32 {
692    // Soft S-curve with adjustable strength per channel
693    s_curve(x, strength)
694}
695
696fn soft_knee_compress(c: Vec3, knee: f32, width: f32) -> Vec3 {
697    Vec3::new(
698        soft_knee_channel(c.x, knee, width),
699        soft_knee_channel(c.y, knee, width),
700        soft_knee_channel(c.z, knee, width),
701    )
702}
703
704fn soft_knee_channel(x: f32, knee: f32, width: f32) -> f32 {
705    if x <= knee - width {
706        x
707    } else if x >= knee + width {
708        knee + (x - knee) * 0.1 // strong compression above knee
709    } else {
710        let t = (x - (knee - width)) / (2.0 * width);
711        let blend = t * t * (3.0 - 2.0 * t);
712        let compressed = knee + (x - knee) * 0.1;
713        lerp_f32(x, compressed, blend)
714    }
715}
716
717// ── GLSL shader source ────────────────────────────────────────────────────────
718
719/// GLSL fragment shader implementing the color grade pass.
720pub const COLOR_GRADE_FRAG: &str = r#"
721#version 330 core
722in  vec2 vUv;
723out vec4 fColor;
724
725uniform sampler2D uScene;
726uniform float uSaturation;
727uniform float uContrast;
728uniform float uBrightness;
729uniform float uHueShift;
730uniform vec3  uTint;
731uniform vec3  uLift;
732uniform vec3  uGamma;
733uniform vec3  uGain;
734uniform float uVignette;
735uniform float uVignetteFeather;
736uniform float uVignetteRoundness;
737
738float luminance(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }
739
740vec3 saturation(vec3 c, float s) {
741    float luma = luminance(c);
742    return mix(vec3(luma), c, s);
743}
744
745vec3 adjustContrast(vec3 c, float contrast) {
746    return (c - 0.5) * contrast + 0.5;
747}
748
749void main() {
750    vec3 c = texture(uScene, vUv).rgb;
751
752    // Brightness / Contrast
753    c += uBrightness;
754    c = adjustContrast(c, uContrast);
755
756    // Lift / Gamma / Gain
757    c = c + uLift;
758    c = pow(max(c, 0.0), 1.0 / max(uGamma, vec3(0.001)));
759    c *= uGain;
760
761    // Tint + Saturation
762    c *= uTint;
763    c = saturation(c, uSaturation);
764
765    // Vignette
766    vec2 uv = vUv * 2.0 - 1.0;
767    float r = mix(max(abs(uv.x), abs(uv.y)), length(uv), uVignetteRoundness);
768    float vig = 1.0 - smoothstep(1.0 - uVignette, 1.0 - uVignette + uVignetteFeather, r);
769    c *= vig;
770
771    fColor = vec4(clamp(c, 0.0, 1.0), 1.0);
772}
773"#;
774
775// ── Unit tests ─────────────────────────────────────────────────────────────────
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn test_identity_grade() {
783        let grade = ColorGradeParams::neutral();
784        let pixel = Vec3::new(0.5, 0.3, 0.7);
785        let out   = grade.apply_to_pixel(pixel);
786        // Should be approximately unchanged
787        assert!((out.x - pixel.x).abs() < 0.05);
788        assert!((out.y - pixel.y).abs() < 0.05);
789        assert!((out.z - pixel.z).abs() < 0.05);
790    }
791
792    #[test]
793    fn test_greyscale() {
794        let mut grade = ColorGradeParams::neutral();
795        grade.saturation = 0.0;
796        let pixel = Vec3::new(1.0, 0.0, 0.0);
797        let out = grade.apply_to_pixel(pixel);
798        // All channels should be equal (greyscale)
799        assert!((out.x - out.y).abs() < 0.01);
800        assert!((out.y - out.z).abs() < 0.01);
801    }
802
803    #[test]
804    fn test_vignette_center() {
805        let grade = ColorGradeParams::default();
806        let v = grade.vignette_at(0.5, 0.5);
807        assert!(v > 0.99); // center should be fully lit
808    }
809
810    #[test]
811    fn test_vignette_corner() {
812        let grade = ColorGradeParams::default();
813        let v = grade.vignette_at(0.0, 0.0);
814        assert!(v < 0.9); // corner should be dimmed
815    }
816
817    #[test]
818    fn test_lerp() {
819        let a = ColorGradeParams::neutral();
820        let b = ColorGradeParams::death(1.0);
821        let mid = ColorGradeParams::lerp(&a, &b, 0.5);
822        assert!((mid.saturation - 0.5 * (1.0 + (1.0 - 0.85))).abs() < 0.1);
823    }
824
825    #[test]
826    fn test_lut_identity() {
827        let lut = ColorLut::identity(17);
828        let c = Vec3::new(0.4, 0.6, 0.8);
829        let out = lut.sample(c);
830        assert!((out.x - c.x).abs() < 0.01);
831        assert!((out.y - c.y).abs() < 0.01);
832        assert!((out.z - c.z).abs() < 0.01);
833    }
834
835    #[test]
836    fn test_lut_from_grade() {
837        let grade = ColorGradeParams::death(0.5);
838        let lut = ColorLut::from_grade(&grade, 9);
839        assert_eq!(lut.entries.len(), 9 * 9 * 9);
840    }
841
842    #[test]
843    fn test_film_looks_dont_panic() {
844        let pixel = Vec3::new(0.3, 0.5, 0.7);
845        for look in [FilmLook::Aces, FilmLook::Kodak, FilmLook::Fuji, FilmLook::Noir,
846                     FilmLook::Golden, FilmLook::Soft, FilmLook::RetroTv,
847                     FilmLook::FadedFilm, FilmLook::TealOrange] {
848            let out = look.apply(pixel);
849            assert!(out.x.is_finite());
850            assert!(out.y.is_finite());
851            assert!(out.z.is_finite());
852        }
853    }
854
855    #[test]
856    fn test_animated_grade() {
857        let anim = AnimatedColorGrade::new()
858            .add(0.0, ColorGradeParams::neutral())
859            .add(1.0, ColorGradeParams::death(1.0));
860        let mid = anim.evaluate(0.5);
861        assert!((mid.saturation - 0.5 * (1.0 + 0.15)).abs() < 0.3);
862        assert_eq!(anim.evaluate(0.0).brightness, 0.0);
863    }
864
865    #[test]
866    fn test_image_processing() {
867        let mut pixels = vec![0.5_f32, 0.5, 0.5, 0.3, 0.6, 0.9];
868        let grade = ColorGradeParams::neutral();
869        grade.apply_to_image(&mut pixels, 2, 1);
870        // Should not panic or produce NaN
871        for p in &pixels { assert!(p.is_finite()); }
872    }
873
874    #[test]
875    fn test_cube_export() {
876        let lut = ColorLut::identity(3);
877        let cube = lut.to_cube_string("Test LUT");
878        assert!(cube.contains("LUT_3D_SIZE 3"));
879        assert!(cube.contains("TITLE"));
880    }
881}