id: white_balance label: op.white_balance order: 10 attributes: [colour] doc: | White balance — temperature and tint, relative to as-shot. Expressed as an offset from what the camera chose rather than an absolute kelvin value. Neutral means "as shot", so the control starts where the image already is and a reset returns there. An absolute scale would make the neutral position depend on the file, which is exactly the confusion Lightroom's temperature slider creates on non-raw files. The as-shot multipliers themselves are applied by the composer's preamble rather than here — every image has them even when this operation is neutral, so they cannot live in a fragment that vanishes at neutral. placement: | First. It is a correction to how the scene was captured, and every tonal operation after it should act on a correctly balanced image. params: temperature: label: param.temperature kind: amount doc: | Warmer is positive, matching every other raw developer: dragging right makes the image warmer, even though that means *lowering* the colour temperature being corrected for. tint: label: param.tint kind: amount # Temperature trades red against blue; tint trades green against magenta. # Both are scaled so the full range is a strong but not destructive # correction: ±0.5 in log2 at the extremes — half a stop of channel shift, # which covers ordinary illuminant error without letting the slider blow a # channel on its own. uniforms: mul_r: exp2(temperature / 100 * 0.5) mul_g: value: exp2(-(tint / 100 * 0.5)) doc: | Green is held at unity by temperature, so the control does not double as an exposure slider — green carries most of the luminance. mul_b: value: exp2(-(temperature / 100 * 0.5)) doc: Blue moves opposite red, so a neutral grey stays grey as the control moves. wgsl: | c = c * vec3(mul_r, mul_g, mul_b); tests: - name: neutral_is_as_shot expect: { mul_r: 1.0, mul_g: 1.0, mul_b: 1.0 } expect_active: false - name: warming_raises_red_and_lowers_blue set: { temperature: 100 } expect: { mul_r: 1.4142135, mul_b: 0.70710677 } - name: temperature_leaves_green_alone why: | Otherwise the control doubles as an exposure slider, because green carries most of the luminance. set: { temperature: 100 } expect: { mul_g: 1.0 } - name: tint_moves_green_against_magenta why: Positive tint reduces green, and must not touch red or blue. set: { tint: 100 } expect: { mul_g: 0.70710677, mul_r: 1.0, mul_b: 1.0 } - name: cooling_is_the_inverse_of_warming why: | Warming by n then cooling by n must return to neutral, so the two directions have to be exact reciprocals rather than merely similar. set: { temperature: -100 } expect: { mul_r: 0.70710677, mul_b: 1.4142135 } - name: the_extremes_stay_within_half_a_stop why: | A white balance control that can blow a channel by itself is a trap; correction belongs in a range where highlights survive. set: { temperature: 100, tint: 100 } expect_range: { mul_r: [0.70, 1.42], mul_g: [0.70, 1.42], mul_b: [0.70, 1.42] }