From 9ff0e5aa3c0bbffe01f4d97b7ad96626889d66ec Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 10:31:31 -0400 Subject: [PATCH 1/4] M13.13: gpu texture pool, Auto per-comp backend choice, warp-family GPU ports - gpu: pooled working textures (released once every encoder that could read them is submitted), shared zero images, reused readback staging buffers, direct readback into Image; NaN-poisoned scratch images under test and NaN-aware diffs; validation errors panic under test - render: AutoPick (per comp / scale / path EWMA of CPU vs GPU frame times, warm-up and periodic re-probe) behind Backend::Auto; viewer uses it too; cached adjustment footprints (uploaded once) - gpu fx_warp: Warp, Bezier Warp, CC Bend It, CC Page Turn, Smear, Reshape, Color Emboss, Cartoon; sliding-histogram median for large radii; CC Power Pin perspective < 100 - cli: bench --gpu reports Auto and per-frame upload / readback MB Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 22 +- crates/effects/src/catalog.rs | 9 + crates/effects/src/distort2.rs | 16 +- crates/effects/src/distort3.rs | 78 +++- crates/effects/src/lib.rs | 12 +- crates/gpu/src/bokeh.rs | 4 +- crates/gpu/src/classic3d.rs | 2 +- crates/gpu/src/context.rs | 335 ++++++++++++++-- crates/gpu/src/effects.rs | 13 +- crates/gpu/src/fx_color.rs | 14 +- crates/gpu/src/fx_distort.rs | 13 +- crates/gpu/src/fx_generate.rs | 8 +- crates/gpu/src/fx_key.rs | 6 +- crates/gpu/src/fx_noise.rs | 33 +- crates/gpu/src/fx_stylize.rs | 27 +- crates/gpu/src/fx_tone.rs | 4 +- crates/gpu/src/fx_warp.rs | 275 +++++++++++++ crates/gpu/src/lib.rs | 13 +- crates/gpu/src/ops.rs | 20 +- crates/gpu/src/shaders/fx_noise.wgsl | 62 +++ crates/gpu/src/shaders/fx_stylize.wgsl | 30 ++ crates/gpu/src/shaders/fx_warp.wgsl | 532 +++++++++++++++++++++++++ crates/gpu/src/shaders/kernels.wgsl | 13 + crates/gpu/src/tests.rs | 13 +- crates/gpu/src/tests_adjust.rs | 4 +- crates/gpu/src/tests_fx_noise.rs | 11 + crates/gpu/src/tests_fx_stylize.rs | 3 + crates/gpu/src/tests_fx_warp.rs | 191 +++++++++ crates/gpu/src/walk.rs | 16 +- crates/render/src/auto.rs | 144 +++++++ crates/render/src/cache.rs | 10 + crates/render/src/lib.rs | 53 ++- crates/render/src/tests_auto.rs | 151 +++++++ crates/ui-egui/src/frames.rs | 36 +- docs/effects.md | 18 +- 35 files changed, 2039 insertions(+), 152 deletions(-) create mode 100644 crates/gpu/src/fx_warp.rs create mode 100644 crates/gpu/src/shaders/fx_warp.wgsl create mode 100644 crates/gpu/src/tests_fx_warp.rs create mode 100644 crates/render/src/auto.rs create mode 100644 crates/render/src/tests_auto.rs diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 8aa1381b..5524d10f 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -788,7 +788,9 @@ fn adjustment_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option /// `bench --gpu`: CPU vs GPU ms/frame for every comp (or `--comp`) at Full and Half. "cold" /// renders everything (no layer cache); "warm" reuses the layer cache (playback / scrubbing of -/// unchanged layers: compositing cost); "viewer" is the GPU display path without readback. +/// unchanged layers: compositing cost); "viewer" is the GPU display path without readback; +/// "auto" is Backend::Auto (Mercury GPU Acceleration) choosing per comp from measured times, +/// warm; "up/dn MB" are the GPU's uploads / readbacks per warm GPU frame. fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { use effectcraft_render::Backend; let gpu = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?; @@ -796,6 +798,7 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { // Every comp, plus an adjustment-layer comp built for the benchmark (the main comp under a // full-frame adjustment layer with a GPU effect stack). let mut project = (*s.project).clone(); + project.settings.gpu_acceleration = true; let comps: Vec = match args.opt("--comp") { Some(_) => s.active_comp_id().into_iter().collect(), None => { @@ -807,8 +810,8 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { let project = &project; eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu)); eprintln!( - "{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9}", - "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "gpu≠cpu" + "{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9} {:>9} {:>11}", + "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "auto", "gpu≠cpu", "up/dn MB" ); for cid in comps { let Some(comp) = project.comp(cid) else { continue }; @@ -833,9 +836,20 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { std::hint::black_box(mk(Backend::Cpu, Some(&cache))); }); let gcache = LayerCache::default(); + let t0 = gpu.context().transfer_stats(); let gpu_warm = time_ms(n, || { std::hint::black_box(mk(Backend::Gpu, Some(&gcache))); }); + let t1 = gpu.context().transfer_stats(); + let per = |b: u64| b as f64 / (n + 1) as f64 / (1u64 << 20) as f64; + let traffic = format!("{:.1}/{:.1}", per(t1.upload_bytes - t0.upload_bytes), per(t1.readback_bytes - t0.readback_bytes)); + // Auto: warm-up frames on both sides, then the measured choice. + for _ in 0..2 * effectcraft_render::AutoPick::WARMUP { + std::hint::black_box(mk(Backend::Auto, Some(&gcache))); + } + let auto = time_ms(n, || { + std::hint::black_box(mk(Backend::Auto, Some(&gcache))); + }); let view = time_ms(n, || { let mut r = Renderer::new(project, s.footage.as_ref(), RenderOpts { scale, backend: Backend::Gpu, ..Default::default() }); r.expr = s.expr.as_deref(); @@ -852,7 +866,7 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { let pct = 100.0 * off as f64 / a.data.len().max(1) as f64; let speedup = cpu_warm / gpu_warm.max(1e-9); eprintln!( - "{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {pct:>8.3}%" + "{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {auto:>9.2} {pct:>8.3}% {traffic:>11}" ); } } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index a711ce43..102bd84e 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -100,6 +100,15 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { match id { "ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false, "ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false, + // Fisheye / Twist (a crease at the unit circle) bent past 50 % or with Horizontal / + // Vertical Distortion: Newton's inverse wanders chaotically before converging near the + // fold, so f32 lands on other pixels than the CPU's f64. + "ec.distort.warp" + if matches!(ctx.params.e("warpStyle"), 11 | 14) + && (ctx.params.f("bend").abs() > 50.0 || ctx.params.f("horizontalDistortion") != 0.0 || ctx.params.f("verticalDistortion") != 0.0) => + { + return false; + } _ => {} } let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true }; diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 8f430a96..f672d149 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -507,6 +507,15 @@ fn bez(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; } fn bezier_warp(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (n, dest, srcp) = bezier_grid(ctx, b.offset, b.scale); + b.img = grid_warp(&b.img, b.img.width, b.img.height, n, n, &dest, &srcp); + b +} + +/// Bezier Warp's n×n grid: (n, destination points, source points) in buffer pixels (the GPU +/// rasterises the same grid). +pub fn bezier_grid(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> (usize, Vec, Vec) { + let to_px = |p: [f64; 2]| (p[0] * scale + offset[0], p[1] * scale + offset[1]); let g = |id: &str| ctx.params.v2(id); let (tl, tlt, trt, tr) = (g("topLeftVertex"), g("topLeftTangent"), g("topRightTangent"), g("rightTopVertex")); let (rtt, rbt, br) = (g("rightTopTangent"), g("rightBottomTangent"), g("bottomRightVertex")); @@ -529,12 +538,11 @@ fn bezier_warp(ctx: &EffectCtx, mut b: Buf) -> Buf { let bil = (1.0 - u) * (1.0 - v) * tl[k] + u * (1.0 - v) * tr[k] + (1.0 - u) * v * bl[k] + u * v * br[k]; s[k] = (1.0 - v) * c0[k] + v * c1[k] + (1.0 - u) * d0[k] + u * d1[k] - bil; } - dest.push(b.to_px(s)); - srcp.push(b.to_px([u * lw, v * lh])); + dest.push(to_px(s)); + srcp.push(to_px([u * lw, v * lh])); } } - b.img = grid_warp(&b.img, b.img.width, b.img.height, n, n, &dest, &srcp); - b + (n, dest, srcp) } // ---------------------------------------------------------------- CC Bend It diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index e1f0a898..a882d856 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -386,8 +386,25 @@ fn cc_split2(ctx: &EffectCtx, b: Buf) -> Buf { // ---------------------------------------------------------------- Smear (mask based) -fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf { - let (Some((src_m, _)), Some((bound, _))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "boundaryMask")) else { return b }; +/// Smear's set-up in buffer pixels (shared with the GPU kernel). +pub struct SmearSetup { + /// Boundary outline and the source outline moved by offset / rotation / scale. + pub bound: Vec<[f64; 2]>, + pub moved: Vec<[f64; 2]>, + /// Source outline centroid, rotation (cos, sin), scale, offset (buffer pixels). + pub c: [f64; 2], + pub cs: f64, + pub sn: f64, + pub scl: f64, + pub off: [f64; 2], + pub pct: f64, + /// Elasticity exponent. + pub kexp: f64, +} + +/// `None` when Smear leaves the buffer as it is (a mask missing, Percent 0). +pub fn smear_setup(ctx: &EffectCtx, b: &Buf) -> Option { + let (Some((src_m, _)), Some((bound, _))) = (mask_px(ctx, b, "sourceMask"), mask_px(ctx, b, "boundaryMask")) else { return None }; let pct = ctx.params.f("percent") / 100.0; let off = ctx.params.v2("maskOffset"); let (ox, oy) = (off[0] * b.scale, off[1] * b.scale); @@ -400,23 +417,30 @@ fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf { let (dx, dy) = ((p[0] - c[0]) * scl, (p[1] - c[1]) * scl); [c[0] + dx * cs - dy * sn + ox, c[1] + dx * sn + dy * cs + oy] }; + let moved: Vec<[f64; 2]> = src_m.iter().map(|&q| fwd(q)).collect(); + if pct == 0.0 { + return None; + } + Some(SmearSetup { bound, moved, c, cs, sn, scl, off: [ox, oy], pct, kexp }) +} + +fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(s) = smear_setup(ctx, &b) else { return b }; + let (c, cs, sn, scl, [ox, oy]) = (s.c, s.cs, s.sn, s.scl, s.off); let inv = |x: f64, y: f64| -> (f64, f64) { let (dx, dy) = (x - ox - c[0], y - oy - c[1]); (c[0] + (dx * cs + dy * sn) / scl, c[1] + (-dx * sn + dy * cs) / scl) }; - let moved: Vec<[f64; 2]> = src_m.iter().map(|&q| fwd(q)).collect(); - if pct == 0.0 { - return b; - } + let (bound, moved, pct, kexp) = (&s.bound, &s.moved, s.pct, s.kexp); b.img = remap(&b.img, false, |x, y| { - if !point_in_poly(&bound, x, y) { + if !point_in_poly(bound, x, y) { return Some((x, y)); } - let w = if point_in_poly(&moved, x, y) { + let w = if point_in_poly(moved, x, y) { 1.0 } else { - let dm = dist_to_poly(&moved, true, x, y); - let db = dist_to_poly(&bound, true, x, y); + let dm = dist_to_poly(moved, true, x, y); + let db = dist_to_poly(bound, true, x, y); (db / (db + dm).max(1e-9)).powf(1.0 / kexp) }; let (ix, iy) = inv(x, y); @@ -464,15 +488,33 @@ fn map_fraction(pairs: &[(f64, f64)], u: f64, closed: bool) -> f64 { (a.0 + ds * t).rem_euclid(1.0) } -fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf { - let (Some((sm, sc)), Some((dm, dc))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "destinationMask")) else { return b }; +/// Points Reshape samples each outline at. +pub const RESHAPE_POINTS: usize = 48; + +/// Reshape's set-up in buffer pixels (shared with the GPU kernel). +pub struct ReshapeSetup { + /// The destination outline's sample points and the displacement back to the source + /// outline at each. + pub dest: Vec<[f64; 2]>, + pub disp: Vec<[f64; 2]>, + pub bound: Option>, + /// Elasticity exponent. + pub kexp: f64, + pub pct: f64, + /// Smooth interpolation of the boundary fade. + pub smooth: bool, +} + +/// `None` when Reshape leaves the buffer as it is (a mask missing, Percent 0). +pub fn reshape_setup(ctx: &EffectCtx, b: &Buf) -> Option { + let (Some((sm, sc)), Some((dm, dc))) = (mask_px(ctx, b, "sourceMask"), mask_px(ctx, b, "destinationMask")) else { return None }; let pct = ctx.params.f("percent") / 100.0; if pct == 0.0 { - return b; + return None; } - let bound = mask_px(ctx, &b, "boundaryMask").map(|m| m.0); + let bound = mask_px(ctx, b, "boundaryMask").map(|m| m.0); let kexp = elastic_exp(ctx.params.e("elasticity")); - const N: usize = 48; + const N: usize = RESHAPE_POINTS; let s = resample_poly(&sm, sc, N); let d = resample_poly(&dm, dc, N); // Align the starting correspondence point (closed shapes): best cyclic rotation. @@ -506,6 +548,12 @@ fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf { }) .collect(); let smooth = ctx.params.e("interpolation") == 2; + Some(ReshapeSetup { dest: d, disp, bound, kexp, pct, smooth }) +} + +fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf { + const N: usize = RESHAPE_POINTS; + let Some(ReshapeSetup { dest: d, disp, bound, kexp, pct, smooth }) = reshape_setup(ctx, &b) else { return b }; b.img = remap(&b.img, false, |x, y| { if let Some(bd) = &bound && !point_in_poly(bd, x, y) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 597eed8a..5182a5d5 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -72,7 +72,8 @@ pub use color_fx::{ }; pub use color2::{Curve, auto_correct_settings, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use distort::transform_shutter; -pub use distort2::{MAGNIFY_MODES, parse_mesh}; +pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh}; +pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, reshape_setup, smear_setup}; pub use distort4::liquify_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; @@ -912,6 +913,15 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.channel.minimax", "ec.time.echo", "ec.time.posterizetime", + // effectcraft-gpu fx_warp (warps, cartoon) + "ec.distort.warp", + "ec.distort.bezierwarp", + "ec.distort.ccbendit", + "ec.distort.ccpageturn", + "ec.distort.smear", + "ec.distort.reshape", + "ec.stylize.coloremboss", + "ec.stylize.cartoon", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs index db7ca9f7..ac07448c 100644 --- a/crates/gpu/src/bokeh.rs +++ b/crates/gpu/src/bokeh.rs @@ -17,7 +17,7 @@ pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option 0.0 { - let out = e.image(w, h); + let out = e.scratch(w, h); let mut p = Params::default(); p.f[0] = [d.highlight.gain as f32, d.highlight.threshold as f32, d.highlight.saturation as f32, 0.0]; e.pixels("bokeh_boost", &p, &padded, None, &out, None); @@ -47,7 +47,7 @@ pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option Option>, +} + +impl GpuImage { + /// An image on a texture of its own (not pooled). + pub fn new(texture: wgpu::Texture, width: u32, height: u32) -> GpuImage { + GpuImage { texture, width, height, _lease: None } + } +} + +/// Hands a pooled texture back to its [`Pool`] when the last [`GpuImage`] holding it drops. +struct Lease { + texture: wgpu::Texture, + pool: Weak>, +} + +impl std::fmt::Debug for Lease { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Lease") + } +} + +impl Drop for Lease { + fn drop(&mut self) { + if let Some(pool) = self.pool.upgrade() + && let Ok(mut p) = pool.lock() + { + p.release(self.texture.clone()); + } + } +} + +/// Recycled working textures: allocating (and zero-initialising) a 1080p RGBA f32 texture +/// costs more than compositing a small layer into it, and a frame makes several per layer. +/// +/// A released texture may still be read by commands recorded in an encoder that has not been +/// submitted yet. Reusing it in another encoder that is submitted first would overwrite it +/// before those reads run, so a released texture waits until every encoder that was open when +/// it was released has been submitted (or dropped). +#[derive(Default)] +pub(crate) struct Pool { + free: HashMap<(u32, u32), Vec>, + /// (texture, newest encoder open at its release). + released: Vec<(wgpu::Texture, u64)>, + /// Encoders with recorded, unsubmitted work. + open: BTreeSet, + next: u64, + bytes: usize, + /// Working textures reused / newly allocated (diagnostics, tests). + hits: u64, + misses: u64, +} + +/// Counts of uploads (CPU → GPU) and readbacks (GPU → CPU). +#[derive(Default)] +pub(crate) struct Transfers { + ups: std::sync::atomic::AtomicU64, + up_bytes: std::sync::atomic::AtomicU64, + downs: std::sync::atomic::AtomicU64, + down_bytes: std::sync::atomic::AtomicU64, +} + +/// A snapshot of [`GpuContext::transfer_stats`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TransferStats { + pub uploads: u64, + pub upload_bytes: u64, + pub readbacks: u64, + pub readback_bytes: u64, +} + +impl Transfers { + fn count(&self, up: bool, bytes: usize) { + use std::sync::atomic::Ordering::Relaxed; + let (n, b) = if up { (&self.ups, &self.up_bytes) } else { (&self.downs, &self.down_bytes) }; + n.fetch_add(1, Relaxed); + b.fetch_add(bytes as u64, Relaxed); + } +} + +fn tex_bytes(t: &wgpu::Texture) -> usize { + t.width() as usize * t.height() as usize * 16 +} + +impl Pool { + fn release(&mut self, t: wgpu::Texture) { + match self.open.last() { + Some(&newest) => self.released.push((t, newest)), + None => self.make_free(t), + } + } + + fn make_free(&mut self, t: wgpu::Texture) { + let b = tex_bytes(&t); + if self.bytes + b > POOL_BUDGET { + return; + } + self.bytes += b; + self.free.entry((t.width(), t.height())).or_default().push(t); + } + + fn open(&mut self) -> u64 { + self.next += 1; + self.open.insert(self.next); + self.next + } + + /// Encoder `id` was submitted or dropped: free what no open encoder can still read. + fn close(&mut self, id: u64) { + self.open.remove(&id); + let oldest = self.open.first().copied().unwrap_or(u64::MAX); + if self.released.iter().all(|(_, w)| *w >= oldest) { + return; + } + let (ready, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.released).into_iter().partition(|(_, w)| *w < oldest); + self.released = wait; + for (t, _) in ready { + self.make_free(t); + } + } + + fn take(&mut self, w: u32, h: u32) -> Option { + let t = self.free.get_mut(&(w, h)).and_then(Vec::pop); + match &t { + Some(t) => { + self.bytes -= tex_bytes(t); + self.hits += 1; + } + None => self.misses += 1, + } + t + } + + fn clear(&mut self) { + self.free.clear(); + self.bytes = 0; + } } /// Uniform parameter block shared by every kernel (see `shaders/common.wgsl`). @@ -100,9 +243,17 @@ pub struct GpuContext { pipelines: HashMap<&'static str, wgpu::ComputePipeline>, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, + dummy: wgpu::Texture, dummy_tex: wgpu::TextureView, dummy_buf: wgpu::Buffer, uploads: Mutex, + pool: Arc>, + /// CPU ↔ GPU traffic (diagnostics: `bench --gpu`). + pub(crate) transfers: Transfers, + /// Readback staging buffers by size. + staging: Mutex>>, + /// Transparent read-only images by size (see [`Enc::zeros`]). + zeros: Mutex>, /// Advanced 3D render pipelines (built on first use). pub(crate) adv3d: std::sync::OnceLock, /// GPU particle pipeline (built on first use) and simulation checkpoints. @@ -158,6 +309,7 @@ impl GpuContext { include_str!("shaders/fx_stylize.wgsl"), include_str!("shaders/fx_noise.wgsl"), include_str!("shaders/fx_tone.wgsl"), + include_str!("shaders/fx_warp.wgsl"), ] .concat(); let module = @@ -215,6 +367,7 @@ impl GpuContext { .chain(crate::fx_stylize::KERNELS) .chain(crate::fx_noise::KERNELS) .chain(crate::fx_tone::KERNELS) + .chain(crate::fx_warp::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { @@ -274,8 +427,13 @@ impl GpuContext { display_bgl, display, dummy_tex: dummy.create_view(&Default::default()), + dummy, dummy_buf, uploads: Mutex::new(Uploads::default()), + pool: Default::default(), + staging: Default::default(), + transfers: Default::default(), + zeros: Default::default(), adv3d: std::sync::OnceLock::new(), particles: Default::default(), particle_states: Default::default(), @@ -300,7 +458,13 @@ impl GpuContext { let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })) .ok()?; - device.on_uncaptured_error(Arc::new(|e| log::error!("wgpu: {e}"))); + device.on_uncaptured_error(Arc::new(|e| { + // Under test a validation error (a WGSL typo invalidates every kernel) fails loudly. + if cfg!(test) { + panic!("wgpu: {e}"); + } + log::error!("wgpu: {e}") + })); GpuContext::new(&adapter, device, queue).map_err(|e| log::info!("gpu: {e}")).ok() } @@ -313,9 +477,13 @@ impl GpuContext { w > 0 && h > 0 && w <= self.max_dim && h <= self.max_dim } - /// A new (zeroed) working texture. + /// A new (zeroed) texture of its own (not pooled). pub(crate) fn image(&self, w: u32, h: u32) -> GpuImage { - let texture = self.device.create_texture(&wgpu::TextureDescriptor { + GpuImage::new(self.texture(w, h), w, h) + } + + fn texture(&self, w: u32, h: u32) -> wgpu::Texture { + self.device.create_texture(&wgpu::TextureDescriptor { label: None, size: wgpu::Extent3d { width: w.max(1), height: h.max(1), depth_or_array_layers: 1 }, mip_level_count: 1, @@ -324,8 +492,35 @@ impl GpuContext { format: FORMAT, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, view_formats: &[], - }); - GpuImage { texture, width: w, height: h } + }) + } + + /// A pooled working texture: (image, reused). A reused texture holds old pixels; a new + /// one is zeroed. + fn pooled(&self, w: u32, h: u32) -> (GpuImage, bool) { + let (w, h) = (w.max(1), h.max(1)); + let reused = self.pool.lock().ok().and_then(|mut p| p.take(w, h)); + let was_reused = reused.is_some(); + let texture = reused.unwrap_or_else(|| self.texture(w, h)); + let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool) }); + (GpuImage { texture, width: w, height: h, _lease: Some(lease) }, was_reused) + } + + /// Uploads and readbacks so far (images; uploads of cached layers count once). + pub fn transfer_stats(&self) -> TransferStats { + use std::sync::atomic::Ordering::Relaxed; + let t = &self.transfers; + TransferStats { + uploads: t.ups.load(Relaxed), + upload_bytes: t.up_bytes.load(Relaxed), + readbacks: t.downs.load(Relaxed), + readback_bytes: t.down_bytes.load(Relaxed), + } + } + + /// Texture pool counters: (working textures reused, newly allocated). + pub fn pool_stats(&self) -> (u64, u64) { + self.pool.lock().map(|p| (p.hits, p.misses)).unwrap_or_default() } /// Upload a CPU image (`None` when it does not fit the device). @@ -333,6 +528,7 @@ impl GpuContext { if !self.fits(img.width, img.height) { return None; } + self.transfers.count(true, img.data.len() * 16); let g = self.image(img.width, img.height); self.queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, @@ -390,16 +586,34 @@ impl GpuContext { u.map.clear(); u.bytes = 0; } + if let Ok(mut p) = self.pool.lock() { + p.clear(); + } + if let Ok(mut s) = self.staging.lock() { + s.clear(); + } } } /// Records GPU work for one render (a command encoder submitted at readback or the end). pub(crate) struct Enc<'g> { pub g: &'g GpuContext, - enc: Option, + /// The open command encoder and its id in the texture pool. + enc: Option<(wgpu::CommandEncoder, u64)>, pending: usize, } +impl Drop for Enc<'_> { + fn drop(&mut self) { + // Unsubmitted work is discarded, so nothing recorded will read pooled textures. + if let Some((_, id)) = self.enc.take() + && let Ok(mut p) = self.g.pool.lock() + { + p.close(id); + } + } +} + impl<'g> Enc<'g> { pub fn new(g: &'g GpuContext) -> Enc<'g> { Enc { g, enc: None, pending: 0 } @@ -407,12 +621,50 @@ impl<'g> Enc<'g> { fn encoder(&mut self) -> &mut wgpu::CommandEncoder { let g = self.g; - self.enc.get_or_insert_with(|| g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("effectcraft") })) + &mut self + .enc + .get_or_insert_with(|| { + let id = g.pool.lock().map(|mut p| p.open()).unwrap_or(0); + (g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("effectcraft") }), id) + }) + .0 } - /// A new zeroed image. - pub fn image(&self, w: u32, h: u32) -> GpuImage { - self.g.image(w, h) + /// A new zeroed image (pooled; a reused texture is cleared by a kernel). + pub fn image(&mut self, w: u32, h: u32) -> GpuImage { + let (img, reused) = self.g.pooled(w, h); + if reused { + self.fill(&img, 0.0); + } + img + } + + /// A new image for a kernel that writes every pixel of it (pooled; contents undefined). + /// Under test it starts out as NaNs, so a kernel that skips pixels fails the CPU oracle. + pub fn scratch(&mut self, w: u32, h: u32) -> GpuImage { + let (img, _) = self.g.pooled(w, h); + if cfg!(test) { + self.fill(&img, f32::NAN); + } + img + } + + /// A transparent image to read from (shared per size; never written). + pub fn zeros(&self, w: u32, h: u32) -> GpuImage { + let (w, h) = (w.max(1), h.max(1)); + let Ok(mut z) = self.g.zeros.lock() else { return self.g.image(w, h) }; + if z.len() > 8 && !z.contains_key(&(w, h)) { + z.clear(); + } + z.entry((w, h)).or_insert_with(|| self.g.image(w, h)).clone() + } + + /// Set every pixel of `img` to `v`. + fn fill(&mut self, img: &GpuImage, v: f32) { + let mut p = Params::default(); + p.f[0] = [v; 4]; + let dummy = GpuImage::new(self.g.dummy.clone(), 1, 1); + self.pixels("fill", &p, &dummy, None, img, None); } /// Run kernel `entry` writing `out` (dispatched over `groups` workgroups). @@ -509,37 +761,46 @@ impl<'g> Enc<'g> { /// Submit recorded work. pub fn submit(&mut self) { - if let Some(enc) = self.enc.take() { + if let Some((enc, id)) = self.enc.take() { self.g.queue.submit([enc.finish()]); + if let Ok(mut p) = self.g.pool.lock() { + p.close(id); + } } self.pending = 0; } /// Read an image back to the CPU (submits and waits). `None` where readback is impossible. pub fn download(&mut self, img: &GpuImage) -> Option { - let bytes = self.read_texture(&img.texture, img.width, img.height, 16)?; let mut out = Image::new(img.width, img.height); - for (p, c) in out.data.iter_mut().zip(bytes.chunks_exact(16)) { - for (k, v) in p.iter_mut().enumerate() { - *v = f32::from_le_bytes([c[4 * k], c[4 * k + 1], c[4 * k + 2], c[4 * k + 3]]); - } - } + let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut out.data); + self.read_texture_into(&img.texture, img.width, img.height, 16, dst)?; Some(out) } /// Tightly packed texel bytes of a texture (`bpp` bytes per pixel). pub fn read_texture(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32) -> Option> { + let mut out = vec![0u8; (w * bpp) as usize * h as usize]; + self.read_texture_into(texture, w, h, bpp, &mut out)?; + Some(out) + } + + /// [`Enc::read_texture`] into `out` (`w × bpp × h` bytes), through a reused staging buffer. + fn read_texture_into(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32, out: &mut [u8]) -> Option<()> { if !self.g.can_readback() { return None; } - let row = w * bpp; - let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + self.g.transfers.count(false, out.len()); + let row = (w * bpp) as usize; + let padded = (w * bpp).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let size = padded as u64 * h as u64; - let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("readback"), - size, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, + let buf = self.g.staging.lock().ok().and_then(|mut s| s.get_mut(&size).and_then(Vec::pop)).unwrap_or_else(|| { + self.g.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) }); self.encoder().copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, @@ -556,14 +817,26 @@ impl<'g> Enc<'g> { return None; } rx.recv().ok()?.ok()?; - let view = buf.get_mapped_range(..).ok()?; - let mut out = Vec::with_capacity((row * h) as usize); - for y in 0..h as usize { - out.extend_from_slice(&view[y * padded as usize..y * padded as usize + row as usize]); + { + let view = buf.get_mapped_range(..).ok()?; + if row == padded as usize { + out.copy_from_slice(&view[..out.len()]); + } else { + for (y, dst) in out.chunks_exact_mut(row).enumerate() { + let s = y * padded as usize; + dst.copy_from_slice(&view[s..s + row]); + } + } } - drop(view); buf.unmap(); - Some(out) + // Keep a few staging buffers per size (frames read back at the same size every time). + if let Ok(mut s) = self.g.staging.lock() { + let v = s.entry(size).or_default(); + if v.len() < 2 && size <= 256 << 20 { + v.push(buf); + } + } + Some(()) } /// [`Enc::read_texture`] without waiting for the GPU (the browser's main thread can't): `done` diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 0b97bc7a..ba59580a 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -131,6 +131,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ if crate::fx_stylize::IDS.contains(&id) => crate::fx_stylize::apply(e, id, ctx, b), _ 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), _ => pointwise(e, id, ctx, b), } } @@ -162,7 +163,7 @@ pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize] let (lines, n) = if vertical { (cur.width, cur.height) } else { (cur.height, cur.width) }; let mut p = Params::default(); p.u[0] = [r as u32, repeat as u32, block, 0]; - let out = e.image(cur.width, cur.height); + let out = e.scratch(cur.width, cur.height); e.dispatch(if vertical { "box_v" } else { "box_h" }, &p, &cur, None, &out, None, (lines.div_ceil(64), n.div_ceil(block))); cur = out; } @@ -225,7 +226,7 @@ fn directional(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let n = (len.ceil() as usize * 2 + 1).clamp(3, 257); let mut p = Params::default(); p.f[0] = [a.sin() as f32, -a.cos() as f32, len as f32, n as f32]; - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("directional", &p, &b.img, None, &out, None); b.img = out; Some(b) @@ -258,7 +259,7 @@ fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let mut p = Params::default(); p.u[0][0] = ctx.params.e("operation").min(2); p.f[0][0] = intensity; - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("glow_combine", &p, &b.img, Some(&blurred), &out, None); b.img = out; Some(b) @@ -285,7 +286,7 @@ fn drop_shadow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { sh = gaussian_blur(e, &sh, soft / 2.0, soft / 2.0, false); } if !only { - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("shadow_combine", &Params::default(), &sh, Some(&b.img), &out, None); sh = out; } @@ -338,7 +339,7 @@ fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.u[0][0] = 8; p.f[0] = [offs[0], offs[1], offs[2], offs[3]]; p.f[1][0] = offs[4]; - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("pointwise", &p, &b.img, None, &out, Some(&buf)); Some(GBuf { img: out, ..b }) } @@ -464,7 +465,7 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { } _ => return None, } - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("pointwise", &p, &b.img, None, &out, None); Some(GBuf { img: out, ..b }) } diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index f411e97e..6a1a1660 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -143,7 +143,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { } _ => return None, } - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("fxc_point", &p, &b.img, None, &out, data.as_ref()); Some(GBuf { img: out, ..b }) } @@ -198,7 +198,7 @@ fn lumetri(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { ]); debug_assert_eq!(d.len(), 50); let buf = e.data(&d); - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("fxc_lumetri", &Params::default(), &b.img, None, &out, Some(&buf)); let sharpen = f("creative/adjustments/sharpen"); if sharpen == 0.0 { @@ -227,7 +227,7 @@ fn morph(e: &mut Enc, img: &GpuImage, r: u32, modes: [u32; 4]) -> GpuImage { let mut p = Params::default(); p.u[0] = [r, vertical, 0, 0]; p.u[1] = modes; - let out = e.image(cur.width, cur.height); + let out = e.scratch(cur.width, cur.height); e.pixels("fxc_morph", &p, &cur, None, &out, None); cur = out; } @@ -248,7 +248,7 @@ pub(crate) fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; let b = morph(e, img, r0 + 1, modes); let mut p = Params::default(); p.f[0][0] = t; - let out = e.image(img.width, img.height); + let out = e.scratch(img.width, img.height); e.pixels("fxc_lerp", &p, &a, Some(&b), &out, None); out } @@ -328,7 +328,7 @@ fn key_light(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { let mut m = raw.clone(); if rollback > 0.0 { let sg_img = morph_frac(e, &m, rollback, [0, 1, 2, 2]); - let out = e.image(w, h); + let out = e.scratch(w, h); e.pixels("fxc_kl_rollback", &Params::default(), &sg_img, Some(&raw), &out, None); m = out; } @@ -367,7 +367,7 @@ fn key_light(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { } let mut p = Params::default(); p.f[0][0] = hard; - let out = e.image(w, h); + let out = e.scratch(w, h); e.pixels("fxc_kl_edge", &p, &planes, Some(&band), &out, None); planes = out; } @@ -390,7 +390,7 @@ fn key_light(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.f[5] = ["sourceCrops/cropLeft", "sourceCrops/cropRight", "sourceCrops/cropTop", "sourceCrops/cropBottom"].map(|k| (pr.f(k) / 100.0) as f32); p.f[6] = [b.offset[0] as f32, b.offset[1] as f32, b.scale as f32, 0.0]; p.f[7] = [ctx.layer_size[0].max(1e-9) as f32, ctx.layer_size[1].max(1e-9) as f32, 0.0, 0.0]; - let out = e.image(w, h); + let out = e.scratch(w, h); e.pixels("fxc_kl_final", &p, &b.img, Some(&planes), &out, None); Some(GBuf { img: out, ..b }) } diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index bcd7214f..15d13bdc 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -66,7 +66,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option) -> Option { - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels(entry, p, &b.img, None, &out, data); b.img = out; Some(b) @@ -267,7 +267,7 @@ fn displacement_map(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { p.u[1] = [w as u32, h as u32, 0, 0]; p.f[0] = [mh as f32, mv as f32, 0.0, 0.0]; let buf = e.data(&data); - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("dst_displace", &p, &b.img, map.as_ref(), &out, Some(&buf)); b.img = out; Some(b) @@ -300,7 +300,12 @@ fn mesh_warp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { srcp.push(b.to_px([gi as f64 / nx as f64 * lw, gj as f64 / ny as f64 * lh])); } } - // distort2::grid_warp: triangles in order, bucketed per output row. + grid_warp(e, b, nx, ny, &dest, &srcp) +} + +/// distort2::grid_warp: grid point `i` of the (nx+1)×(ny+1) grid at `dest[i]` shows the source at +/// `srcp[i]` (Mesh Warp, Bezier Warp). Triangles in order, bucketed per output row. +pub(crate) fn grid_warp(e: &mut Enc, b: GBuf, nx: usize, ny: usize, dest: &[(f64, f64)], srcp: &[(f64, f64)]) -> Option { let idx = |i: usize, j: usize| j * (nx + 1) + i; let hh = b.img.height as usize; let mut tri_data: Vec = Vec::with_capacity(nx * ny * 24); @@ -390,7 +395,7 @@ fn mosaic(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { e.pixels("dst_mosaic_rows", &p, &b.img, None, &rows, Some(&buf)); let tiles = e.image(nx as u32, ny as u32); e.pixels("dst_mosaic_tiles", &p, &rows, None, &tiles, Some(&buf)); - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("dst_mosaic_out", &p, &tiles, None, &out, Some(&buf)); b.img = out; Some(b) diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 976bfc8e..1917f399 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -71,7 +71,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option, data: Option<&[f32]>) -> Option { let buf = data.map(|d| e.data(d)); - let out = e.image(b.img.width, b.img.height); + 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 }) } @@ -149,7 +149,7 @@ fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { 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.image(w, h); + let out = e.scratch(w, h); e.pixels("gen_lens_pre", &p, &src, None, &out, None); src = out; } @@ -172,7 +172,7 @@ fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { 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.image(w, h); + let out = e.scratch(w, h); e.pixels("gen_lens_blur", &p, &src, pre.as_ref(), &out, Some(&buf)); b.img = out; Some(b) @@ -275,7 +275,7 @@ fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { 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.image(b.img.width, b.img.height); + 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 }) } diff --git a/crates/gpu/src/fx_key.rs b/crates/gpu/src/fx_key.rs index 8aedbd3d..b3a3ecfe 100644 --- a/crates/gpu/src/fx_key.rs +++ b/crates/gpu/src/fx_key.rs @@ -71,7 +71,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option, data: Option<&wgpu::Buffer>) -> GpuImage { - let out = e.image(src.width, src.height); + let out = e.scratch(src.width, src.height); e.pixels("fxk_point", p, src, aux, &out, data); out } @@ -105,7 +105,7 @@ fn fitted(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str, masks_and_effects: b data.extend((0..h).map(|y| (((y as f64 + 0.5 - b.offset[1]) * inv * sy + dy) * o.buf.scale + o.buf.offset[1]) as f32)); let src = e.g.upload_image(&o.buf.img)?; let buf = e.data(&data); - let out = e.image(w, h); + let out = e.scratch(w, h); e.pixels("fxk_fit", &Params::default(), &src, None, &out, Some(&buf)); Some(Some(out)) } @@ -272,7 +272,7 @@ fn advanced_spill(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { let mut cur = b.img.clone(); let mut first = true; while first || cur.width > 1 || cur.height > 1 { - let out = e.image(cur.width.div_ceil(16), cur.height.div_ceil(16)); + let out = e.scratch(cur.width.div_ceil(16), cur.height.div_ceil(16)); let mut rp = Params::default(); rp.u[0][0] = first as u32; e.pixels("fxk_reduce", &rp, &cur, None, &out, None); diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs index eaa7324a..a0055e4f 100644 --- a/crates/gpu/src/fx_noise.rs +++ b/crates/gpu/src/fx_noise.rs @@ -2,7 +2,8 @@ //! `fxn_`): the CPU effects' exact steps with the pixel loops as compute kernels. //! //! Median, Median (Legacy) and Dust & Scratches take the median of the 512-level quantised -//! channels by bisection over the window (radius ≤ [`MEDIAN_MAX_R`] on the GPU). Fractal and +//! channels: by bisection over the window up to radius [`MEDIAN_BISECT_R`], above that with the +//! CPU's sliding 512-bin histogram per channel (one invocation per row segment). Fractal and //! Turbulent Noise resolve every per-octave term on the CPU in the CPU's f32 order //! (`effectcraft_effects::fractal_gpu`) and evaluate the pixels on the GPU. Echo and Posterize //! Time fetch the layer's other frames through the effect host like the CPU does, then @@ -21,6 +22,7 @@ use crate::effects::{GBuf, box_passes, gaussian_blur}; pub(crate) const KERNELS: &[&str] = &[ "fxn_quant", "fxn_median", + "fxn_median_huang", "fxn_median_out", "fxn_edge", "fxn_smart_edges", @@ -66,8 +68,10 @@ pub(crate) const IDS: &[&str] = &[ "ec.time.posterizetime", ]; -/// Largest median radius (buffer pixels) the GPU takes; larger ones render on the CPU. -pub(crate) const MEDIAN_MAX_R: usize = 16; +/// Largest median radius (buffer pixels) taken by bisection over the window; larger radii +/// slide a histogram along each row (`fxn_median_huang`), whose cost grows with the radius, +/// not its square. +pub(crate) const MEDIAN_BISECT_R: usize = 4; /// 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 { @@ -99,7 +103,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option, data: Option<&wgpu::Buffer>) -> GpuImage { - let out = e.image(src.width, src.height); + let out = e.scratch(src.width, src.height); e.pixels(entry, p, src, aux, &out, data); out } @@ -152,7 +156,15 @@ pub(crate) fn fractal(e: &mut Enc, ctx: &EffectCtx, b: GBuf, turbulent: bool) -> /// noise::median_image (of the straight colour with alpha 1 when `straight`). fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool) -> GpuImage { let q = run(e, "fxn_quant", &with_u0([straight as u32, 0, 0, 0]), img, None, None); - run(e, "fxn_median", &with_u0([r as u32, 0, 0, 0]), &q, None, None) + if r <= MEDIAN_BISECT_R { + return run(e, "fxn_median", &with_u0([r as u32, 0, 0, 0]), &q, None, None); + } + // Segments long enough that filling the first window (2r + 1)² costs about as much as + // sliding along the rest. + let seg = (4 * r as u32).clamp(32, 512).min(q.width); + let out = e.scratch(q.width, q.height); + e.dispatch("fxn_median_huang", &with_u0([r as u32, seg, 0, 0]), &q, None, &out, None, (q.height.div_ceil(64), q.width.div_ceil(seg))); + out } fn median(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { @@ -160,9 +172,6 @@ fn median(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { if r == 0 { return Some(b); } - if r > MEDIAN_MAX_R { - return None; - } let (mode, on_alpha, straight) = match id { "ec.noise.medianlegacy" => { let on = ctx.params.b("operateOnAlphaChannel"); @@ -251,7 +260,7 @@ fn gf(e: &mut Enc, mode: u32, s: &GpuImage, a: &GpuImage, d: Option<&GpuImage>, } /// util::guided_filter on every channel at once (guide `g`, input `p`). -fn guided_filter(e: &mut Enc, g: &GpuImage, p: &GpuImage, r: usize, eps: f32) -> GpuImage { +pub(crate) fn guided_filter(e: &mut Enc, g: &GpuImage, p: &GpuImage, r: usize, eps: f32) -> GpuImage { let bx = |e: &mut Enc, img: &GpuImage| box_passes(e, img, &[r], &[r], true); let mean_g = bx(e, g); let mean_p = bx(e, p); @@ -443,7 +452,7 @@ fn compound_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let buf = e.data(&data); let mut p = with_u0([1, invert as u32, 0, 0]); p.u[1][0] = w as u32; - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("fxn_cb_map", &p, &layer, None, &out, Some(&buf)); out } @@ -461,7 +470,7 @@ fn compound_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { }) .collect(); let (rows, row) = e.image_rows(&map); - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); for k in 0..N - 1 { e.pixels("fxn_cb_level", &with_u0([k as u32, row, 0, 0]), &levels[k], Some(&levels[k + 1]), &out, Some(&rows)); } @@ -531,7 +540,7 @@ fn minimax(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { planes = morph(e, &planes, rx, ry, modes(max)); } if !dont_shrink { - let out = e.image(w, h); + let out = e.scratch(w, h); e.pixels("fxn_crop", &with_u0([rx as u32, ry as u32, 0, 0]), &planes, None, &out, None); planes = out; } diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index 6e83c7c8..421015f5 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -117,7 +117,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option) -> Option { - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels(entry, p, &b.img, None, &out, data); b.img = out; Some(b) @@ -486,11 +486,7 @@ fn ripple_pulse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { } fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { - // Perspective below 100 % (a bilinear inverse by Newton iterations with finite - // differences) renders on the CPU. - if ctx.params.f("perspective") / 100.0 < 1.0 { - return None; - } + let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); @@ -522,6 +518,13 @@ fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { p.f[4] = r[2]; p.f[5] = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; p.f[6] = [sc as f32, off[0] as f32, off[1] as f32, 0.0]; + if persp < 1.0 { + let rel = |i: usize| [(q[i].x - q[0].x) as f32, (q[i].y - q[0].y) as f32]; + let (q1, q2, q3) = (rel(1), rel(2), rel(3)); + p.f[7] = [q[0].x as f32, q[0].y as f32, persp as f32, 1.0]; + p.f[8] = [q1[0], q1[1], q2[0], q2[1]]; + p.f[9] = [q3[0], q3[1], 0.0, 0.0]; + } }, b, None, @@ -642,7 +645,7 @@ fn motion_tile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0] = [phase as f32, (lw * s) as f32, (lh * s) as f32, 0.0]; p.f[1] = [b.offset[0] as f32, b.offset[1] as f32, 0.0, 0.0]; let buf = e.data(&data); - let out = e.image(nw, nh); + let out = e.scratch(nw, nh); e.pixels("fxs_motiontile", &p, &b.img, None, &out, Some(&buf)); Some(GBuf { img: out, offset: noff, scale: s }) } @@ -676,7 +679,7 @@ fn repetile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.u[0] = [nw, ctx.params.e("tiling"), 0, 0]; p.f[0] = [x0 as f32, y0 as f32, w as f32, h as f32]; let buf = e.data(&data); - let out = e.image(nw, nh); + let out = e.scratch(nw, nh); e.pixels("fxs_repetile", &p, &b.img, None, &out, Some(&buf)); Some(GBuf { img: out, offset: [l, u], scale: s }) } @@ -814,7 +817,7 @@ fn roughen_edges(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { p.f[1] = [fm, evo, c, evo_w]; p.f[2] = [oct + extra, infl, im, sharp]; p.f[3] = [ec[0], ec[1], ec[2], sm]; - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("fxs_roughen", &p, &b.img, Some(&d), &out, None); b.img = out; Some(b) @@ -855,7 +858,7 @@ fn texturize(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let hgt = gaussian_blur(e, &luma, s, s, true); let mut p = Params::default(); p.f[0] = [ang.cos() as f32, -ang.sin() as f32, k, 0.0]; - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("fxs_texturize", &p, &b.img, Some(&hgt), &out, None); b.img = out; Some(b) @@ -937,7 +940,7 @@ pub(crate) fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let glow_op = effectcraft_effects::glow_operation(ctx); let mut p = Params::default(); p.f[0][0] = intensity; - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); if operation == 0 && glow_op != BlendMode::Add { p.u[0][0] = ops::mode_id(glow_op); e.pixels("fxs_glow_op", &p, &b.img, Some(&blurred), &out, None); @@ -995,7 +998,7 @@ pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option Option { (pr.f("blend") / 100.0).clamp(0.0, 1.0) as f32, 0.0, ]; - let out = e.image(w, h); + let out = e.scratch(w, h); e.pixels("fxt_point", &p, &b.img, Some(&bases), &out, None); let Some((lo0, hi0)) = clip0 else { return Some(GBuf { img: out, ..b }) }; let adjusted = e.download(&out)?; @@ -158,7 +158,7 @@ fn op(code: u32) -> Params { fn run(e: &mut Enc, p: &Params, b: GBuf, data: Option>) -> Option { let buf = data.map(|d| e.data(&d)); - let out = e.image(b.img.width, b.img.height); + let out = e.scratch(b.img.width, b.img.height); e.pixels("fxt_point", p, &b.img, None, &out, buf.as_ref()); Some(GBuf { img: out, ..b }) } diff --git a/crates/gpu/src/fx_warp.rs b/crates/gpu/src/fx_warp.rs new file mode 100644 index 00000000..0bea6bc1 --- /dev/null +++ b/crates/gpu/src/fx_warp.rs @@ -0,0 +1,275 @@ +//! GPU effects, warp family (kernels in `shaders/fx_warp.wgsl`, every entry point prefixed +//! `fxw_`): Warp, Bezier Warp, CC Bend It, CC Page Turn, Smear, Reshape, Color Emboss and +//! Cartoon, with the CPU effects' exact steps. +//! +//! Scalar set-up runs on the CPU in f64 exactly as the CPU effect does it (Bezier Warp's grid, +//! Smear's moved outline, Reshape's outline correspondence, Bend It's arc geometry) and goes to +//! the kernels as parameters or tables; the kernels do the per-pixel work in f32. Warp inverts +//! its forward map per pixel with the CPU's Newton iterations, relative to the warp centre so +//! f32 keeps sub-pixel precision; Bend It's arc distance is evaluated in a cancellation-free +//! form so large radii (small bends) keep their precision too. + +use effectcraft_effects::{Buf, EffectCtx, Image}; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_warp.wgsl`. +pub(crate) const KERNELS: &[&str] = &[ + "fxw_warp", + "fxw_bendit", + "fxw_pageturn", + "fxw_smear", + "fxw_reshape", + "fxw_coloremboss", + "fxw_luma", + "fxw_keep_alpha", + "fxw_shock", + "fxw_edges", + "fxw_cartoon", +]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.distort.warp", + "ec.distort.bezierwarp", + "ec.distort.ccbendit", + "ec.distort.ccpageturn", + "ec.distort.smear", + "ec.distort.reshape", + "ec.stylize.coloremboss", + "ec.stylize.cartoon", +]; + +/// 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.distort.warp" => warp(e, ctx, b), + "ec.distort.bezierwarp" => bezier_warp(e, ctx, b), + "ec.distort.ccbendit" => bend_it(e, ctx, b), + "ec.distort.ccpageturn" => page_turn(e, ctx, b), + "ec.distort.smear" => smear(e, ctx, b), + "ec.distort.reshape" => reshape(e, ctx, b), + "ec.stylize.coloremboss" => color_emboss(e, ctx, b), + "ec.stylize.cartoon" => cartoon(e, ctx, b), + _ => None, + } +} + +/// A same-size per-pixel kernel. +fn run(e: &mut Enc, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.scratch(src.width, src.height); + e.pixels(entry, p, src, aux, &out, data); + out +} + +fn f4(v: [f64; 4]) -> [f32; 4] { + v.map(|x| x as f32) +} + +/// The buffer's geometry without its pixels (the CPU set-up functions read only that). +fn geometry(b: &GBuf) -> Buf { + Buf { img: Image::new(0, 0), offset: b.offset, scale: b.scale } +} + +/// Points as f32 pairs. +fn points(v: &mut Vec, pts: &[[f64; 2]]) { + v.extend(pts.iter().flat_map(|q| [q[0] as f32, q[1] as f32])); +} + +// ---------------------------------------------------------------- Warp (distort3::warp) + +fn warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let k = ctx.params.f("bend") / 100.0; + let hd = ctx.params.f("horizontalDistortion") / 100.0; + let vd = ctx.params.f("verticalDistortion") / 100.0; + if k == 0.0 && hd == 0.0 && vd == 0.0 { + return Some(b); + } + let style = ctx.params.e("warpStyle"); + let vertical = ctx.params.e("warpAxis") == 1; + let (hx, hy) = (ctx.layer_size[0] * b.scale * 0.5, ctx.layer_size[1] * b.scale * 0.5); + if !ctx.adjustment { + let pad = ((k.abs() + hd.abs() + vd.abs()) * 0.6 * hx.max(hy)).ceil().min(4096.0); + b.pad(e, pad as u32 + 1)?; + } + let c = b.to_px([ctx.layer_size[0] * 0.5, ctx.layer_size[1] * 0.5]); + let mut p = Params::default(); + p.u[0] = [0, style.min(14), vertical as u32, 0]; + p.f[0] = f4([c.0, c.1, hx.max(1e-6), hy.max(1e-6)]); + p.f[1] = f4([k, hd, vd, 0.0]); + b.img = run(e, "fxw_warp", &p, &b.img, None, None); + Some(b) +} + +// ---------------------------------------------------------------- Bezier Warp (distort2::bezier_warp) + +fn bezier_warp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let (n, dest, srcp) = effectcraft_effects::bezier_grid(ctx, b.offset, b.scale); + crate::fx_distort::grid_warp(e, b, n, n, &dest, &srcp) +} + +// ---------------------------------------------------------------- CC Bend It (distort2::bend_it) + +fn bend_it(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let mut beta = ctx.params.f("bend").to_radians(); + if beta.abs() < 1e-6 { + return Some(b); + } + let s = b.to_px(ctx.params.v2("start")); + let end = b.to_px(ctx.params.v2("end")); + let static_pre = ctx.params.e("renderPrestart") == 1; + let l = ((end.0 - s.0).powi(2) + (end.1 - s.1).powi(2)).sqrt(); + if l < 1e-6 { + return Some(b); + } + let a = ((end.0 - s.0) / l, (end.1 - s.1) / l); + let mut n = (-a.1, a.0); + if beta < 0.0 { + beta = -beta; + n = (-n.0, -n.1); + } + let rho = l / beta; + let pe = (rho * beta.sin(), rho - rho * beta.cos()); + let mut p = Params::default(); + p.u[0][1] = static_pre as u32; + p.f[0] = f4([s.0, s.1, a.0, a.1]); + p.f[1] = f4([n.0, n.1, rho, beta]); + p.f[2] = f4([pe.0, pe.1, l, 0.0]); + p.f[3] = f4([beta.cos(), beta.sin(), -beta.sin(), beta.cos()]); + b.img = run(e, "fxw_bendit", &p, &b.img, None, None); + Some(b) +} + +// ---------------------------------------------------------------- CC Page Turn (distort2::page_turn) + +fn page_turn(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let f = b.to_px(ctx.params.v2("foldPosition")); + let phi = ctx.params.f("foldDirection").to_radians(); + let r = (ctx.params.f("foldRadius") * b.scale).max(0.5); + let lam = ctx.params.f("lightDirection").to_radians(); + let render = ctx.params.e("render"); + let bo = ctx.params.f("backPageOpacity") / 100.0; + let paper = ctx.params.color("paperColor"); + let n = (-phi.sin(), phi.cos()); + let ldot = n.0 * lam.sin() - n.1 * lam.cos(); + let mut p = Params::default(); + p.u[0] = [0, (render != 1) as u32, (render != 2) as u32, 0]; + p.f[0] = f4([f.0, f.1, n.0, n.1]); + p.f[1] = [r as f32, ldot as f32, bo as f32, 0.0]; + p.f[2] = [paper[0], paper[1], paper[2], 0.0]; + b.img = run(e, "fxw_pageturn", &p, &b.img, None, None); + Some(b) +} + +// ---------------------------------------------------------------- Smear (distort3::smear) + +fn smear(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let Some(s) = effectcraft_effects::smear_setup(ctx, &geometry(&b)) else { return Some(b) }; + let mut data = Vec::with_capacity(2 * (s.bound.len() + s.moved.len())); + points(&mut data, &s.bound); + points(&mut data, &s.moved); + let buf = e.data(&data); + let mut p = Params::default(); + p.u[0] = [0, s.bound.len() as u32, s.moved.len() as u32, 0]; + p.f[0] = f4([s.c[0], s.c[1], s.cs, s.sn]); + p.f[1] = f4([s.scl, s.off[0], s.off[1], s.pct]); + p.f[2][0] = (1.0 / s.kexp) as f32; + b.img = run(e, "fxw_smear", &p, &b.img, None, Some(&buf)); + Some(b) +} + +// ---------------------------------------------------------------- Reshape (distort3::reshape) + +fn reshape(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let Some(s) = effectcraft_effects::reshape_setup(ctx, &geometry(&b)) else { return Some(b) }; + debug_assert_eq!(s.dest.len(), effectcraft_effects::RESHAPE_POINTS); + let mut data = Vec::new(); + points(&mut data, &s.dest); + points(&mut data, &s.disp); + let nb = s.bound.as_ref().map_or(0, Vec::len); + if let Some(bd) = &s.bound { + points(&mut data, bd); + } + let buf = e.data(&data); + let mut p = Params::default(); + p.u[0] = [0, nb as u32, s.smooth as u32, 0]; + p.f[0] = [(s.kexp * 0.5) as f32, s.pct as f32, 0.0, 0.0]; + b.img = run(e, "fxw_reshape", &p, &b.img, None, Some(&buf)); + Some(b) +} + +// ---------------------------------------------------------------- Color Emboss (stylize2::color_emboss) + +fn color_emboss(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let ang = ctx.params.f("direction").to_radians(); + let relief = ctx.params.f("relief") * b.scale; + let mut p = Params::default(); + p.f[0] = [(ang.cos() * relief) as f32, (-ang.sin() * relief) as f32, ctx.params.f("contrast") as f32 / 100.0, ctx.params.f("blend") as f32 / 100.0]; + b.img = run(e, "fxw_coloremboss", &p, &b.img, None, None); + Some(b) +} + +// ---------------------------------------------------------------- Cartoon (stylize2::cartoon) + +fn cartoon(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let pr = ctx.params; + let render = pr.e("render"); + let r = (pr.f("detailRadius") * b.scale).round().max(0.0) as usize; + let thr = pr.f("detailThreshold") as f32 / 100.0; + let steps = pr.f("fill/shadingSteps").max(1.0) as f32; + let smooth = pr.f("fill/shadingSmoothness") as f32 / 100.0; + let et = pr.f("edge/edgeThreshold") as f32; + let ew = pr.f("edge/edgeWidth") * b.scale; + let es = pr.f("edge/edgeSoftness") as f32 / 100.0; + let eo = pr.f("edge/edgeOpacity") as f32 / 100.0; + let black = (pr.f("advanced/edgeBlackLevel") as f32 / 100.0).clamp(0.0, 1.0); + let contrast = ((pr.f("advanced/edgeContrast").clamp(0.0, 0.999) * std::f64::consts::FRAC_PI_2).tan() as f32).min(100.0); + let none = Params::default(); + // Edge-preserving smoothing: guided filter on each premultiplied colour channel, guided by + // the luma; alpha stays. + let mut smoothed = if r > 0 { + let guide = run(e, "fxw_luma", &none, &b.img, None, None); + let eps = thr * thr * 0.25 + 1e-5; + let f = crate::fx_noise::guided_filter(e, &guide, &b.img, r, eps); + run(e, "fxw_keep_alpha", &none, &b.img, Some(&f), None) + } else { + b.img.clone() + }; + let enh = pr.f("advanced/edgeEnhancement") / 100.0; + if enh < 0.0 { + let s = 1.5 * b.scale; + let blur = gaussian_blur(e, &smoothed, s, s, true); + let mut p = Params::default(); + p.f[0][0] = (-enh) as f32; + smoothed = run(e, "fxc_lerp", &p, &smoothed, Some(&blur), None); + } else if enh > 0.0 { + let l = run(e, "fxw_luma", &none, &smoothed, None, None); + let mut p = Params::default(); + p.f[0][0] = (enh * 2.0 * b.scale) as f32; + smoothed = run(e, "fxw_shock", &p, &smoothed, Some(&l), None); + } + let mut p = Params::default(); + let mut edge_k = eo; + let rows = if render >= 1 { + let luma = run(e, "fxw_luma", &none, &smoothed, None, None); + let t1 = et * 0.2; + let t0 = t1 * (1.0 - es * 0.9); + let mut ep = Params::default(); + ep.f[0] = [t0, t1.max(t0 + 1e-4), 0.0, 0.0]; + let mut edges = run(e, "fxw_edges", &ep, &luma, None, None); + if ew > 1.0 { + edges = crate::fx_color::morph_frac(e, &edges, (ew - 1.0) * 0.5, crate::fx_color::DILATE_X); + } else { + edge_k *= ew.max(0.0) as f32; + } + Some(e.image_rows(&edges)) + } else { + None + }; + p.u[0] = [0, render, rows.as_ref().map_or(0, |r| r.1), rows.is_some() as u32]; + p.f[0] = [steps, smooth, edge_k, black]; + p.f[1][0] = contrast; + b.img = run(e, "fxw_cartoon", &p, &b.img, Some(&smoothed), rows.as_ref().map(|r| &r.0)); + Some(b) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 559393bc..11a2ca1d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -42,13 +42,14 @@ mod fx_key; mod fx_noise; mod fx_stylize; mod fx_tone; +mod fx_warp; mod ops; mod particles; mod walk; use std::sync::Arc; -pub use context::{GpuContext, GpuImage}; +pub use context::{GpuContext, GpuImage, TransferStats}; use effectcraft_effects::Buf; use effectcraft_project::ItemId; use effectcraft_raster::Image; @@ -62,6 +63,8 @@ use crate::context::Enc; #[derive(Clone)] pub struct Gpu { ctx: Arc, + /// Backend::Auto's per-comp CPU / GPU timings. + auto: Arc, } /// A viewer frame left on the GPU: premultiplied RGBA8 (`wgpu::TextureFormat::Rgba8Unorm`), @@ -86,7 +89,7 @@ impl Gpu { } pub fn from_context(ctx: GpuContext) -> Gpu { - Gpu { ctx: Arc::new(ctx) } + Gpu { ctx: Arc::new(ctx), auto: Default::default() } } pub fn context(&self) -> &GpuContext { @@ -158,6 +161,10 @@ impl Accelerator for Gpu { fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim) } + + fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> { + Some(&self.auto) + } } impl effectcraft_effects::psim::ParticleSim for Gpu { @@ -189,4 +196,6 @@ mod tests_fx_stylize; #[cfg(test)] mod tests_fx_tone; #[cfg(test)] +mod tests_fx_warp; +#[cfg(test)] mod tests_particles; diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index 42bd9f37..8e476408 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -27,7 +27,7 @@ fn rows(m: &Mat3) -> [[f32; 4]; 3] { /// Box-filter halving (minification pre-filter). pub fn half(e: &mut Enc, src: &GpuImage) -> GpuImage { - let out = e.image(src.width.div_ceil(2).max(1), src.height.div_ceil(2).max(1)); + let out = e.scratch(src.width.div_ceil(2).max(1), src.height.div_ceil(2).max(1)); e.pixels("half", &Params::default(), src, None, &out, None); out } @@ -81,7 +81,7 @@ pub fn warp( p.f[2] = r[2]; p.f[3] = [opacity, accumulate.unwrap_or(1.0), 0.0, 0.0]; p.f[4] = [x0 as f32, y0 as f32, x1 as f32, y1 as f32]; - let out = e.image(dst.width, dst.height); + let out = e.scratch(dst.width, dst.height); e.pixels("warp_blend", &p, &img, Some(dst), &out, None); out } @@ -91,7 +91,7 @@ pub fn blend_full(e: &mut Enc, dst: &GpuImage, src: &GpuImage, mode: BlendMode, let mut p = Params::default(); p.u[0] = [mode_id(mode), 0, seed, 0]; p.f[0] = [opacity, 0.0, 0.0, 0.0]; - let out = e.image(dst.width, dst.height); + let out = e.scratch(dst.width, dst.height); e.pixels("blend_full", &p, src, Some(dst), &out, None); out } @@ -105,21 +105,21 @@ pub fn matte(e: &mut Enc, iso: &GpuImage, matte: &GpuImage, kind: MatteKind) -> MatteKind::Luma => 2, MatteKind::LumaInverted => 3, }; - let out = e.image(iso.width, iso.height); + let out = e.scratch(iso.width, iso.height); e.pixels("matte", &p, iso, Some(matte), &out, None); out } /// Preserve Transparency: multiply by the alpha below. pub fn preserve(e: &mut Enc, iso: &GpuImage, canvas: &GpuImage) -> GpuImage { - let out = e.image(iso.width, iso.height); + let out = e.scratch(iso.width, iso.height); e.pixels("preserve", &Params::default(), iso, Some(canvas), &out, None); out } /// Knockout: clear what lies below the layer's content. pub fn knockout(e: &mut Enc, canvas: &GpuImage, shape: &GpuImage) -> GpuImage { - let out = e.image(canvas.width, canvas.height); + let out = e.scratch(canvas.width, canvas.height); e.pixels("knockout", &Params::default(), canvas, Some(shape), &out, None); out } @@ -129,7 +129,7 @@ pub fn channel_mix(e: &mut Enc, canvas: &GpuImage, tmp: &GpuImage, channels: [bo let mut p = Params::default(); p.u[0] = [channels[0] as u32, channels[1] as u32, channels[2] as u32, 0]; p.f[0][0] = opacity; - let out = e.image(canvas.width, canvas.height); + let out = e.scratch(canvas.width, canvas.height); e.pixels("channel_mix", &p, canvas, Some(tmp), &out, None); out } @@ -140,7 +140,7 @@ pub fn adjust_mix(e: &mut Enc, canvas: &GpuImage, adjusted: &GpuImage, matte: &G let mut p = Params::default(); p.u[0][0] = stride; p.f[0][0] = opacity; - let out = e.image(canvas.width, canvas.height); + let out = e.scratch(canvas.width, canvas.height); e.pixels("adjust_mix", &p, canvas, Some(adjusted), &out, Some(&rows)); out } @@ -149,7 +149,7 @@ pub fn adjust_mix(e: &mut Enc, canvas: &GpuImage, adjusted: &GpuImage, matte: &G pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage { let mut p = Params::default(); p.f[0][0] = levels; - let out = e.image(img.width, img.height); + let out = e.scratch(img.width, img.height); e.pixels("quantize", &p, img, None, &out, None); out } @@ -174,7 +174,7 @@ pub fn convert(e: &mut Enc, img: &GpuImage, c: &Conversion) -> GpuImage { p.f[i] = [m[i][0], m[i][1], m[i][2], 0.0]; } } - let out = e.image(img.width, img.height); + let out = e.scratch(img.width, img.height); e.pixels("convert", &p, img, None, &out, None); out } diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 56281158..d5f9fa9e 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -76,6 +76,68 @@ fn fxn_median(@builtin(global_invocation_id) gid: vec3) { textureStore(out, p, fxn_div4(lo, vec4(511.0))); } +// The same median for large radii, as the CPU computes it: a sliding 512-bin histogram per +// channel (Huang). One invocation per row segment of u[0].y pixels: it fills the histogram for +// the segment's first window, then slides right one column at a time (remove the column +// leaving, add the one entering) and moves each channel's median by the counts below it. +// u[0] = (r, segment length); dispatched over (rows, segments) with 64 × 1 workgroups. +@compute @workgroup_size(64, 1) +fn fxn_median_huang(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let y = i32(gid.x); + let seg = i32(P.u[0].y); + let x0 = i32(gid.y) * seg; + if (y >= dims.y || x0 >= dims.x) { + return; + } + let x1 = min(x0 + seg, dims.x); + let r = i32(P.u[0].x); + let half = u32((2 * r + 1) * (2 * r + 1) / 2); + var hist: array; + for (var dy = -r; dy <= r; dy++) { + for (var dx = -r; dx <= r; dx++) { + let v = vec4(tex_get_clamped(src, x0 + dx, y + dy)); + hist[v.x] += 1u; + hist[512u + v.y] += 1u; + hist[1024u + v.z] += 1u; + hist[1536u + v.w] += 1u; + } + } + // Per channel: the median level and the count of values below it. + var med = vec4(0u); + var lt = vec4(0u); + for (var x = x0; x < x1; x++) { + if (x > x0) { + for (var dy = -r; dy <= r; dy++) { + let o = vec4(tex_get_clamped(src, x - 1 - r, y + dy)); + let n = vec4(tex_get_clamped(src, x + r, y + dy)); + for (var c = 0u; c < 4u; c++) { + hist[c * 512u + o[c]] -= 1u; + if (o[c] < med[c]) { + lt[c] -= 1u; + } + hist[c * 512u + n[c]] += 1u; + if (n[c] < med[c]) { + lt[c] += 1u; + } + } + } + } + for (var c = 0u; c < 4u; c++) { + let base = c * 512u; + while (lt[c] > half) { + med[c] -= 1u; + lt[c] -= hist[base + med[c]]; + } + while (lt[c] + hist[base + med[c]] <= half) { + lt[c] += hist[base + med[c]]; + med[c] += 1u; + } + } + textureStore(out, vec2(x, y), fxn_div4(vec4(med), vec4(511.0))); + } +} + // noise::median_px. fn fxn_median_px(o: vec4, m: vec4, on_alpha: bool) -> vec4 { if (on_alpha) { diff --git a/crates/gpu/src/shaders/fx_stylize.wgsl b/crates/gpu/src/shaders/fx_stylize.wgsl index bc119e8c..d39f0f7a 100644 --- a/crates/gpu/src/shaders/fx_stylize.wgsl +++ b/crates/gpu/src/shaders/fx_stylize.wgsl @@ -423,6 +423,36 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3) { u = u / w; v = v / w; } + // Perspective below 100 %: blend toward the bilinear (non-perspective) inverse, found + // by Newton iterations from the projective guess as on the CPU; positions relative to + // the first corner keep f32's precision. f[7] = (corner 0, perspective, on); + // f[8] = (corner 1, corner 2) and f[9].xy = corner 3, relative to corner 0. + if (P.f[7].w != 0.0) { + let q1 = P.f[8].xy; + let q2 = P.f[8].zw; + let q3 = P.f[9].xy; + let k = q2 - q3 - q1; + let t = vec2(x, y) - P.f[7].xy; + var bu = u; + var bv = v; + for (var i = 0; i < 8; i++) { + let f = q1 * bu + q3 * bv + k * (bu * bv) - t; + if (abs(f.x) + abs(f.y) < 1e-4) { + break; + } + let ju = q1 + k * bv; + let jv = q3 + k * bu; + let det = ju.x * jv.y - jv.x * ju.y; + if (abs(det) < 1e-12) { + break; + } + bu -= (jv.y * f.x - jv.x * f.y) / det; + bv -= (-ju.y * f.x + ju.x * f.y) / det; + } + let persp = P.f[7].z; + u = bu + (u - bu) * persp; + v = bv + (v - bv) * persp; + } if (u < -1e-9 || u > 1.0 + 1e-9 || v < -1e-9 || v > 1.0 + 1e-9) { textureStore(out, p, vec4(0.0)); return; diff --git a/crates/gpu/src/shaders/fx_warp.wgsl b/crates/gpu/src/shaders/fx_warp.wgsl new file mode 100644 index 00000000..4a6a27e1 --- /dev/null +++ b/crates/gpu/src/shaders/fx_warp.wgsl @@ -0,0 +1,532 @@ +// GPU effects (warp family): see src/fx_warp.rs. Warp, CC Bend It, CC Page Turn, Smear, Reshape, +// Color Emboss and Cartoon, each mirroring the CPU effect in effectcraft-effects operation for +// operation. Coordinates are pixel centres (x + 0.5, y + 0.5) in buffer pixels, as in +// util::remap; scalar set-up (radii, angles, outlines, correspondence) is computed on the CPU in +// f64 and passed in. + +const FXW_PI: f32 = 3.1415927; + +fn fxw_centre(p: vec2) -> vec2 { + return vec2(f32(p.x) + 0.5, f32(p.y) + 0.5); +} + +fn fxw_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +// util::unpremul / premul. +fn fxw_unpremul(p: vec4) -> vec3 { + if (p.w > 1e-6) { + return p.xyz / p.w; + } + return vec3(0.0); +} + +fn fxw_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); +} + +// ---------------------------------------------------------------- Warp (distort3::warp) +// u[0] = (_, style, vertical); f[0] = (cx, cy, hx, hy); f[1] = (bend, horizontal, vertical +// distortion). Positions are relative to the centre. + +fn fxw_style(style: u32, k: f32, u: f32, v: f32) -> vec2 { + let bell = 1.0 - u * u; + switch style { + case 0u: { + return vec2(u * (1.0 - 0.3 * k * v), v - k * bell); + } + case 1u: { + return vec2(u, v - k * bell * (v + 1.0) * 0.5); + } + case 2u: { + return vec2(u, v - k * bell * (1.0 - v) * 0.5); + } + case 3u: { + return vec2(u, v - k * bell); + } + case 4u: { + return vec2(u, v * (1.0 + k * bell)); + } + case 5u: { + return vec2(u, v + k * bell * bell * (v + 1.0) * 0.5); + } + case 6u: { + return vec2(u, v - k * bell * bell * (1.0 - v) * 0.5); + } + case 7u: { + return vec2(u, v - 0.5 * k * sin(FXW_PI * u)); + } + case 8u: { + return vec2(u, v - 0.5 * k * sin(FXW_PI * u + 0.5 * FXW_PI * v)); + } + case 9u: { + return vec2(u, v * (1.0 + 0.5 * k * sin(FXW_PI * u))); + } + case 10u: { + return vec2(u, v - 0.5 * k * sin(0.5 * FXW_PI * u)); + } + case 11u: { + let r2 = u * u + v * v; + if (r2 >= 1.0) { + return vec2(u, v); + } + let f = 1.0 + k * (1.0 - r2); + return vec2(u * f, v * f); + } + case 12u: { + return vec2(u * (1.0 + 0.5 * k * (1.0 - v * v)), v * (1.0 + 0.5 * k * bell)); + } + case 13u: { + return vec2(u * (1.0 - 0.5 * k * (1.0 - v * v)), v * (1.0 + 0.5 * k * bell)); + } + default: { + let r = sqrt(u * u + v * v); + if (r >= 1.0) { + return vec2(u, v); + } + let a = k * FXW_PI * (1.0 - r); + let s = sin(a); + let c = cos(a); + return vec2(u * c - v * s, u * s + v * c); + } + } +} + +fn fxw_warp_fwd(q: vec2) -> vec2 { + let h = P.f[0].zw; + let k = P.f[1].x; + let hd = P.f[1].y; + let vd = P.f[1].z; + let vertical = P.u[0].z != 0u; + let n = q / h; + var uv = n; + if (vertical) { + uv = n.yx; + } + let w = fxw_style(P.u[0].y, k, uv.x, uv.y); + var o = w; + if (vertical) { + o = w.yx; + } + o.y *= 1.0 + 0.5 * hd * o.x; + o.x *= 1.0 + 0.5 * vd * o.y; + return o * h; +} + +@compute @workgroup_size(16, 16) +fn fxw_warp(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let c = P.f[0].xy; + let t = fxw_centre(p) - c; + var s = t; + for (var i = 0; i < 12; i++) { + let f = fxw_warp_fwd(s); + let r = f - t; + // The CPU stops at 1e-7 px in f64; f32 cannot get there, and iterating on past + // convergence can hop across a fold (Twist, Fisheye edges), so stop at 1e-4 px. + if (abs(r.x) + abs(r.y) < 1e-4) { + break; + } + let e = 0.01; + let a = fxw_warp_fwd(s + vec2(e, 0.0)); + let b = fxw_warp_fwd(s + vec2(0.0, e)); + let j00 = (a.x - f.x) / e; + let j10 = (a.y - f.y) / e; + let j01 = (b.x - f.x) / e; + let j11 = (b.y - f.y) / e; + let det = j00 * j11 - j01 * j10; + if (abs(det) < 1e-9) { + textureStore(out, p, vec4(0.0)); + return; + } + s.x -= (j11 * r.x - j01 * r.y) / det; + s.y -= (-j10 * r.x + j00 * r.y) / det; + } + let f = fxw_warp_fwd(s); + if (abs(f.x - t.x) + abs(f.y - t.y) > 0.5) { + textureStore(out, p, vec4(0.0)); + return; + } + textureStore(out, p, sample_bilinear(src, s.x + c.x, s.y + c.y)); +} + +// ---------------------------------------------------------------- CC Bend It (distort2::bend_it) +// u[0].y = static prestart; f[0] = (start x, y, axis a x, y); f[1] = (normal n x, y, rho, beta); +// f[2] = (end of arc pe x, y, length, _); f[3] = (t x, y, nn x, y). + +@compute @workgroup_size(16, 16) +fn fxw_bendit(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let s = P.f[0].xy; + let a = P.f[0].zw; + let n = P.f[1].xy; + let rho = P.f[1].z; + let beta = P.f[1].w; + let pe = P.f[2].xy; + let l = P.f[2].z; + let t = P.f[3].xy; + let nn = P.f[3].zw; + let v = fxw_centre(p) - s; + let lx = dot(v, a); + let ly = dot(v, n); + // Arc: rho - |(lx, ly - rho)| without cancellation for large radii. + let dy = ly - rho; + let th = atan2(lx, -dy); + let dd = lx * lx + ly * ly - 2.0 * ly * rho; + let w_arc = -dd / (rho + sqrt(max(rho * rho + dd, 0.0))); + var cand: array, 3>; + cand[0] = vec3(rho * th, w_arc, select(0.0, 1.0, th >= 0.0 && th <= beta)); + let e = vec2(lx, ly) - pe; + let u2 = dot(e, t); + cand[1] = vec3(l + u2, dot(e, nn), select(0.0, 1.0, u2 >= 0.0)); + cand[2] = vec3(lx, ly, select(0.0, 1.0, lx < 0.0 && P.u[0].y != 0u)); + for (var i = 0; i < 3; i++) { + let c = cand[i]; + if (c.z == 0.0) { + continue; + } + let q = s + a * c.x + n * c.y; + let px = sample_bilinear(src, q.x, q.y); + if (px.w > 1e-4) { + textureStore(out, p, px); + return; + } + } + textureStore(out, p, vec4(0.0)); +} + +// ---------------------------------------------------------------- CC Page Turn (distort2::page_turn) +// u[0] = (_, show front, show back); f[0] = (fold x, y, n x, y); f[1] = (radius, light dot, +// back opacity, _); f[2] = paper colour. + +@compute @workgroup_size(16, 16) +fn fxw_pageturn(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let q = fxw_centre(p); + let f = P.f[0].xy; + let n = P.f[0].zw; + let r = P.f[1].x; + let ldot = P.f[1].y; + let bo = P.f[1].z; + let paper = P.f[2].xyz; + let show_front = P.u[0].y != 0u; + let show_back = P.u[0].z != 0u; + let d = dot(q - f, n); + if (d > r) { + textureStore(out, p, vec4(0.0)); + return; + } + // (u, back side, shade, valid) from the top layer down. + var cand: array, 4>; + if (d <= 0.0) { + cand[0] = vec4(FXW_PI * r - d, 1.0, 0.9, 1.0); + cand[3] = vec4(d, 0.0, 1.0, 1.0); + } else { + let a = asin(clamp(d / r, -1.0, 1.0)); + cand[1] = vec4(FXW_PI * r - r * a, 1.0, 0.6 + 0.4 * cos(a), 1.0); + let psi = a / (0.5 * FXW_PI); + cand[2] = vec4(r * a, 0.0, 1.0 - 0.4 * psi * (0.5 + 0.5 * ldot), 1.0); + } + for (var i = 0; i < 4; i++) { + let c = cand[i]; + if (c.w == 0.0) { + continue; + } + let back = c.y != 0.0; + if ((back && !show_back) || (!back && !show_front)) { + continue; + } + let k = c.x - d; + let v = sample_bilinear(src, q.x + k * n.x, q.y + k * n.y); + if (v.w <= 1e-4) { + continue; + } + var col = fxw_unpremul(v); + if (back) { + col = paper * (1.0 - bo) + col * bo; + } + textureStore(out, p, vec4(col * c.z * v.w, v.w)); + return; + } + textureStore(out, p, vec4(0.0)); +} + +// ---------------------------------------------------------------- polygons (util) +// A polygon of `n` points at data[base..base + 2n]. + +fn fxw_pt(base: u32, i: u32) -> vec2 { + return vec2(data[base + 2u * i], data[base + 2u * i + 1u]); +} + +fn fxw_in_poly(base: u32, n: u32, x: f32, y: f32) -> bool { + if (n < 3u) { + return false; + } + var inside = false; + var j = n - 1u; + for (var i = 0u; i < n; i++) { + let a = fxw_pt(base, i); + let b = fxw_pt(base, j); + if ((a.y > y) != (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) { + inside = !inside; + } + j = i; + } + return inside; +} + +// Distance to a closed polyline. +fn fxw_dist_poly(base: u32, n: u32, q: vec2) -> f32 { + if (n == 0u) { + return 3.0e38; + } + if (n == 1u) { + return length(fxw_pt(base, 0u) - q); + } + var best = 3.0e38; + for (var i = 0u; i < n; i++) { + let a = fxw_pt(base, i); + let b = fxw_pt(base, (i + 1u) % n); + let d = b - a; + let l2 = dot(d, d); + var t = 0.0; + if (l2 > 0.0) { + t = clamp(dot(q - a, d) / l2, 0.0, 1.0); + } + let e = a + d * t - q; + best = min(best, dot(e, e)); + } + return sqrt(best); +} + +// ---------------------------------------------------------------- Smear (distort3::smear) +// data = boundary outline, moved source outline; u[0] = (_, boundary points, moved points); +// f[0] = (centroid x, y, cos, sin); f[1] = (scale, offset x, y, percent); f[2].x = 1 / elasticity. + +@compute @workgroup_size(16, 16) +fn fxw_smear(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let q = fxw_centre(p); + let nb = P.u[0].y; + let nm = P.u[0].z; + if (!fxw_in_poly(0u, nb, q.x, q.y)) { + textureStore(out, p, textureLoad(src, p, 0)); + return; + } + var w = 1.0; + if (!fxw_in_poly(2u * nb, nm, q.x, q.y)) { + let dm = fxw_dist_poly(2u * nb, nm, q); + let db = fxw_dist_poly(0u, nb, q); + w = pow(db / max(db + dm, 1e-9), P.f[2].x); + } + let c = P.f[0].xy; + let cs = P.f[0].z; + let sn = P.f[0].w; + let scl = P.f[1].x; + let o = P.f[1].yz; + let d = q - o - c; + let iv = vec2(c.x + (d.x * cs + d.y * sn) / scl, c.y + (-d.x * sn + d.y * cs) / scl); + let k = clamp(w * P.f[1].w, 0.0, 1.0); + let s = q + (iv - q) * k; + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Reshape (distort3::reshape) +// data = 48 destination points, their 48 displacements, then the boundary outline; +// u[0] = (_, boundary points (0 = none), smooth); f[0] = (elasticity / 2, percent). + +@compute @workgroup_size(16, 16) +fn fxw_reshape(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let q = fxw_centre(p); + let nb = P.u[0].y; + let bbase = 4u * 48u; + if (nb > 0u && !fxw_in_poly(bbase, nb, q.x, q.y)) { + textureStore(out, p, textureLoad(src, p, 0)); + return; + } + var wv = vec2(0.0); + var ws = 0.0; + var dmin = 3.0e38; + for (var i = 0u; i < 48u; i++) { + let e = q - fxw_pt(0u, i); + let d2 = dot(e, e); + dmin = min(dmin, d2); + let w = 1.0 / pow(d2 + 1e-6, P.f[0].x); + wv += w * fxw_pt(96u, i); + ws += w; + } + var fade = 1.0; + if (nb > 0u) { + let db = fxw_dist_poly(bbase, nb, q); + fade = db / max(db + sqrt(dmin), 1e-9); + if (P.u[0].z != 0u) { + fade = fade * fade * (3.0 - 2.0 * fade); + } + } + let k = fade * P.f[0].y / ws; + let s = q + wv * k; + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Color Emboss (stylize2::color_emboss) +// f[0] = (dx, dy, contrast, blend). + +@compute @workgroup_size(16, 16) +fn fxw_coloremboss(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + let q = fxw_centre(p); + let d = P.f[0].xy; + let c = fxw_unpremul(px); + let pa = fxw_unpremul(sample_bilinear_clamped(src, q.x + d.x, q.y + d.y)); + let pb = fxw_unpremul(sample_bilinear_clamped(src, q.x - d.x, q.y - d.y)); + let e = max(c + (pa - pb) * P.f[0].z, vec3(0.0)); + let o = e + (c - e) * P.f[0].w; + textureStore(out, p, vec4(o * px.w, px.w)); +} + +// ---------------------------------------------------------------- Cartoon (stylize2::cartoon) + +// Plane::luma in every channel (the guided filter's guide). +@compute @workgroup_size(16, 16) +fn fxw_luma(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let l = luminance(fxw_unpremul(textureLoad(src, p, 0))); + textureStore(out, p, vec4(l)); +} + +// Colour from aux (the filtered channels), alpha from src. +@compute @workgroup_size(16, 16) +fn fxw_keep_alpha(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + textureStore(out, p, vec4(textureLoad(aux, p, 0).xyz, textureLoad(src, p, 0).w)); +} + +// Edge Enhancement > 0: one shock-filter step. src = the smoothed picture, aux = its luma (x); +// f[0].x = step length. +@compute @workgroup_size(16, 16) +fn fxw_shock(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let g0 = tex_get_clamped(aux, p.x, p.y).x; + let gr = tex_get_clamped(aux, p.x + 1, p.y).x; + let gl = tex_get_clamped(aux, p.x - 1, p.y).x; + let gd = tex_get_clamped(aux, p.x, p.y + 1).x; + let gu = tex_get_clamped(aux, p.x, p.y - 1).x; + let gx = (gr - gl) * 0.5; + let gy = (gd - gu) * 0.5; + let lap = gr + gl + gd + gu - 4.0 * g0; + let m = sqrt(gx * gx + gy * gy); + if (m < 1e-4 || abs(lap) < 1e-5) { + textureStore(out, p, textureLoad(src, p, 0)); + return; + } + let k = -sign(lap) * P.f[0].x / m; + let q = fxw_centre(p); + textureStore(out, p, sample_bilinear_clamped(src, q.x + gx * k, q.y + gy * k)); +} + +// Sobel magnitude of the luma (src.x) through the edge threshold: f[0] = (t0, t1). +@compute @workgroup_size(16, 16) +fn fxw_edges(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let a = tex_get_clamped(src, p.x - 1, p.y - 1).x; + let b = tex_get_clamped(src, p.x, p.y - 1).x; + let c = tex_get_clamped(src, p.x + 1, p.y - 1).x; + let d = tex_get_clamped(src, p.x - 1, p.y).x; + let f = tex_get_clamped(src, p.x + 1, p.y).x; + let g = tex_get_clamped(src, p.x - 1, p.y + 1).x; + let h = tex_get_clamped(src, p.x, p.y + 1).x; + let i = tex_get_clamped(src, p.x + 1, p.y + 1).x; + let gx = c + 2.0 * f + i - a - 2.0 * d - g; + let gy = g + 2.0 * h + i - a - 2.0 * b - c; + let m = sqrt(gx * gx + gy * gy); + textureStore(out, p, vec4(fxw_smoothstep(P.f[0].x, P.f[0].y, m), 0.0, 0.0, 0.0)); +} + +// The cartoon fill and edges. src = the layer, aux = the smoothed picture, data = the edge +// plane's rows (x); u[0] = (_, render, edge row stride, has edges); f[0] = (steps, smoothness, +// edge opacity × width factor, edge black level); f[1].x = edge contrast. +@compute @workgroup_size(16, 16) +fn fxw_cartoon(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxw_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let render = P.u[0].y; + let c = fxw_unpremul(textureLoad(aux, p, 0)); + let steps = P.f[0].x; + let smoothness = P.f[0].y; + let l = luminance(c); + let t = max(l, 0.0) * steps; + let fr = t - floor(t); + var f2 = 0.0; + if (smoothness <= 0.0) { + f2 = select(0.0, 1.0, fr >= 0.5); + } else { + f2 = fxw_smoothstep(0.5 - smoothness * 0.5, 0.5 + smoothness * 0.5, fr); + } + let lq = (floor(t) + f2) / steps; + var fill = vec3(lq); + if (l > 1e-4) { + fill = c * lq / l; + } + var e = 0.0; + if (P.u[0].w != 0u) { + e = data[(u32(p.y) * P.u[0].z + u32(p.x)) * 4u] * P.f[0].z; + } + let g = clamp((abs((1.0 - e) - P.f[0].w) - 0.5) * P.f[1].x + 0.5, 0.0, 1.0); + var o = fill; + if (render == 1u) { + o = vec3(g); + } else if (render > 1u) { + o = fill * g; + } + textureStore(out, p, vec4(o * a, a)); +} diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index f471a86b..c66e2497 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -959,3 +959,16 @@ fn bokeh_gather(@builtin(global_invocation_id) gid: vec3) { } textureStore(out, p, acc); } + +// ---------------------------------------------------------------- pool + +// Set every pixel to f[0] (clearing reused working textures; NaN poison under test). +@compute @workgroup_size(16, 16) +fn fill(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + textureStore(out, p, P.f[0]); +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 7a3a1712..b30658cf 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -156,8 +156,15 @@ pub(crate) fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { let mut d = Diff { max: 0.0, over: 0, total: cpu.data.len(), worst: (0, 0, [0.0; 4], [0.0; 4]), quantized: false }; for (i, (a, b)) in cpu.data.iter().zip(&gpu.data).enumerate() { // Absolute below 1, relative above (32 bpc over-range values, e.g. Divide by ~0). - let m = (0..4).map(|c| (a[c] - b[c]).abs() / a[c].abs().max(1.0)).fold(0.0f32, f32::max); - let m = if m.is_nan() { f32::INFINITY } else { m }; + // A NaN on one side only (e.g. a kernel that skipped pixels of a NaN-poisoned scratch + // image) is the largest difference. + let m = (0..4) + .map(|c| match (a[c].is_nan(), b[c].is_nan()) { + (true, true) => 0.0, + (false, false) => (a[c] - b[c]).abs() / a[c].abs().max(1.0), + _ => f32::INFINITY, + }) + .fold(0.0f32, f32::max); if m > tol { d.over += 1; } @@ -511,7 +518,7 @@ fn effect_chains_and_mixed_stacks() { let mut l = s.footage(80, 50); s.effect(&mut l, "ec.color.exposure", &[("master/exposure", n(0.7))]); s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(5.0))]); - s.effect(&mut l, "ec.stylize.coloremboss", &[]); + s.effect(&mut l, "ec.stylize.ccplastic", &[]); s.effect(&mut l, "ec.stylize.glow", &[]); s.effect(&mut l, "ec.channel.invert", &[("blend", n(50.0))]); s.push(l); diff --git a/crates/gpu/src/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs index bc6be0cf..8b8cbf11 100644 --- a/crates/gpu/src/tests_adjust.rs +++ b/crates/gpu/src/tests_adjust.rs @@ -57,7 +57,7 @@ fn gpu_adjustment_chains_match_the_cpu() { #[test] fn mixed_adjustment_chains_fall_back_per_effect() { let mut stack = gpu_stack(); - stack.insert(1, ("ec.stylize.coloremboss", vec![])); + stack.insert(1, ("ec.stylize.ccplastic", vec![])); stack.push(("ec.stylize.glow", vec![("threshold", n(30.0))])); for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { let s = adjustment_scene(depth, &stack, true); @@ -93,7 +93,7 @@ impl FxTarget for Probe { fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() { let Some(g) = gpu() else { return }; let mut stack = gpu_stack(); - stack.insert(1, ("ec.stylize.coloremboss", vec![])); + stack.insert(1, ("ec.stylize.ccplastic", vec![])); let s = adjustment_scene(BitDepth::Bpc32, &stack, false); let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); r.accel = Some(g); diff --git a/crates/gpu/src/tests_fx_noise.rs b/crates/gpu/src/tests_fx_noise.rs index 70f893e8..50d84780 100644 --- a/crates/gpu/src/tests_fx_noise.rs +++ b/crates/gpu/src/tests_fx_noise.rs @@ -32,6 +32,17 @@ fn medians() { effect_case("ec.noise.dustscratches", &[("radius", n(2.0)), ("threshold", n(0.0)), ("operateOnAlpha", on())]); } +#[test] +fn large_radius_medians() { + // Past the bisection radius: the sliding-histogram kernel (windows larger than the layer). + effect_case("ec.noise.median", &[("radius", n(9.0))]); + effect_case("ec.noise.median", &[("radius", n(24.0)), ("operateOnAlpha", on())]); + effect_case("ec.noise.medianlegacy", &[("radius", n(17.0))]); + effect_case("ec.noise.medianlegacy", &[("radius", n(40.0)), ("operateOnAlphaChannel", on())]); + effect_case("ec.noise.dustscratches", &[("radius", n(20.0)), ("threshold", n(10.0))]); + effect_case("ec.noise.dustscratches", &[("radius", n(60.0)), ("threshold", n(0.0)), ("operateOnAlpha", on())]); +} + #[test] fn edge_preserving_blurs() { effect_case("ec.blur.bilateral", &[]); diff --git a/crates/gpu/src/tests_fx_stylize.rs b/crates/gpu/src/tests_fx_stylize.rs index 35e3e99b..44e156ae 100644 --- a/crates/gpu/src/tests_fx_stylize.rs +++ b/crates/gpu/src/tests_fx_stylize.rs @@ -149,6 +149,9 @@ fn twirl_legacy_ripple_pulse_power_pin() { effect_case("ec.distort.ccripplepulse", &[("pulseLevel", n(-60.0)), ("amplitude", n(150.0)), ("renderBump", on()), ("center", pt(25.0, 20.0))]); effect_case("ec.distort.ccpowerpin", &[("topLeft", pt(5.0, 3.0)), ("bottomRight", pt(62.0, 38.0))]); effect_case("ec.distort.ccpowerpin", &[("topRight", pt(80.0, -6.0)), ("bottomLeft", pt(-4.0, 50.0)), ("expandLeft", n(20.0)), ("expandBottom", n(-10.0))]); + // Perspective below 100 %: blended toward the bilinear inverse. + effect_case("ec.distort.ccpowerpin", &[("topRight", pt(80.0, -6.0)), ("bottomLeft", pt(-4.0, 50.0)), ("perspective", n(0.0))]); + effect_case("ec.distort.ccpowerpin", &[("topLeft", pt(12.0, 6.0)), ("bottomRight", pt(75.0, 30.0)), ("perspective", n(40.0)), ("expandTop", n(15.0))]); } #[test] diff --git a/crates/gpu/src/tests_fx_warp.rs b/crates/gpu/src/tests_fx_warp.rs new file mode 100644 index 00000000..6cf936fd --- /dev/null +++ b/crates/gpu/src/tests_fx_warp.rs @@ -0,0 +1,191 @@ +//! GPU warp family effects (Warp, Bezier Warp, CC Bend It, CC Page Turn, Smear, Reshape, Color +//! Emboss, Cartoon) 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_effects::MaskShape; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, MaskMode}; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, diff, effect_case, gpu, n, opts, pattern}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn warp_styles() { + for style in 0..15 { + effect_case("ec.distort.warp", &[("warpStyle", e(style)), ("bend", n(45.0))]); + } + effect_case("ec.distort.warp", &[("warpStyle", e(8)), ("warpAxis", e(1)), ("bend", n(-60.0)), ("horizontalDistortion", n(20.0))]); + effect_case("ec.distort.warp", &[("warpStyle", e(4)), ("bend", n(80.0)), ("verticalDistortion", n(-35.0))]); + effect_case("ec.distort.warp", &[("warpStyle", e(11)), ("bend", n(-50.0))]); + effect_case("ec.distort.warp", &[("warpStyle", e(14)), ("bend", n(-35.0))]); + // Fisheye / Twist with a distortion render on the CPU (see catalog::gpu_supported). + let spec = effectcraft_effects::find("ec.distort.warp").unwrap(); + let mut params = effectcraft_effects::Params { + values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, [70.0, 44.0]))).collect(), + }; + params.values.insert("warpStyle".into(), e(14)); + params.values.insert("verticalDistortion".into(), n(-35.0)); + let ctx = effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: [70.0, 44.0], seed: 0, adjustment: false, env: Default::default() }; + assert!(!effectcraft_effects::catalog::gpu_supported("ec.distort.warp", &ctx)); + effect_case("ec.distort.warp", &[("warpStyle", e(3)), ("bend", n(0.0)), ("horizontalDistortion", n(-40.0)), ("verticalDistortion", n(25.0))]); +} + +#[test] +fn bezier_warp() { + effect_case("ec.distort.bezierwarp", &[]); + effect_case( + "ec.distort.bezierwarp", + &[("topLeftVertex", pt(8.0, -4.0)), ("topLeftTangent", pt(30.0, 12.0)), ("rightBottomTangent", pt(80.0, 30.0)), ("leftBottomVertex", pt(-6.0, 50.0))], + ); + effect_case("ec.distort.bezierwarp", &[("topRightTangent", pt(40.0, -20.0)), ("bottomLeftTangent", pt(20.0, 60.0)), ("quality", n(3.0))]); +} + +#[test] +fn bend_it_and_page_turn() { + effect_case("ec.distort.ccbendit", &[("bend", n(45.0))]); + effect_case("ec.distort.ccbendit", &[("bend", n(-120.0)), ("start", pt(10.0, 30.0)), ("end", pt(55.0, 8.0)), ("renderPrestart", e(1))]); + // A slight bend: a large arc radius. + effect_case("ec.distort.ccbendit", &[("bend", n(0.05)), ("start", pt(5.0, 22.0)), ("end", pt(60.0, 22.0))]); + effect_case("ec.distort.ccpageturn", &[]); + effect_case("ec.distort.ccpageturn", &[("foldRadius", n(4.0)), ("foldDirection", n(30.0)), ("foldPosition", pt(40.0, 20.0))]); + effect_case( + "ec.distort.ccpageturn", + &[("render", e(2)), ("backPageOpacity", n(40.0)), ("paperColor", c(0.9, 0.85, 0.7)), ("lightDirection", n(120.0)), ("foldPosition", pt(30.0, 25.0))], + ); + effect_case("ec.distort.ccpageturn", &[("render", e(1)), ("foldPosition", pt(50.0, 10.0))]); +} + +#[test] +fn color_emboss_and_cartoon() { + effect_case("ec.stylize.coloremboss", &[]); + effect_case("ec.stylize.coloremboss", &[("direction", n(30.0)), ("relief", n(3.5)), ("contrast", n(250.0)), ("blend", n(30.0))]); + effect_case("ec.stylize.cartoon", &[]); + effect_case("ec.stylize.cartoon", &[("render", e(0)), ("detailRadius", n(4.0)), ("fill/shadingSteps", n(5.0)), ("fill/shadingSmoothness", n(0.0))]); + effect_case("ec.stylize.cartoon", &[("render", e(1)), ("edge/edgeWidth", n(0.6)), ("advanced/edgeBlackLevel", n(40.0)), ("advanced/edgeContrast", n(0.8))]); + effect_case("ec.stylize.cartoon", &[("edge/edgeWidth", n(3.3)), ("advanced/edgeEnhancement", n(60.0)), ("edge/edgeSoftness", n(20.0))]); + effect_case("ec.stylize.cartoon", &[("advanced/edgeEnhancement", n(-50.0)), ("detailRadius", n(0.0))]); +} + +/// Mask outlines in layer pixels (70 × 44 layers): a source blob, a destination blob and a +/// boundary around both. +fn outlines() -> Vec { + let ellipse = |cx: f64, cy: f64, rx: f64, ry: f64| -> Vec<[f64; 2]> { + (0..40).map(|i| i as f64 / 40.0 * std::f64::consts::TAU).map(|a| [cx + rx * a.cos(), cy + ry * a.sin()]).collect() + }; + let shape = |name: &str, points| MaskShape { name: name.into(), points, closed: true, inverted: false }; + vec![shape("Mask 1", ellipse(25.0, 20.0, 9.0, 7.0)), shape("Mask 2", ellipse(42.0, 24.0, 12.0, 6.0)), shape("Mask 3", ellipse(34.0, 22.0, 30.0, 19.0))] +} + +/// The effect alone on a buffer with masks (`effect_direct` with the layer's masks). +fn direct_with_masks(id: &str, vals: &[(&str, Value)]) { + let Some(g) = gpu() else { return }; + let spec = effectcraft_effects::find(id).unwrap(); + let size = [70.0, 44.0]; + let masks = outlines(); + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in vals { + params.values.insert(k.to_string(), v.clone()); + } + for adjustment in [false, true] { + for scale in [1.0, 0.5] { + let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); + let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; + let ctx = || effectcraft_effects::EffectCtx { + params: ¶ms, + time: 0.25, + layer_size: size, + seed: 11, + adjustment, + env: effectcraft_effects::EffectEnv { masks: &masks, ..Default::default() }, + }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let out = + effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry"); + assert!(cpu.img.data != buf.img.data, "{id} {vals:?}: the case changes the picture"); + let d = diff(&cpu.img, &out.img, 1.0 / 255.0); + // f32 point-in-polygon tests may flip a pixel exactly on an outline. + assert!( + d.over * 1000 <= d.total, + "{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1/255 (max {}); worst {:?}", + d.over, + d.total, + d.max, + d.worst + ); + } + } +} + +/// Composited at 8 and 32 bpc, with the masks on the layer (mode None: they only feed the effect). +fn composite_with_masks(id: &str, vals: &[(&str, Value)]) { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut l = s.footage(70, 44); + let mut next = s.p.next_id; + for m in outlines() { + let path = ShapePath { + vertices: m.points.clone(), + in_tangents: vec![[0.0; 2]; m.points.len()], + out_tangents: vec![[0.0; 2]; m.points.len()], + closed: true, + ..Default::default() + }; + let g = build::mask(&mut Ids(&mut next), &m.name, path, MaskMode::None, [255, 255, 0]); + l.props.sub_mut("masks").unwrap().children.push(g.into()); + } + s.p.next_id = next; + s.effect(&mut l, id, vals); + s.push(l); + check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.003); + check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.003); + } +} + +#[test] +fn smear_and_reshape() { + let smear: &[&[(&str, Value)]] = &[ + &[("sourceMask", e(1)), ("boundaryMask", e(3)), ("maskOffset", pt(10.0, 3.0))], + &[ + ("sourceMask", e(1)), + ("boundaryMask", e(3)), + ("maskOffset", pt(-4.0, 6.0)), + ("maskRotation", n(30.0)), + ("maskScale", n(130.0)), + ("elasticity", e(6)), + ("percent", n(70.0)), + ], + ]; + for vals in smear { + direct_with_masks("ec.distort.smear", vals); + composite_with_masks("ec.distort.smear", vals); + } + let reshape: &[&[(&str, Value)]] = &[ + &[("sourceMask", e(1)), ("destinationMask", e(2))], + &[("sourceMask", e(1)), ("destinationMask", e(2)), ("boundaryMask", e(3)), ("interpolation", e(2)), ("elasticity", e(0))], + &[ + ("sourceMask", e(1)), + ("destinationMask", e(2)), + ("boundaryMask", e(3)), + ("correspondencePoints", Value::Str("0,0.25 0.5,0.8".into())), + ("percent", n(60.0)), + ], + ]; + for vals in reshape { + direct_with_masks("ec.distort.reshape", vals); + composite_with_masks("ec.distort.reshape", vals); + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index edef7ef1..fae7572f 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -32,7 +32,7 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick if r.depth() > 16 || !e.g.fits(w, h) { return None; } - let mut canvas = e.image(w, h); + let mut canvas = e.zeros(w, h); draw_comp(e, r, &ctx, &mut canvas)?; let pipe = r.pipe(); if let Some(c) = pipe.from_blend() { @@ -127,7 +127,7 @@ fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: & return Some(()); } let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, 1.0) else { return Some(()) }; - let mut iso = e.image(canvas.width, canvas.height); + let mut iso = e.zeros(canvas.width, canvas.height); draw_comp(e, &sub, &nctx, &mut iso)?; composite_iso(e, r, ctx, layer, iso, canvas, opacity) } @@ -156,7 +156,7 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & let bl = styles::blending(ctx, layer); let (w, h) = (canvas.width, canvas.height); if r.track_matte(ctx, layer).is_some() || layer.preserve_transparency { - let mut iso = e.image(w, h); + let mut iso = e.zeros(w, h); for (b, m) in &st.passes { iso = place(e, r, ctx, layer, b, &iso, *m, 1.0)?; } @@ -165,7 +165,7 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & } let mut tmp = canvas.clone(); if bl.knockout > 0 { - let k = place(e, r, ctx, layer, &st.content, &e.image(w, h), BlendMode::Normal, 1.0)?; + let k = place(e, r, ctx, layer, &st.content, &e.zeros(w, h), BlendMode::Normal, 1.0)?; tmp = ops::knockout(e, &tmp, &k); } for (b, m) in &st.passes { @@ -183,7 +183,7 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv let opacity = r.layer_opacity(ctx, layer); let Some(foot) = r.adjustment_footprint(ctx, layer) else { return Some(()) }; let (w, h) = (canvas.width, canvas.height); - let matte = place(e, r, ctx, layer, &Arc::new(foot), &e.image(w, h), BlendMode::Normal, 1.0)?; + let matte = place(e, r, ctx, layer, &foot, &e.zeros(w, h), BlendMode::Normal, 1.0)?; let pipe = r.pipe(); // Effects run in the working space, not the linear blending space. let below = match pipe.from_blend() { @@ -210,14 +210,14 @@ fn composite_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: return Some(()); } // Render in isolation, then matte / preserve transparency, then blend. - let iso = place(e, r, ctx, layer, buf, &e.image(canvas.width, canvas.height), BlendMode::Normal, 1.0)?; + let iso = place(e, r, ctx, layer, buf, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0)?; composite_iso(e, r, ctx, layer, iso, canvas, opacity) } /// Track matte / Preserve Transparency on an isolated layer render, then blend. fn composite_iso(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, mut iso: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> { if let Some((m, kind)) = r.track_matte(ctx, layer) { - let mut mimg = e.image(canvas.width, canvas.height); + let mut mimg = e.zeros(canvas.width, canvas.height); if m.is_active_at(ctx.time) && let Some(mb) = r.layer_buf(ctx, m) { @@ -243,7 +243,7 @@ fn place(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc if let [m] = pl.matrices.as_slice() { return Some(ops::warp(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None)); } - let mut acc = e.image(target.width, target.height); + let mut acc = e.zeros(target.width, target.height); let k = 1.0 / pl.matrices.len() as f32; for m in &pl.matrices { acc = ops::warp(e, &acc, &src, m, pl.sampling, BlendMode::Normal, 1.0, 0, Some(k)); diff --git a/crates/render/src/auto.rs b/crates/render/src/auto.rs new file mode 100644 index 00000000..39b3b02e --- /dev/null +++ b/crates/render/src/auto.rs @@ -0,0 +1,144 @@ +//! [`Backend::Auto`](crate::Backend::Auto)'s per-comp choice between the CPU and the GPU +//! compositor, from measured frame times. +//! +//! A light comp (a couple of small layers) composites faster on the CPU, which only touches +//! the layers' bounds, than on the GPU, which works on whole frames and reads the result back; +//! heavy comps (many layers, 3D, GPU effects) are faster on the GPU. Neither a layer count nor +//! a pixel count predicts the crossover across machines, so Auto measures: per comp, output +//! scale and path (readback / viewer display), it keeps an exponentially weighted average of +//! the CPU and GPU frame times and renders on the faster one. Each side is warmed up first +//! (its first frame, with layer cache misses and texture uploads, is not counted; the fastest of +//! the next ones starts the average), and the slower side is re-measured every +//! [`AutoPick::REPROBE`] frames so the choice follows the comp as it changes. + +use std::collections::HashMap; +use std::sync::Mutex; + +use effectcraft_project::ItemId; + +/// Which frames share timing history: one comp at one output scale on one path. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AutoKey { + pub comp: ItemId, + /// `RenderOpts::scale` bits. + pub scale: u64, + /// The viewer's display path (GPU frames stay on the device) rather than a readback. + pub display: bool, +} + +impl AutoKey { + pub fn new(comp: ItemId, scale: f64, display: bool) -> AutoKey { + AutoKey { comp, scale: scale.to_bits(), display } + } +} + +/// Timing history of one [`AutoKey`]. +#[derive(Clone, Copy, Debug, Default)] +pub struct AutoStats { + /// Averaged ms/frame (`None` until a counted sample). + pub cpu_ms: Option, + pub gpu_ms: Option, + /// Frames measured on each side (the first is the uncounted warm-up). + pub cpu_frames: u32, + pub gpu_frames: u32, + /// Frames since the side not chosen was last measured. + pub since_probe: u32, + /// The GPU declined the last frame it was asked for (the CPU rendered it). + pub declined: bool, +} + +/// Per-comp CPU / GPU timing history for [`Backend::Auto`](crate::Backend::Auto) (held by the +/// accelerator, see [`Accelerator::auto_pick`](crate::Accelerator::auto_pick)). +#[derive(Default)] +pub struct AutoPick { + stats: Mutex>, +} + +impl AutoPick { + /// Frames measured on each side before choosing (the first is not counted). + pub const WARMUP: u32 = 3; + /// Weight of a new sample in the running average (slower / faster than the average). + pub const ALPHA: f64 = 0.3; + pub const ALPHA_DOWN: f64 = 0.5; + /// Re-measure the side not chosen after this many frames. + pub const REPROBE: u32 = 48; + /// The CPU wins only when clearly faster (ties and noise stay on the GPU). + pub const MARGIN: f64 = 0.85; + /// Keys remembered before the history is reset. + const MAX_KEYS: usize = 1024; + + /// Render the frame for `key` on the GPU? + pub fn choose(&self, key: AutoKey) -> bool { + let Ok(mut m) = self.stats.lock() else { return true }; + if m.len() > Self::MAX_KEYS && !m.contains_key(&key) { + m.clear(); + } + let s = m.entry(key).or_default(); + // Warm up both sides, alternating, GPU first. + let gpu_warm = s.declined || s.gpu_frames >= Self::WARMUP; + if !gpu_warm && s.gpu_frames <= s.cpu_frames { + return true; + } + if s.cpu_frames < Self::WARMUP { + return false; + } + let gpu_faster = match (s.gpu_ms, s.cpu_ms) { + (Some(g), Some(c)) => c >= g * Self::MARGIN, + (g, _) => g.is_some(), + }; + // Periodically re-measure the slower side. + if s.since_probe >= Self::REPROBE { + s.since_probe = 0; + return !gpu_faster; + } + s.since_probe += 1; + gpu_faster + } + + /// A frame for `key` took `ms` on the GPU (`gpu`) or the CPU. + /// + /// The warm-up keeps the fastest of its counted frames; after that the average follows + /// improvements quickly and slow-downs gradually, and a single frame counts at most twice + /// the average (a stall of the machine does not flip the choice for a whole re-probe + /// period). + pub fn record(&self, key: AutoKey, gpu: bool, ms: f64) { + let Ok(mut m) = self.stats.lock() else { return }; + let s = m.entry(key).or_default(); + let (avg, frames) = if gpu { (&mut s.gpu_ms, &mut s.gpu_frames) } else { (&mut s.cpu_ms, &mut s.cpu_frames) }; + *frames = frames.saturating_add(1); + if gpu { + s.declined = false; + } + if *frames < 2 { + return; + } + *avg = Some(match *avg { + None => ms, + Some(a) if *frames <= Self::WARMUP => a.min(ms), + Some(a) if ms < a => a + (ms - a) * Self::ALPHA_DOWN, + Some(a) => a + (ms.min(2.0 * a) - a) * Self::ALPHA, + }); + } + + /// The GPU did not handle a frame for `key` (it rendered on the CPU instead). + pub fn declined(&self, key: AutoKey) { + if let Ok(mut m) = self.stats.lock() { + let s = m.entry(key).or_default(); + s.declined = true; + s.gpu_ms = None; + s.since_probe = 0; + } + } + + /// The history of `key`. + pub fn stats(&self, key: AutoKey) -> Option { + self.stats.lock().ok()?.get(&key).copied() + } + + /// Forget all history (settings that change both paths' costs). + pub fn reset(&self) { + if let Ok(mut m) = self.stats.lock() { + m.clear(); + } + } +} diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index ac799388..734573a3 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -326,10 +326,20 @@ fn has_expression(g: &PropGroup) -> bool { }) } +/// Cache key for an adjustment layer's footprint (its source through its masks, see +/// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame. +pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option { + Some(derive(key_any(ctx, layer, scale, draft, false)?, 0xf007_9417)) +} + fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option { if layer.switches.adjustment { return None; } + key_any(ctx, layer, scale, draft, footage) +} + +fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option { let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); match &layer.source { LayerSource::Solid { item } => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 003071e1..ca708696 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ //! Adjustment layers run their effects on everything below, limited by their own bounds/masks. pub mod audio; +pub mod auto; pub mod cache; pub mod color; pub mod disk_cache; @@ -20,6 +21,7 @@ pub mod three_d; use std::sync::Arc; +pub use auto::{AutoKey, AutoPick}; pub use cache::{CacheStats, LayerCache}; use effectcraft_color::BlendMode; use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; @@ -283,6 +285,11 @@ pub trait Accelerator: Send + Sync { fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { None } + /// Timing history [`Backend::Auto`] uses to send each comp's top-level frames to the + /// faster compositor. `None` = Auto always tries the accelerator first. + fn auto_pick(&self) -> Option<&AutoPick> { + None + } } /// Where an effect stack runs (see [`Renderer::run_effects_on`]): a CPU buffer, or an image @@ -627,16 +634,43 @@ impl<'a> Renderer<'a> { /// Render a composition at comp time `t` (transparent background, comp size × scale). /// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them. pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image { - // Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well. + // Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well; + // Auto renders each comp on whichever compositor measured faster ([`AutoPick`]). if self.depth == 0 && let Some(a) = self.active_accel() - && let Some(img) = a.comp_frame(self, comp_id, t) { + let auto = self.frame_auto(a, comp_id, false); + if auto.is_none_or(|(p, key)| p.choose(key)) { + let t0 = web_time::Instant::now(); + if let Some(img) = a.comp_frame(self, comp_id, t) { + if let Some((p, key)) = auto { + p.record(key, true, t0.elapsed().as_secs_f64() * 1e3); + } + return img; + } + if let Some((p, key)) = auto { + p.declined(key); + } + } + let t0 = web_time::Instant::now(); + let img = self.comp_frame_cpu(comp_id, t); + if let Some((p, key)) = auto { + p.record(key, false, t0.elapsed().as_secs_f64() * 1e3); + } return img; } self.comp_frame_cpu(comp_id, t) } + /// The Auto timing history and key for a top-level frame of `comp` (`None` unless the + /// backend is [`Backend::Auto`] and the accelerator keeps history). + pub fn frame_auto(&self, a: &'a dyn Accelerator, comp: ItemId, display: bool) -> Option<(&'a AutoPick, AutoKey)> { + if self.opts.backend != Backend::Auto || self.opts.roi.is_some() { + return None; + } + Some((a.auto_pick()?, AutoKey::new(comp, self.opts.scale, display))) + } + /// [`Self::comp_frame`] on the CPU compositor only. pub fn comp_frame_cpu(&self, comp_id: ItemId, t: Tick) -> Image { let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) }; @@ -1580,9 +1614,20 @@ impl<'a> Renderer<'a> { /// An adjustment layer's footprint (its source with masks applied, layer space): where /// its effects show. `None` = the layer changes nothing. - pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + // Cached, so an accelerator sees the same buffer (and uploads it once) every frame. + let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft)); + if let (Some(c), Some(k)) = (self.cache, key) + && let Some(b) = c.get(k) + { + return Some(b); + } let mut foot = self.source(ctx, layer)?; let _ = masks::apply(ctx, layer, &mut foot); + let foot = Arc::new(foot); + if let (Some(c), Some(k)) = (self.cache, key) { + c.insert(k, foot.clone()); + } Some(foot) } @@ -1735,6 +1780,8 @@ pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Ima #[cfg(test)] mod tests; #[cfg(test)] +mod tests_auto; +#[cfg(test)] mod tests_remap; #[cfg(test)] mod tests_roi; diff --git a/crates/render/src/tests_auto.rs b/crates/render/src/tests_auto.rs new file mode 100644 index 00000000..c34321ce --- /dev/null +++ b/crates/render/src/tests_auto.rs @@ -0,0 +1,151 @@ +//! Backend::Auto's per-comp CPU / GPU choice (`auto.rs`), with a mock accelerator. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use effectcraft_color::Label; +use effectcraft_project::build; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Accelerator, AutoKey, AutoPick, Backend, NoFootage, RenderOpts, Renderer}; + +/// An accelerator that takes `delay` per frame (or declines with `handle = false`) and keeps +/// Auto timing history. +struct TimedAccel { + delay: std::time::Duration, + handle: bool, + calls: AtomicUsize, + pick: AutoPick, +} + +impl TimedAccel { + fn new(ms: u64, handle: bool) -> TimedAccel { + TimedAccel { delay: std::time::Duration::from_millis(ms), handle, calls: Default::default(), pick: Default::default() } + } + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +impl Accelerator for TimedAccel { + fn name(&self) -> String { + "timed".into() + } + fn comp_frame(&self, _r: &Renderer, _comp: ItemId, _t: Tick) -> Option { + self.calls.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(self.delay); + // The centre of the comp, as the CPU draws it (a red solid). + self.handle.then(|| { + let mut img = crate::Image::new(200, 100); + img.set(100, 50, [1.0, 0.0, 0.0, 1.0]); + img + }) + } + fn supports_effect(&self, _id: &str) -> bool { + false + } + fn effects(&self, _chain: &[crate::FxStep], _buf: &crate::Buf, _levels: Option) -> Option { + None + } + fn auto_pick(&self) -> Option<&AutoPick> { + Some(&self.pick) + } +} + +fn project() -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let mut comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 50, height: 50, pixel_aspect: 1.0 })); + let l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (50, 50), None); + comp.layers.push(l); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +/// Render `n` frames with `backend`; how many the accelerator was asked for. +fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) -> usize { + let before = a.calls(); + for _ in 0..n { + let mut r = Renderer::new(p, &NoFootage, RenderOpts { backend, ..Default::default() }); + r.accel = Some(a); + let img = r.comp_frame(cid, Tick::ZERO); + // Whichever compositor ran, the frame is the same. + assert!(img.get(100, 50)[0] > 0.99 && img.get(100, 50)[3] > 0.99); + } + a.calls() - before +} + +#[test] +fn auto_backend_moves_light_comps_to_the_cpu() { + // A light comp on a slow accelerator: after the warm-up (three frames on + // each side) Auto renders on the CPU, re-measuring the accelerator now and then. + let (p, cid) = project(); + // Several times the CPU's frame time (unoptimised test builds are slow). + let t0 = std::time::Instant::now(); + for _ in 0..3 { + Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + } + let cpu_ms = t0.elapsed().as_millis() as u64 / 3; + let slow = TimedAccel::new((cpu_ms * 5).max(20), true); + let n = 6 + 2 * AutoPick::REPROBE as usize; + let asked = frames(&p, cid, &slow, Backend::Auto, n); + assert!((3..=5).contains(&asked), "asked the slow accelerator {asked} times in {n} frames"); + let s = slow.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap(); + assert!(s.gpu_ms.unwrap() > s.cpu_ms.unwrap(), "{s:?}"); + // Backend::Gpu ignores the history; Software Only never asks. + assert_eq!(frames(&p, cid, &slow, Backend::Gpu, 3), 3); + let mut sw = p.clone(); + sw.settings.gpu_acceleration = false; + assert_eq!(frames(&sw, cid, &slow, Backend::Auto, 3), 0); +} + +#[test] +fn auto_backend_keeps_fast_accelerators() { + // As fast as the CPU (ties stay on the accelerator): every frame but the CPU warm-up and + // re-probes. + let (p, cid) = project(); + let fast = TimedAccel::new(0, true); + let asked = frames(&p, cid, &fast, Backend::Auto, 40); + assert!(asked >= 36, "asked {asked} of 40"); +} + +#[test] +fn auto_backend_falls_back_when_the_accelerator_declines() { + let (p, cid) = project(); + let declining = TimedAccel::new(0, false); + let n = 2 * AutoPick::REPROBE as usize; + let asked = frames(&p, cid, &declining, Backend::Auto, n); + // Asked first, then only on the periodic re-probes. + assert!((1..=4).contains(&asked), "asked {asked} times in {n} frames"); +} + +#[test] +fn auto_pick_follows_changing_costs() { + let pick = AutoPick::default(); + let key = AutoKey::new(ItemId(7), 0.5, true); + let cpu = 5.0; + let mut gpu = 10.0; + let (mut early, mut late) = (0, 0); + for frame in 0..400 { + // The GPU gets faster halfway (say, its uploads became cached). + if frame == 200 { + gpu = 1.0; + } + let g = pick.choose(key); + pick.record(key, g, if g { gpu } else { cpu }); + if (100..200).contains(&frame) && g { + early += 1; + } + if frame >= 300 && g { + late += 1; + } + } + assert!(early <= 3, "{early} of 100 frames on the slower GPU"); + assert!(late >= 97, "{late} of 100 frames on the faster GPU"); + let s = pick.stats(key).unwrap(); + assert!(s.gpu_ms.unwrap() < 2.0 && s.cpu_ms.unwrap() > 4.0, "{s:?}"); + // Keys are separate per comp, scale and path. + assert!(pick.stats(AutoKey::new(ItemId(7), 1.0, true)).is_none()); + assert!(pick.stats(AutoKey::new(ItemId(7), 0.5, false)).is_none()); +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index fd628351..b5a0c2f5 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -437,14 +437,36 @@ impl Worker { r.accel = job.src.gpu.as_ref().map(|g| g as &dyn effectcraft_engine::render::Accelerator); // The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no // adapter, or a frame the GPU cannot finish here) the CPU renders it. - let gpu_frame = match (&job.src.gpu, r.active_accel()) { - (Some(g), Some(_)) if job.src.gpu_display => g.render_display(&r, job.comp, job.t), - _ => None, - }; - match gpu_frame { - Some(f) => FrameImage::Gpu(Arc::new(f)), - None => FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t)))), + // Auto (Mercury GPU Acceleration) shows each comp on whichever compositor measured + // faster for it (light comps composite faster on the CPU). + if let (Some(g), Some(a)) = (&job.src.gpu, r.active_accel()) + && job.src.gpu_display + { + // The browser cannot wait for the GPU to time it (and its CPU path is slow): always + // the GPU there. + let auto = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true) }; + if auto.is_none_or(|(p, key)| p.choose(key)) { + let t0 = web_time::Instant::now(); + if let Some(f) = g.render_display(&r, job.comp, job.t) { + if let Some((p, key)) = auto { + // Time the GPU's work, not just its recording. + g.wait(); + p.record(key, true, t0.elapsed().as_secs_f64() * 1000.0); + } + return FrameImage::Gpu(Arc::new(f)); + } + if let Some((p, key)) = auto { + p.declined(key); + } + } + let t0 = web_time::Instant::now(); + let img = to_color_image(&r.comp_frame_cpu(job.comp, job.t)); + if let Some((p, key)) = auto { + p.record(key, false, t0.elapsed().as_secs_f64() * 1000.0); + } + return FrameImage::Cpu(Arc::new(img)); } + FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t)))) } } diff --git a/docs/effects.md b/docs/effects.md index 32eabfa1..1ccbfc66 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**: 152 effects also run on the GPU compositor with identical results. +- **GPU**: 160 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). @@ -130,15 +130,15 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Bezier Warp | `ec.distort.bezierwarp` | 13 | | 32 | Implemented | +| Bezier Warp | `ec.distort.bezierwarp` | 13 | GPU | 32 | Implemented | | Bulge | `ec.distort.bulge` | 6 | GPU | 32 | Implemented | -| CC Bend It | `ec.distort.ccbendit` | 4 | | 32 | Implemented | +| CC Bend It | `ec.distort.ccbendit` | 4 | GPU | 32 | Implemented | | CC Bender | `ec.distort.ccbender` | 5 | | 32 | Implemented | | CC Blobbylize | `ec.distort.ccblobbylize` | 15 | | 32 | Implemented | | CC Flo Motion | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented | | CC Griddler | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented | | CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented | -| CC Page Turn | `ec.distort.ccpageturn` | 7 | | 32 | Implemented | +| CC Page Turn | `ec.distort.ccpageturn` | 7 | GPU | 32 | Implemented | | CC Power Pin | `ec.distort.ccpowerpin` | 10 | GPU | 32 | Implemented | | CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | GPU | 32 | Implemented | | CC Slant | `ec.distort.ccslant` | 4 | GPU | 32 | Implemented | @@ -157,16 +157,16 @@ Effects are addressed by id (`ec..`) or display name in commands | Optics Compensation | `ec.distort.opticscompensation` | 6 | GPU | 32 | Implemented | | Polar Coordinates | `ec.distort.polar` | 2 | GPU | 32 | Implemented | | Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented | -| Reshape | `ec.distort.reshape` | 6 | | 32 | Implemented | +| Reshape | `ec.distort.reshape` | 6 | GPU | 32 | Implemented | | Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented | | Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented | -| Smear | `ec.distort.smear` | 8 | | 32 | Implemented | +| Smear | `ec.distort.smear` | 8 | GPU | 32 | Implemented | | Spherize | `ec.distort.spherize` | 2 | GPU | 32 | Implemented | | Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented | | Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Implemented | | Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented | | Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | GPU | 32 | Implemented | -| Warp | `ec.distort.warp` | 5 | | 32 | Implemented | +| Warp | `ec.distort.warp` | 5 | GPU | 32 | Implemented | | Warp Stabilizer | `ec.distort.warpstabilizer` | 20 | | 32 | Implemented | | Wave Warp | `ec.distort.wavewarp` | 7 | GPU | 32 | Implemented | @@ -352,8 +352,8 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Threshold | `ec.stylize.ccthreshold` | 4 | GPU | 32 | Implemented | | CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | GPU | 32 | Implemented | | CC Vignette | `ec.stylize.ccvignette` | 4 | GPU | 32 | Implemented | -| Cartoon | `ec.stylize.cartoon` | 12 | | 32 | Implemented | -| Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented | +| Cartoon | `ec.stylize.cartoon` | 12 | GPU | 32 | Implemented | +| Color Emboss | `ec.stylize.coloremboss` | 4 | GPU | 32 | Implemented | | Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented | | Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented | | Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented | From ea292a6e814f16f98b18f00a8f8ec630e78b8dd1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 10:44:16 -0400 Subject: [PATCH 2/4] M13.13: fuse 8/16 bpc quantise into the layer composite; steady-state bench traffic Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 2 ++ crates/gpu/src/context.rs | 7 +++-- crates/gpu/src/ops.rs | 40 +++++++++++++++++++++++----- crates/gpu/src/shaders/kernels.wgsl | 32 +++++++++++++++------- crates/gpu/src/walk.rs | 41 +++++++++++++++++++---------- 5 files changed, 89 insertions(+), 33 deletions(-) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 5524d10f..ca904233 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -836,6 +836,8 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { std::hint::black_box(mk(Backend::Cpu, Some(&cache))); }); let gcache = LayerCache::default(); + // Traffic in steady state: after the frame that fills the layer cache. + std::hint::black_box(mk(Backend::Gpu, Some(&gcache))); let t0 = gpu.context().transfer_stats(); let gpu_warm = time_ms(n, || { std::hint::black_box(mk(Backend::Gpu, Some(&gcache))); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index b96a0bdc..4efa3b86 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -56,12 +56,15 @@ pub struct GpuImage { pub width: u32, pub height: u32, _lease: Option>, + /// Already clamped and quantised to this many levels (8/16 bpc), so quantising again is a + /// no-op (see `ops::quantize`). + pub(crate) levels: Option, } impl GpuImage { /// An image on a texture of its own (not pooled). pub fn new(texture: wgpu::Texture, width: u32, height: u32) -> GpuImage { - GpuImage { texture, width, height, _lease: None } + GpuImage { texture, width, height, _lease: None, levels: None } } } @@ -503,7 +506,7 @@ impl GpuContext { let was_reused = reused.is_some(); let texture = reused.unwrap_or_else(|| self.texture(w, h)); let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool) }); - (GpuImage { texture, width: w, height: h, _lease: Some(lease) }, was_reused) + (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } /// Uploads and readbacks so far (images; uploads of cached layers count once). diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index 8e476408..151039aa 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -44,6 +44,24 @@ pub fn warp( opacity: f32, seed: u32, accumulate: Option, +) -> GpuImage { + warp_q(e, dst, src, m, sampling, mode, opacity, seed, accumulate, None) +} + +/// [`warp`], clamping and quantising the result to `levels` (8/16 bpc: the comp after a layer, +/// in the same pass). +#[allow(clippy::too_many_arguments)] +pub fn warp_q( + e: &mut Enc, + dst: &GpuImage, + src: &GpuImage, + m: &Mat3, + sampling: Sampling, + mode: BlendMode, + opacity: f32, + seed: u32, + accumulate: Option, + levels: Option, ) -> GpuImage { if src.width == 0 || src.height == 0 || opacity <= 0.0 { return dst.clone(); @@ -79,20 +97,23 @@ pub fn warp( p.f[0] = r[0]; p.f[1] = r[1]; p.f[2] = r[2]; - p.f[3] = [opacity, accumulate.unwrap_or(1.0), 0.0, 0.0]; + p.f[3] = [opacity, accumulate.unwrap_or(1.0), levels.unwrap_or(0.0), 0.0]; p.f[4] = [x0 as f32, y0 as f32, x1 as f32, y1 as f32]; - let out = e.scratch(dst.width, dst.height); + let mut out = e.scratch(dst.width, dst.height); e.pixels("warp_blend", &p, &img, Some(dst), &out, None); + out.levels = levels; out } -/// `Image::blend_from`: blend `src` (same size) onto `dst`. -pub fn blend_full(e: &mut Enc, dst: &GpuImage, src: &GpuImage, mode: BlendMode, opacity: f32, seed: u32) -> GpuImage { +/// `Image::blend_from`: blend `src` (same size) onto `dst`, clamping and quantising the result +/// to `levels` when set (see [`warp_q`]). +pub fn blend_full(e: &mut Enc, dst: &GpuImage, src: &GpuImage, mode: BlendMode, opacity: f32, seed: u32, levels: Option) -> GpuImage { let mut p = Params::default(); p.u[0] = [mode_id(mode), 0, seed, 0]; - p.f[0] = [opacity, 0.0, 0.0, 0.0]; - let out = e.scratch(dst.width, dst.height); + p.f[0] = [opacity, levels.unwrap_or(0.0), 0.0, 0.0]; + let mut out = e.scratch(dst.width, dst.height); e.pixels("blend_full", &p, src, Some(dst), &out, None); + out.levels = levels; out } @@ -147,10 +168,15 @@ pub fn adjust_mix(e: &mut Enc, canvas: &GpuImage, adjusted: &GpuImage, matte: &G /// Clamp and quantise to `levels` steps (8/16 bpc). pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage { + // Quantising is idempotent: an image a fused kernel already quantised stays as it is. + if img.levels == Some(levels) { + return img.clone(); + } let mut p = Params::default(); p.f[0][0] = levels; - let out = e.scratch(img.width, img.height); + let mut out = e.scratch(img.width, img.height); e.pixels("quantize", &p, img, None, &out, None); + out.levels = Some(levels); out } diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index c66e2497..858d8741 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -33,7 +33,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3) { if ((flags & 2u) != 0u) { d = vec4(0.0); } - textureStore(out, vec2(x, y), d); + store_q(vec2(x, y), d, P.f[3].z); return; } let r0 = P.f[0]; @@ -52,7 +52,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3) { if (stencil && (flags & 1u) == 0u) { d = blend_pixel(mode, d, vec4(0.0), 0.5); } - textureStore(out, vec2(x, y), d); + store_q(vec2(x, y), d, P.f[3].z); return; } var s = sample(src, sampling, px, py); @@ -60,11 +60,11 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3) { if (s.w > 0.0) { d = d + s * P.f[3].y; } - textureStore(out, vec2(x, y), d); + store_q(vec2(x, y), d, P.f[3].z); return; } if (s.w <= 0.0 && !stencil) { - textureStore(out, vec2(x, y), d); + store_q(vec2(x, y), d, P.f[3].z); return; } let op = P.f[3].x; @@ -75,7 +75,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3) { if (mode == 1u || mode == 2u) { n = hash_noise(u32(x), u32(y), seed); } - textureStore(out, vec2(x, y), blend_pixel(mode, d, s, n)); + store_q(vec2(x, y), blend_pixel(mode, d, s, n), P.f[3].z); } // Blend `src` (same size) onto `aux` (Image::blend_from). @@ -91,7 +91,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3) { let d = textureLoad(aux, p, 0); var s = textureLoad(src, p, 0); if (s.w <= 0.0 && !is_stencil(mode)) { - textureStore(out, p, d); + store_q(p, d, P.f[0].y); return; } s = s * P.f[0].x; @@ -99,7 +99,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3) { if (mode == 1u || mode == 2u) { n = hash_noise(gid.x, gid.y, P.u[0].z); } - textureStore(out, p, blend_pixel(mode, d, s, n)); + store_q(p, blend_pixel(mode, d, s, n), P.f[0].y); } // Track matte: `src` × matte factor of `aux`. u[0].x = 0 alpha, 1 alpha inverted, 2 luma, @@ -179,15 +179,27 @@ fn quantize(@builtin(global_invocation_id) gid: vec3) { if (p.x >= dims.x || p.y >= dims.y) { return; } - let levels = P.f[0].x; + textureStore(out, p, quantize_px(textureLoad(src, p, 0), P.f[0].x)); +} + +fn quantize_px(s: vec4, levels: f32) -> vec4 { let inv = 1.0 / levels; - let s = textureLoad(src, p, 0); let a = round_away(clamp(s.w, 0.0, 1.0) * levels) * inv; var o = vec4(0.0, 0.0, 0.0, a); o.x = round_away(clamp(s.x, 0.0, a) * levels) * inv; o.y = round_away(clamp(s.y, 0.0, a) * levels) * inv; o.z = round_away(clamp(s.z, 0.0, a) * levels) * inv; - textureStore(out, p, o); + return o; +} + +// Store a compositing result, clamped and quantised to `levels` when > 0 (8/16 bpc: the comp +// after a layer, fused into the layer's composite). +fn store_q(p: vec2, v: vec4, levels: f32) { + if (levels > 0.0) { + textureStore(out, p, quantize_px(v, levels)); + } else { + textureStore(out, p, v); + } } // Colour conversion on straight colour (render::color::convert). diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index fae7572f..dcca81a7 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -158,20 +158,20 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & if r.track_matte(ctx, layer).is_some() || layer.preserve_transparency { let mut iso = e.zeros(w, h); for (b, m) in &st.passes { - iso = place(e, r, ctx, layer, b, &iso, *m, 1.0)?; + iso = place(e, r, ctx, layer, b, &iso, *m, 1.0, None)?; } - iso = place(e, r, ctx, layer, &st.body, &iso, BlendMode::Normal, 1.0)?; + iso = place(e, r, ctx, layer, &st.body, &iso, BlendMode::Normal, 1.0, None)?; return composite_iso(e, r, ctx, layer, iso, canvas, opacity); } let mut tmp = canvas.clone(); if bl.knockout > 0 { - let k = place(e, r, ctx, layer, &st.content, &e.zeros(w, h), BlendMode::Normal, 1.0)?; + let k = place(e, r, ctx, layer, &st.content, &e.zeros(w, h), BlendMode::Normal, 1.0, None)?; tmp = ops::knockout(e, &tmp, &k); } for (b, m) in &st.passes { - tmp = place(e, r, ctx, layer, b, &tmp, *m, 1.0)?; + tmp = place(e, r, ctx, layer, b, &tmp, *m, 1.0, None)?; } - tmp = place(e, r, ctx, layer, &st.body, &tmp, layer.blend_mode, 1.0)?; + tmp = place(e, r, ctx, layer, &st.body, &tmp, layer.blend_mode, 1.0, None)?; *canvas = ops::channel_mix(e, canvas, &tmp, bl.channels, opacity); Some(()) } @@ -183,7 +183,7 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv let opacity = r.layer_opacity(ctx, layer); let Some(foot) = r.adjustment_footprint(ctx, layer) else { return Some(()) }; let (w, h) = (canvas.width, canvas.height); - let matte = place(e, r, ctx, layer, &foot, &e.zeros(w, h), BlendMode::Normal, 1.0)?; + let matte = place(e, r, ctx, layer, &foot, &e.zeros(w, h), BlendMode::Normal, 1.0, None)?; let pipe = r.pipe(); // Effects run in the working space, not the linear blending space. let below = match pipe.from_blend() { @@ -206,11 +206,12 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv fn composite_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc, canvas: &mut GpuImage, opacity: f32) -> Option<()> { let matte = r.track_matte(ctx, layer).is_some(); if !matte && !layer.preserve_transparency { - *canvas = place(e, r, ctx, layer, buf, canvas, layer.blend_mode, opacity)?; + // The comp is quantised after the layer (8/16 bpc) in the same pass. + *canvas = place(e, r, ctx, layer, buf, canvas, layer.blend_mode, opacity, r.pipe().levels)?; return Some(()); } // Render in isolation, then matte / preserve transparency, then blend. - let iso = place(e, r, ctx, layer, buf, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0)?; + let iso = place(e, r, ctx, layer, buf, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0, None)?; composite_iso(e, r, ctx, layer, iso, canvas, opacity) } @@ -222,31 +223,43 @@ fn composite_iso(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, mut is && let Some(mb) = r.layer_buf(ctx, m) { let mo = ctx.opacity(m) as f32; - mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo)?; + mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo, None)?; } iso = ops::matte(e, &iso, &mimg, kind); } if layer.preserve_transparency { iso = ops::preserve(e, &iso, canvas); } - *canvas = ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32); + *canvas = ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32, r.pipe().levels); Some(()) } -/// Draw a processed layer buffer onto `target` (transform, motion blur sub-samples). -fn place(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc, target: &GpuImage, mode: BlendMode, opacity: f32) -> Option { +/// Draw a processed layer buffer onto `target` (transform, motion blur sub-samples), quantising +/// the result to `levels` when set. +#[allow(clippy::too_many_arguments)] +fn place( + e: &mut Enc, + r: &Renderer, + ctx: &EvalCtx, + layer: &Layer, + buf: &Arc, + target: &GpuImage, + mode: BlendMode, + opacity: f32, + levels: Option, +) -> Option { if buf.img.is_empty() || opacity <= 0.0 { return Some(target.clone()); } let src = e.g.upload_buf(buf)?; let pl = r.placement(ctx, layer, buf); if let [m] = pl.matrices.as_slice() { - return Some(ops::warp(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None)); + return Some(ops::warp_q(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None, levels)); } let mut acc = e.zeros(target.width, target.height); let k = 1.0 / pl.matrices.len() as f32; for m in &pl.matrices { acc = ops::warp(e, &acc, &src, m, pl.sampling, BlendMode::Normal, 1.0, 0, Some(k)); } - Some(ops::blend_full(e, target, &acc, mode, opacity, pl.seed)) + Some(ops::blend_full(e, target, &acc, mode, opacity, pl.seed, levels)) } From 750868244a95feef9e4fc43de44d5067495fb76f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 10:53:00 -0400 Subject: [PATCH 3/4] 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 | From 39d53108f02672b22f9d8ffdd35d7356e2b99404 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 11:04:47 -0400 Subject: [PATCH 4/4] M13.13: docs (parity EFF-5 / PRV-3, architecture GPU section) Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 30 ++++++++++++++++++++++-------- docs/parity.md | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ff210e5c..b53b059d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,8 +161,14 @@ Half, Third and Quarter resolution render proportionally fewer pixels end to end Acceleration, the default, or Mercury Software Only; `render.backend`). The CPU renderer is the reference and keeps rendering layer content (sources, masks, CPU effects, layer styles) into the layer cache. The render crate defines an `Accelerator` trait; `effectcraft_gpu::Gpu` implements it -on wgpu compute shaders. `RenderOpts::backend` picks `Cpu`, `Gpu` or `Auto` (GPU when an -accelerator is attached and the project's renderer is the GPU). A GPU frame walks the comp like +on wgpu compute shaders. `RenderOpts::backend` picks `Cpu`, `Gpu` or `Auto` (an accelerator +attached and the project's renderer the GPU). Auto renders each comp's top-level frames on +whichever compositor measured faster for it (`render::auto::AutoPick`, kept by the accelerator): +per comp, output scale and path (readback or viewer display) it averages CPU and GPU frame times +(warm-up on both sides, the fastest warm-up frame starting the average, single stalls capped), +picks the GPU unless the CPU is clearly faster, and re-measures the other side every 48 frames. +Light comps (a few small layers, which the CPU composites only within their bounds) then stay on +the CPU instead of paying for full-frame passes and a readback. A GPU frame walks the comp like `draw_comp`: cached layer buffers are uploaded once per buffer, then transformed with the CPU's sampling (nearest, bilinear, Catmull-Rom bicubic, the same minification pre-filter), motion-blur sub-samples are accumulated, and track mattes, Preserve Transparency, layer style passes, @@ -184,16 +190,23 @@ GPU canvas; only the depth of field reads back its 8-byte radius range. Advanced blend modes, track mattes or Preserve Transparency and environment backgrounds take the CPU's 2D compositing path (their scenes still render through `render_3d`). Wireframe outlines draw on the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU -effects (`effects::GPU_EFFECTS`, 152 of them: blurs, colour correction, keying incl. Key Light, -mattes, channel, stylize, distortion, transitions, generators, noise, grain and time; see +effects (`effects::GPU_EFFECTS`, 166 of them: blurs, colour correction, keying incl. Key Light, +mattes, channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, CC Bend It, CC +Page Turn), Cartoon, bevels, shapes, transitions, generators, noise, grain and time; see [effects.md](effects.md)) repeat the CPU effect's steps (padding, box radii, parameters, hashes) as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); consecutive GPU effects run as one chain with one upload and one readback. Statistics that need the whole frame (Auto Levels / Contrast / Color, Equalize, Shadow/Highlight, Color Stabilizer, Remove Grain's noise level) are measured on the CPU from one readback and applied on the GPU; effects reading other frames (Echo, Posterize Time) upload the frames the host renders. Settings a kernel cannot -match render on the CPU (`effects::catalog::gpu_supported`). Tests render scenes on both paths and compare them -(≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. The desktop viewer builds the +match render on the CPU (`effects::catalog::gpu_supported`; e.g. Warp's Fisheye and Twist past +50 % or with a distortion, whose Newton inverse wanders chaotically near the crease). Tests render +scenes on both paths and compare them (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an +adapter. Working textures (premultiplied RGBA f32) come from a pool: a released texture is reused +once every encoder that could still read it has been submitted, transparent inputs share one +zero texture per size, readback staging buffers are reused, and the 8/16 bpc quantisation after +each layer is fused into the layer's composite kernel; under test, pooled scratch images start +as NaNs (a kernel that skips pixels fails the oracle) and validation errors panic. The desktop viewer builds the `Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web (WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the @@ -203,8 +216,9 @@ Playground cannons without interacting forces evolve every particle independentl backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). -`effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up and pixel agreement for every -comp plus an adjustment-layer comp. +`effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel +agreement and the steady-state upload / readback MB per frame for every comp plus an +adjustment-layer comp. **Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are `f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and diff --git a/docs/parity.md b/docs/parity.md index 4eac5a3c..6165318b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much | Unweighted | ≈ 94% (half-credit) / 98% (fractions) | | P0 / P1 / P2 | ≈ 96% / 92% / 85% (half-credit); 99% / 97% / 96% (fractions) | | Features done / partial / missing | 80 / 12 / 0 of 92 | -| **Effects** | **298 of 298** After Effects 2026 effects exist; 42 still simplified ([effects.md](effects.md)); 152 run on the GPU | +| **Effects** | **298 of 298** After Effects 2026 effects exist; 42 still simplified ([effects.md](effects.md)); 166 run on the GPU | | Disabled menu entries left | 0 (the stub list is empty) | | Remaining work | ≈ 19–22 agent-hours at the pace measured so far (≈ 58 on the conservative audit scale) | | **Wall-clock estimate** | **≈ 4–5 hours** with five agents in parallel; ≈ 3 hours for 100% of P0 + P1 | @@ -136,11 +136,11 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) | | 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | -| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (152 run on the GPU since M13.9, EFF-5: keying, mattes, channel, colour correction, stylize, distort, noise, blurs and time effects joined the 58 of M12.7; Warp, Bezier Warp, Reshape, Smear, Cartoon, CC Page Turn and others still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | +| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (166 run on the GPU since M13.13, EFF-5: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon, Color Emboss, Circle, Ellipse, Iris Wipe, Bevel Alpha / Edges and Gaussian Blur (Legacy) joined the 152 of M13.9; simulations, 3D channel, VR, audio-driven, OCIO, text and the CC light / transition families still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | -| Preview | 77% | 2.8 | GPU compositing of Advanced 3D layers with blend modes / track mattes and of environment backgrounds (Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | +| Preview | 77% | 2.8 | GPU compositing of Advanced 3D layers with blend modes / track mattes and of environment backgrounds (pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 96% | 0.3 | face tracking is a classical (skin model + feature components + shape model) fitter, not a learned detector: profile views and occluded faces are weak; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -204,6 +204,33 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). as real geometry lit by the parent (2D collapsed precomps draw nested 3D layers with the parent's renderer); text and shape strokes extrude as bevelled meshes in paint order. +## Update: M13.13 GPU performance and the remaining GPU ports + +- **Small comps on the GPU (PRV-3)**: the GPU frame allocated (and zero-initialised) several + full-frame RGBA f32 textures per layer; working textures now come from a pool (reused once + every encoder that could read them is submitted), transparent inputs share one zero texture, + readback staging buffers are reused and the 8/16 bpc quantisation after each layer is fused + into the layer's composite. `bench --gpu` on an M4 Pro under load (both binaries run back to + back, median of 15): Lower Third 1920×1080 GPU warm 21.0 → 4.1 ms/frame (viewer 11.1 → 2.6; + Half 10.1 → 3.1), EffectCraft Intro 27.5 → 7.8 (viewer 21.7 → 3.6), 3D Showcase ≈ 95 → ≈ 60 + (viewer ≈ 80 → ≈ 40), Adjustment Layers 93 → 36. In steady state the only transfer left is + the frame's own readback (0.0 MB uploads per frame on every demo comp; the adjustment + footprint is now cached and uploaded once). +- **Auto picks per comp**: `Backend::Auto` (Mercury GPU Acceleration) times each comp on both + compositors (`render::AutoPick`: per comp, scale and path; warm-up, capped stalls, re-probe + every 48 frames) and renders on the faster one, in renders and in the viewer. A light comp + stays on the CPU, which composites only the layers' bounds: Lower Third Auto 0.8 ms/frame + (CPU warm 1.7, GPU warm 4.1, which is mostly the 32 MB readback). +- **GPU ports (EFF-5)**: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon, + Color Emboss, Circle, Ellipse, Iris Wipe, Bevel Alpha, Bevel Edges and Gaussian Blur (Legacy) + run on the GPU (166 GPU effects); Median, Median (Legacy) and Dust & Scratches take any radius + (a sliding 512-bin histogram per row segment above radius 4); CC Power Pin's Perspective + below 100 % runs on the GPU. All match the CPU oracle within 1/255 (8 bpc) / 1e-3 (32 bpc) on + every tested pixel except: Smear and Reshape allow 0.1 % (f32 point-in-outline tests on + boundary pixels; measured 0); Warp's Fisheye and Twist bent past 50 % or with a Horizontal / + Vertical Distortion render on the CPU (`gpu_supported`), since their Newton inverse wanders + chaotically near the crease and f32 settles on other pixels. + ## Update: M13.11 Advanced 3D on the GPU, Essential Graphics mirrors, ScriptUI paint - **Advanced 3D on the GPU (PRV-3, 3D-3)**: a whole Advanced 3D run renders on the device