id: contrast label: op.contrast order: 30 doc: | Contrast — an S-curve about a fixed mid-point. Pushes tones away from middle grey (positive) or toward it (negative), pivoting where the eye reads "neither light nor dark". In linear light that point is 0.18, not 0.5: a scene-referred value of 0.5 is roughly a stop and a half above middle grey, and pivoting there would darken almost every photograph. The curve is applied in a perceptual domain rather than directly to linear values. Applied linearly, an S-curve crushes shadows far harder than it lifts highlights, because linear light devotes most of its range to the brightest stop. placement: | After the capture corrections, before the region controls: it is the broad tonal statement those controls then refine. params: contrast: label: param.contrast kind: amount uniforms: amount: value: contrast / 100 doc: The shader's curve expects -1..1; the descriptor speaks -100..100. helpers: [luminance, apply_tone_gain] define: contrast_curve: | // A symmetric S-curve on a 0..1 perceptual position. // // `amount` above zero steepens, below zero flattens. The smoothstep form is // used for the steepening direction because it has zero gradient at both // ends, so the curve cannot invert however hard it is pushed — the failure // that makes naive gain-about-a-pivot unusable past moderate settings. fn contrast_curve(x: f32, amount: f32) -> f32 { let clamped = clamp(x, 0.0, 1.0); if (amount >= 0.0) { // Blend toward a smoothstep, which is the S. let s = clamped * clamped * (3.0 - 2.0 * clamped); return mix(clamped, s, amount); } // Flattening: pull toward the mid-point. At amount = -1 every tone // collapses to 0.5, which is the meaningful limit of 'no contrast'. return mix(clamped, 0.5, -amount); } wgsl: | let luma = luminance(c); if (luma > 0.0001) { // Work on luminance and rescale the colour by the ratio, rather than // curving each channel independently. Per-channel contrast shifts hue // wherever the channels differ — the classic symptom being skies going // cyan as contrast rises. // // MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The // curve operates on luma/(2*0.18) so that middle grey lands at the // curve's own 0.5 pivot. let pos = clamp(luma / 0.36, 0.0, 1.0); let curved = contrast_curve(pos, amount); // Not `target`: that is a WGSL reserved keyword, and using it produces a // parse error in generated code rather than anywhere a reader would look. let curved_luma = curved * 0.36; c = apply_tone_gain(c, curved_luma / luma); } c = max(c, vec3(0.0)); tests: - name: neutral_does_nothing expect: { amount: 0.0 } expect_active: false - name: the_amount_is_normalised_to_unit_range set: { contrast: 100 } expect: { amount: 1.0 } - name: the_negative_direction_normalises_too set: { contrast: -100 } expect: { amount: -1.0 } - name: contrast_works_on_luminance_not_per_channel why: | Curving each channel separately shifts hue; the ratio form is what keeps a blue sky blue as contrast rises. expect_wgsl: ["luminance(c)", "apply_tone_gain"] - name: the_pivot_is_middle_grey_not_half why: | Pivoting at 0.5 in linear light would darken nearly every image: scene-referred 0.5 is well above what the eye calls mid-tone. expect_wgsl: ["0.36"] - name: a_division_by_luminance_is_guarded why: | A black pixel has zero luminance; dividing by it would produce NaN and propagate through everything downstream. expect_wgsl: ["luma > 0.0001"] - name: the_curve_cannot_invert why: | A gain-about-a-pivot form produces a non-monotonic curve past moderate settings, which inverts tones. smoothstep cannot. expect_helper_wgsl: { contrast_curve: ["3.0 - 2.0 * clamped"] }