Perf: hue/saturation tables skip the sRGB round trip and fmodf in their per-pixel path

HsvTable::apply took the value through linear -> sRGB -> linear even when the table's value
scale is 1 (every hue/saturation-only table, as fitted camera looks are), and wrapped hues with
rem_euclid (an fmodf call) three times per pixel. The value is now kept as it is for a unit
scale and the sRGB-encoded value is reused otherwise; hues are wrapped by a helper that equals
rem_euclid bit for bit (tested) without the call for values within one period.

2048 px render of a 24 MP ARW, single thread: 16.4 -> 15.1 G instructions, 4.94 -> 4.76 G cycles.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-11 19:30:29 +11:00
co-authored by Claude Opus 5.5
parent 07ae88e923
commit ab240d6ece
+52 -4
View File
@@ -76,7 +76,7 @@ impl HsvTable {
/// Tri-linear lookup at hue `h` (degrees), saturation `s` and value `v` (table coordinates).
pub fn lookup(&self, h: f32, s: f32, v: f32) -> [f32; 3] {
let hf = (h.rem_euclid(360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32);
let hf = (wrap(h, 360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32);
let h0 = (hf as usize).min(self.hue_divisions - 1);
let h1 = (h0 + 1) % self.hue_divisions;
let th = (hf - h0 as f32).clamp(0.0, 1.0);
@@ -113,7 +113,9 @@ impl HsvTable {
// already above 1 (out-of-ProPhoto colours, highlight headroom) are only ever lowered.
let s2 = limited(sat, ss);
let v2 = if self.srgb_value && max <= 1.0 {
srgb_to_linear((linear_to_srgb(max) * vs).min(1.0))
// (a value scale of 1 — every hue/saturation-only table — leaves the value as it is:
// no round trip through the sRGB curve, two `powf` per pixel)
if vs == 1.0 { max } else { srgb_to_linear((v_index * vs).min(1.0)) }
} else if self.srgb_value {
max * srgb_to_linear(vs.min(1.0)).min(1.0)
} else {
@@ -128,12 +130,27 @@ fn limited(x: f32, k: f32) -> f32 {
if x <= 1.0 { (x * k).min(1.0) } else { x * k.min(1.0) }
}
/// `x.rem_euclid(p)` (`p > 0`), bit for bit, without the `fmodf` call for the values it nearly always
/// gets here (within one period of `[0, p)`; both subtractions are exact there).
#[inline]
fn wrap(x: f32, p: f32) -> f32 {
if (0.0..p).contains(&x) {
x
} else if x > -p && x < 0.0 {
x + p
} else if (p..2.0 * p).contains(&x) {
x - p
} else {
x.rem_euclid(p)
}
}
fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 {
if d <= 0.0 {
return 0.0;
}
let h = if max == c[0] {
((c[1] - c[2]) / d).rem_euclid(6.0)
wrap((c[1] - c[2]) / d, 6.0)
} else if max == c[1] {
(c[2] - c[0]) / d + 2.0
} else {
@@ -145,7 +162,7 @@ fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 {
/// HSV → RGB for any saturation ≥ 0 (above 1 yields negative components, the inverse of
/// [`hue_of`] for out-of-gamut inputs).
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
let hp = h.rem_euclid(360.0) / 60.0;
let hp = wrap(h, 360.0) / 60.0;
let sector = (hp as u32).min(5);
let f = hp - sector as f32;
let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f)));
@@ -282,6 +299,37 @@ mod tests {
HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data: vec![[0.0, 1.0, 1.0]; h * s * v], srgb_value: false }
}
#[test]
fn wrap_is_rem_euclid_bit_for_bit() {
for p in [6.0f32, 360.0] {
let mut x = -3.0 * p;
while x < 3.0 * p {
for v in [x, x.next_up(), x.next_down(), -0.0, 0.0, p, -p, 2.0 * p, p.next_down(), (-p).next_up()] {
assert_eq!(wrap(v, p).to_bits(), v.rem_euclid(p).to_bits(), "{v} mod {p}");
}
x += p / 97.0;
}
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1e30, -1e30] {
assert_eq!(wrap(v, p).is_nan(), v.rem_euclid(p).is_nan());
}
}
}
#[test]
fn unit_value_scale_with_srgb_values_keeps_the_value() {
let mut t = identity(6, 2, 3);
t.srgb_value = true;
for (i, e) in t.data.iter_mut().enumerate() {
*e = [(i % 7) as f32 - 3.0, 1.1, 1.0];
}
for c in [[0.2f32, 0.1, 0.05], [0.9, 0.5, 0.7], [0.01, 0.02, 0.015], [1.5, 0.4, 0.2]] {
let max = c[0].max(c[1]).max(c[2]);
let out = t.apply(c);
let got = out[0].max(out[1]).max(out[2]);
assert!((got - max).abs() <= 1e-6 * max.max(1.0), "{c:?} -> {out:?}");
}
}
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol)
}