Make vibrance deliver the strength its value names

Vibrance delivered about a third of its nominal effect. Its falloff measured
saturation on scene-linear values, where an ordinary tan reads as 0.78 and
keeps a twentieth of the effect; and its skin guard halved it wherever red led
green led blue — 38 % of the pixels of the gallery's exports, every warm colour
rather than skin. The Vivid presets lean on vibrance, which is why they added
less colour than their values promised.

Saturation is now judged on display-encoded values, the guard covers skin hues
(about 10-50 degrees, not strongly saturated), and the gain is fitted: on 45
Lightroom exports whose only colour setting was Vibrance (about +24), the
measured-to-nominal scale was 1.9 before and 1.08 at a gain of 1.2, so 1.3.
This commit is contained in:
2026-10-03 15:54:41 -04:00
parent a4ff7ec2b9
commit 7ae1e27810
+36 -10
View File
@@ -21,24 +21,41 @@ params:
kind: amount kind: amount
uniforms: uniforms:
amount: vibrance / 100 amount:
value: vibrance / 100 * 1.3
doc: |
Scaled so that a value here does what the same value of Lightroom's
Vibrance does. Measured, not chosen: fitted on 45 Lightroom exports whose
only colour setting was Vibrance (about +24), against their raws.
helpers: [luminance, tone_position, colour_saturation] helpers: [luminance]
wgsl: | wgsl: |
let luma = luminance(c); let luma = luminance(c);
let sat = colour_saturation(c);
// How saturated a colour *looks*, so measured on display-encoded values.
// In scene-linear light an ordinary tan reads as 0.78 saturated and the
// falloff below would leave it a twentieth of the effect; encoded, it reads
// as 0.5, which is what the eye sees.
let e = pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
let e_hi = max(e.r, max(e.g, e.b));
let e_lo = min(e.r, min(e.g, e.b));
let sat = select(0.0, (e_hi - e_lo) / e_hi, e_hi > 0.00001);
// The vibrance curve: full effect on grey, tapering to nothing on colours // The vibrance curve: full effect on grey, tapering to nothing on colours
// that are already saturated. Squaring the falloff keeps the mid-range // that are already saturated. Squaring the falloff keeps the mid-range
// responsive while still protecting the extremes. // responsive while still protecting the extremes.
let falloff = (1.0 - sat) * (1.0 - sat); let falloff = (1.0 - sat) * (1.0 - sat);
// Skin protection. Skin sits in a narrow band of hue where red leads green // Skin protection, for skin: hues between about 10 and 50 degrees (red
// leads blue; pushing it is what makes vibrance look wrong on portraits. // leading, green between red and blue) that are not strongly saturated.
// Detected by channel ordering rather than a hue angle, which costs a // Red-over-green-over-blue alone is every warm colour in a photograph —
// conversion and buys nothing here. // wood, sand, brick, sunlit grass — and halving all of them is most of why
let is_skin = f32(c.r > c.g && c.g > c.b); // vibrance used to do so little.
let span = max(e_hi - e_lo, 0.00001);
let skin_hue = select(0.0, 60.0 * (e.g - e.b) / span, e.r >= e.g && e.g >= e.b);
let in_band = smoothstep(4.0, 12.0, skin_hue) * (1.0 - smoothstep(42.0, 52.0, skin_hue));
let is_skin = in_band * (1.0 - smoothstep(0.45, 0.7, sat)) * f32(e.r >= e.g && e.g >= e.b);
let skin_guard = 1.0 - is_skin * 0.5; let skin_guard = 1.0 - is_skin * 0.5;
let strength = amount * falloff * skin_guard; let strength = amount * falloff * skin_guard;
@@ -49,9 +66,18 @@ tests:
- name: it_starts_neutral - name: it_starts_neutral
expect_active: false expect_active: false
- name: the_amount_is_normalised_to_unit_range - name: the_amount_is_lightrooms_scale
why: |
Fitted against Lightroom exports: DarkRoom's vibrance at a value does
what Lightroom's does at the same value.
set: { vibrance: 100 } set: { vibrance: 100 }
expect: { amount: 1.0 } expect: { amount: 1.3 }
- name: saturation_is_judged_as_displayed
why: |
Judged in scene-linear light, ordinary warm colours read as nearly
saturated and get almost none of the effect.
expect_wgsl: ["let e = pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));"]
- name: muted_colours_get_more_than_saturated_ones - name: muted_colours_get_more_than_saturated_ones
why: | why: |