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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 10:31:31 -04:00
co-authored by Claude Opus 5.5
parent f5a136e346
commit 9ff0e5aa3c
35 changed files with 2039 additions and 152 deletions
+18 -4
View File
@@ -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<ItemId> = 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}"
);
}
}
+9
View File
@@ -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 };
+12 -4
View File
@@ -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<Pt>, Vec<Pt>) {
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
+63 -15
View File
@@ -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<SmearSetup> {
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<Vec<[f64; 2]>>,
/// 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<ReshapeSetup> {
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)
+11 -1
View File
@@ -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
+2 -2
View File
@@ -17,7 +17,7 @@ pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option<GpuImag
let padded = e.image(w, h);
e.copy_into(src, &padded, d.pad, d.pad);
let boosted = if d.highlight.gain > 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<GpuImag
let (lo, hi) = (d.levels[0], d.levels[d.levels.len() - 1]);
let mut acc = e.image(w, h);
for (k, (off, count, norm)) in levels.into_iter().enumerate() {
let out = e.image(w, h);
let out = e.scratch(w, h);
let mut p = Params::default();
p.u[0] = [off, count, k as u32, n];
p.u[1] = [u32::from(k == 0), 0, 0, 0];
+1 -1
View File
@@ -213,7 +213,7 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option<Gp
let mb = e.data(&mattes);
let mut p = Params::default();
p.u[0] = [run.planes.len() as u32, run.lights.len() as u32, run.casters.len() as u32, 0];
let out = e.image(w, h);
let out = e.scratch(w, h);
e.dispatch_ext("classic3d", &p, &atlas, Some(canvas), &out, Some(&mb), (w.div_ceil(16), h.div_ceil(16)), Some([&pb, &gb, &lb, &cb]));
Some(out)
}
+304 -31
View File
@@ -1,6 +1,6 @@
//! Device, pipelines, textures, uploads and readback.
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use std::sync::{Arc, Mutex, Weak};
use effectcraft_effects::Buf;
@@ -30,6 +30,7 @@ const ENTRIES: &[&str] = &[
"bokeh_boost",
"bokeh_prefix",
"bokeh_gather",
"fill",
];
/// Entry points that also bind group 1 (four read-only storage buffers; see
@@ -43,12 +44,154 @@ pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// Uploaded layer buffers kept on the GPU (bytes).
const UPLOAD_BUDGET: usize = 1536 << 20;
/// Working textures kept for reuse once their last image is dropped (bytes).
const POOL_BUDGET: usize = 768 << 20;
/// A GPU image: premultiplied RGBA f32 in a texture (cheap to clone; immutable once written).
/// Working images come from the context's texture pool and go back to it when the last clone
/// is dropped.
#[derive(Clone, Debug)]
pub struct GpuImage {
pub texture: wgpu::Texture,
pub width: u32,
pub height: u32,
_lease: Option<Arc<Lease>>,
}
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<Mutex<Pool>>,
}
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<wgpu::Texture>>,
/// (texture, newest encoder open at its release).
released: Vec<(wgpu::Texture, u64)>,
/// Encoders with recorded, unsubmitted work.
open: BTreeSet<u64>,
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<wgpu::Texture> {
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<Uploads>,
pool: Arc<Mutex<Pool>>,
/// CPU ↔ GPU traffic (diagnostics: `bench --gpu`).
pub(crate) transfers: Transfers,
/// Readback staging buffers by size.
staging: Mutex<HashMap<u64, Vec<wgpu::Buffer>>>,
/// Transparent read-only images by size (see [`Enc::zeros`]).
zeros: Mutex<HashMap<(u32, u32), GpuImage>>,
/// Advanced 3D render pipelines (built on first use).
pub(crate) adv3d: std::sync::OnceLock<crate::adv3d::Pipes>,
/// 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<wgpu::CommandEncoder>,
/// 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<Image> {
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<Vec<u8>> {
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`
+7 -6
View File
@@ -131,6 +131,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
_ 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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
}
_ => 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 })
}
+7 -7
View File
@@ -143,7 +143,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
}
_ => 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<GBuf> {
]);
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<GBuf> {
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<GBuf> {
}
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<GBuf> {
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 })
}
+9 -4
View File
@@ -66,7 +66,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
/// Run a same-size per-pixel kernel over the buffer.
fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, data: Option<&wgpu::Buffer>) -> Option<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
let idx = |i: usize, j: usize| j * (nx + 1) + i;
let hh = b.img.height as usize;
let mut tri_data: Vec<f32> = Vec::with_capacity(nx * ny * 24);
@@ -390,7 +395,7 @@ fn mosaic(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
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)
+4 -4
View File
@@ -71,7 +71,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
/// Run a per-pixel kernel over the buffer.
fn run(e: &mut Enc, entry: &str, p: &Params, b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&[f32]>) -> Option<GBuf> {
let buf = data.map(|d| e.data(d));
let out = e.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<GBuf> {
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<GBuf> {
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<GBuf> {
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 })
}
+3 -3
View File
@@ -71,7 +71,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
/// One `fxk_point` pass over `src`'s size.
fn run(e: &mut Enc, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, 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<GBuf> {
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);
+21 -12
View File
@@ -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<GBuf> {
@@ -99,7 +103,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
/// 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.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<GBuf> {
@@ -160,9 +172,6 @@ fn median(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
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<GBuf> {
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<GBuf> {
})
.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<GBuf> {
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;
}
+15 -12
View File
@@ -117,7 +117,7 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
/// Run a same-size per-pixel kernel over the buffer.
fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, data: Option<&wgpu::Buffer>) -> Option<GBuf> {
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<GBuf> {
}
fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
// 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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBu
}
let mut p = Params::default();
p.f[0][0] = count as f32;
let out = e.image(w, h);
let out = e.scratch(w, h);
e.pixels("fxs_div", &p, &acc, None, &out, None);
b.img = out;
Some(b)
+2 -2
View File
@@ -84,7 +84,7 @@ fn shadow_highlight(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
(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<Vec<f32>>) -> Option<GBuf> {
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 })
}
+275
View File
@@ -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<GBuf> {
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<f32>, 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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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)
}
+11 -2
View File
@@ -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<GpuContext>,
/// Backend::Auto's per-comp CPU / GPU timings.
auto: Arc<effectcraft_render::AutoPick>,
}
/// 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;
+10 -10
View File
@@ -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
}
+62
View File
@@ -76,6 +76,68 @@ fn fxn_median(@builtin(global_invocation_id) gid: vec3<u32>) {
textureStore(out, p, fxn_div4(lo, vec4<f32>(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<u32>) {
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<u32, 2048>;
for (var dy = -r; dy <= r; dy++) {
for (var dx = -r; dx <= r; dx++) {
let v = vec4<u32>(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<u32>(0u);
var lt = vec4<u32>(0u);
for (var x = x0; x < x1; x++) {
if (x > x0) {
for (var dy = -r; dy <= r; dy++) {
let o = vec4<u32>(tex_get_clamped(src, x - 1 - r, y + dy));
let n = vec4<u32>(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<i32>(x, y), fxn_div4(vec4<f32>(med), vec4<f32>(511.0)));
}
}
// noise::median_px.
fn fxn_median_px(o: vec4<f32>, m: vec4<f32>, on_alpha: bool) -> vec4<f32> {
if (on_alpha) {
+30
View File
@@ -423,6 +423,36 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3<u32>) {
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<f32>(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<f32>(0.0));
return;
+532
View File
@@ -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<i32>) -> vec2<f32> {
return vec2<f32>(f32(p.x) + 0.5, f32(p.y) + 0.5);
}
fn fxw_inside(p: vec2<i32>) -> bool {
let d = out_dims();
return p.x < d.x && p.y < d.y;
}
// util::unpremul / premul.
fn fxw_unpremul(p: vec4<f32>) -> vec3<f32> {
if (p.w > 1e-6) {
return p.xyz / p.w;
}
return vec3<f32>(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<f32> {
let bell = 1.0 - u * u;
switch style {
case 0u: {
return vec2<f32>(u * (1.0 - 0.3 * k * v), v - k * bell);
}
case 1u: {
return vec2<f32>(u, v - k * bell * (v + 1.0) * 0.5);
}
case 2u: {
return vec2<f32>(u, v - k * bell * (1.0 - v) * 0.5);
}
case 3u: {
return vec2<f32>(u, v - k * bell);
}
case 4u: {
return vec2<f32>(u, v * (1.0 + k * bell));
}
case 5u: {
return vec2<f32>(u, v + k * bell * bell * (v + 1.0) * 0.5);
}
case 6u: {
return vec2<f32>(u, v - k * bell * bell * (1.0 - v) * 0.5);
}
case 7u: {
return vec2<f32>(u, v - 0.5 * k * sin(FXW_PI * u));
}
case 8u: {
return vec2<f32>(u, v - 0.5 * k * sin(FXW_PI * u + 0.5 * FXW_PI * v));
}
case 9u: {
return vec2<f32>(u, v * (1.0 + 0.5 * k * sin(FXW_PI * u)));
}
case 10u: {
return vec2<f32>(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<f32>(u, v);
}
let f = 1.0 + k * (1.0 - r2);
return vec2<f32>(u * f, v * f);
}
case 12u: {
return vec2<f32>(u * (1.0 + 0.5 * k * (1.0 - v * v)), v * (1.0 + 0.5 * k * bell));
}
case 13u: {
return vec2<f32>(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<f32>(u, v);
}
let a = k * FXW_PI * (1.0 - r);
let s = sin(a);
let c = cos(a);
return vec2<f32>(u * c - v * s, u * s + v * c);
}
}
}
fn fxw_warp_fwd(q: vec2<f32>) -> vec2<f32> {
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<u32>) {
let p = vec2<i32>(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<f32>(e, 0.0));
let b = fxw_warp_fwd(s + vec2<f32>(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<f32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<vec3<f32>, 3>;
cand[0] = vec3<f32>(rho * th, w_arc, select(0.0, 1.0, th >= 0.0 && th <= beta));
let e = vec2<f32>(lx, ly) - pe;
let u2 = dot(e, t);
cand[1] = vec3<f32>(l + u2, dot(e, nn), select(0.0, 1.0, u2 >= 0.0));
cand[2] = vec3<f32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(0.0));
return;
}
// (u, back side, shade, valid) from the top layer down.
var cand: array<vec4<f32>, 4>;
if (d <= 0.0) {
cand[0] = vec4<f32>(FXW_PI * r - d, 1.0, 0.9, 1.0);
cand[3] = vec4<f32>(d, 0.0, 1.0, 1.0);
} else {
let a = asin(clamp(d / r, -1.0, 1.0));
cand[1] = vec4<f32>(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<f32>(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<f32>(col * c.z * v.w, v.w));
return;
}
textureStore(out, p, vec4<f32>(0.0));
}
// ---------------------------------------------------------------- polygons (util)
// A polygon of `n` points at data[base..base + 2n].
fn fxw_pt(base: u32, i: u32) -> vec2<f32> {
return vec2<f32>(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>) -> 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<u32>) {
let p = vec2<i32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(0.0));
let o = e + (c - e) * P.f[0].w;
textureStore(out, p, vec4<f32>(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<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxw_inside(p)) {
return;
}
let l = luminance(fxw_unpremul(textureLoad(src, p, 0)));
textureStore(out, p, vec4<f32>(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<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxw_inside(p)) {
return;
}
textureStore(out, p, vec4<f32>(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<u32>) {
let p = vec2<i32>(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<u32>) {
let p = vec2<i32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(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<f32>(g);
} else if (render > 1u) {
o = fill * g;
}
textureStore(out, p, vec4<f32>(o * a, a));
}
+13
View File
@@ -959,3 +959,16 @@ fn bokeh_gather(@builtin(global_invocation_id) gid: vec3<u32>) {
}
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<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
textureStore(out, p, P.f[0]);
}
+10 -3
View File
@@ -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);
+2 -2
View File
@@ -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);
+11
View File
@@ -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", &[]);
+3
View File
@@ -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]
+191
View File
@@ -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: &params, 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<MaskShape> {
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: &params,
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);
}
}
+8 -8
View File
@@ -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<Buf>
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));
+144
View File
@@ -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<f64>,
pub gpu_ms: Option<f64>,
/// 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<HashMap<AutoKey, AutoStats>>,
}
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<AutoStats> {
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();
}
}
}
+10
View File
@@ -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<u64> {
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<u64> {
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<u64> {
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
match &layer.source {
LayerSource::Solid { item } => {
+50 -3
View File
@@ -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<Buf> {
pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
// 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;
+151
View File
@@ -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<crate::Image> {
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<f32>) -> Option<crate::Buf> {
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());
}
+29 -7
View File
@@ -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))))
}
}
+9 -9
View File
@@ -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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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 |