Files
effectcraft/crates/gpu/src/fx_generate.rs
T
Dylan M. Taylor e339a5b375 Effects: cap Camera Lens Blur and median radii (#2951)
* Effects: cap Camera Lens Blur and median radii

Camera Lens Blur built its iris table (one row per pixel of radius) from an
uncapped Blur Radius before any size check, and the median window is filled
(2r + 1)^2 per row from an uncapped Radius, on the CPU and the GPU. The
radius is now capped where the work is built: 2000 buffer pixels for the
lens iris (UI max 500) and 512 for the median window (UI max 255), on both
paths.

* address review: cap camera lens reach with the spans
2026-11-11 15:53:34 -05:00

436 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GPU effects, blur, transition and generate family (kernels in `shaders/fx_generate.wgsl`): the CPU effects' exact
//! steps with the pixel loops as compute kernels.
//!
//! Every effect here covers all of its parameters. Where the CPU works in f64 around a hard
//! edge (the wipes), the per-column and per-row terms are computed in f64 here and uploaded as
//! (hi, lo) f32 pairs so the GPU puts the edge where the CPU does.
use effectcraft_color::BlendMode;
use effectcraft_effects::{EffectCtx, GrainLook};
use crate::context::{Enc, Params};
use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_generate.wgsl`.
pub(crate) const KERNELS: &[&str] = &[
"gen_radial_blur",
"gen_ccradialfast",
"gen_lens_pre",
"gen_row_prefix",
"gen_lens_blur",
"gen_linear_wipe",
"gen_venetian",
"gen_radial_wipe",
"gen_gradient_wipe",
"gen_cell_pattern",
"gen_checker",
"gen_grid",
"gen_fourcolor",
"gen_noise",
"gen_grain_planes",
"gen_grain_apply",
];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.blur.radial",
"ec.blur.cameralens",
"ec.blur.ccradialfast",
"ec.transition.venetian",
"ec.transition.linearwipe",
"ec.transition.radialwipe",
"ec.transition.gradientwipe",
"ec.generate.cellpattern",
"ec.generate.checkerboard",
"ec.generate.grid",
"ec.generate.fourcolor",
"ec.noise.noise",
"ec.noise.addgrain",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
match id {
"ec.blur.radial" => radial_blur(e, ctx, b),
"ec.blur.cameralens" => camera_lens(e, ctx, b),
"ec.blur.ccradialfast" => radial_fast(e, ctx, b),
"ec.transition.venetian" => venetian(e, ctx, b),
"ec.transition.linearwipe" => linear_wipe(e, ctx, b),
"ec.transition.radialwipe" => radial_wipe(e, ctx, b),
"ec.transition.gradientwipe" => gradient_wipe(e, ctx, b),
"ec.generate.cellpattern" => cell_pattern(e, ctx, b),
"ec.generate.checkerboard" => checkerboard(e, ctx, b),
"ec.generate.grid" => grid(e, ctx, b),
"ec.generate.fourcolor" => four_color(e, ctx, b),
"ec.noise.noise" => noise(e, ctx, b),
"ec.noise.addgrain" => add_grain(e, ctx, b),
_ => None,
}
}
/// Run a per-pixel kernel over the buffer.
fn run(e: &mut Enc, entry: &str, p: &Params, b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&[f32]>) -> Option<GBuf> {
let buf = data.map(|d| e.data(d));
let out = e.scratch(b.img.width, b.img.height);
e.pixels(entry, p, &b.img, aux, &out, buf.as_ref());
Some(GBuf { img: out, ..b })
}
/// `v` as an f32 (hi, lo) pair.
fn hl(v: f64) -> [f32; 2] {
let h = v as f32;
[h, (v - h as f64) as f32]
}
/// Generate blending mode index → `BlendMode::ALL` index (255 = None: replace).
pub(crate) fn gen_mode(i: u32) -> u32 {
match effectcraft_effects::gen_mode(i) {
None => 255,
Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32,
}
}
// ---------------------------------------------------------------- blurs
fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let amt = ctx.params.f("amount");
let zoom = ctx.params.e("type") == 1;
let c = b.to_px(ctx.params.v2("center"));
let amount = if zoom { amt / 100.0 } else { amt };
if amount.abs() < 1e-6 {
return Some(b);
}
let n = 32usize;
let mut data = Vec::with_capacity(2 * n);
for i in 0..n {
let t = i as f64 / (n - 1) as f64 - 0.5;
if zoom {
data.push((1.0 + t * amount) as f32);
} else {
let (s, c) = (t * amount).to_radians().sin_cos();
data.extend([s as f32, c as f32]);
}
}
let mut p = Params::default();
p.u[0] = [zoom as u32, n as u32, 0, 0];
p.f[0] = [c.0 as f32, c.1 as f32, 0.0, 0.0];
run(e, "gen_radial_blur", &p, b, None, Some(&data))
}
fn radial_fast(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5;
if s <= 0.0 {
return Some(b);
}
if !ctx.adjustment {
b.pad(e, effectcraft_effects::radial_fast_pad(b.to_px(ctx.params.v2("center")), b.img.width, b.img.height, s))?;
}
let c = b.to_px(ctx.params.v2("center"));
let mut p = Params::default();
p.u[0][0] = ctx.params.e("zoom");
p.f[0] = [c.0 as f32, c.1 as f32, s as f32, 0.0];
run(e, "gen_ccradialfast", &p, b, None, None)
}
fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let r = effectcraft_effects::camera_lens_radius(ctx, b.scale);
if r < 0.5 {
return Some(b);
}
let (spans, reach) = effectcraft_effects::camera_lens_spans(ctx, r);
let repeat = ctx.params.b("repeatEdge") || ctx.adjustment;
if !repeat {
b.pad(e, reach.ceil() as u32 + 1)?;
}
let linear = ctx.params.b("useLinear");
let thr = ctx.params.f("highlight/specularThreshold") as f32 / 255.0;
let boost = 1.0 + ctx.params.f("highlight/specularBrightness").max(0.0) as f32 / 100.0 * 4.0;
let sat = (ctx.params.f("highlight/highlightSaturation") / 100.0).clamp(0.0, 1.0) as f32;
let (w, h) = (b.img.width, b.img.height);
let mut src = b.img.clone();
if linear || boost > 1.0 {
let mut p = Params::default();
p.u[0] = [linear as u32, (boost > 1.0) as u32, 0, 0];
p.f[0] = [thr, boost, sat, 0.0];
let out = e.scratch(w, h);
e.pixels("gen_lens_pre", &p, &src, None, &out, None);
src = out;
}
let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum();
let norm = 1.0 / count as f64;
// Large irises sum each span from per-row prefix sums; small ones add the pixels directly.
let prefix = count > 256;
let pre = if prefix {
if !e.g.fits(w + 1, h) {
return None;
}
let pre = e.image(w + 1, h);
e.dispatch("gen_row_prefix", &Params::default(), crate::context::KernelImages { src: &src, aux: None, out: &pre }, None, (h.div_ceil(64), 1));
Some(pre)
} else {
None
};
let data: Vec<f32> = spans.iter().flat_map(|&(dy, l, r)| [dy as f32, l as f32, r as f32]).collect();
let mut p = Params::default();
p.u[0] = [spans.len() as u32, repeat as u32, linear as u32, prefix as u32];
p.f[0][0] = norm as f32;
let buf = e.data(&data);
let out = e.scratch(w, h);
e.pixels("gen_lens_blur", &p, &src, pre.as_ref(), &out, Some(&buf));
b.img = out;
Some(b)
}
// ---------------------------------------------------------------- transitions
/// Linear Wipe (`effects::misc::linear_wipe`): the hard matte at `lo + completion·(hi − lo)`
/// blurred by Feather with the layer's edges repeated, or the legacy linear ramp.
fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let done = ctx.params.f("completion") / 100.0;
let (w, h) = (b.img.width as f64, b.img.height as f64);
let ([dx, dy], lo, hi) = effectcraft_effects::linear_wipe_axis(ctx.params.f("angle"), w, h);
// Per column ((x + 0.5)·dx − edge), per row ((y + 0.5)·dy), as (hi, lo) pairs.
let table = |edge: f64| {
let mut data = Vec::with_capacity(2 * (b.img.width + b.img.height) as usize);
for x in 0..b.img.width {
data.extend(hl((x as f64 + 0.5) * dx - edge));
}
for y in 0..b.img.height {
data.extend(hl((y as f64 + 0.5) * dy));
}
data
};
let params = |width: f64, mode: u32| {
let mut p = Params::default();
p.u[0][0] = 2 * b.img.width;
p.u[0][1] = mode;
p.f[0][0] = width as f32;
p
};
if ctx.params.b("legacyFeather") {
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let data = table(lo - feather + (hi - lo + 2.0 * feather) * done);
return run(e, "gen_linear_wipe", &params(feather, 0), b, None, Some(&data));
}
if done.is_nan() || done <= 0.0 {
return Some(b);
}
// 100 % clears the layer whatever the angle (the CPU clears before it reads it).
if done >= 1.0 {
b.img = e.image(b.img.width, b.img.height);
return Some(b);
}
let edge = lo + (hi - lo) * done;
if !(edge.is_finite() && dx.is_finite() && dy.is_finite()) {
return Some(b);
}
let data = table(edge);
let sigma = effectcraft_effects::linear_wipe_sigma(ctx.params.f("feather"), b.scale, w, h);
if sigma <= 1e-3 {
return run(e, "gen_linear_wipe", &params(1.0, 0), b, None, Some(&data));
}
let buf = e.data(&data);
let matte = e.scratch(b.img.width, b.img.height);
e.pixels("gen_linear_wipe", &params(1.0, 1), &b.img, None, &matte, Some(&buf));
let blurred = gaussian_blur(e, &matte, sigma, sigma, true);
run(e, "gen_linear_wipe", &params(1.0, 2), b, Some(&blurred), Some(&data))
}
fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if done <= 0.0 {
return Some(b);
}
let ang = ctx.params.f("direction").to_radians();
let width = (ctx.params.f("width") * b.scale).max(1.0);
let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0;
// The CPU's even veil and term count (effects::venetian_cover).
if sigma > 8.0 * width {
return None;
}
let (dx, dy) = (ang.cos(), ang.sin());
let (ox, oy) = b.to_px(effectcraft_effects::venetian_origin(ctx.layer_size));
let mut data = Vec::with_capacity((4 * b.img.width + 2 * b.img.height) as usize);
for x in 0..b.img.width {
let cx = ((x as f64 + 0.5 - ox) * dx).rem_euclid(width);
data.extend(hl(cx));
data.extend(hl(cx - width));
}
for y in 0..b.img.height {
data.extend(hl(((y as f64 + 0.5 - oy) * dy).rem_euclid(width)));
}
let mut p = Params::default();
p.u[0][0] = 4 * b.img.width;
p.u[0][1] = if sigma < 1e-3 { 0 } else { ((4.0 * sigma / width).ceil() as u32 + 1).clamp(1, 40) };
p.f[0] = [width as f32, (done * width) as f32, sigma as f32, 0.0];
run(e, "gen_venetian", &p, b, None, Some(&data))
}
fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if done <= 0.0 {
return Some(b);
}
let start = ctx.params.f("startAngle");
let c = b.to_px(ctx.params.v2("center"));
let (w0, span) = effectcraft_effects::radial_wipe_wedge(ctx.params.e("wipe"), done);
let sigma = effectcraft_effects::radial_wipe_sigma(ctx.params.f("feather")) * b.scale;
let ray0 = start + w0;
let (s0, c0) = ray0.to_radians().sin_cos();
let (s1, c1) = (ray0 + span).to_radians().sin_cos();
let mut p = Params::default();
p.u[0] = [(done >= 1.0) as u32, (sigma < 1e-3) as u32, 0, 0];
p.f[0] = [c.0 as f32, c.1 as f32, sigma.max(1e-3) as f32, ray0.rem_euclid(360.0) as f32];
p.f[1] = [s0 as f32, c0 as f32, s1 as f32, c1 as f32];
p.f[2][0] = span as f32;
run(e, "gen_radial_wipe", &p, b, None, None)
}
fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32;
if done <= 0.0 {
return Some(b);
}
let soft = (ctx.params.f("softness") / 100.0) as f32;
// The gradient layer is fetched and placed on the CPU (as the CPU effect does).
let grad = match ctx.layer_param("gradientLayer", false) {
Some(o) => {
// place_layer reads only the buffer's size, offset and scale.
let img = effectcraft_raster::Image { width: b.img.width, height: b.img.height, data: vec![] };
let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale };
let img = effectcraft_effects::place_layer(ctx, &cpu, &o, ctx.params.e("gradientPlacement"));
Some(e.g.upload_image(&img)?)
}
None => None,
};
let mut p = Params::default();
p.u[0][0] = ctx.params.b("invert") as u32;
p.f[0] = [done, soft, 0.0, 0.0];
let out = e.scratch(b.img.width, b.img.height);
e.pixels("gen_gradient_wipe", &p, &b.img, Some(grad.as_ref().unwrap_or(&b.img)), &out, None);
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- generate
fn cell_pattern(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let size = (pr.f("size") * b.scale).max(1.0);
let off = b.to_px(pr.v2("offset"));
let mut evo = pr.f("evolution") / 360.0;
if pr.b("evolutionOptions/cycleEvolution") {
evo = evo.rem_euclid(pr.f("evolutionOptions/cycle").round().max(1.0));
}
let seed = (pr.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11;
let tile = pr.b("tilingOptions/enableTiling");
let nx = pr.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64;
let ny = pr.f("tilingOptions/cellsVertical").round().max(1.0) as i64;
if nx > i32::MAX as i64 || ny > i32::MAX as i64 {
return None;
}
let mut p = Params::default();
p.u[0] = [effectcraft_effects::cell_pattern_kind(pr.e("cellPattern")), pr.e("overflow"), pr.b("invert") as u32, seed];
p.u[1] = [tile as u32, nx as u32, ny as u32, 0];
// cos / sin of 2π·(h + evo) only see evo modulo 1.
p.f[0] = [pr.f("contrast") as f32 / 100.0, pr.f("disperse").clamp(0.0, 1.5) as f32, size as f32, evo.rem_euclid(1.0) as f32];
p.f[1] = [(0.5 - off.0) as f32, (0.5 - off.1) as f32, 0.0, 0.0];
run(e, "gen_cell_pattern", &p, b, None, None)
}
/// generate::cell_size.
fn cell_size(ctx: &EffectCtx, b: &GBuf, anchor: (f64, f64)) -> (f64, f64) {
let (w, h) = match ctx.params.e("sizeFrom") {
0 => {
let c = b.to_px(ctx.params.v2("corner"));
((c.0 - anchor.0).abs(), (c.1 - anchor.1).abs())
}
1 => (ctx.params.f("width") * b.scale, ctx.params.f("width") * b.scale),
_ => (ctx.params.f("width") * b.scale, ctx.params.f("height") * b.scale),
};
(w.max(1.0), h.max(1.0))
}
fn checkerboard(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let anchor = b.to_px(ctx.params.v2("anchor"));
let (w, h) = cell_size(ctx, &b, anchor);
let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0);
let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0);
let mut p = Params::default();
p.u[0][0] = gen_mode(ctx.params.e("blendingMode"));
p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32];
p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, 0.0];
p.f[2] = ctx.params.color("color");
run(e, "gen_checker", &p, b, None, None)
}
fn grid(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let anchor = b.to_px(ctx.params.v2("anchor"));
let (w, h) = cell_size(ctx, &b, anchor);
let border = (ctx.params.f("border") * b.scale).max(0.0);
let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0);
let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0);
let mut p = Params::default();
p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.params.b("invertGrid") as u32, 0, 0];
p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32];
p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, border as f32];
p.f[2] = ctx.params.color("color");
run(e, "gen_grid", &p, b, None, None)
}
fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let mut p = Params::default();
for i in 0..4 {
let q = b.to_px(ctx.params.v2(&format!("positionsColors/point{}", i + 1)));
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 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] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
run(e, "gen_fourcolor", &p, b, None, None)
}
// ---------------------------------------------------------------- noise
fn noise(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let amt = ctx.params.f("amount") as f32 / 100.0;
let clip = ctx.params.get("clip").is_none_or(|v| v.as_bool());
let frame_seed = (ctx.time * 1000.0) as u32 ^ ctx.seed;
let mut p = Params::default();
p.u[0] = [frame_seed, ctx.params.b("color") as u32, clip as u32, b.img.width];
p.f[0][0] = amt;
run(e, "gen_noise", &p, b, None, None)
}
fn add_grain(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let look = GrainLook::from_params(ctx);
if look.intensity <= 0.0 {
return Some(b);
}
let (w, h) = (b.img.width, b.img.height);
// GrainLook::planes.
let size = look.size * b.scale as f32;
let s = |k: usize| (size * if look.mono { 1.0 } else { look.channel_size[k].max(0.05) }).max(0.05);
let mut p = Params::default();
p.u[0] = [look.mono as u32, look.seed, 101, 0];
p.f[0] = [s(0) * look.aspect, s(1) * look.aspect, s(2) * look.aspect, look.frame];
p.f[1] = [s(0), s(1), s(2), 0.0];
let mut planes = e.image(w, h);
e.pixels("gen_grain_planes", &p, &b.img, None, &planes, None);
let soft = look.softness * b.scale;
if soft > 0.05 {
planes = gaussian_blur(e, &planes, soft * look.aspect as f64, soft, true);
}
// GrainLook::color's tint factors.
let l = effectcraft_color::luminance(look.tint[0], look.tint[1], look.tint[2]).max(1e-3);
let tint = [0, 1, 2].map(|k| (1.0 - look.tint_amount) + look.tint_amount * look.tint[k] / l);
let mut p = Params::default();
p.u[0] = [look.mono as u32, look.mode, (look.tint_amount > 0.0) as u32, 0];
p.f[0] = [look.intensity * 0.25, look.saturation, look.midpoint, 0.0];
p.f[1] = [tint[0], tint[1], tint[2], 0.0];
p.f[2] = [look.channel_intensity[0], look.channel_intensity[1], look.channel_intensity[2], 0.0];
p.f[3] = [look.tones[0], look.tones[1], look.tones[2], 0.0];
run(e, "gen_grain_apply", &p, b, Some(&planes), None)
}