Basic adjustments

The Basic-panel sliders below share a mental model (they all sit at the "top of the stack" in Lightroom / Capture One), but their math divides cleanly by color space. White balance and exposure run in linear light before gamma encoding; the tone sliders run in the gamma Rec.2020 working space after. See Color spaces for the linear-vs-gamma distinction and the per-stage rationale.

White balance

White balance shifts the image's overall color cast — pulling it warmer or cooler, more magenta or more green — by scaling the linear-light RGB channels independently and re-normalizing so the average brightness stays put. AgX exposes this as two sliders, temperature and tint, both calibrated around 0.0 (no shift).

How it works

The math runs on linear-light RGB values, before gamma encoding. Channel scaling is only proportional to physical light energy when the data is linear, so doing the work here keeps the adjustment physically meaningful and preserves predictable behavior for downstream tone and color stages.

The two slider inputs are mapped into per-channel multipliers:

r_mult = 1 + temperature / 200
b_mult = 1 - temperature / 200
g_mult = 1 - tint / 200

Positive temperature boosts red and reduces blue, which warms the image. Negative temperature does the opposite and cools it. Positive tint reduces green, pulling the image toward magenta; negative tint boosts green.

Those raw multipliers are then normalized so the adjustment preserves the overall channel-average brightness:

sum  = r_mult + g_mult + b_mult
norm = 3 / sum
output_channel = max(0, input_channel * channel_mult * norm)

The normalization rescales the three multipliers so they still average to 1.0 over the slider's typical operating range. That keeps a neutral gray from drifting brighter or darker when the user only wants to shift color balance. It is a channel-average normalization, not a perceptual luminance guarantee, so very strong shifts can still nudge mid-gray appearance slightly. The trailing max(0, …) keeps a channel from going negative when an extreme shift or invalid upstream value would push it below zero.

Why we chose it

Two design choices drive the implementation. First, linear space: applying scalar multipliers to gamma-encoded values would make the warming or cooling effect track display brightness instead of light energy, so a "+50 warm" applied to a dark midtone would behave very differently from the same shift applied to a highlight. Doing the math in linear light makes the slider feel like a physical color-temperature change.

Second, per-channel multipliers with a brightness-preserving normalization rather than a full Bradford or CIECAM chromatic adaptation transform. Bradford and friends give the most accurate results when you know the source and target illuminants in detail, but AgX's white-balance sliders are creative controls — the photographer just wants the image warmer or cooler — and the linear scaling matches how Lightroom's Temperature/Tint pair feels in practice while keeping the algorithm a few lines of math rather than a 3×3 matrix and a reference white.

The 200.0 denominator is the calibration that makes the slider feel right. At temperature = 100, r_mult = 1.5 and b_mult = 0.5, which is a noticeable but not extreme warming shift. At temperature = 200, blue would clamp to zero entirely, so the practical operating range is [-100, +100].

Parameters and constants

Parameter / constantValueRoleSensitivity
temperature (preset)f32, expected -100.0..=+100.0, default 0.0Warm/cool shift.Linear in the per-channel multiplier. ±50 produces a 25% relative shift between red and blue; ±100 is the practical limit before blue clamps. Values outside the expected range extrapolate rather than error out.
tint (preset)f32, expected -100.0..=+100.0, default 0.0Magenta/green shift.Linear in the green-channel multiplier. Same calibration as temperature.
Calibration denominator200.0Maps slider value to ±0.5 per-channel multiplier swing.Smaller denominators make sliders more sensitive; the chosen value matches the typical Lightroom feel.
Brightness normalizationnorm = 3 / (r_mult + g_mult + b_mult)Keeps channel-average brightness constant.Without normalization, a "warm" shift would also brighten the image overall; with it, the user's shift is purely chromatic.
Channel floor0.0Prevents negative output.Trailing max handles extreme shifts and out-of-range upstream values.

Beyond the expected range: white balance does not preset-validate temperature or tint, so out-of-range values reach the algorithm. At temperature = 200, b_mult = 0 and the blue channel collapses to zero (image goes orange-magenta). Past that, multipliers go negative and the trailing max(0, …) clamps each channel to zero, so very large shifts produce a hard channel kill rather than continuing to intensify. tint follows the same pattern with green. Values past ±100 are not useful in practice.

Preset-slider mapping

[white_balance]
temperature = 25.0   # warm by 25
tint        = -10.0  # slightly green

Both fields are direct numeric mappings — there is no hidden curve or log scaling on the slider value. Preset composition (merge / materialize) treats the two fields independently. A preset that omits [white_balance] entirely, or sets both fields to 0.0, takes the early-out path in apply_white_balance and returns the input untouched.

Source

The CPU and GPU implementations share the formula above; the GPU shader performs the multiply-and-normalize in a single compute dispatch over the linear buffer.

References

