colr-types 0.3.1

Color model ZSTs and marker traits for colr.
Documentation
//! Display Rendering Transforms, also known as tone mapping operators. The latter was chosen for simplicity.
//!
//! Tone mapping compresses unbounded scene-linear light down to
//! bounded display-linear light [0, 1]. This must be done before applying
//! an OETF (like sRGB or PQ) for display.

use crate::math::Float;

/// Marker for a tone mapping operator.
pub trait Tonemapper: 'static {}

/// Apply a tone mapping operator to scene-linear light.
///
/// Operators that map per-channel (like ACES and Reinhard) implement `Tonemap<F>`.
/// A blanket impl provides `Tonemap<[F; 3]>` automatically for them.
/// Operators that couple RGB channels to preserve hue (like Khronos PBR Neutral)
/// implement `Tonemap<[F; 3]>` directly.
pub trait Tonemap<T>: Tonemapper {
    /// Compress unbounded scene-linear light into display-linear light.
    fn tonemap(v: T) -> T;
}

/// ACES Filmic Tone Mapping Curve (Narkowicz 2015 fit).
///
/// An efficient, widely-used approximation of the ACES RRT+ODT pipeline.
/// Applies a characteristic filmic shoulder and toe. Because it applies per-channel,
/// intense highlights inherently desaturate toward yellow/white.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AcesNarkowicz;

impl Tonemapper for AcesNarkowicz {}

impl<F: Float> Tonemap<F> for AcesNarkowicz {
    #[inline(always)]
    fn tonemap(x: F) -> F {
        let a = F::from_f64(2.51);
        let b = F::from_f64(0.03);
        let c = F::from_f64(2.43);
        let d = F::from_f64(0.59);
        let e = F::from_f64(0.14);
        let zero = F::ZERO;

        let x = x.max(zero);
        (x * (a * x + b)) / (x * (c * x + d) + e)
    }
}

impl<F: Float> Tonemap<[F; 3]> for AcesNarkowicz {
    #[inline(always)]
    fn tonemap(v: [F; 3]) -> [F; 3] {
        [
            Self::tonemap(v[0]),
            Self::tonemap(v[1]),
            Self::tonemap(v[2]),
        ]
    }
}

impl<F: Float> Tonemap<[F; 4]> for AcesNarkowicz {
    #[inline(always)]
    fn tonemap(v: [F; 4]) -> [F; 4] {
        [
            Self::tonemap(v[0]),
            Self::tonemap(v[1]),
            Self::tonemap(v[2]),
            v[3],
        ]
    }
}

/// Standard Reinhard Tone Mapping Operator.
///
/// The classic `x / (x + 1)` compression curve. Very gentle, never clips,
/// but can look slightly desaturated and gray compared to filmic curves.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Reinhard;

impl Tonemapper for Reinhard {}

impl<F: Float> Tonemap<F> for Reinhard {
    #[inline(always)]
    fn tonemap(x: F) -> F {
        let x = x.max(F::ZERO);
        x / (x + F::ONE)
    }
}

impl<F: Float> Tonemap<[F; 3]> for Reinhard {
    #[inline(always)]
    fn tonemap(v: [F; 3]) -> [F; 3] {
        [
            Self::tonemap(v[0]),
            Self::tonemap(v[1]),
            Self::tonemap(v[2]),
        ]
    }
}

impl<F: Float> Tonemap<[F; 4]> for Reinhard {
    #[inline(always)]
    fn tonemap(v: [F; 4]) -> [F; 4] {
        [
            Self::tonemap(v[0]),
            Self::tonemap(v[1]),
            Self::tonemap(v[2]),
            v[3],
        ]
    }
}

/// Khronos PBR Neutral Tone Mapper.
///
/// The official tone mapper of the glTF 3D Commerce working group.
/// Unlike ACES or Reinhard, it is RGB-coupled. It compresses high dynamic range
/// without shifting hues (e.g. bright red stays red, instead of shifting to orange/yellow).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct KhronosPbrNeutral;

impl Tonemapper for KhronosPbrNeutral {}

impl<F: Float> Tonemap<[F; 3]> for KhronosPbrNeutral {
    #[inline(always)]
    fn tonemap(color: [F; 3]) -> [F; 3] {
        let start_compression = F::from_f64(0.8 - 0.04);
        let desaturation = F::from_f64(0.15);

        let mut r = color[0];
        let mut g = color[1];
        let mut b = color[2];

        let x = r.min(g).min(b);
        let offset = if x < F::from_f64(0.08) {
            x - F::from_f64(6.25) * x * x
        } else {
            F::from_f64(0.04)
        };

        r = r - offset;
        g = g - offset;
        b = b - offset;

        let peak = r.max(g).max(b);
        if peak < start_compression {
            return [r, g, b];
        }

        let d = F::ONE - start_compression;
        let new_peak = F::ONE - d * d / (peak + d - start_compression);
        let scale = new_peak / peak;

        r = r * scale;
        g = g * scale;
        b = b * scale;

        let g_mix = F::ONE - F::ONE / (desaturation * (peak - new_peak) + F::ONE);

        let mix = |base: F, target: F, alpha: F| -> F { base * (F::ONE - alpha) + target * alpha };

        [
            mix(r, new_peak, g_mix),
            mix(g, new_peak, g_mix),
            mix(b, new_peak, g_mix),
        ]
    }
}

impl<F: Float> Tonemap<[F; 4]> for KhronosPbrNeutral {
    #[inline(always)]
    fn tonemap(v: [F; 4]) -> [F; 4] {
        let [r, g, b] = Self::tonemap([v[0], v[1], v[2]]);
        [r, g, b, v[3]]
    }
}