mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
2 files changed
+75
-14
No files matched your search
+62
-11
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user