Files
dtourolle db7b84795c Convert to the working space before the edits, not after
Every point operation ran in camera RGB and the camera matrix came after
them all, against the order ARCH §5.2 draws. So `luminance()` applied
Rec.709 weights to a body's own primaries, a band in the colour mixer
was a different hue on every make of sensor, and the vibrance skin guard
tested channel order in a space where skin does not have one.

Operations now declare a `Stage`. White balance is the only camera-stage
node — its multipliers scale the sensor's channels, and after a matrix
that mixes them the same numbers are a different correction — and says
so with `stage: camera` in its YAML, a key both the build-time generator
and the load-time declared op read. The composer emits the camera nodes,
then the matrix, then the rest, each group in graph order; an empty
chain still gets the matrix.

Film simulation stops converting out of camera space itself, since it is
now handed working-space colour like every other scene node. The base
curve stays where it was, after the operations, and so now acts on
working-space colour; the next commit replaces it (D19).
2026-09-27 16:52:53 -04:00

147 lines
6.0 KiB
YAML

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<f32>(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<f32>(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<f32>(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"] }