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 / constant | Value | Role | Sensitivity |
|---|---|---|---|
temperature (preset) | f32, expected -100.0..=+100.0, default 0.0 | Warm/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.0 | Magenta/green shift. | Linear in the green-channel multiplier. Same calibration as temperature. |
| Calibration denominator | 200.0 | Maps slider value to ±0.5 per-channel multiplier swing. | Smaller denominators make sliders more sensitive; the chosen value matches the typical Lightroom feel. |
| Brightness normalization | norm = 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 floor | 0.0 | Prevents 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
- CPU (Rust):
crates/agx/src/adjust/white_balance.rs - CPU buffer orchestrator:
apply_white_balance_exposure_bufferbundles white balance with exposure for a single buffer pass. - GPU (WGSL):
crates/agx/src/shaders/linear_adjustments.wgsl(combined linear-space WB + exposure pass).
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:
0stops → multiplier1.0, no change+1stop →2.0, twice as bright-1stop →0.5, half as bright+2stops →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 / constant | Value | Role | Sensitivity |
|---|---|---|---|
exposure (preset) | f32, in stops, default 0.0 | Brightness 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 base | 2.0 | Photographic-stop semantics. | Hard-coded; switching to a different base would break the "stops" mental model. |
| Channel floor | 0.0 | Guards 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
- CPU (Rust):
crates/agx/src/adjust/exposure.rs - CPU buffer orchestrator:
apply_white_balance_exposure_bufferbundles exposure with white balance for a single buffer pass. - GPU (WGSL):
crates/agx/src/shaders/linear_adjustments.wgsl(combined linear-space WB + exposure pass).
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
| Name | Type | Range / value | Used by | Meaning |
|---|---|---|---|---|
contrast | slider | -100..100 | Contrast | Global scale around the 0.5 midpoint. |
highlights | slider | -100..100 | Highlights | Positive values brighten, negative values darken. |
shadows | slider | -100..100 | Shadows | Positive values lift, negative values crush. |
whites | slider | -100..100 | Whites | Adjusts the top quarter of the range. |
blacks | slider | -100..100 | Blacks | Adjusts the bottom quarter of the range. |
0.0 | constant | neutral / floor check | All functions | Neutral value checks and lower clamp bound. |
0.25 | constant | quarter-range cutoff | Whites, Blacks | Boundary for the whites/blacks band. |
0.5 | constant | midpoint cutoff | Contrast, Highlights, Shadows | Midpoint pivot and half-range width. |
0.75 | constant | three-quarter cutoff | Whites | Start of the whites band. |
1.0 | constant | full-scale endpoint | Highlights, Shadows, Blacks | Upper bound of normalized channel space. |
100.0 | constant | percent scale | All functions | Converts 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:
| Slider | Input range | Curve shape |
|---|---|---|
| Contrast | -100..100 | Symmetric linear scaling around 0.5. |
| Highlights | -100..100 | One-sided ramp on the bright half of the range. |
| Shadows | -100..100 | One-sided ramp on the dark half of the range. |
| Whites | -100..100 | Narrow bright-end ramp, limited to the top quarter. |
| Blacks | -100..100 | Narrow 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
- CPU (Rust):
crates/agx/src/adjust/basic_tone.rs - GPU (WGSL):
crates/agx/src/shaders/gamma_adjustments.wgsl(the gamma-space stack also runs tone curves, HSL, and color grading inside the same shader).
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
- Concept references: Tone, Color (white balance entry), Color spaces
- API references: white balance, exposure, basic tone
- Related explanations: HSL, Tone curves, Color grading
- How-tos: Write your own preset, Apply a preset to a folder