Glow: per-channel bright pass with After Effects' gain, glows above the threshold as bright as After Effects

With Glow Colors = Original Colors, After Effects thresholds each channel of
the straight colour separately and the glow is 8 x (value - threshold +
0.061) per channel (times Intensity), nothing below: fitted to AE 2026
renders of grey 0.65 / 0.8 / 0.9 / 1, orange (1, 0.5, 0) and (0.2, 0.6, 1)
solids at Threshold 40 / 60 / 80 % and Radius 10 / 30 (36 channel values,
exact to the 8-bit capture; an orange solid glows red only). EffectCraft
scaled the colour by a luminance threshold (k = (L - thr) / (1 - thr)), about
3.7 x too dim on white. Cases 806 / 810 / 811 (32 bpc): outside the layer
now within 3/255 of AE (806 white at Intensity 1: 119 = AE, was 32); grid
0 points off for 806-810. The glow's alpha (the blurred layer alpha) and the
Arbitrary Map / A & B colours and alpha-based glows are unchanged.

Stacked on fix-glow-halo (same lines). Not changed: Composite Original's
semantics are swapped against AE's names (EffectCraft's "On Top" adds the
glow over the original, which AE calls "Behind"; AE's "On Top" keeps the
original over the glow), so imported AE Glows at the default Behind show
no glow inside the layer (811 interior 204 vs AE 255).

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 f4fbdc6c25
commit a8d4514a1b
3 changed files with 58 additions and 3 deletions
+51 -1
View File
@@ -127,6 +127,20 @@ pub fn glow_sigma(radius: f64) -> f64 {
(radius * 0.384).max(0.5)
}
/// Glow's bright pass for the original colours, per channel of the straight colour `v`:
/// `8 × (v − threshold + 0.0611)`, nothing below. Fitted to After Effects 2026 renders of grey
/// 0.65 / 0.8 / 0.9 / 1, orange and light-blue solids at Threshold 40 / 60 / 80 % and Radius 10
/// / 30 (36 channel values, exact to the 8-bit capture): the glow is linear in each channel,
/// its gain is the same at every threshold and radius, and channels below the threshold add
/// nothing (an orange solid glows red only).
pub fn glow_bright_channel(v: f32, thr: f32) -> f32 {
(GLOW_GAIN * (v - thr + GLOW_KNEE)).max(0.0)
}
/// [`glow_bright_channel`]'s gain and threshold offset.
pub const GLOW_GAIN: f32 = 8.0;
pub const GLOW_KNEE: f32 = 0.0611;
/// Glow Operation options (blend modes the glow is applied with).
pub const GLOW_OPERATIONS: [&str; 17] = [
"Normal",
@@ -201,8 +215,12 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
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, ga];
} else {
} else if alpha_based {
*px = [px[0] * k, px[1] * k, px[2] * k, ga];
} else {
// Original colours: each channel of the straight colour against the threshold.
let c = [px[0] / a, px[1] / a, px[2] / a].map(|v| glow_bright_channel(v, thr) * a);
*px = [c[0], c[1], c[2], ga];
}
});
let s = glow_sigma(radius);
@@ -792,6 +810,38 @@ mod tests {
}
}
/// After Effects 2026, Glow at Threshold 60 %, Radius 10, Intensity 0.25 on 200 × 200
/// solids over red: the glow 1.5 px outside the left edge, premultiplied (the green / blue
/// channels of the render, where the red background adds nothing), and its alpha 0.353.
#[test]
fn glow_brightness_matches_after_effects() {
// (solid colour, channel, AE glow value 0..255)
let cases = [
([0.65, 0.65, 0.65], 1, 20.0),
([0.8, 0.8, 0.8], 1, 47.0),
([0.9, 0.9, 0.9], 1, 65.0),
([1.0, 1.0, 1.0], 1, 83.0),
([1.0, 0.5, 0.0], 1, 0.0),
([0.2, 0.6, 1.0], 1, 11.0),
([0.2, 0.6, 1.0], 2, 83.0),
];
for (c, ch, ae) in cases {
let img = effectcraft_raster::Image::filled(200, 200, [c[0], c[1], c[2], 1.0]);
let set = [("threshold", num(60.0)), ("radius", num(10.0)), ("intensity", num(0.25))];
let o = run_fx("ec.stylize.glow", &set, img, 0.0, EffectEnv::default());
let (bx, by) = o.to_px([-1.5, 100.0]);
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
assert!((p[ch] * 255.0 - ae).abs() <= 2.5, "{c:?} channel {ch}: {} vs AE {ae}", p[ch] * 255.0);
assert!((p[3] - 0.353).abs() < 3.0 / 255.0, "{c:?}: alpha {}", p[3]);
}
// Intensity scales the colour linearly: white at Intensity 1 is 3.7 × its alpha.
let white = effectcraft_raster::Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]);
let o = run_fx("ec.stylize.glow", &[("threshold", num(60.0)), ("radius", num(10.0))], white, 0.0, EffectEnv::default());
let (bx, by) = o.to_px([-1.5, 100.0]);
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
assert!((p[1] / p[3] - 3.69).abs() < 0.05, "{p:?}");
}
#[test]
fn glow_operation_and_arbitrary_map() {
// Pixels addressed in layer space (Glow pads its buffer).
+5 -1
View File
@@ -391,8 +391,12 @@ fn glow_bright(@builtin(global_invocation_id) gid: vec3<u32>) {
let cb = P.f[2].xyz;
let c = ca + (cb - ca) * t;
textureStore(out, p, vec4<f32>(c * k * a, ga));
} else {
} else if (P.u[0].y != 0u) {
textureStore(out, p, vec4<f32>(px.xyz * k, ga));
} else {
// Original colours, per channel (effects::misc::glow_bright_channel).
let c = max(8.0 * (px.xyz / a - vec3<f32>(thr - 0.0611)), vec3<f32>(0.0));
textureStore(out, p, vec4<f32>(c * a, ga));
}
}
+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 | 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) |
| Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; glows above the threshold match too (each channel 8 × (value − threshold + 0.061), measured at three thresholds and two radii); Composite Original's labels are swapped against After Effects (EffectCraft's On Top adds the glow over the original, which After Effects calls Behind); 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 (original colours): bright pass per channel, 8 × (value − threshold + 0.061), measured against After Effects 2026 (CPU and GPU) |
| 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 |