id: contrast label: op.contrast order: 30 attributes: [tone] 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: | // The steepening S, on a 0..1 perceptual position. // // Blends toward a smoothstep, which 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. Only the // positive direction comes here: flattening is not a curve at all (see // the fragment). fn contrast_curve(x: f32, amount: f32) -> f32 { let clamped = clamp(x, 0.0, 1.0); let s = clamped * clamped * (3.0 - 2.0 * clamped); return mix(clamped, s, amount); } wgsl: | if (amount < 0.0) { // **Flattening mixes toward middle grey; it does not scale.** // // Every tone moves the same fraction of the way to 0.18, which at -1 // collapses the picture to grey — the meaningful limit of 'no contrast'. // In luminance this is exactly what the ratio form below would compute, // but the ratio form reaches it by multiplying: a pixel at 0.001 has to // be lifted to 0.09, a gain of ninety, and in the deepest shadows the // channels are sensor noise, not a colour. After white balance the red // and blue noise sits above the green (their multipliers are nearly // twice its), so ninety times that noise is magenta — every black in the // frame turned pink. Mixing adds the lift as a neutral, so a black goes // to grey and its noise stays the size it was. // // The grey is (1, 1, 1) scaled, because this runs after white balance // and the camera matrix, which carries a balanced neutral to equal // channels. c = mix(c, vec3(0.18), -amount); } else { let luma = luminance(c); // Only up to twice middle grey, which is the curve's whole domain. Above // it the curve's value is 1 and its slope 0, so leaving those tones // alone is the continuous continuation — where scaling them to the // curve's top, as this once did through a clamp, pinned every highlight // in the photograph to 0.36 at the smallest touch of the slider. if (luma > 0.0001 && luma < 0.36) { // 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. Safe here where it was not for flattening: // the S only ever pulls a shadow down, so the gain is at most one // below the pivot and noise is never amplified. // // 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 = luma / 0.36; 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: flattening_mixes_toward_grey_rather_than_scaling why: | Lifting a shadow by a luminance ratio multiplies its noise by the same ratio — ninety at the bottom of a night photograph — and after white balance that noise is magenta. A mix adds the lift as a neutral. expect_wgsl: ["mix(c, vec3(0.18), -amount)"] - name: highlights_are_not_pinned_to_the_top_of_the_curve why: | The curve covers 0..0.36. A clamp into that range scaled every brighter pixel down to 0.36; tones above it are left as they are. expect_wgsl: ["luma < 0.36"] - 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"] }