Files
lightcraft/crates/pipeline/src/finish.rs
T

796 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The per-pixel stage: everything after the spatial planes are ready, in one parallel pass.
use lightcraft_color::luminance_2020;
use lightcraft_color::spline::{Lut1, MonotoneCurve};
use lightcraft_color::transfer::linear_to_srgb;
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Process, ToneCurve, VignetteStyle};
use lightcraft_geom::Point;
use lightcraft_raster::Rgba8;
use crate::colorops::ColorOps;
use crate::geometry::Frame;
use crate::local::log_lum;
use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc};
use crate::tone::ToneMap;
use crate::{Prepared, SourceInfo, for_rows};
#[inline]
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Parametric region curve (encoded domain) composed with the master point curve.
fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> {
let parametric = c.highlights != 0.0 || c.lights != 0.0 || c.darks != 0.0 || c.shadows != 0.0;
let master = !ToneCurve::point_curve_is_identity(&c.master);
let chans = [&c.red, &c.green, &c.blue].map(|p| !ToneCurve::point_curve_is_identity(p));
if !parametric && !master && !chans.iter().any(|b| *b) {
return None;
}
const N: usize = 1024;
let (s1, s2, s3) = ((c.split_shadows / 100.0) as f32, (c.split_mid / 100.0) as f32, (c.split_highlights / 100.0) as f32);
let regions = [(0.0, s1, c.shadows), (s1, s2, c.darks), (s2, s3, c.lights), (s3, 1.0, c.highlights)];
let mut base = Lut1::from_fn(N, |x| {
let mut d = 0.0;
for (a, b, amt) in regions {
if amt == 0.0 {
continue;
}
let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05);
let t = ((x - ctr) / half).clamp(-1.0, 1.0);
let win = 0.5 + 0.5 * (t * std::f32::consts::PI).cos();
d += (amt / 100.0) as f32 * 0.22 * win;
}
(x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0)
});
// keep monotone
for i in 1..N {
base.v[i] = base.v[i].max(base.v[i - 1]);
}
let to_pts = |p: &[Point]| p.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>();
if master {
base = MonotoneCurve::new(&to_pts(&c.master)).to_lut(N).compose(&base);
}
let per = [&c.red, &c.green, &c.blue];
Some(std::array::from_fn(|i| if chans[i] { MonotoneCurve::new(&to_pts(per[i])).to_lut(N).compose(&base) } else { base.clone() }))
}
/// Vignette (post-crop) parameters.
#[derive(Clone, Copy, Debug)]
pub struct Vig {
pub amount: f32,
pub start: f32,
pub width: f32,
pub aspect_mix: f32,
pub power: f32,
pub highlights: f32,
pub style: VignetteStyle,
}
fn vignette(s: &DevelopSettings) -> Option<Vig> {
let v = &s.vignette;
(v.amount != 0.0).then(|| {
let r = (v.roundness / 100.0) as f32;
Vig {
amount: (v.amount / 100.0) as f32,
start: 0.15 + (v.midpoint / 100.0) as f32 * 0.95,
width: 0.05 + (v.feather / 100.0) as f32 * 1.1,
aspect_mix: ((r + 1.0) / 2.0).clamp(0.0, 1.0),
power: if r >= 0.0 { 2.0 } else { 2.0 + (-r) * 6.0 },
highlights: (v.highlights / 100.0) as f32,
style: v.style,
}
})
}
/// Hash constants of [`grain_noise`] (shared with the GPU kernel).
pub const GRAIN_HASH: [u32; 4] = [0x8da6_b343, 0xd816_3841, 0xcb1a_b31f, 0x5bd1_e995];
#[inline]
fn grain_noise(x: f32, y: f32, seed: u32) -> f32 {
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let h = |i: i32, j: i32| {
let mut v = (i as u32).wrapping_mul(GRAIN_HASH[0]) ^ (j as u32).wrapping_mul(GRAIN_HASH[1]) ^ seed.wrapping_mul(GRAIN_HASH[2]);
v ^= v >> 13;
v = v.wrapping_mul(GRAIN_HASH[3]);
v ^= v >> 15;
(v & 0xffff) as f32 / 32768.0 - 1.0
};
let (i, j) = (x0 as i32, y0 as i32);
let (u, v) = (fx * fx * (3.0 - 2.0 * fx), fy * fy * (3.0 - 2.0 * fy));
let a = h(i, j) + (h(i + 1, j) - h(i, j)) * u;
let b = h(i, j + 1) + (h(i + 1, j + 1) - h(i, j + 1)) * u;
a + (b - a) * v
}
/// Number of per-mask terms in [`mask_terms`].
pub const MASK_TERMS: usize = 21;
/// Number of alpha-weighted terms at the start of [`mask_terms`].
pub const MASK_SUMS: usize = 17;
/// A mask's local adjustments as the per-pixel stage uses them (alpha-weighted sums), in order:
/// exposure, temp, tint, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze,
/// saturation, hue, sharpness, noise, moiré, defringe, then the colour overlay: on (0/1), cos and
/// sin of its OkLCh hue, and its strength.
pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
let (on, cos, sin, amt) = if j.color_sat > 0.0 {
let hue = crate::colorops::oklch_hue_of_srgb_hue(j.color_hue);
(1.0, hue.cos(), hue.sin(), (j.color_sat / 100.0) as f32)
} else {
(0.0, 0.0, 0.0, 0.0)
};
[
j.exposure as f32,
(j.temp / 100.0) as f32,
(j.tint / 100.0) as f32,
(j.contrast / 100.0) as f32,
(j.highlights / 100.0) as f32,
(j.shadows / 100.0) as f32,
(j.whites / 100.0) as f32,
(j.blacks / 100.0) as f32,
(j.texture / 100.0) as f32,
(j.clarity / 100.0) as f32,
(j.dehaze / 100.0) as f32,
(j.saturation / 100.0) as f32,
(j.hue / 100.0) as f32 * 0.6,
(j.sharpness / 100.0) as f32,
(j.noise / 100.0) as f32,
(j.moire / 100.0) as f32,
(j.defringe / 100.0) as f32,
on,
cos,
sin,
amt,
]
}
/// Everything the per-pixel stage computes once per render: the CPU loop below and the GPU kernel
/// (`lightcraft-gpu`) both read their parameters from here, so the two cannot drift apart.
pub struct FinishParams {
/// A LUT profile and its amount (0..2), applied to the display-encoded colour.
pub lut: Option<(std::sync::Arc<crate::lut::Lut3d>, f32)>,
pub tone: ToneMap,
pub ops: ColorOps,
/// Calibration: primaries matrix (row-major, linear Rec.2020) and shadows tint (−1..1).
pub calib: Option<[[f32; 3]; 3]>,
pub shadow_tint: f32,
/// Tone curves (parametric ∘ point, per channel) on encoded values, 1024 entries each.
pub curves: Option<[Lut1; 3]>,
/// Refine Saturation as 0..1 (1 = the curves' own saturation).
pub refine_sat: f32,
pub vig: Option<Vig>,
/// Linear Rec.2020 → linear output RGB, the output's luminance weights (gamut mapping) and its
/// encoding curve (see [`crate::output`]).
pub to_out: [[f32; 3]; 3],
pub out_luma: [f32; 3],
pub out_trc: OutputTrc,
/// Soft proofing (CPU only; the GPU path declines proof renders).
pub proof: Option<crate::output::ProofParams>,
pub hl: f32,
pub sh: f32,
pub clar: f32,
pub tex: f32,
pub dehaze: f32,
pub sharpen: f32,
pub sharpen_mask: f32,
/// Airlight after and before exposure, exposure gain and EV (see [`crate::Prepared`]).
pub air: f32,
pub air_pre: f32,
pub gain: f32,
pub ev: f32,
/// Grain: amount, cell size (px), roughness, seed.
pub grain: Option<(f32, f32, f32, u32)>,
/// Output px → normalized oriented coordinates.
pub out_to_norm: lightcraft_geom::Affine,
pub ow: f64,
pub oh: f64,
pub w: usize,
pub h: usize,
pub px_per_long: f64,
/// The window's offset and the whole output's size (pixels): `(0, 0, w, h)` unless windowed.
pub view: [f32; 4],
/// Peak linear output relative to SDR white: 1 for SDR renders; `2^max_ev` for an HDR render
/// ([`OutputDepth::F32Hdr`] with HDR on), which keeps values above 1 instead of clipping them.
pub peak: f32,
}
impl FinishParams {
/// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`].
#[allow(clippy::too_many_arguments)]
pub fn new(
s: &DevelopSettings,
frame: &Frame,
info: &SourceInfo,
w: usize,
h: usize,
px_per_long: f64,
air_pre: f32,
space: OutputSpace,
) -> FinishParams {
let effects = s.section_enabled("effects");
let (clar, tex, dehaze) = if effects {
((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32)
} else {
(0.0, 0.0, 0.0)
};
let ev = s.light.exposure as f32;
let gain = 2f32.powf(ev);
let grain = (s.grain.amount > 0.0 && effects).then(|| {
let cell = (0.0006 + (s.grain.size / 100.0) as f32 * 0.0024) * px_per_long as f32;
((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed)
});
let calibration = s.section_enabled("calibration");
let peak = s.hdr.peak();
FinishParams {
peak,
calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None },
shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 },
tone: base_tone(s, info, peak),
lut: crate::lut::get(&s.profile.id).map(|l| (l, (s.profile.amount / 100.0).clamp(0.0, 2.0) as f32)),
ops: ColorOps::new(s),
curves: curve_luts(&s.curve),
refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32,
vig: if effects { vignette(s) } else { None },
to_out: space.from_working(),
out_luma: space.luma(),
out_trc: space.trc(),
proof: None,
hl: (s.light.highlights / 100.0) as f32,
sh: (s.light.shadows / 100.0) as f32,
clar,
tex,
dehaze,
sharpen: (s.detail.sharpen_amount / 150.0) as f32,
sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32,
air: air_pre * gain,
air_pre,
gain,
ev,
grain,
out_to_norm: frame.out_to_norm(w, h),
ow: frame.ow,
oh: frame.oh,
w,
h,
px_per_long,
view: frame.view.map_or([0.0, 0.0, w as f32, h as f32], |v| [v.x as f32, v.y as f32, v.full_w as f32, v.full_h as f32]),
}
}
/// Render into a display's primaries (sRGB curve) instead of the output space, soft proof
/// included (see [`crate::DisplaySpace`]).
pub fn for_display(&mut self, d: &crate::DisplaySpace, proof: Option<crate::Proof>) {
self.to_out = d.from_working;
self.out_luma = d.luma;
self.out_trc = OutputTrc::Srgb;
self.proof = proof.map(|pr| pr.params_display(d));
}
}
/// The base tone map (scene → display luminance, with Contrast, Whites and Blacks) of `s`'s
/// rendering process. This is where a new tone model branches: a retuned default tone curve (issue
/// #146) is a new [`Process`] with its own arm here, and V1's arm stays as it is, so photos edited
/// under V1 keep their look (`docs/process-versions.md`). CPU and GPU both take it from here.
/// `peak` is the HDR render's peak relative to SDR white ([`lightcraft_develop::Hdr::peak`]); at 1.0 (SDR)
/// every arm is the SDR tone map. The camera's look is bounded at white, so HDR renders use the HDR tone map.
pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap {
let l = &s.light;
match s.process.process() {
Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw && peak <= 1.0) {
Some(curve) => ToneMap::camera(curve, l.contrast, l.whites, l.blacks),
None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak),
None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak),
},
}
}
pub(crate) fn finish(
p: &Prepared,
s: &DevelopSettings,
frame: &Frame,
info: &SourceInfo,
space: OutputSpace,
display: Option<&crate::DisplaySpace>,
proof: Option<crate::Proof>,
) -> Rgba8 {
let (w, h) = (p.img.width, p.img.height);
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
fp.proof = proof.map(|pr| pr.params(space));
if let Some(d) = display {
fp.for_display(d, proof);
}
let trc = fp.out_trc;
let data = finish_with(p, &fp, false, |e| match trc {
OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255],
t => {
let x = e.map(|v| enc(t.encode(lightcraft_color::transfer::srgb_to_linear(v.clamp(0.0, 1.0)))));
[x[0], x[1], x[2], 255]
}
});
Rgba8 { width: w, height: h, data }
}
/// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries).
#[allow(clippy::too_many_arguments)]
pub(crate) fn finish_deep(
p: &Prepared,
s: &DevelopSettings,
frame: &Frame,
info: &SourceInfo,
space: OutputSpace,
depth: OutputDepth,
proof: Option<crate::Proof>,
) -> DeepImage {
use lightcraft_color::transfer::srgb_to_linear;
let (w, h) = (p.img.width, p.img.height);
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
fp.proof = proof.map(|pr| pr.params(space));
let trc = fp.out_trc;
let samples = match depth {
OutputDepth::F32Linear => {
let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.clamp(0.0, 1.0))));
DeepSamples::F32(v.into_flattened())
}
OutputDepth::F32Hdr => {
// the sRGB curve extended past 1 (it is unbounded), so values up to `peak` survive
let peak = fp.peak;
let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.max(0.0)).clamp(0.0, peak)));
DeepSamples::F32(v.into_flattened())
}
_ => {
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
let v = finish_with(p, &fp, true, |e| match trc {
OutputTrc::Srgb => e.map(q),
t => e.map(|v| q(t.encode(srgb_to_linear(v.clamp(0.0, 1.0))))),
});
DeepSamples::U16(v.into_flattened())
}
};
DeepImage { width: w, height: h, space, samples }
}
/// The per-pixel stage: every output pixel's colour in the output primaries, encoded with the sRGB
/// curve (tone curves and grain applied; not clamped), handed to `store` for the final encoding.
/// `exact`: encode with the exact sRGB curve instead of the (8/10-bit accurate) table.
pub(crate) fn finish_with<T: Copy + Default + Send>(
p: &Prepared,
fp: &FinishParams,
exact: bool,
store: impl Fn([f32; 3]) -> T + Sync + Send,
) -> Vec<T> {
let (w, h) = (p.img.width, p.img.height);
let p_lut = fp.lut.clone();
let FinishParams {
tone,
ops,
curves,
vig,
to_out,
out_luma,
hl,
sh,
clar,
tex,
dehaze,
sharpen,
sharpen_mask,
air,
air_pre,
gain,
ev,
grain,
..
} = fp;
let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) =
(*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev);
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
let out_to_norm = fp.out_to_norm;
let long = fp.ow.max(fp.oh);
let [vx, vy, vw, vh] = fp.view;
// the vignette belongs to the whole output frame, not to the window being drawn
let aspect = vw / vh;
let srgb = srgb_lut();
let peak = fp.peak;
let hdr = peak > 1.0;
let mut out = vec![T::default(); w * h];
for_rows(&mut out, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let i = y * w + x;
let raw = p.img.data[i];
let mut c = if gain == 1.0 { raw } else { raw.map(|v| v * gain) };
let l_pre = p.log_l.data[i];
let l0 = l_pre + ev;
// --- local (mask) contributions: alpha-weighted sums of the masks' terms
let mut l = [0.0f32; MASK_SUMS];
let mut tint_col: Option<([f32; 3], f32)> = None;
for (m, t) in p.masks.iter().zip(&terms) {
let a = m.alpha.data[i];
if a <= 0.0 {
continue;
}
for k in 0..MASK_SUMS {
l[k] += a * t[k];
}
if t[MASK_SUMS] > 0.0 {
tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3]));
}
}
let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] =
l;
// --- local Moiré (and the colour part of Noise): chromaticity towards its blur
let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0);
if mt != 0.0
&& let Some(cb) = &p.chroma_blur
{
let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]);
c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0));
}
// --- local Defringe: desaturate purple / green fringes along edges
let df = l_defringe.clamp(0.0, 1.0);
if df > 0.0
&& let Some(b) = &p.texture_blur
{
let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]);
if k > 0.0 {
let y = luminance_2020(c);
c = c.map(|v| v + (y - v) * k);
}
}
// --- dehaze (scene linear)
let dz = dehaze + l_dehaze;
if dz != 0.0
&& let Some(dark) = &p.dark
{
let d = (dark.data[i] / air_pre).clamp(0.0, 1.0);
if dz > 0.0 {
let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12);
c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0));
} else {
let k = (-dz).min(1.0) * 0.7 * (0.35 + 0.65 * d);
c = c.map(|v| v + (air * 0.9 - v) * k);
}
}
// --- local exposure / temp / tint
if l_exp != 0.0 {
let g = l_exp.exp2();
c = c.map(|v| v * g);
}
if l_temp != 0.0 || l_tint != 0.0 {
let y0 = luminance_2020(c);
c = [c[0] * (1.0 + 0.3 * l_temp), c[1] * (1.0 - 0.22 * l_tint), c[2] * (1.0 - 0.3 * l_temp).max(0.0)];
let y1 = luminance_2020(c).max(1e-9);
c = c.map(|v| v * y0 / y1);
}
// --- local tone in log luminance
let l1 = if dz != 0.0 || l_exp != 0.0 { log_lum(c) } else { l0 };
let shift = l1 - l0;
let base = p.base.data[i] + ev + shift;
let mut delta = 0.0f32;
let (hh, ss) = (hl + l_hl, sh + l_sh);
if hh != 0.0 || ss != 0.0 {
let ws = 1.0 - smooth(-4.8, 0.3, base);
let wh = smooth(-1.0, 2.8, base);
delta += ss * 1.7 * ws * ws.sqrt() + hh * 1.7 * wh;
}
if l_wh != 0.0 {
delta += l_wh * 0.8 * smooth(0.5, 3.0, l1);
}
if l_bl != 0.0 {
delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1));
}
if l_con != 0.0 {
delta += l_con * 0.14 * l1.clamp(-6.0, 4.0);
}
let cl = clar + l_clar;
if cl != 0.0
&& let Some(b) = &p.clarity_blur
{
let det = (l_pre - b.data[i]).clamp(-2.5, 2.5);
let mid = (-(base / 3.2).powi(2)).exp();
delta += cl * 0.85 * det * (0.35 + 0.65 * mid);
}
let tx = tex + l_tex;
let sp = l_sharp * 0.6 + sharpen;
if (tx != 0.0 || sp != 0.0)
&& let Some(b) = &p.texture_blur
{
let det = l_pre - b.data[i];
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs());
delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame;
if sp != 0.0 {
let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 };
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m;
}
}
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
if l_noise != 0.0
&& let Some(b) = &p.texture_blur
{
let det = l_pre - b.data[i];
delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs()));
}
if delta != 0.0 {
let g = delta.exp2();
c = c.map(|v| v * g);
}
// --- calibration (scene linear, before the tone map)
if fp.calib.is_some() || fp.shadow_tint != 0.0 {
c = crate::colorops::calibrate(c, fp.calib.as_ref(), fp.shadow_tint);
}
// --- tone map on luminance, highlight desaturation
let yl = luminance_2020(c);
let o = tone.apply(yl);
let mut d = if yl > 1e-9 { c.map(|v| v * o / yl) } else { [0.0; 3] };
let k = tone.chroma_scale(o);
if k != 1.0 {
d = d.map(|v| o + (v - o) * k);
}
let mx = d[0].max(d[1]).max(d[2]);
if mx > peak {
let t = ((mx - peak) / (mx - o).max(1e-6)).clamp(0.0, 1.0);
d = d.map(|v| v + (o - v) * t);
}
// --- colour
d = ops.apply(d, l_sat, l_hue);
if let Some((dir, amt)) = tint_col {
let lab = lightcraft_color::perceptual::oklab_from_2020(d);
d = lightcraft_color::perceptual::oklab_to_2020([lab[0], lab[1] + dir[0] * 0.08 * amt, lab[2] + dir[1] * 0.08 * amt]);
}
// --- vignette (display linear, post-crop)
if let Some(v) = vig {
let u = (vx + x as f32 + 0.5) / vw * 2.0 - 1.0;
let vv = (vy + y as f32 + 0.5) / vh * 2.0 - 1.0;
let sx = 1.0 + (aspect - 1.0) * v.aspect_mix;
let sy = 1.0 + (1.0 / aspect - 1.0) * v.aspect_mix;
let (ax, ay) = ((u * sx.max(1.0) / sx.max(sy)).abs(), (vv * sy.max(1.0) / sx.max(sy)).abs());
let dist = (ax.powf(v.power) + ay.powf(v.power)).powf(1.0 / v.power);
let t = smooth(v.start, v.start + v.width, dist);
if t > 0.0 {
let lum = luminance_2020(d).clamp(0.0, 1.0);
if v.amount < 0.0 {
let mut f = 1.0 + v.amount * t;
if v.style == VignetteStyle::HighlightPriority {
f += (1.0 - f) * v.highlights * smooth(0.4, 1.0, lum);
}
if v.style == VignetteStyle::PaintOverlay {
d = d.map(|c| c * (1.0 - (-v.amount) * t) + 0.0);
} else {
d = d.map(|c| c * f);
}
} else if hdr {
// lighten towards SDR white; never pull HDR highlights down to it
d = d.map(|c| c + (1.0 - c).max(0.0) * v.amount * t * 0.85);
} else {
d = d.map(|c| c + (1.0 - c) * v.amount * t * 0.85);
}
}
}
// --- gamut map to the output space (desaturate towards luminance until in range);
// soft proofing maps into the proof space first and shows that in the output space
let mut warn = None;
let mut r = match &fp.proof {
Some(pp) => {
let q0 = mul3(&pp.to_proof, d);
let (q, t) = gamut_map_to(q0, pp.luma, peak);
if pp.dest_warning && out_of_gamut(q0, t) {
warn = Some(crate::output::PROOF_DEST_WARNING);
}
mul3(&pp.proof_to_out, q)
}
None => mul3(to_out, d),
};
let (mapped, t) = gamut_map_to(r, *out_luma, peak);
if fp.proof.is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) {
warn = Some(crate::output::PROOF_DISPLAY_WARNING);
}
r = mapped;
// --- encode, curves, grain
let mut e = if hdr {
// HDR: the (unbounded) sRGB curve, so values above SDR white encode above 1
r.map(|v| {
if v > 1.0 {
linear_to_srgb(v)
} else if exact {
linear_to_srgb(v.max(0.0))
} else {
encode_srgb(srgb, v)
}
})
} else if exact {
r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0)))
} else {
r.map(|v| encode_srgb(srgb, v))
};
if let Some(l) = curves {
let e0 = e;
e = if hdr {
// above SDR white the curves continue with slope 1 from their end point
std::array::from_fn(|k| if e[k] > 1.0 { l[k].eval(1.0) + (e[k] - 1.0) } else { l[k].eval(e[k]) })
} else {
[l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]
};
if fp.refine_sat < 1.0 {
e = refine_saturation(e0, e, fp.refine_sat);
}
}
if let Some((amt, cell, rough, seed)) = *grain {
let n = out_to_norm.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let (gx, gy) = ((n.x * fp.ow) as f32 / long as f32, (n.y * fp.oh) as f32 / long as f32);
let sc = fp.px_per_long as f32 / cell;
let mut g = grain_noise(gx * sc, gy * sc, seed);
g = g * (1.0 - rough * 0.5) + grain_noise(gx * sc * 2.3, gy * sc * 2.3, seed ^ 0x55) * rough * 0.7;
let mut lum = 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2];
if hdr {
lum = lum.clamp(0.0, 1.0);
}
let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e.map(|v| v + k);
}
let e = match &p_lut {
Some((l, k)) if hdr => {
// the LUT sees SDR values; the part above SDR white is carried over
let c = e.map(|v| v.clamp(0.0, 1.0));
let m = l.apply(c);
[e[0] + (m[0] - c[0]) * k, e[1] + (m[1] - c[1]) * k, e[2] + (m[2] - c[2]) * k]
}
Some((l, k)) => {
let m = l.apply(e.map(|v| v.clamp(0.0, 1.0)));
[e[0] + (m[0] - e[0]) * k, e[1] + (m[1] - e[1]) * k, e[2] + (m[2] - e[2]) * k]
}
None => e,
};
*px = store(warn.unwrap_or(e));
}
});
out
}
/// A colour needing more desaturation than this (scale < `GAMUT_WARN`) to fit is out of gamut for
/// the gamut warnings (tolerates rounding at the gamut boundary).
const GAMUT_WARN: f32 = 0.995;
/// For the gamut warnings: `r` needed desaturating by `t` to fit, and isn't simply a neutral
/// beyond white or below black (that's clipping, which the clipping warnings show).
#[inline]
fn out_of_gamut(r: [f32; 3], t: f32) -> bool {
let (lo, hi) = (r[0].min(r[1]).min(r[2]), r[0].max(r[1]).max(r[2]));
t < GAMUT_WARN && (hi - lo) > 0.02 * hi.abs().max(1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0))
}
#[inline]
fn mul3(m: &[[f32; 3]; 3], d: [f32; 3]) -> [f32; 3] {
[
m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2],
m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2],
m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2],
]
}
/// Desaturate linear `r` towards its luminance (weights `luma`) until every channel is in 0..1;
/// returns the mapped colour and the chroma scale used (1 = already in gamut).
#[inline]
pub fn gamut_map(r: [f32; 3], luma: [f32; 3]) -> ([f32; 3], f32) {
gamut_map_to(r, luma, 1.0)
}
/// [`gamut_map`] into `0..peak` (an HDR render's range; `peak` = 1 is [`gamut_map`]).
#[inline]
pub fn gamut_map_to(r: [f32; 3], luma: [f32; 3], peak: f32) -> ([f32; 3], f32) {
let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, peak);
let mut t = 1.0f32;
for c in r {
if c < 0.0 {
t = t.min(yy / (yy - c).max(1e-9));
} else if c > peak {
t = t.min((peak - yy) / (c - yy).max(1e-9));
}
}
if t < 1.0 { (r.map(|c| yy + (c - yy) * t), t) } else { (r, 1.0) }
}
/// Local Defringe weight of a scene-linear colour `c` whose log luminance differs by `det` from
/// its fine blur: high on strong edges with a purple or green cast.
#[inline]
pub fn defringe_weight(c: [f32; 3], det: f32) -> f32 {
let y = luminance_2020(c).max(1e-6);
let purple = (c[0].min(c[2]) - c[1]) / y;
let green = (c[1] - c[0].max(c[2])) / y;
smooth(0.04, 0.3, det.abs()) * smooth(0.02, 0.2, purple).max(smooth(0.02, 0.2, green))
}
/// Refine Saturation: scale the curved colour's chroma (around its luma, encoded values) so its
/// saturation (chroma / luma) moves from the curve's towards the pre-curve one: the ratio is
/// `(s_curve / s_before)^refine`, so 1 keeps the curve, 0 restores the original saturation.
#[inline]
pub fn refine_saturation(before: [f32; 3], after: [f32; 3], refine: f32) -> [f32; 3] {
let luma = |e: [f32; 3]| 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2];
let chroma = |e: [f32; 3]| e[0].max(e[1]).max(e[2]) - e[0].min(e[1]).min(e[2]);
let (y0, y1) = (luma(before), luma(after));
let s0 = chroma(before) / y0.max(1e-4);
let s1 = chroma(after) / y1.max(1e-4);
if s1 <= 1e-6 || s0 <= 1e-6 {
return after;
}
let k = (s0 / s1).powf(1.0 - refine).clamp(0.0, 4.0);
after.map(|v| y1 + (v - y1) * k)
}
pub const SRGB_LUT_N: usize = 4096;
/// Linear → sRGB-encoded table (`SRGB_LUT_N + 1` entries over 0..1), interpolated linearly.
pub fn srgb_lut() -> &'static [f32; SRGB_LUT_N + 1] {
static LUT: std::sync::OnceLock<Box<[f32; SRGB_LUT_N + 1]>> = std::sync::OnceLock::new();
LUT.get_or_init(|| {
let mut t = Box::new([0.0f32; SRGB_LUT_N + 1]);
for (i, v) in t.iter_mut().enumerate() {
*v = linear_to_srgb(i as f32 / SRGB_LUT_N as f32);
}
t
})
}
/// Linear → sRGB-encoded (clamped to 0..1) by an interpolated table: within 2e-5 of the exact
/// curve (≪ one 8-bit or 10-bit step), several times faster than `powf`.
#[inline]
fn encode_srgb(lut: &[f32; SRGB_LUT_N + 1], v: f32) -> f32 {
let f = v.clamp(0.0, 1.0) * SRGB_LUT_N as f32;
let i = (f as usize).min(SRGB_LUT_N - 1);
let t = f - i as f32;
lut[i] + (lut[i + 1] - lut[i]) * t
}
#[inline]
fn enc(v: f32) -> u8 {
// `v` is already sRGB-encoded; round to 8 bits.
(v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refine_saturation_restores_the_pre_curve_saturation() {
let before = [0.5, 0.3, 0.2];
let after = [0.7, 0.35, 0.15]; // a contrasty curve: more saturated
assert_eq!(refine_saturation(before, after, 1.0), after);
let r = refine_saturation(before, after, 0.0);
let sat = |e: [f32; 3]| (e[0] - e[2]) / (0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]);
assert!((sat(r) - sat(before)).abs() < 1e-4, "{r:?}");
let half = refine_saturation(before, after, 0.5);
assert!(sat(half) > sat(before) && sat(half) < sat(after));
// luma is kept
let y = |e: [f32; 3]| 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2];
assert!((y(r) - y(after)).abs() < 1e-5);
}
#[test]
fn srgb_table_matches_the_exact_curve() {
let lut = srgb_lut();
let mut worst = 0.0f32;
for i in 0..=200_000 {
// dense near black, where the curve bends most
let v = (i as f32 / 200_000.0).powi(3);
worst = worst.max((encode_srgb(lut, v) - linear_to_srgb(v)).abs());
}
assert!(worst < 2e-5, "{worst}");
assert_eq!(encode_srgb(lut, -1.0), 0.0);
assert!((encode_srgb(lut, 2.0) - 1.0).abs() < 1e-6);
}
}