No canonical external paper applies — temperature/tint sliders backed by linear-space channel multipliers are the conventional creative-WB formulation in Lightroom-class editors. AgX-specific motivation is recorded inline in the source comments.

Exposure

Exposure scales linear-light pixel values by a power-of-two factor so that the slider value reads as photographic stops: +1 brightens by a factor of two, -1 halves the light, 0 leaves the image alone. AgX applies this in linear space before gamma encoding so the math behaves like a real exposure change.

How it works

The slider value (stops) is converted into a multiplier and applied per channel:

factor          = 2^stops
output_channel  = max(0, input_channel * factor)

The multiplier is always positive because it comes from a power of two, so the trailing max(0, …) only matters when an upstream value would otherwise be negative — the clamp keeps the output well-defined even for invalid input.

The expected slider feel:

  • 0 stops → multiplier 1.0, no change
  • +1 stop → 2.0, twice as bright
  • -1 stop → 0.5, half as bright
  • +2 stops → 4.0, four times as bright

Working in linear space matters. Stops are a ratio of light energy, not a ratio of display-encoded brightness, so the multiplier only behaves photographically when it lands on linear pixel values. Applied after gamma encoding the same multiplier would skew midtones and clip the highlights asymmetrically, no longer matching how a camera's exposure dial behaves.

Why we chose it

The whole adjustment is a single multiply, which is exactly the level of machinery the operation deserves. Some editors expose exposure as a log slider with a hidden non-linearity; AgX keeps it as a literal 2^stops so a preset author can reason about a "+0.5 stop" lift the same way they reason about a half-stop in a camera.

The pipeline placement is the other choice. Exposure runs in linear space alongside white balance, before sRGB encoding. This means the later tone sliders, HSL, and color grading all see the exposure-adjusted image — which is what photographers expect: "correct" exposure first, then shape the look on top.

Parameters and constants

Parameter / constantValueRoleSensitivity
exposure (preset)f32, in stops, default 0.0Brightness shift.Each unit doubles or halves the light. +0.5 ≈ 41% brighter, -0.5 ≈ 29% darker. No hard upper bound, but very large values quickly push everything past 1.0 in linear space — the highlight tail is then handled by downstream clamping or tone shaping.
Power base2.0Photographic-stop semantics.Hard-coded; switching to a different base would break the "stops" mental model.
Channel floor0.0Guards against negative output from invalid upstream values.Multiplier is always positive, so this only matters for malformed input.

Beyond the expected range: exposure does not preset-validate the exposure field. The math accepts any finite stops value — at +10 stops the multiplier is 1024, which pushes essentially every channel past 1.0 and downstream clamping turns the image solid white. At -10 stops the multiplier is ~0.001 and the image goes near-black. Practical preset values stay within ±5 stops; anything more is better expressed as a multiplied raw or a different exposure on the camera.

Preset-slider mapping

[tone]
exposure = 0.5   # +½ stop

The exposure field maps directly to stops — no hidden non-linearity on the slider value. Preset composition merges this field independently of the other tone sliders. A preset that omits [tone] or sets exposure = 0.0 leaves the image unchanged at this stage.

Source

The CPU and GPU implementations share the same 2^stops multiplier and linear-space placement.

References

No canonical external paper applies — 2^stops is the standard photographic exposure formulation. The pipeline placement and slider range are documented inline in the source.

Tone sliders

The basic-tone sliders — contrast, highlights, shadows, whites, blacks — shape the brightness distribution of the image without re-introducing hue shifts. Each slider runs as a small piecewise-linear curve targeted at a specific part of the tone range, in the gamma-encoded working space (sRGB transfer curve applied to Rec.2020 linear values) so the adjustments track perceptual brightness rather than physical light energy.

Working space

This stage runs in gamma-encoded Rec.2020 — the sRGB transfer curve applied to Rec.2020 linear values — alongside HSL, color grading, tone curves, detail, grain, and vignette. The 0.5 midpoint and the 0.25 / 0.75 band anchors keep their perceptual meaning because the gamma curve shape is unchanged from the previous sRGB-only design; what's different is the wider gamut underneath, so wide-gamut headroom from Display P3 or other wide-gamut inputs survives the slider math instead of being clamped at the boundary. The final clamp to display gamut happens only at encode. The per-channel [0, 1] clamps inside each slider stay as domain-safety bounds.

How it works

The five sliders all run in gamma Rec.2020, after the image has been white-balanced and exposure-corrected in linear light. Each slider remaps a single channel value with a small piecewise-linear curve that targets a specific part of the tone range.

Contrast

Contrast is the only truly global control here. The code pivots around 0.5, the midpoint of the normalized gamma-encoded range, and scales the distance from that pivot:

factor = (100 + contrast) / 100
output = clamp(0.5 + (input - 0.5) * factor, 0, 1)

