mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
fix(effects): calibrate CC Blobbylize to the AE oracle
Use the measured Softness kernel (sigma = 0.42 * Softness), threshold the blurred height with a ~1 px antialias, and allow Cut Away 100 to empty the matte. Split the source-matte displacement from the height-field RGB refraction: the matte uses a z=1 bevel normal fitted to the T2 step edges, while RGB follows the measured z=height refraction. Align the documented AE defaults and add CPU regression tests for the cut threshold, Cut Away 100, and the inward matte warp. Update the GPU kernel and parameters to match, keeping CPU/GPU parity.
This commit is contained in:
+122
-28
@@ -107,7 +107,14 @@ fn norm3(v: [f64; 3]) -> [f64; 3] {
|
||||
[v[0] / l, v[1] / l, v[2] / l]
|
||||
}
|
||||
|
||||
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32;
|
||||
/// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance.
|
||||
fn norm3f(v: [f32; 3]) -> [f32; 3] {
|
||||
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
|
||||
[v[0] / l, v[1] / l, v[2] / l]
|
||||
}
|
||||
|
||||
fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
@@ -115,14 +122,17 @@ fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
|
||||
fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let soft = ctx.params.f("softness").max(0.0) * b.scale;
|
||||
let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 0.99) as f32;
|
||||
let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let blob = layer_or_self(ctx, &b, "blobLayer", true, false);
|
||||
let prop = src_at(ctx.params.e("property"));
|
||||
let raw = Plane::from_image(&blob, |px| {
|
||||
let (c, a) = unpremul(px);
|
||||
pick(prop, c, a) * if matches!(prop, crate::util::Src::Alpha) { 1.0 } else { a }
|
||||
});
|
||||
let h = gauss_plane(&raw, soft, soft);
|
||||
// Oracle (AE 26.2, 16 bpc): a step thresholded at Cut 25/75 with Softness 20 moves the
|
||||
// 50% contour by 5.55 px. For a Gaussian that is 0.67449 sigma, so sigma = 0.42 Softness.
|
||||
// The same probe shows the matte stays a ~1 px antialiased threshold, not a broad ramp.
|
||||
let h = gauss_plane(&raw, soft * 0.42, soft * 0.42);
|
||||
let intensity = ctx.params.f("lightIntensity") / 100.0;
|
||||
let lc = ctx.params.color("lightColor");
|
||||
let point = ctx.params.e("lightType") == 1;
|
||||
@@ -135,37 +145,69 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let rough = ctx.params.f("roughness").clamp(0.001, 1.0);
|
||||
let metal = (ctx.params.f("metal") / 100.0) as f32;
|
||||
let shininess = (1.0 / rough).min(500.0);
|
||||
let depth = (soft * 0.5).max(1.0);
|
||||
let src = b.img.clone();
|
||||
let (w, hh) = (b.img.width as usize, b.img.height as usize);
|
||||
let span = (w.max(hh) as f64).max(1.0);
|
||||
|
||||
// The source matte and the RGB refraction are separate in the oracle:
|
||||
// - the matte edge of a self step moves 5/9/14/18 px at Softness 5/10/20/40. A normal
|
||||
// with z = 1, a ~30 px depth at 1920 and a slope scale of 1.5 Softness reproduces that;
|
||||
// - RGB follows a height-field refraction with z = height: on a linear ramp the measured
|
||||
// horizontal offset is dx = -0.34 / h, independent of Softness.
|
||||
let matte_slope = soft * 1.5;
|
||||
let matte_depth = if soft > 0.05 { span / 64.0 } else { 0.0 };
|
||||
let rgb_depth = span * 0.34 * b.scale;
|
||||
let light_slope = (soft * 0.65).max(1.0);
|
||||
let limit = span * 0.5;
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (xi, yi) = (x as i64, y as i64);
|
||||
let hv = h.get(x, y);
|
||||
let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5;
|
||||
let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5;
|
||||
// Refraction-like displacement down the slope, then the blobby matte.
|
||||
let sx = x as f64 + 0.5 - gx * depth * 2.0;
|
||||
let sy = y as f64 + 0.5 - gy * depth * 2.0;
|
||||
let s = src.sample_bilinear(sx, sy);
|
||||
let (c, a) = unpremul(s);
|
||||
let m = ((hv - cut) / ((1.0 - cut) * 0.5).max(1e-3)).clamp(0.0, 1.0);
|
||||
let alpha = a.max(s[3]) * m;
|
||||
let grad = (gx * gx + gy * gy).sqrt();
|
||||
// One-pixel antialiased threshold. A flat field is either fully in or fully out.
|
||||
let aa = (1.25 * grad).max(5e-4);
|
||||
let m = if grad > 1e-9 {
|
||||
(0.5 + (hv - cut) as f64 / aa).clamp(0.0, 1.0) as f32
|
||||
} else if hv > cut {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if m <= 0.0 {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
}
|
||||
// Warp the source matte with the softness-scaled bevel normal (z = 1).
|
||||
let na = norm3([-gx * matte_slope, -gy * matte_slope, 1.0]);
|
||||
let sa = src.sample_bilinear(x as f64 + 0.5 + na[0] * matte_depth, y as f64 + 0.5 + na[1] * matte_depth);
|
||||
let alpha = sa[3].max(0.0) * m;
|
||||
if alpha <= 0.0 {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
}
|
||||
let n = norm3([-gx * depth, -gy * depth, 1.0]);
|
||||
let l = if point { norm3([lpos.0 - x as f64, lpos.1 - y as f64, lheight / 100.0 * span]) } else { ldir };
|
||||
let ndl = dot3(n, l).max(0.0);
|
||||
let hv3 = norm3([l[0], l[1], l[2] + 1.0]);
|
||||
let sp = dot3(n, hv3).max(0.0).powf(shininess) * specular;
|
||||
// Refract the colour through the height field (z = height for this pass).
|
||||
let hz = (hv as f64).max(1e-3);
|
||||
let rx = (-rgb_depth * gx / hz).clamp(-limit, limit);
|
||||
let ry = (-rgb_depth * gy / hz).clamp(-limit, limit);
|
||||
let srgb = src.sample_bilinear(x as f64 + 0.5 + rx, y as f64 + 0.5 + ry);
|
||||
let (c, _) = unpremul(srgb);
|
||||
let (gx32, gy32) = (gx as f32, gy as f32);
|
||||
let n = norm3f([-gx32 * light_slope as f32, -gy32 * light_slope as f32, 1.0]);
|
||||
let l = if point {
|
||||
norm3f([lpos.0 as f32 - (x as f32 + 0.5), lpos.1 as f32 - (y as f32 + 0.5), (lheight / 100.0 * span) as f32])
|
||||
} else {
|
||||
[ldir[0] as f32, ldir[1] as f32, ldir[2] as f32]
|
||||
};
|
||||
let ndl = dot3f(n, l).max(0.0);
|
||||
let hv3 = norm3f([l[0], l[1], l[2] + 1.0]);
|
||||
let sp = dot3f(n, hv3).max(0.0).powf(shininess as f32) * specular as f32;
|
||||
let mut out = [0.0f32; 3];
|
||||
for i in 0..3 {
|
||||
let lit = (ambient + diffuse * ndl * intensity) as f32 * lc[i];
|
||||
let lit = (ambient + diffuse * ndl as f64 * intensity) as f32 * lc[i];
|
||||
let spec_col = (1.0 - metal) * lc[i] + metal * c[i] * lc[i];
|
||||
out[i] = c[i] * lit + (sp * intensity) as f32 * spec_col;
|
||||
out[i] = c[i] * lit + (sp as f64 * intensity) as f32 * spec_col;
|
||||
}
|
||||
*px = premul(out, alpha);
|
||||
}
|
||||
@@ -880,19 +922,19 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
"CC Blobbylize",
|
||||
vec![
|
||||
p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("property", "Property", Value::Enum(3), popup(&crate::util::SRC_NAMES[..8])),
|
||||
p("softness", "Softness", num(6.0), slider(0.0, 200.0, 0.0, 50.0, 1)),
|
||||
p("cutAway", "Cut Away", num(0.0), pct()),
|
||||
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])),
|
||||
p("softness", "Softness", num(20.0), slider(0.0, 500.0, 0.0, 50.0, 1)),
|
||||
p("cutAway", "Cut Away", num(25.0), pct()),
|
||||
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
|
||||
p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])),
|
||||
p("lightHeight", "Light Height", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point),
|
||||
p("lightHeight", "Light Height", num(65.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
|
||||
p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("diffuse", "Diffuse", num(70.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("specular", "Specular", num(40.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)),
|
||||
p("ambient", "Ambient", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("diffuse", "Diffuse", num(25.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("specular", "Specular", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("roughness", "Roughness", num(0.025), slider(0.001, 1.0, 0.001, 1.0, 3)),
|
||||
p("metal", "Metal", num(100.0), pct()),
|
||||
],
|
||||
cc_blobbylize,
|
||||
@@ -1127,6 +1169,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_thresholds_a_height_ramp() {
|
||||
// AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge.
|
||||
let im = crate::util::gen_image(1920, 8, |x, _| {
|
||||
let h = x as f32 / 1920.0;
|
||||
[h, h, h, 1.0]
|
||||
});
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]);
|
||||
assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent");
|
||||
assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque");
|
||||
let edge = o.img.get(480, 4)[3];
|
||||
assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold");
|
||||
let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count();
|
||||
assert!(partial <= 2, "{partial} antialiased columns instead of one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_100_is_empty() {
|
||||
// Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100.
|
||||
let im = crate::util::gen_image(1920, 8, |x, _| {
|
||||
let h = x as f32 / 1920.0;
|
||||
[h, h, h, 1.0]
|
||||
});
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
|
||||
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_softness_warps_a_step_matte_inward() {
|
||||
// AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte
|
||||
// edge at about x = 946, not at the source edge. The PR's matte-preserving version
|
||||
// left it at 960; the effect must warp the source matte.
|
||||
let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] });
|
||||
let o = run(
|
||||
"ec.distort.ccblobbylize",
|
||||
&[
|
||||
("property", Value::Enum(3)),
|
||||
("softness", num(20.0)),
|
||||
("cutAway", num(0.0)),
|
||||
("ambient", num(100.0)),
|
||||
("diffuse", num(0.0)),
|
||||
("specular", num(0.0)),
|
||||
("metal", num(0.0)),
|
||||
],
|
||||
im,
|
||||
&[],
|
||||
);
|
||||
assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]);
|
||||
assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge");
|
||||
assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped");
|
||||
}
|
||||
|
||||
/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
|
||||
/// (200 × 100) layer bounds, after `id` with `set`.
|
||||
fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
|
||||
|
||||
@@ -586,17 +586,19 @@ fn blobbylize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let soft = pr.f("softness").max(0.0) * b.scale;
|
||||
let blob = layer_or_self(e, ctx, &b, "blobLayer")?;
|
||||
let raw = plane(e, &blob, pr.e("property").min(10));
|
||||
let h = gaussian_blur(e, &raw, soft, soft, true);
|
||||
let h = gaussian_blur(e, &raw, soft * 0.42, soft * 0.42, true);
|
||||
let lpos = b.to_px(pr.v2("lightPosition"));
|
||||
let lheight = pr.f("lightHeight");
|
||||
let (d, el) = (pr.f("lightDirection").to_radians(), (lheight / 100.0).clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2);
|
||||
let ldir = [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()];
|
||||
let span = (b.img.width.max(b.img.height) as f64).max(1.0);
|
||||
let mut p = Params::default();
|
||||
p.f[0] = f4([(soft * 0.5).max(1.0), (pr.f("cutAway") / 100.0).clamp(0.0, 0.99), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0]);
|
||||
p.f[0] = f4([(pr.f("cutAway") / 100.0).clamp(0.0, 1.0), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0, soft * 1.5]);
|
||||
p.f[1] = f4([pr.f("diffuse") / 100.0, pr.f("specular") / 100.0, (1.0 / pr.f("roughness").clamp(0.001, 1.0)).min(500.0), pr.f("metal") / 100.0]);
|
||||
p.f[2] = f4([lpos.0, lpos.1, lheight / 100.0 * span, (pr.e("lightType") == 1) as u32 as f64]);
|
||||
p.f[3] = f4([ldir[0], ldir[1], ldir[2], 0.0]);
|
||||
p.f[4] = rgb(pr.color("lightColor"), 0.0);
|
||||
p.f[5] = f4([if soft > 0.05 { span / 64.0 } else { 0.0 }, span * 0.34 * b.scale, (soft * 0.65).max(1.0), 1.25]);
|
||||
p.f[6] = f4([span * 0.5, 0.0, 0.0, 0.0]);
|
||||
run(e, "ftr_blob", &p, b, Some(&h), None)
|
||||
}
|
||||
|
||||
@@ -569,9 +569,11 @@ fn ftr_bender(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Blobbylize (distort3::cc_blobbylize)
|
||||
// aux = the blurred blob plane (x). f[0] = (depth, cut away, intensity, ambient);
|
||||
// f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y, point light height,
|
||||
// point light); f[3] = distant light direction; f[4] = light colour.
|
||||
// aux = the Softness-blurred blob plane (x). f[0] = (cut away, intensity, ambient,
|
||||
// matte slope); f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y,
|
||||
// point light height, point); f[3] = distant light direction; f[4] = light colour;
|
||||
// f[5] = (matte depth, RGB depth, light slope, matte antialias width);
|
||||
// f[6].x = displacement clamp.
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_blob(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
@@ -579,32 +581,49 @@ fn ftr_blob(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let depth = P.f[0].x;
|
||||
let cut = P.f[0].y;
|
||||
let cut = P.f[0].x;
|
||||
let hv = textureLoad(aux, p, 0).x;
|
||||
let g = ftr_grad(p);
|
||||
let grad = length(g);
|
||||
var m = 0.0;
|
||||
if (grad > 1e-9) {
|
||||
let aa = max(P.f[5].w * grad, 5e-4);
|
||||
m = clamp(0.5 + (hv - cut) / aa, 0.0, 1.0);
|
||||
} else if (hv > cut) {
|
||||
m = 1.0;
|
||||
}
|
||||
if (m <= 0.0) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let f = ftr_centre(p);
|
||||
let s = sample_bilinear(src, f.x - g.x * depth * 2.0, f.y - g.y * depth * 2.0);
|
||||
let c = ftr_unpremul(s);
|
||||
let a = max(s.w, 0.0);
|
||||
let m = clamp((hv - cut) / max((1.0 - cut) * 0.5, 1e-3), 0.0, 1.0);
|
||||
let alpha = max(a, s.w) * m;
|
||||
// Softness-scaled bevel warp of the source matte (normal z = 1).
|
||||
let na = normalize(vec3<f32>(-g.x * P.f[0].w, -g.y * P.f[0].w, 1.0));
|
||||
let sa = sample_bilinear(src, f.x + na.x * P.f[5].x, f.y + na.y * P.f[5].x);
|
||||
let alpha = max(sa.w, 0.0) * m;
|
||||
if (alpha <= 0.0) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let n = normalize(vec3<f32>(-g.x * depth, -g.y * depth, 1.0));
|
||||
// Height-field refraction of the colour (normal z = height), independent of Softness.
|
||||
let hz = max(hv, 1e-3);
|
||||
let d = -g * P.f[5].y / hz;
|
||||
let lim = P.f[6].x;
|
||||
let disp = vec2<f32>(clamp(d.x, -lim, lim), clamp(d.y, -lim, lim));
|
||||
let srgb = sample_bilinear(src, f.x + disp.x, f.y + disp.y);
|
||||
let c = ftr_unpremul(srgb);
|
||||
let n = normalize(vec3<f32>(-g.x * P.f[5].z, -g.y * P.f[5].z, 1.0));
|
||||
var l = P.f[3].xyz;
|
||||
if (P.f[2].w != 0.0) {
|
||||
l = normalize(vec3<f32>(P.f[2].x - f32(p.x), P.f[2].y - f32(p.y), P.f[2].z));
|
||||
l = normalize(vec3<f32>(P.f[2].x - f.x, P.f[2].y - f.y, P.f[2].z));
|
||||
}
|
||||
let intensity = P.f[0].z;
|
||||
let intensity = P.f[0].y;
|
||||
let ndl = max(dot(n, l), 0.0);
|
||||
let h3 = normalize(vec3<f32>(l.x, l.y, l.z + 1.0));
|
||||
let sp = powz(max(dot(n, h3), 0.0), P.f[1].z) * P.f[1].y;
|
||||
let lc = P.f[4].xyz;
|
||||
let metal = P.f[1].w;
|
||||
let lit = (P.f[0].w + P.f[1].x * ndl * intensity) * lc;
|
||||
let lit = (P.f[0].z + P.f[1].x * ndl * intensity) * lc;
|
||||
let spec_col = (1.0 - metal) * lc + metal * c * lc;
|
||||
let o = c * lit + (sp * intensity) * spec_col;
|
||||
textureStore(out, p, vec4<f32>(o * alpha, alpha));
|
||||
|
||||
Reference in New Issue
Block a user