mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ef74d82902
commit
f4fbdc6c25
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user