Positive contrast pushes values away from the midpoint. Negative contrast pulls them toward it.

Highlights

Highlights only affect values above 0.5. The weight rises linearly from 0 at 0.5 to 1 at 1.0, so brighter pixels are affected more than dimmer ones in the highlight band:

weight = (input - 0.5) / 0.5
output = clamp(input + weight * highlights / 100 * 0.5, 0, 1)

This gives a soft, one-sided curve that leaves the lower half of the range unchanged.

Shadows

Shadows mirror the highlight curve below 0.5. The darker the pixel, the larger the weight:

weight = 1 - input / 0.5
output = clamp(input + weight * shadows / 100 * 0.5, 0, 1)

Values at or above midpoint are left alone, so the adjustment stays localized to the dark half of the tone range.

Whites

Whites target only the upper quarter of the range. The curve is the same idea as highlights, but it starts later and uses a narrower band:

weight = (input - 0.75) / 0.25
output = clamp(input + weight * whites / 100 * 0.25, 0, 1)

This gives finer control over near-white detail without pushing midtones as aggressively.

Blacks

Blacks are the lower-quarter counterpart to whites:

weight = 1 - input / 0.25
output = clamp(input + weight * blacks / 100 * 0.25, 0, 1)

Only values below 0.25 are affected, so the control can lift or crush deep shadows without changing the rest of the image much.

Why we chose it

The five-slider model — contrast plus four band-localized controls — matches the Lightroom Basic panel one-for-one, which is what most preset authors expect when they think about "tone shaping." Splitting "highlights" and "whites" (and similarly shadows / blacks) into two overlapping sliders gives photographers fine control: highlights can roll off the bright midtones while whites separately decide where the brightest pixels sit. Folding them into a single curve would lose that separation.

The implementation deliberately stays piecewise-linear and gamma-space rather than reaching for a smooth global tone curve. That keeps each slider's effect localized and predictable for batch-applied presets, makes the math cheap on both CPU and GPU, and leaves global re-shaping for the dedicated tone_curves stage downstream. Working in gamma Rec.2020 is the standard choice for these sliders because it matches the "perceptual brightness" mental model the controls are named for — running them in linear light would make the slider behave differently in shadows than in highlights.

The triggering thresholds (0.5 for contrast/highlights/shadows, 0.75 and 0.25 for whites/blacks) are calibrated to give Lightroom-shaped slider feel: highlights and shadows each affect roughly half the range, whites and blacks each affect roughly a quarter.

Parameters and constants

NameTypeRange / valueUsed byMeaning
contrastslider-100..100ContrastGlobal scale around the 0.5 midpoint.
highlightsslider-100..100HighlightsPositive values brighten, negative values darken.
shadowsslider-100..100ShadowsPositive values lift, negative values crush.
whitesslider-100..100WhitesAdjusts the top quarter of the range.
blacksslider-100..100BlacksAdjusts the bottom quarter of the range.
0.0constantneutral / floor checkAll functionsNeutral value checks and lower clamp bound.
0.25constantquarter-range cutoffWhites, BlacksBoundary for the whites/blacks band.
0.5constantmidpoint cutoffContrast, Highlights, ShadowsMidpoint pivot and half-range width.
0.75constantthree-quarter cutoffWhitesStart of the whites band.
1.0constantfull-scale endpointHighlights, Shadows, BlacksUpper bound of normalized channel space.
100.0constantpercent scaleAll functionsConverts slider percentages into fractional adjustments.

Beyond the expected range: none of the basic-tone sliders are preset-validated, so out-of-range values reach the algorithm directly. Each per-channel adjustment is clamped to [0, 1] after the math, so extreme positive contrast/highlights/whites push pixels to the upper clamp (highlights blow out toward solid white) and extreme negative values crush toward the lower clamp. Values past ±200 produce no additional visible change because almost every pixel is already at the clamp; the practical operating range is ±100.

Preset-slider mapping

The preset values map directly to the slider ranges used by the code:

SliderInput rangeCurve shape
Contrast-100..100Symmetric linear scaling around 0.5.
Highlights-100..100One-sided ramp on the bright half of the range.
Shadows-100..100One-sided ramp on the dark half of the range.
Whites-100..100Narrow bright-end ramp, limited to the top quarter.
Blacks-100..100Narrow dark-end ramp, limited to the bottom quarter.

All five sliders are direct numeric mappings; there is no hidden non-linearity in the slider value itself. The curve shape comes from the piecewise weights in the adjustment functions, which localize each control to the tone band that photographers expect.

Source

The CPU and GPU implementations share the same per-slider piecewise formulas; the GPU shader runs all five sliders inside the bundled gamma-space pass.

References

No canonical external paper applies — these are the conventional Lightroom-style Basic-panel sliders. AgX-specific calibration notes live inline in the source.

See also