Blend modes: classic modes fade with opacity and HDR modes keep over-range values, as in After Effects (#494)

Measured against After Effects at 32 bpc:

- Classic Color Dodge, Classic Color Burn and Classic Difference at partial opacity: AE
  fades the source toward the mode's neutral colour (0 for Dodge and Difference, 1 for
  Burn) by the opacity and applies the classic formula at full strength (b / (1 − k·o),
  |b − k·o|, 1 − (1 − b) / (1 − k·(1 − o))). We mixed the full-strength result by opacity:
  up to 45/255 off on ordinary 0–1 footage.
- Overlay, Exclusion, Luminosity, Color Burn and Classic Color Burn keep their plain formula
  for values above 1 (HDR passes). They were missing from `supports_hdr`, so their inputs
  were clamped to 0–1 (Exclusion was up to 208/255 off); Classic Color Burn also clamped
  its result at 0, where AE keeps negative values.

The CPU blend (color::blend_pixel) and the GPU kernel (common.wgsl) change together.

Tests: the reporter's formulas for each case (blend.rs); the CPU/GPU parity test of every
blend mode at every bit depth (over-range values, 70 % opacity) passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude committed 2026-10-10 20:51:24 +00:00
1 parent 1c9b5f1828
commit ecb2cf1d32
2 files changed
+75 -14

No files matched your search

