Glow: the glow covers the layer's blurred alpha, blur sigma 0.384 x radius, as After Effects

With Glow Based On Color Channels, After Effects gives the glow the layer's whole
alpha (blurred), not the thresholded part, and Intensity scales only its colour:
a layer below the threshold darkens what lies under its edge. Fitted to AE 2026
renders of a grey solid over red (cases 524, 807-809): the halo is the same at
Intensity 1 and 3 and with Composite Original Behind or On Top, and its edge is
an erfc step of sigma = 0.384 x Glow Radius at radius 10 and 30 (was radius / 2).

524 (Glow defaults on a 50 % grey solid): EffectCraft had no halo (max 116/255
off at the edge); now red 248/234/208/165/140 at 7.5/5.5/3.5/1.5/0.5 px outside
vs AE 249/235/206/164/140, max 2/255 over the frame, CPU and GPU. The blur
width applies to every glow; alpha-based glows keep their coverage rule
(thresholded alpha, scaled by Intensity). Not matched yet: the brightness of glows above the
threshold (AE about 3.7 x brighter for a white solid, cases 806, 810, 811).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:23:25 +02:00
co-authored by Claude Opus 5.5
parent ef74d82902
commit f4fbdc6c25
7 changed files with 82 additions and 16 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
pub use effectcraft_raster::{AuxChannels, Image};
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation};
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma};
pub use sim::particle_state;
pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state};
pub use stylize2::strobe_on;
+48 -5
View File
@@ -120,6 +120,13 @@ pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32
t.max(0.0).powf(0.5f32.ln() / m.ln())
}
/// Glow's blur: a Gaussian of σ = 0.384 × Glow Radius. Fitted to After Effects 2026 (cases
/// 807 and 808, Glow Radius 10 and 30 on a grey solid): the edge profile of the glow's
/// coverage is an erfc step of that σ within 1.7/255 (it was σ = radius / 2).
pub fn glow_sigma(radius: f64) -> f64 {
(radius * 0.384).max(0.5)
}
/// Glow Operation options (blend modes the glow is applied with).
pub const GLOW_OPERATIONS: [&str; 17] = [
"Normal",
@@ -183,23 +190,27 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Glow Based On: brightness from the colours, or from the alpha channel.
let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) };
let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
// Based on the colour channels, the glow's coverage is the layer's whole alpha, below
// the threshold too: After Effects darkens what lies under a dim layer's edge (glow_alpha).
let ga = if alpha_based { k * a } else { a };
if let Some(map) = &arbitrary {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::<Vec<f32>>();
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
} else if use_colors {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
} else {
*px = px.map(|v| v * k);
*px = [px[0] * k, px[1] * k, px[2] * k, ga];
}
});
let s = (radius / 2.0).max(0.5);
let s = glow_sigma(radius);
let blurred = gaussian_blur(&bright, s * kx, s * ky, false);
let mode = ctx.params.e("operation");
b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| {
let g = g.map(|v| v * intensity);
// Intensity scales the glow's colour; with the colour channels its coverage stays.
let g = [g[0] * intensity, g[1] * intensity, g[2] * intensity, if alpha_based { g[3] * intensity } else { g[3] }];
match mode {
// Behind: the glow shows only where the original is transparent.
1 => {
@@ -749,6 +760,38 @@ mod tests {
img
}
/// After Effects 2026 (cases 524, 807, 808, 809): Glow on a 50 % grey solid, which is below
/// the 60 % threshold, still covers a blurred copy of the layer's alpha with black, so the
/// red layer underneath darkens outside the edge. The halo is the same at Intensity 1 and 3
/// and with Composite Original On Top or Behind; its width follows Glow Radius.
#[test]
fn glow_halo_matches_after_effects() {
let grey = effectcraft_raster::Image::filled(200, 120, [0.5, 0.5, 0.5, 1.0]);
// AE's red under the halo at 0.5, 1.5, 3.5, 5.5 and 7.5 px outside the left edge
// (radius 10) and at 0.5, 5.5, 10.5, 20.5 px (radius 30); halo alpha = 1 − red.
let r10 = [(0.5, 140.0), (1.5, 165.0), (3.5, 208.0), (5.5, 234.0), (7.5, 248.0)];
let r30 = [(0.5, 132.0), (5.5, 173.0), (10.5, 208.0), (20.5, 245.0)];
for (radius, intensity, operation, profile) in [(10.0, 1.0, 1, &r10[..]), (10.0, 3.0, 1, &r10[..]), (10.0, 1.0, 0, &r10[..]), (30.0, 1.0, 1, &r30[..])]
{
let set = [
("based", Value::Enum(1)),
("threshold", num(60.0)),
("radius", num(radius)),
("intensity", num(intensity)),
("operation", Value::Enum(operation)),
];
let o = run_fx("ec.stylize.glow", &set, grey.clone(), 0.0, EffectEnv::default());
for &(d, red) in profile {
// Layer x = −d (pixel centre), mid-height; over AE's red: R = 1 − α, colour 0.
let (bx, by) = o.to_px([-d, 60.0]);
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
let ae = 1.0 - red / 255.0;
assert!((p[3] - ae).abs() < 3.0 / 255.0, "radius {radius} intensity {intensity} op {operation} at {d} px: alpha {} vs AE {ae}", p[3]);
assert!(p[0].abs() < 1e-6, "the halo is black: {p:?}");
}
}
}
#[test]
fn glow_operation_and_arbitrary_map() {
// Pixels addressed in layer space (Glow pads its buffer).
+2 -1
View File
@@ -324,10 +324,11 @@ fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
p.f[2] = cb;
let bright = e.image(b.img.width, b.img.height);
e.pixels("glow_bright", &p, &b.img, None, &bright, None);
let s = (radius / 2.0).max(0.5);
let s = effectcraft_effects::glow_sigma(radius);
let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false);
let mut p = Params::default();
p.u[0][0] = ctx.params.e("operation").min(2);
p.u[0][1] = (ctx.params.e("based") == 0) as u32;
p.f[0][0] = intensity;
let out = e.scratch(b.img.width, b.img.height);
e.pixels("glow_combine", &p, &b.img, Some(&blurred), &out, None);
+2 -1
View File
@@ -943,11 +943,12 @@ pub(crate) fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
p.f[2] = ctx.params.color("colorB");
e.pixels("glow_bright", &p, &b.img, None, &bright, None);
}
let s = (radius / 2.0).max(0.5);
let s = effectcraft_effects::glow_sigma(radius);
let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false);
let operation = ctx.params.e("operation");
let glow_op = effectcraft_effects::glow_operation(ctx);
let mut p = Params::default();
p.u[0][1] = (ctx.params.e("based") == 0) as u32;
p.f[0][0] = intensity;
let out = e.scratch(b.img.width, b.img.height);
if operation == 0 && glow_op != BlendMode::Add {
+13 -3
View File
@@ -922,18 +922,28 @@ fn fxs_glow_map(@builtin(global_invocation_id) gid: vec3<u32>) {
let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0);
let t = glow_ab_t(l);
let c = vec3<f32>(curve_eval(P.f[1].x, t), curve_eval(P.f[1].y, t), curve_eval(P.f[1].z, t));
textureStore(out, p, vec4<f32>(c * k * a, k * a));
// Based on the colour channels the glow covers the layer's whole alpha (effects::misc::glow).
var ga = a;
if (P.u[0].y != 0u) {
ga = k * a;
}
textureStore(out, p, vec4<f32>(c * k * a, ga));
}
// On Top with another Glow Operation: blend_pixel(mode, original, glow · intensity, 0).
// aux = the blurred glow; u[0].x = blend mode id; f[0].x = intensity
// aux = the blurred glow; u[0].x = blend mode id; u[0].y = based on alpha (intensity also
// scales the glow's alpha); f[0].x = intensity
@compute @workgroup_size(16, 16)
fn fxs_glow_op(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxs_inside(p)) {
return;
}
let g = textureLoad(aux, p, 0) * P.f[0].x;
let gs = textureLoad(aux, p, 0);
var g = vec4<f32>(gs.xyz * P.f[0].x, gs.w);
if (P.u[0].y != 0u) {
g.w = gs.w * P.f[0].x;
}
var o = blend_pixel(P.u[0].x, textureLoad(src, p, 0), g, 0.0);
o.w = clamp(o.w, 0.0, 1.0);
textureStore(out, p, o);
+14 -4
View File
@@ -380,19 +380,25 @@ fn glow_bright(@builtin(global_invocation_id) gid: vec3<u32>) {
l = a;
}
let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0);
// Based on the colour channels the glow covers the layer's whole alpha (effects::misc::glow).
var ga = a;
if (P.u[0].y != 0u) {
ga = k * a;
}
if (P.u[0].x != 0u) {
let t = glow_ab_t(l);
let ca = P.f[1].xyz;
let cb = P.f[2].xyz;
let c = ca + (cb - ca) * t;
textureStore(out, p, vec4<f32>(c * k * a, k * a));
textureStore(out, p, vec4<f32>(c * k * a, ga));
} else {
textureStore(out, p, px * k);
textureStore(out, p, vec4<f32>(px.xyz * k, ga));
}
}
// Glow composite: original `src` with the blurred glow `aux`. u[0].x = 0 on top, 1 behind,
// 2 none (glow only); f[0].x = intensity.
// 2 none (glow only); u[0].y = based on alpha (intensity also scales the glow's alpha);
// f[0].x = intensity.
@compute @workgroup_size(16, 16)
fn glow_combine(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
@@ -401,7 +407,11 @@ fn glow_combine(@builtin(global_invocation_id) gid: vec3<u32>) {
return;
}
var o = textureLoad(src, p, 0);
let g = textureLoad(aux, p, 0) * P.f[0].x;
let gs = textureLoad(aux, p, 0);
var g = vec4<f32>(gs.xyz * P.f[0].x, gs.w);
if (P.u[0].y != 0u) {
g.w = gs.w * P.f[0].x;
}
if (P.u[0].x == 2u) {
o = g;
} else if (P.u[0].x == 1u) {
+2 -1
View File
@@ -42,7 +42,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) |
| Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) |
| Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) |
| Stylize | 25 | 25 | Motion Tile output size (#369) |
| Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; the brightness of glows above the threshold is not matched yet; Motion Tile output size (#369) |
| Text | 2 | 2 | — |
| Time | 8 | 8 | #535 (Pixel Motion Blur, Vector Detail) is closed |
| Transition | 17 | 17 | — |
@@ -64,6 +64,7 @@ the GPU is performance, not parity.
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) |
| 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending |
| 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls |
| 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests |