Color grading
Pipeline
flowchart TD
Pixel["Per-pixel RGB (gamma space)"] --> Lum["luminance l (Rec. 709)"]
Lum --> Bal["l_adj = l ^ (2 ^ (-balance/100))"]
Bal --> Masks["zone weights<br/>w_s = (1 - l_adj)^2<br/>w_h = l_adj^2<br/>w_m = 1 - w_s - w_h"]
SH["Shadow wheel<br/>(hue, sat, lum)"] --> RT
MID["Midtone wheel"] --> RT
HI["Highlight wheel"] --> RT
Masks --> RT["regional_tint =<br/>w_s*tint_s + w_m*tint_m + w_h*tint_h"]
GL["Global wheel"] --> CT
RT --> CT["combined_tint =<br/>regional_tint * tint_g"]
Pixel --> Mul["pixel * combined_tint"]
CT --> Mul
Mul --> Add["+ weighted luminance offsets<br/>w_s*lum_s + w_m*lum_m + w_h*lum_h + lum_g"]
Masks --> Add
Add --> Out["Clamp 0..1"]
Each wheel's hue/saturation pair is converted to an RGB tint once per render via three cosine lobes spaced 120° apart; the balance exponent and the precomputed wheel data are then fixed inputs to the per-pixel inner loop. The three zone weights always sum to one, so regions blend smoothly instead of producing hard boundaries.
Color grading blends three tonal wheels - shadows, midtones, and highlights - plus a global wheel, using the same lift/gamma/gain mental model that photographers already know from tools like DaVinci Resolve. Each wheel can push hue, saturation, and luminance independently, so the effect can stay subtle and neutral or move all the way into a stylized split-tone look.
Working space
This stage runs in gamma-encoded Rec.2020 — the sRGB transfer curve
applied to Rec.2020 linear values — alongside basic tone, HSL, tone
curves, detail, grain, and vignette. The luminance crossover, balance
exponent, and quadratic zone masks 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 wheel math instead of being clamped at the boundary.
The final clamp to display gamut happens only at encode. The Rec. 709
luminance weights and the per-pixel [0, 1] clamp after the tint
multiply stay as domain-safety bounds — the luminance proxy needs to
sit on a defined range for the zone weights to behave.
How it works
The public data model is a ColorWheel for each tonal region. A wheel
stores:
huein degreessaturationas a percentageluminanceas a signed brightness shift
The hue/saturation pair is treated as a polar representation of a tint.
During precomputation, AgX converts each wheel into an RGB multiplier by
sampling three cosine lobes spaced 120 degrees apart. That gives a
compact, stable way to turn one angle plus one radius into a neutral
[1.0, 1.0, 1.0] tint at zero saturation and a smooth color bias at
higher saturation. Luminance stays separate because it is an additive
offset, not a chroma rotation.
The implementation splits work into a precompute phase and a per-pixel
hot path. apply_color_grading_pre is the hot-path entry point, but it
relies on a ColorGradingPrecomputed struct built once per render. That
precompute step does the expensive, loop-invariant work:
- convert each wheel from hue/saturation into an RGB tint
- normalize each wheel's luminance shift into
[-1.0, 1.0] - compute the balance exponent —
2.0raised to the power of-balance / 100(in code,2.0_f32.powf(-balance / 100.0); not bitwise XOR) - cache whether balance is active at all
That keeps the inner loop free of repeated trig and powf work when the
effect is active. When the parameters are neutral, the CPU and GPU paths
still run their shared per-pixel gamma-adjustment stage, but they skip
the color-grading substep inside that stage.
Per pixel, the algorithm first measures luminance in gamma Rec.2020 with the Rec. 709 coefficients:
lum = 0.2126*r + 0.7152*g + 0.0722*b
This is a perceptual proxy, not a linear-light measurement. That choice matches the rest of the gamma-space adjustment stack and makes the region weights feel closer to what an editor user expects to see.
The balance slider shifts where the tonal crossover lands. With a neutral balance, the pixel luminance passes straight into the mask curves. When balance moves negative or positive, the code remaps the luminance with a power curve before weighting the zones. Negative balance expands the shadow region; positive balance gives the highlights more room.
The three zone masks are smooth, overlapping boundaries rather than hard cutoffs. In code they are quadratic crossfades:
w_shadow = (1 - lum_adj)^2
w_highlight = lum_adj^2
w_midtone = 1 - w_shadow - w_highlight
Those masks always sum to 1.0, so the three tonal regions stay
complementary. Near black, the shadow wheel dominates; near white, the
highlight wheel dominates; and the midtone wheel fills the overlap in
between. This behaves like a smoothstep-style transition even though the
implementation uses squared ramps rather than a literal smoothstep
call.
Once the weights are known, AgX blends the shadow, midtone, and highlight RGB tints into one regional tint, then multiplies that by the global wheel. The per-pixel color change is a channel-wise multiply followed by an additive luminance shift:
regional_tint = shadow_tint*w_shadow + midtone_tint*w_midtone + highlight_tint*w_highlight
combined_tint = regional_tint * global_tint
out = clamp(pixel * combined_tint, 0.0, 1.0)
out += shadow_lum*w_shadow + midtone_lum*w_midtone + highlight_lum*w_highlight + global_lum
The order matters. The tint multiply applies the color cast first, and the luminance offset rides on top of it afterward. That keeps the slider behavior close to a classic grading wheel: hue and saturation change the color balance, while luminance pushes the tonal weight of that region brighter or darker.
The three wheels map naturally onto the lift/gamma/gain vocabulary:
- shadows behaves like lift
- midtones behaves like gamma
- highlights behaves like gain
The global wheel is separate because it acts as a uniform finishing trim on the whole image instead of one specific tonal region. That extra global control is useful when the regional wheels establish the look but the whole frame still needs a small overall color bias.
Why we chose it
This model matches the way users already think about grading. The three zone wheels give a direct path from a creative intention - cooler shadows, warmer highlights, cleaner midtones - to a preset parameter set. The global wheel adds the final broad correction without forcing the user to rebalance every region by hand.
The cosine-based hue representation is also a good fit for this problem. It is cheap to compute, it wraps cleanly around the color circle, and it gives a smooth transition from neutral to strongly tinted without needing a larger color model or a lookup table. In practice, the wheel behaves like an intuitive angle-plus-strength dial rather than a fragile RGB recipe.
The luminance masks deliberately stay soft. Hard region boundaries would make the effect visible as bands or halos whenever the user pushes the wheels hard. The smooth overlap keeps the tonal zones continuous, so the image can cross from shadow to midtone to highlight without an obvious seam.
The precompute split is the other important choice. Hue-to-RGB
conversion and balance setup are invariant across the image, so they
belong outside the pixel loop. apply_color_grading_pre keeps the hot
path lean while still using the same math on CPU and GPU. That makes the
rendering cost predictable and keeps the implementation easy to mirror
in shader code.
Parameters and constants
The public model is ColorGradingParams, which contains four wheels and
one balance slider. The internal constants below shape how the math
behaves.
| Constant | Value | Role | Sensitivity |
|---|---|---|---|
| Hue units | degrees | User-facing hue angle for each wheel | Wraps modulo 360°; out-of-range hues land at the equivalent angle. |
| Saturation units | percent | Strength of the tint derived from each hue angle | Scales the tint before mixing; doubling has near-doubled visible effect when other controls are neutral. |
| Luminance units | -100 to +100 (informal — no runtime range validation; the per-pixel luminance result is clamped after the adjustment) | Additive brightness shift for each wheel | At small values feels like "lift the shadows by N%"; large values quickly saturate against the per-pixel [0, 1] clamp and stop being visually proportional. |
| Balance units | -100 to +100 | Shifts the shadow/highlight crossover | The whole feel of the wheel weighting; ±50 is a strong shift, ±100 collapses one zone almost entirely. |
| Rec. 709 luma coefficients | 0.2126, 0.7152, 0.0722 | Gamma-space luminance proxy used for zone weighting | Standard Rec. 709 — changing them shifts which pixels count as shadows vs highlights and changes the feel of the entire tool. |
| Hue-lobe spacing | 120° | Separates the RGB cosine lobes used to form the tint | Tied to RGB channel symmetry; changing it would distort the tint conversion and break expected channel balance. |
| Balance exponent | 2.0 raised to the power of -balance / 100 | Remaps luminance before the zone weights are computed | The exponential shape gives a smooth crossover as the user drags the slider; a linear remap would feel abrupt at the extremes. |
Beyond the expected range: color grading does not preset-validate
its slider values, so out-of-range numbers reach the algorithm directly.
Per-pixel luminance is clamped to [0, 1] after the adjustment, so
pushing a wheel's luminance past ±100 mostly saturates against that
clamp rather than producing larger visible change. Hue values wrap
modulo 360°. Saturation behaves as a multiplier — values above 100
just amplify the tint proportionally.
Preset-slider mapping
Preset TOML uses one root [color_grading] table plus four nested
wheel tables:
[color_grading]
balance = -10.0
[color_grading.shadows]
hue = 200.0
saturation = 30.0
luminance = -5.0
[color_grading.midtones]
hue = 45.0
saturation = 10.0
[color_grading.highlights]
hue = 30.0
saturation = 25.0
[color_grading.global]
hue = 15.0
saturation = 5.0
Each wheel field maps directly to ColorWheel { hue, saturation, luminance }. Missing fields stay neutral, so an untouched wheel still
acts like a no-op. That keeps presets compact and lets users store only
the parts of a grade they actually care about.
The mapping is intentionally literal:
shadowscontrols the lift-like regionmidtonescontrols the gamma-like regionhighlightscontrols the gain-like regionglobalapplies the same tint and luminance bias everywherebalancemoves the tonal crossover point between shadow and highlight
A saturation of zero is neutral regardless of hue, and a luminance of zero leaves that wheel's brightness contribution unchanged. That makes it easy to keep a wheel present in a preset without forcing it to affect the image.
Source
- CPU (Rust):
crates/agx/src/adjust/color_grading.rs - CPU render-stage hookup:
crates/agx/src/engine/stages/per_pixel.rs - GPU upload path:
crates/agx/src/engine/gpu/params.rsandcrates/agx/src/engine/gpu/mod.rs - GPU dispatch path:
crates/agx/src/engine/gpu/stages/gamma_adjustments.rs - GPU shader path:
crates/agx/src/shaders/gamma_adjustments.wgsl
The CPU and GPU implementations follow the same math. The CPU version precomputes wheel tints and balance data once per render, and the GPU path uploads the same derived values into storage buffers before running the per-pixel grading pass.
References
No canonical external paper applies — three-way lift/gamma/gain color grading is a long-standing convention in colorist tooling rather than a published algorithm. AgX-specific calibration (Rec. 709 luma proxy, 120° hue-lobe spacing, exponential balance) is recorded inline in the source.
See also
- Concept references: Color (color grading entry), Color models
- API references: color grading
- Related explanations: Basic adjustments, HSL, Tone curves
- How-tos: Write your own preset, Compose layered looks