+62 -11
View File
@@ -159,7 +159,8 @@ impl BlendMode {
/// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp
/// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects
/// (in 8/16 bpc everything is already in range).
/// (in 8/16 bpc everything is already in range). Overlay, Exclusion, Luminosity and the
/// Color Burns keep their plain formula above 1 in After Effects (#494).
pub fn supports_hdr(self) -> bool {
matches!(
self,
@@ -168,6 +169,8 @@ impl BlendMode {
| BlendMode::DancingDissolve
| BlendMode::Darken
| BlendMode::Multiply
| BlendMode::ColorBurn
| BlendMode::ClassicColorBurn
| BlendMode::LinearBurn
| BlendMode::DarkerColor
| BlendMode::Add
@@ -177,6 +180,9 @@ impl BlendMode {
| BlendMode::LighterColor
| BlendMode::Difference
| BlendMode::ClassicDifference
| BlendMode::Overlay
| BlendMode::Exclusion
| BlendMode::Luminosity
| BlendMode::Subtract
| BlendMode::Divide
| BlendMode::StencilAlpha
@@ -188,6 +194,17 @@ impl BlendMode {
)
}
/// The source colour a classic mode leaves the backdrop unchanged with. After Effects fades
/// the source toward it by the layer's opacity before applying the classic formula at full
/// strength, rather than mixing the full-strength result by opacity (#494).
fn classic_neutral(self) -> Option<f32> {
match self {
BlendMode::ClassicColorDodge | BlendMode::ClassicDifference => Some(0.0),
BlendMode::ClassicColorBurn => Some(1.0),
_ => None,
}
}
pub fn next(self) -> BlendMode {
let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0);
BlendMode::ALL[(i + 1) % BlendMode::ALL.len()]
@@ -242,11 +259,8 @@ fn separable(mode: BlendMode, cb: f32, cs: f32) -> f32 {
BlendMode::Multiply => cb * cs,
BlendMode::ColorBurn => burn(cb, cs),
BlendMode::ClassicColorBurn => {
if cs <= 0.0 {
0.0
} else {
(1.0 - (1.0 - cb) / cs).max(0.0)
}
// (negative results are kept, as in After Effects)
if cs <= 0.0 { 0.0 } else { 1.0 - (1.0 - cb) / cs }
}
BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0),
BlendMode::Add | BlendMode::LinearDodge => cb + cs,
@@ -401,11 +415,19 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) ->
cs = cs.map(|v| v.clamp(0.0, 1.0));
cb = cb.map(|v| v.clamp(0.0, 1.0));
}
let b = non_separable(mode, cb, cs);
let ao = sa + da - sa * da;
let mut o = [0.0f32; 4];
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
if let Some(neutral) = mode.classic_neutral() {
// Fade the source toward the neutral colour by its coverage, then apply the formula.
let b = non_separable(mode, cb, cs.map(|v| neutral + sa * (v - neutral)));
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + da * b[i];
}
} else {
let b = non_separable(mode, cb, cs);
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
}
}
o[3] = ao;
o
@@ -469,12 +491,41 @@ mod tests {
#[test]
fn hdr_modes() {
// Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs.
// Add and Screen keep over-range values (32 bpc); Soft Light clamps its inputs.
let d = [2.0, 2.0, 2.0, 1.0];
let s = [0.5, 0.5, 0.5, 1.0];
assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6);
assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6);
assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0);
assert!(blend_pixel(BlendMode::SoftLight, d, s, 0.5)[0] <= 1.0);
}
/// #494: After Effects' 32 bpc formulas, measured by the reporter. b = bottom, o = top,
/// k = opacity (premultiplied into the source, as the compositor passes it).
#[test]
fn after_effects_32bpc_formulas() {
let at = |m, b: f32, o: f32, k: f32| blend_pixel(m, opaque(b), [o * k, o * k, o * k, k], 0.5)[0];
let close = |a: f32, e: f32| (a - e).abs() < 1e-5;
// Classic modes fade the source toward their neutral colour first.
for (b, o) in [(0.4, 0.5), (0.8, 0.9), (0.1, 0.3)] {
let k = 0.15;
assert!(close(at(BlendMode::ClassicColorDodge, b, o, k), (b / (1.0 - k * o)).min(1.0)));
assert!(close(at(BlendMode::ClassicDifference, b, o, k), (b - k * o).abs()));
assert!(close(at(BlendMode::ClassicColorBurn, b, o, k), 1.0 - (1.0 - b) / (1.0 - k * (1.0 - o))));
}
// At full opacity the classic formulas are unchanged.
assert!(close(at(BlendMode::ClassicDifference, 0.6, 0.3, 1.0), 0.3));
// Over-range top layers keep the plain formulas.
let (b, o) = (0.7, 3.0);
assert!(close(at(BlendMode::Exclusion, b, o, 1.0), b + o - 2.0 * b * o));
let s = 2.0 * b - 1.0;
assert!(close(at(BlendMode::Overlay, b, o, 1.0), o + s - o * s));
assert!(close(at(BlendMode::ColorBurn, 0.0, 1.35, 0.15), 0.15 * (1.0 - 1.0 / 1.35)));
assert!(close(at(BlendMode::ClassicColorBurn, 0.0, 0.1, 1.0), -9.0), "negative results are kept");
// Luminosity: SetLum + ClipColor on the unclamped top layer.
let l = blend_pixel(BlendMode::Luminosity, [0.2, 0.4, 0.6, 1.0], [2.0, 2.0, 2.0, 1.0], 0.5);
let e = set_lum([0.2, 0.4, 0.6], 2.0);
assert!((0..3).all(|i| close(l[i], e[i])), "{l:?} {e:?}");
assert!((l[0] - 1.0).abs() > 0.1, "not the white of a top layer clamped to 1: {l:?}");
}
#[test]
+13 -3
View File
@@ -235,7 +235,7 @@ fn separable(mode: u32, cb: f32, cs: f32) -> f32 {
if (cs <= 0.0) {
return 0.0;
}
return max(1.0 - (1.0 - cb) / cs, 0.0);
return 1.0 - (1.0 - cb) / cs;
}
case 7u: { return max(cb + cs - 1.0, 0.0); }
case 9u, 14u: { return cb + cs; }
@@ -343,7 +343,7 @@ fn non_separable(mode: u32, cb: vec3<f32>, cs: vec3<f32>) -> vec3<f32> {
fn supports_hdr(mode: u32) -> bool {
switch mode {
case 0u, 1u, 2u, 3u, 4u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 23u, 24u, 26u, 27u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; }
case 0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 16u, 23u, 24u, 25u, 26u, 27u, 31u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; }
default: { return false; }
}
}
@@ -388,8 +388,18 @@ fn blend_pixel(mode: u32, dst: vec4<f32>, src: vec4<f32>, noise: f32) -> vec4<f3
cs = clamp(cs, vec3<f32>(0.0), vec3<f32>(1.0));
cb = clamp(cb, vec3<f32>(0.0), vec3<f32>(1.0));
}
let b = non_separable(mode, cb, cs);
let ao = sa + da - sa * da;
// Classic Color Dodge / Difference (neutral 0) and Classic Color Burn (neutral 1): the
// source fades toward the neutral colour by its coverage, then the formula applies.
if (mode == 13u || mode == 24u || mode == 6u) {
var neutral = 0.0;
if (mode == 6u) {
neutral = 1.0;
}
let bc = non_separable(mode, cb, neutral + sa * (cs - neutral));
return vec4<f32>((1.0 - da) * src.xyz + da * bc, ao);
}
let b = non_separable(mode, cb, cs);
return vec4<f32>((1.0 - da) * src.xyz + (1.0 - sa) * dst.xyz + sa * da * b, ao);
}