From 750868244a95feef9e4fc43de44d5067495fb76f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 10:53:00 -0400 Subject: [PATCH] M13.13: GPU Circle, Ellipse, Iris Wipe, Bevel Alpha, Bevel Edges, Gaussian Blur (Legacy) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 7 + crates/gpu/src/context.rs | 2 + crates/gpu/src/effects.rs | 1 + crates/gpu/src/fx_extra.rs | 196 +++++++++++++++++++++++++ crates/gpu/src/fx_generate.rs | 2 +- crates/gpu/src/lib.rs | 18 ++- crates/gpu/src/shaders/fx_extra.wgsl | 206 +++++++++++++++++++++++++++ crates/gpu/src/tests_fx_extra.rs | 51 +++++++ docs/effects.md | 14 +- 9 files changed, 486 insertions(+), 11 deletions(-) create mode 100644 crates/gpu/src/fx_extra.rs create mode 100644 crates/gpu/src/shaders/fx_extra.wgsl create mode 100644 crates/gpu/src/tests_fx_extra.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 5182a5d5..3486454d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -922,6 +922,13 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.distort.reshape", "ec.stylize.coloremboss", "ec.stylize.cartoon", + // effectcraft-gpu fx_extra (shapes, bevels) + "ec.generate.circle", + "ec.generate.ellipse", + "ec.transition.iriswipe", + "ec.perspective.bevelalpha", + "ec.perspective.beveledges", + "ec.obsolete.gaussianlegacy", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 4efa3b86..1de302ce 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -313,6 +313,7 @@ impl GpuContext { include_str!("shaders/fx_noise.wgsl"), include_str!("shaders/fx_tone.wgsl"), include_str!("shaders/fx_warp.wgsl"), + include_str!("shaders/fx_extra.wgsl"), ] .concat(); let module = @@ -371,6 +372,7 @@ impl GpuContext { .chain(crate::fx_noise::KERNELS) .chain(crate::fx_tone::KERNELS) .chain(crate::fx_warp::KERNELS) + .chain(crate::fx_extra::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index ba59580a..d8ab4746 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -132,6 +132,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ if crate::fx_noise::IDS.contains(&id) => crate::fx_noise::apply(e, id, ctx, b), _ if crate::fx_tone::IDS.contains(&id) => crate::fx_tone::apply(e, id, ctx, b), _ if crate::fx_warp::IDS.contains(&id) => crate::fx_warp::apply(e, id, ctx, b), + _ if crate::fx_extra::IDS.contains(&id) => crate::fx_extra::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } diff --git a/crates/gpu/src/fx_extra.rs b/crates/gpu/src/fx_extra.rs new file mode 100644 index 00000000..2e8bb85b --- /dev/null +++ b/crates/gpu/src/fx_extra.rs @@ -0,0 +1,196 @@ +//! GPU effects, shapes and bevels (kernels in `shaders/fx_extra.wgsl`, every entry point +//! prefixed `fxe_`): Circle, Ellipse, Iris Wipe, Bevel Alpha, Bevel Edges and Gaussian Blur +//! (Legacy), with the CPU effects' exact steps. Radii, feathers, light directions and Iris +//! Wipe's polygon are computed on the CPU in f64 as the CPU effect does; the kernels work +//! relative to the shape's centre. + +use effectcraft_effects::EffectCtx; + +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_extra.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxe_circle", "fxe_ellipse", "fxe_iris", "fxe_alpha", "fxe_bevel_alpha", "fxe_bevel_edges"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.generate.circle", + "ec.generate.ellipse", + "ec.transition.iriswipe", + "ec.perspective.bevelalpha", + "ec.perspective.beveledges", + "ec.obsolete.gaussianlegacy", +]; + +/// 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 { + match id { + "ec.generate.circle" => circle(e, ctx, b), + "ec.generate.ellipse" => ellipse(e, ctx, b), + "ec.transition.iriswipe" => iris_wipe(e, ctx, b), + "ec.perspective.bevelalpha" => bevel_alpha(e, ctx, b), + "ec.perspective.beveledges" => bevel_edges(e, ctx, b), + "ec.obsolete.gaussianlegacy" => gaussian_legacy(e, ctx, b), + _ => None, + } +} + +/// A same-size per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&wgpu::Buffer>) -> Option { + let out = e.scratch(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, aux, &out, data); + b.img = out; + Some(b) +} + +fn f4(v: [f64; 4]) -> [f32; 4] { + v.map(|x| x as f32) +} + +// ---------------------------------------------------------------- Circle (generate::circle) + +fn circle(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let c = b.to_px(pr.v2("center")); + let r = pr.f("radius") * b.scale; + let thick = pr.f("thickness").max(0.0); + let mut f_out = pr.f("feather/feather").max(0.0) * b.scale; + let mut f_in = pr.f("feather/featherInner").max(0.0) * b.scale; + let inner = match pr.e("edge") { + 1 => Some(pr.f("edgeRadius") * b.scale), + 2 => Some(r - thick * b.scale), + 3 => Some(r - thick / 100.0 * r), + 4 => { + f_out = pr.f("feather/feather").max(0.0) / 100.0 * r; + f_in = pr.f("feather/featherInner").max(0.0) / 100.0 * r; + Some(r - thick / 100.0 * r) + } + _ => None, + }; + let (ro, ri) = match inner { + Some(i) => (r.max(i), Some(r.min(i).max(0.0))), + None => (r, None), + }; + let (f_out, f_in) = (f_out.max(0.5), f_in.max(0.5)); + let color = pr.color("color"); + let mut p = Params::default(); + p.u[0] = [crate::fx_generate::gen_mode(pr.e("blendingMode")), ri.is_some() as u32, pr.b("invert") as u32, 0]; + p.f[0] = f4([c.0, c.1, ro, ri.unwrap_or(0.0)]); + p.f[1] = [f_out as f32, f_in as f32, pr.f("opacity") as f32 / 100.0, 0.0]; + p.f[2] = [color[0], color[1], color[2], 0.0]; + run(e, "fxe_circle", &p, b, None, None) +} + +// ---------------------------------------------------------------- Ellipse (generate2::ellipse) + +fn ellipse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let c = b.to_px(pr.v2("center")); + let rx = (pr.f("width") * b.scale * 0.5).max(0.5); + let ry = (pr.f("height") * b.scale * 0.5).max(0.5); + let half = (pr.f("thickness") * b.scale * 0.5).max(0.25); + let soft = (pr.f("softness") / 100.0).clamp(0.0, 1.0); + let (inside, outside) = (pr.color("insideColor"), pr.color("outsideColor")); + let mut p = Params::default(); + p.u[0][1] = pr.b("compositeOnOriginal") as u32; + p.f[0] = f4([c.0, c.1, rx, ry]); + p.f[1] = [half as f32, soft as f32, 0.0, 0.0]; + p.f[2] = [inside[0], inside[1], inside[2], 0.0]; + p.f[3] = [outside[0], outside[1], outside[2], 0.0]; + run(e, "fxe_ellipse", &p, b, None, None) +} + +// ---------------------------------------------------------------- Iris Wipe (transition::iris_wipe) + +fn iris_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let outer = pr.f("outerRadius").max(0.0) * b.scale; + if outer <= 0.0 { + return Some(b); + } + let n = pr.f("points").round().clamp(3.0, 64.0) as usize; + let star = pr.b("useInnerRadius"); + let inner = if star { pr.f("innerRadius").max(0.0) * b.scale } else { outer }; + let rot = pr.f("rotation").to_radians(); + let c = b.to_px(pr.v2("center")); + let m = if star { n * 2 } else { n }; + let step = std::f64::consts::TAU / m as f64; + let verts: Vec = (0..m) + .flat_map(|i| { + let r = if star && i % 2 == 1 { inner } else { outer }; + let a = rot + i as f64 * step; + [(r * a.sin()) as f32, (-r * a.cos()) as f32] + }) + .collect(); + let buf = e.data(&verts); + let mut p = Params::default(); + p.u[0][1] = m as u32; + p.f[0] = f4([c.0, c.1, rot, step]); + p.f[1] = f4([pr.f("feather") * b.scale, outer.min(inner), outer, 0.0]); + run(e, "fxe_iris", &p, b, None, Some(&buf)) +} + +// ---------------------------------------------------------------- Bevels (perspective.rs) + +/// perspective::light_dir. +fn light_dir(angle_deg: f64, elevation_deg: f64) -> [f64; 3] { + let (a, el) = (angle_deg.to_radians(), elevation_deg.to_radians()); + let v = [a.sin() * el.cos(), -a.cos() * el.cos(), el.sin()]; + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + v.map(|x| x / l) +} + +fn bevel_alpha(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let t = pr.f("edgeThickness") * b.scale; + let inten = pr.f("lightIntensity") as f32; + if t <= 0.0 || inten == 0.0 { + return Some(b); + } + let light = pr.color("lightColor"); + let l = light_dir(pr.f("lightAngle"), 45.0); + // The alpha plane's Gaussian (util::gauss_plane: box passes, edges repeated); kernels read x. + let alpha = e.scratch(b.img.width, b.img.height); + e.pixels("fxe_alpha", &Params::default(), &b.img, None, &alpha, None); + let h = gaussian_blur(e, &alpha, t * 0.5, t * 0.5, true); + let mut p = Params::default(); + p.f[0] = f4([l[0], l[1], l[2], t * 1.25]); + p.f[1] = [light[0], light[1], light[2], inten]; + run(e, "fxe_bevel_alpha", &p, b, Some(&h), None) +} + +fn bevel_edges(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let (x0, y0, w, h) = (b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let t = pr.f("edgeThickness").clamp(0.0, 0.5) * w.min(h); + let inten = pr.f("lightIntensity") as f32; + if t <= 0.0 || inten == 0.0 { + return Some(b); + } + let light = pr.color("lightColor"); + let l = light_dir(pr.f("lightAngle"), 45.0); + let mut p = Params::default(); + p.f[0] = f4([l[0], l[1], l[2], t]); + p.f[1] = [light[0], light[1], light[2], inten]; + p.f[2] = f4([x0, y0, w, h]); + run(e, "fxe_bevel_edges", &p, b, None, None) +} + +// ---------------------------------------------------------------- Gaussian Blur (Legacy) (obsolete.rs) + +fn gaussian_legacy(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let s = ctx.params.f("blurriness").max(0.0) * 0.5 * b.scale; + if s <= 0.0 { + return Some(b); + } + let (kx, ky) = match ctx.params.e("blurDimensions") { + 1 => (1.0, 0.0), + 2 => (0.0, 1.0), + _ => (1.0, 1.0), + }; + if !ctx.adjustment { + b.pad(e, (s * 3.0).ceil() as u32)?; + } + b.img = gaussian_blur(e, &b.img, s * kx, s * ky, ctx.adjustment); + Some(b) +} diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 1917f399..3744ec4f 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -83,7 +83,7 @@ fn hl(v: f64) -> [f32; 2] { } /// Generate blending mode index → `BlendMode::ALL` index (255 = None: replace). -fn gen_mode(i: u32) -> u32 { +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, diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 11a2ca1d..9750cf79 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -17,9 +17,18 @@ //! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU //! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are //! uploaded and read back once. Each family lives in its own module with its own WGSL file -//! (`fx_color`, `fx_distort`, `fx_generate`, `fx_key`, `fx_noise`, `fx_stylize`, `fx_tone`); -//! settings a kernel cannot match fall back to the CPU (`catalog::gpu_supported`, or `None` -//! from the family's `apply`). +//! (`fx_color`, `fx_distort`, `fx_extra`, `fx_generate`, `fx_key`, `fx_noise`, `fx_stylize`, +//! `fx_tone`, `fx_warp`); settings a kernel cannot match fall back to the CPU +//! (`catalog::gpu_supported`, or `None` from the family's `apply`). +//! +//! Working textures come from a pool (`context::Pool`): a frame allocates several full-frame +//! RGBA f32 images per layer, and creating and zeroing those cost more than compositing a small +//! layer. A released texture is reused once every encoder that could still read it has been +//! submitted. 8/16 bpc quantisation after each layer is fused into that layer's composite. +//! [`Backend::Auto`](effectcraft_render::Backend) renders each comp on whichever compositor +//! measured faster for it ([`effectcraft_render::AutoPick`], kept by [`Gpu`]): a light comp +//! that the CPU composites in a millisecond stays there rather than paying for a full-frame +//! readback. //! //! GPU particles (`particles`): the stepped particle effects hand their simulation to //! [`effectcraft_effects::psim::ParticleSim`], implemented here with one invocation per particle @@ -37,6 +46,7 @@ mod context; mod effects; mod fx_color; mod fx_distort; +mod fx_extra; mod fx_generate; mod fx_key; mod fx_noise; @@ -186,6 +196,8 @@ mod tests_fx_color; #[cfg(test)] mod tests_fx_distort; #[cfg(test)] +mod tests_fx_extra; +#[cfg(test)] mod tests_fx_generate; #[cfg(test)] mod tests_fx_key; diff --git a/crates/gpu/src/shaders/fx_extra.wgsl b/crates/gpu/src/shaders/fx_extra.wgsl new file mode 100644 index 00000000..e1425ddc --- /dev/null +++ b/crates/gpu/src/shaders/fx_extra.wgsl @@ -0,0 +1,206 @@ +// GPU effects (shapes and bevels): see src/fx_extra.rs. Circle, Ellipse, Iris Wipe, Bevel Alpha +// and Bevel Edges, each mirroring the CPU effect operation for operation. Coordinates are pixel +// centres relative to the shape's centre (or the layer's corner), so f32 keeps its precision. + +fn fxe_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +fn fxe_rel(p: vec2, c: vec2) -> vec2 { + return vec2(f32(p.x) + 0.5, f32(p.y) + 0.5) - c; +} + +// ---------------------------------------------------------------- Circle (generate::circle) +// u[0] = (gen mode, has inner, invert); f[0] = (cx, cy, outer radius, inner radius); +// f[1] = (outer feather, inner feather, opacity); f[2] = colour. + +@compute @workgroup_size(16, 16) +fn fxe_circle(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxe_inside(p)) { + return; + } + let d = length(fxe_rel(p, P.f[0].xy)); + var cov = clamp((P.f[0].z - d) / P.f[1].x + 0.5, 0.0, 1.0); + if (P.u[0].y != 0u) { + cov *= clamp((d - P.f[0].w) / P.f[1].y + 0.5, 0.0, 1.0); + } + if (P.u[0].z != 0u) { + cov = 1.0 - cov; + } + let a = cov * P.f[1].z; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(P.f[2].xyz * a, a), P.u[0].x)); +} + +// ---------------------------------------------------------------- Ellipse (generate2::ellipse) +// u[0].y = composite on original; f[0] = (cx, cy, rx, ry); f[1] = (half thickness, softness); +// f[2] = inside colour; f[3] = outside colour. + +fn fxe_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +@compute @workgroup_size(16, 16) +fn fxe_ellipse(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxe_inside(p)) { + return; + } + let d = fxe_rel(p, P.f[0].xy); + let r = P.f[0].zw; + let q = length(d / r); + var dist = min(r.x, r.y); + if (q >= 1e-9) { + let g = d / (r * r) / q; + dist = abs((q - 1.0) / max(length(g), 1e-9)); + } + let half = P.f[1].x; + let soft = P.f[1].y; + let t = dist / half; + var a = clamp(half - dist + 0.5, 0.0, 1.0); + if (soft > 1e-3) { + a = 1.0 - fxe_smoothstep(1.0 - soft, 1.0, t); + } + let k = fxe_smoothstep(0.0, 1.0, t); + let cc = P.f[2].xyz + (P.f[3].xyz - P.f[2].xyz) * k; + let g4 = vec4(cc * a, a); + if (P.u[0].y != 0u) { + let o = textureLoad(src, p, 0); + textureStore(out, p, g4 + o * (1.0 - g4.w)); + } else { + textureStore(out, p, g4); + } +} + +// ---------------------------------------------------------------- Iris Wipe (transition::iris_wipe) +// data = polygon vertices relative to the centre; u[0] = (_, vertex count); +// f[0] = (cx, cy, rotation, angle step); f[1] = (feather, radius at the centre, outer radius). + +fn fxe_cross(a: vec2, b: vec2) -> f32 { + return a.x * b.y - a.y * b.x; +} + +@compute @workgroup_size(16, 16) +fn fxe_iris(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxe_inside(p)) { + return; + } + let d = fxe_rel(p, P.f[0].xy); + let r = length(d); + let m = P.u[0].y; + let rot = P.f[0].z; + let step = P.f[0].w; + var rb = P.f[1].y; + if (r >= 1e-9) { + let tau = 6.2831855; + let a0 = atan2(d.x, -d.y) - rot; + let a = a0 - floor(a0 / tau) * tau; + let i = min(u32(floor(a / step)), m - 1u); + let vi = vec2(data[2u * i], data[2u * i + 1u]); + let j = (i + 1u) % m; + let vj = vec2(data[2u * j], data[2u * j + 1u]); + let e = vj - vi; + let den = fxe_cross(d / r, e); + rb = select(fxe_cross(vi, e) / den, P.f[1].z, abs(den) < 1e-12); + } + let feather = P.f[1].x; + var k = select(0.0, 1.0, r >= rb); + if (feather > 0.0) { + k = clamp((r - rb) / feather + 0.5, 0.0, 1.0); + } + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// ---------------------------------------------------------------- Bevels (perspective.rs) + +// perspective::apply_shade. +fn fxe_shade(px: vec4, s: f32, light: vec3) -> vec4 { + if (s > 0.0) { + return vec4(px.xyz + light * s * px.w, px.w); + } + if (s < 0.0) { + return vec4(px.xyz * max(1.0 + s, 0.0), px.w); + } + return px; +} + +// Plane::alpha (in x). +@compute @workgroup_size(16, 16) +fn fxe_alpha(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxe_inside(p)) { + return; + } + textureStore(out, p, vec4(textureLoad(src, p, 0).w, 0.0, 0.0, 0.0)); +} + +// Bevel Alpha: src = the layer, aux = its blurred alpha (x). f[0] = (light direction, normal +// scale k); f[1] = (light colour, intensity). +@compute @workgroup_size(16, 16) +fn fxe_bevel_alpha(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxe_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + let dx = (tex_get_clamped(aux, p.x + 1, p.y).x - tex_get_clamped(aux, p.x - 1, p.y).x) * 0.5; + let dy = (tex_get_clamped(aux, p.x, p.y + 1).x - tex_get_clamped(aux, p.x, p.y - 1).x) * 0.5; + let k = P.f[0].w; + let n = normalize(vec3(-dx * k, -dy * k, 1.0)); + let l = P.f[0].xyz; + let s = (dot(n, l) - l.z) * P.f[1].w * 2.0; + textureStore(out, p, fxe_shade(px, s, P.f[1].xyz)); +} + +// Bevel Edges: f[0] = (light direction, thickness); f[1] = (light colour, intensity); +// f[2] = layer rectangle (x0, y0, w, h). +@compute @workgroup_size(16, 16) +fn fxe_bevel_edges(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxe_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + let rc = P.f[2]; + let q = vec2(f32(p.x) + 0.5, f32(p.y) + 0.5); + let d = vec4(q.x - rc.x, rc.x + rc.z - q.x, q.y - rc.y, rc.y + rc.w - q.y); + if (d.x < 0.0 || d.y < 0.0 || d.z < 0.0 || d.w < 0.0) { + textureStore(out, p, px); + return; + } + var mi = 0; + var mv = d.x; + for (var i = 1; i < 4; i++) { + if (d[i] < mv) { + mv = d[i]; + mi = i; + } + } + if (mv >= P.f[0].w) { + textureStore(out, p, px); + return; + } + var n2 = vec2(-1.0, 0.0); + if (mi == 1) { + n2 = vec2(1.0, 0.0); + } else if (mi == 2) { + n2 = vec2(0.0, -1.0); + } else if (mi == 3) { + n2 = vec2(0.0, 1.0); + } + let tilt = 0.70710677; + let n = vec3(n2 * tilt, tilt); + let l = P.f[0].xyz; + let s = (dot(n, l) - l.z) * P.f[1].w * 2.0; + textureStore(out, p, fxe_shade(px, s, P.f[1].xyz)); +} diff --git a/crates/gpu/src/tests_fx_extra.rs b/crates/gpu/src/tests_fx_extra.rs new file mode 100644 index 00000000..b7fe6cbd --- /dev/null +++ b/crates/gpu/src/tests_fx_extra.rs @@ -0,0 +1,51 @@ +//! GPU shape and bevel effects (Circle, Ellipse, Iris Wipe, Bevel Alpha, Bevel Edges, Gaussian +//! Blur (Legacy)) vs the CPU effects (the oracle): direct on a buffer at full and half +//! resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_keyframe::Value; + +use crate::tests::{c, effect_case, n}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn circle_and_ellipse() { + effect_case("ec.generate.circle", &[("center", pt(30.0, 20.0)), ("radius", n(15.0))]); + effect_case("ec.generate.circle", &[("radius", n(18.0)), ("edge", e(2)), ("thickness", n(5.0)), ("feather/feather", n(3.0)), ("blendingMode", e(3))]); + effect_case( + "ec.generate.circle", + &[("radius", n(20.0)), ("edge", e(4)), ("thickness", n(30.0)), ("feather/featherInner", n(10.0)), ("invert", on()), ("opacity", n(60.0))], + ); + effect_case("ec.generate.circle", &[("radius", n(12.0)), ("edge", e(1)), ("edgeRadius", n(25.0)), ("color", c(0.2, 0.8, 0.4))]); + effect_case("ec.generate.ellipse", &[("center", pt(35.0, 22.0)), ("width", n(50.0)), ("height", n(30.0)), ("thickness", n(6.0))]); + effect_case("ec.generate.ellipse", &[("width", n(40.0)), ("height", n(60.0)), ("softness", n(40.0)), ("compositeOnOriginal", on())]); +} + +#[test] +fn iris_wipe() { + effect_case("ec.transition.iriswipe", &[("outerRadius", n(20.0)), ("center", pt(35.0, 22.0))]); + effect_case( + "ec.transition.iriswipe", + &[("outerRadius", n(25.0)), ("points", n(5.0)), ("useInnerRadius", on()), ("innerRadius", n(10.0)), ("rotation", n(20.0)), ("feather", n(4.0))], + ); +} + +#[test] +fn bevels_and_legacy_gaussian() { + effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(4.0)), ("lightIntensity", n(0.6))]); + effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(2.0)), ("lightAngle", n(200.0)), ("lightColor", c(1.0, 0.8, 0.5))]); + effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.1)), ("lightIntensity", n(0.5))]); + effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.3)), ("lightAngle", n(130.0))]); + effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(7.0))]); + effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(5.0)), ("blurDimensions", e(2))]); +} diff --git a/docs/effects.md b/docs/effects.md index 1ccbfc66..1ad51686 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 306 effects with 3009 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. -- **GPU**: 160 effects also run on the GPU compositor with identical results. +- **GPU**: 166 effects also run on the GPU compositor with identical results. - **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 306 implemented in full, 0 partial (what is missing is listed). @@ -200,8 +200,8 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented | | Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Implemented | | Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented | -| Circle | `ec.generate.circle` | 11 | | 32 | Implemented | -| Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented | +| Circle | `ec.generate.circle` | 11 | GPU | 32 | Implemented | +| Ellipse | `ec.generate.ellipse` | 8 | GPU | 32 | Implemented | | Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented | | Fill | `ec.generate.fill` | 7 | GPU | 32 | Implemented | | Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented | @@ -285,7 +285,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Basic 3D | `ec.obsolete.basic3d` | 5 | | 32 | Implemented | | Basic Text | `ec.obsolete.basictext` | 11 | | 32 | Implemented | | Color Key | `ec.key.colorkey` | 4 | GPU | 32 | Implemented | -| Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | | 32 | Implemented | +| Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | GPU | 32 | Implemented | | Lightning | `ec.obsolete.lightning` | 25 | | 32 | Implemented | | Luma Key | `ec.key.luma` | 5 | GPU | 32 | Implemented | | Path Text | `ec.obsolete.pathtext` | 32 | | 32 | Implemented | @@ -304,8 +304,8 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | 3D Camera Tracker | `ec.perspective.cameratracker` | 12 | | 32 | Implemented | | 3D Glasses | `ec.perspective.3dglasses` | 8 | | 32 | Implemented | -| Bevel Alpha | `ec.perspective.bevelalpha` | 4 | | 32 | Implemented | -| Bevel Edges | `ec.perspective.beveledges` | 4 | | 32 | Implemented | +| Bevel Alpha | `ec.perspective.bevelalpha` | 4 | GPU | 32 | Implemented | +| Bevel Edges | `ec.perspective.beveledges` | 4 | GPU | 32 | Implemented | | CC Cylinder | `ec.perspective.cccylinder` | 12 | | 32 | Implemented | | CC Environment | `ec.perspective.ccenvironment` | 4 | | 32 | Implemented | | CC Sphere | `ec.perspective.ccsphere` | 14 | | 32 | Implemented | @@ -403,7 +403,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | | Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented | | Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | -| Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented | +| Iris Wipe | `ec.transition.iriswipe` | 7 | GPU | 32 | Implemented | | Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented | | Radial Wipe | `ec.transition.radialwipe` | 5 | GPU | 32 | Implemented | | Venetian Blinds | `ec.transition.venetian` | 4 | GPU | 32 | Implemented |