4-Color Gradient: weights 1 / (d² + Blend²), as After Effects

After Effects weighs each of the four points by its inverse squared
distance softened by Blend squared, 1 / (d^2 + Blend^2) in pixels: fitted to
AE 2026 renders of the default gradient at Blend 10, 100 and 300 and case
904 E16 at Blend 50 (power 2.00 at every Blend, softening 0 / 2367 / 9817 /
90127 = about Blend^2; mean error 0.27/255, max 0.9). EffectCraft used
1 / (d^2 / (100 Blend))^1.5, in which Blend cancels out: E16 was 34/255 off
on 99 % of the grid, now 0 grid points off, max 1/255, CPU and GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:16:02 +02:00
co-authored by Claude Opus 5.5
parent 75271bfe6c
commit d7854b84a3
5 changed files with 70 additions and 7 deletions
+64 -2
View File
@@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px {
}
}
/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point
/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100
/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error
/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0.
pub fn four_color_softening(blend: f64, scale: f64) -> f64 {
let b = blend.max(0.0) * scale;
(b * b).max(1e-6)
}
fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pts: Vec<((f64, f64), [f32; 4])> =
(1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect();
let blend = ctx.params.f("blend").max(1.0);
let k = four_color_softening(ctx.params.f("blend"), b.scale);
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
let op = ctx.params.f("opacity") as f32 / 100.0;
let mode = ctx.params.e("blendingMode");
@@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let mut wsum = 0.0;
for ((qx, qy), c) in &pts {
let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2);
let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5);
let w = 1.0 / (d2 + k);
for i in 0..3 {
acc[i] += c[i] as f64 * w;
}
@@ -423,6 +432,59 @@ mod generator_tests {
}
}
/// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 %
/// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10
/// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5).
#[test]
fn four_color_blend_matches_after_effects() {
let pts = [
("positionsColors/point1", [19.2, 10.8]),
("positionsColors/point2", [172.8, 10.8]),
("positionsColors/point3", [19.2, 97.2]),
("positionsColors/point4", [172.8, 97.2]),
];
let cols = [
("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]),
("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]),
("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]),
("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]),
];
let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [
(
10.0,
[
(30, 20, [249.0, 240.0, 15.0]),
(150, 30, [20.0, 208.0, 47.0]),
(60, 90, [213.0, 56.0, 199.0]),
(100, 10, [115.0, 177.0, 78.0]),
(10, 50, [238.0, 145.0, 110.0]),
],
),
(
30.0,
[
(30, 20, [236.0, 214.0, 41.0]),
(150, 30, [30.0, 189.0, 66.0]),
(60, 90, [203.0, 67.0, 188.0]),
(100, 10, [117.0, 173.0, 82.0]),
(10, 50, [233.0, 140.0, 115.0]),
],
),
];
for (blend, samples) in ae {
let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect();
vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v))));
vals.push(("blend", num(blend)));
let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default());
for (x, y, want) in samples {
let p = o.img.get(x, y);
for c in 0..3 {
assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]);
}
}
}
}
#[test]
fn four_color_none_stays_inside_the_layer_alpha() {
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
+1 -1
View File
@@ -91,7 +91,7 @@ pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state};
pub use stylize2::strobe_on;
// CPU helpers the GPU kernels share (effectcraft-gpu).
pub use blur2::{camera_lens_plain, camera_lens_spans};
pub use generate::gen_mode;
pub use generate::{four_color_softening, gen_mode};
pub use generate2::pattern_kind as cell_pattern_kind;
pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan};
pub use generate3::{
+2 -2
View File
@@ -353,10 +353,10 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0];
p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1));
}
let blend = ctx.params.f("blend").max(1.0);
let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale);
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0];
p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
run(e, "gen_fourcolor", &p, b, None, None)
}
+1 -1
View File
@@ -500,7 +500,7 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3<u32>) {
let dx = f32(p.x) + 0.5 - q.x;
let dy = f32(p.y) + 0.5 - q.y;
let d2 = dx * dx + dy * dy;
let w = 1.0 / pow(d2 / bl + 1e-6, 1.5);
let w = 1.0 / (d2 + bl);
acc += P.f[4 + i].xyz * w;
wsum += w;
}
+2 -1
View File
@@ -33,7 +33,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
| Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed |
| Expression Controls | 9 | 9 | — |
| Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed |
| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed |
| Immersive Video | 12 | 12 | — |
| Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed |
| Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format |
@@ -70,6 +70,7 @@ the GPU is performance, not parity.
| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU |
| 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) |
| 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) |
| 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) |
| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) |
| 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending |